'first'
This commit is contained in:
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# http://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
**/node_modules
|
||||||
|
# roadhog-api-doc ignore
|
||||||
|
/src/utils/request-temp.js
|
||||||
|
_roadhog-api-doc
|
||||||
|
|
||||||
|
# production
|
||||||
|
/dist
|
||||||
|
*.map
|
||||||
|
.sentryclirc
|
||||||
|
sentry-cli.log
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
/coverage
|
||||||
|
.idea
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
*bak
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
|
||||||
|
# visual studio code
|
||||||
|
.history
|
||||||
|
*.log
|
||||||
|
functions/*
|
||||||
|
.temp/**
|
||||||
|
|
||||||
|
# umi
|
||||||
|
.umi
|
||||||
|
.umi-production
|
||||||
|
.umi-test
|
||||||
|
|
||||||
|
# screenshot
|
||||||
|
screenshot
|
||||||
|
.firebase
|
||||||
|
|
||||||
|
build
|
||||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec commitlint --edit $1
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec lint-staged
|
||||||
105
README.md
Normal file
105
README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Risk-Guard
|
||||||
|
|
||||||
|
This project is a secondary development based on the Alibaba open-source template [Ant Design Pro](https://pro.ant.design), which uses React, UmiJS, Ant Design and other technology stacks.
|
||||||
|
|
||||||
|
## Install Dependencies
|
||||||
|
|
||||||
|
Install `node_modules`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm i
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install dependencies using `yarn` or `npm`.
|
||||||
|
|
||||||
|
### Start the Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
You can view the complete documentation at the [official website](https://pro.ant.design).
|
||||||
|
|
||||||
|
Since the project routing uses history mode, nginx configuration is required, otherwise refreshing the page will result in 404 errors. Additionally, since SSE interfaces are used, some special configurations are also needed.
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
root /data/react;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Homepage negotiation cache
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static resources
|
||||||
|
location ~* \.(?:js|css|woff2?|eot|ttf|svg|ico|png|jpg|jpeg|gif|webp)$ {
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 10k;
|
||||||
|
gzip_types application/javascript text/plain application/x-javascript text/css application/xml text/javascript;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE interface
|
||||||
|
location = /api/sse/connect {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_set_header Cache-Control "no-transform";
|
||||||
|
proxy_set_header X-Accel-Buffering "no";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Path rewriting
|
||||||
|
rewrite ^/api(/.*)$ $1 break;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
# Path rewriting
|
||||||
|
rewrite ^/api(/.*)$ $1 break;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types application/json;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
105
README_CN.md
Normal file
105
README_CN.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Risk-Guard
|
||||||
|
|
||||||
|
本项目基于 [Ant Design Pro](https://pro.ant.design) 的阿里开源模板二开, Ant Design Pro 则使用了 React, UmiJS, Ant Design等技术栈.
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
安装 `node_modules`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm i
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用 `yarn` 或 `npm` 安装依赖。
|
||||||
|
|
||||||
|
### 启动项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更多信息
|
||||||
|
|
||||||
|
您可以在 [官方网站](https://pro.ant.design) 查看完整文档。
|
||||||
|
|
||||||
|
由于项目路由使用history模式,需要配置nginx,否则刷新页面会404,并且用了sse接口,也需要一些特殊配置。
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
root /data/react;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 首页协商缓存
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源
|
||||||
|
location ~* \.(?:js|css|woff2?|eot|ttf|svg|ico|png|jpg|jpeg|gif|webp)$ {
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 10k;
|
||||||
|
gzip_types application/javascript text/plain application/x-javascript text/css application/xml text/javascript;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA路由
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE接口
|
||||||
|
location = /api/sse/connect {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
proxy_set_header Cache-Control "no-transform";
|
||||||
|
proxy_set_header X-Accel-Buffering "no";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 路径重写
|
||||||
|
rewrite ^/api(/.*)$ $1 break;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API代理
|
||||||
|
location /api/ {
|
||||||
|
# 路径重写
|
||||||
|
rewrite ^/api(/.*)$ $1 break;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types application/json;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
48
biome.json
Normal file
48
biome.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"includes": [
|
||||||
|
"**/*",
|
||||||
|
"!**/.umi",
|
||||||
|
"!**/.umi-production",
|
||||||
|
"!**/.umi-test",
|
||||||
|
"!**/.umi-test-production",
|
||||||
|
"!**/src/services",
|
||||||
|
"!**/mock",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/server",
|
||||||
|
"!**/public",
|
||||||
|
"!**/coverage",
|
||||||
|
"!**/node_modules/",
|
||||||
|
"!biome.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"noStaticElementInteractions": "off",
|
||||||
|
"useValidAnchor": "off",
|
||||||
|
"useKeyWithClickEvents": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"jsxRuntime": "reactClassic",
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
config/config.ts
Normal file
181
config/config.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// Https://umijs.org/config/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { defineConfig } from '@umijs/max';
|
||||||
|
import defaultSettings from './defaultSettings';
|
||||||
|
import proxy from './proxy';
|
||||||
|
import { commonRoutes, roleRoutes } from './routes';
|
||||||
|
|
||||||
|
type ConfigType = ReturnType<typeof defineConfig>;
|
||||||
|
|
||||||
|
// Project custom environment
|
||||||
|
const { REACT_APP_ENV = 'dev' } = process.env;
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name Use public path
|
||||||
|
* @description The path during deployment. If deployed in a non-root directory, this variable needs to be configured.
|
||||||
|
* @doc https://umijs.org/docs/api/config#publicpath
|
||||||
|
*/
|
||||||
|
const PUBLIC_PATH: string = '/';
|
||||||
|
|
||||||
|
const config: ConfigType = defineConfig({
|
||||||
|
/**
|
||||||
|
* @name Enable hash mode
|
||||||
|
* @description Include a hash suffix in build outputs. Commonly used for incremental deployments and to prevent browser caching.
|
||||||
|
* @doc https://umijs.org/docs/api/config#hash
|
||||||
|
*/
|
||||||
|
hash: true,
|
||||||
|
publicPath: PUBLIC_PATH,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name Compatibility settings
|
||||||
|
* @description Enabling IE11 may not be fully compatible; you need to check all dependencies you use
|
||||||
|
* @doc https://umijs.org/docs/api/config#targets
|
||||||
|
*/
|
||||||
|
// targets: {
|
||||||
|
// ie: 11,
|
||||||
|
// },
|
||||||
|
/**
|
||||||
|
* @name Route configuration (files not imported in routes won't be compiled)
|
||||||
|
* @description Only supports configuration keys: path, component, routes, redirect, wrappers, title
|
||||||
|
* @doc https://umijs.org/docs/guides/routes
|
||||||
|
*/
|
||||||
|
// umi routes: https://umijs.org/docs/routing
|
||||||
|
routes: [...commonRoutes, ...roleRoutes],
|
||||||
|
/**
|
||||||
|
* @name Theme configuration
|
||||||
|
* @description Although called theme, this is actually just less variable settings
|
||||||
|
* @doc antd theme settings https://ant.design/docs/react/customize-theme-cn
|
||||||
|
* @doc umi theme config https://umijs.org/docs/api/config#theme
|
||||||
|
*/
|
||||||
|
// theme: { '@primary-color': '#1DA57A' }
|
||||||
|
/**
|
||||||
|
* @name Moment i18n configuration
|
||||||
|
* @description If you don't require internationalization, enabling this can reduce the JS bundle size
|
||||||
|
* @doc https://umijs.org/docs/api/config#ignoremomentlocale
|
||||||
|
*/
|
||||||
|
ignoreMomentLocale: true,
|
||||||
|
|
||||||
|
// Only enable sourcemap during the dev stage
|
||||||
|
devtool: isDev ? 'source-map' : false,
|
||||||
|
/**
|
||||||
|
* @name Proxy configuration
|
||||||
|
* @description Allows your local server to proxy requests to your backend so you can access server data locally
|
||||||
|
* @see Note: the proxy can only be used during local development and won't work after building for production.
|
||||||
|
* @doc Proxy intro https://umijs.org/docs/guides/proxy
|
||||||
|
* @doc Proxy config https://umijs.org/docs/api/config#proxy
|
||||||
|
*/
|
||||||
|
proxy: proxy[REACT_APP_ENV as keyof typeof proxy],
|
||||||
|
|
||||||
|
links: [
|
||||||
|
{ rel: 'manifest', href: '/manifest.json' },
|
||||||
|
{ rel: 'apple-touch-icon', href: '/logo.svg' },
|
||||||
|
],
|
||||||
|
// https: {},
|
||||||
|
/**
|
||||||
|
* @name Fast refresh configuration
|
||||||
|
* @description A solid hot-reload feature that can preserve component state during updates
|
||||||
|
*/
|
||||||
|
fastRefresh: true,
|
||||||
|
//============== The following are max plugin configurations ===============
|
||||||
|
/**
|
||||||
|
* @name 数据流插件
|
||||||
|
* @@doc https://umijs.org/docs/max/data-flow
|
||||||
|
*/
|
||||||
|
model: {},
|
||||||
|
/**
|
||||||
|
* 一个全局的初始数据流,可以用它在插件之间共享数据
|
||||||
|
* @description Can be used to store global data such as user info or global state. The global initial state is created at the very start of the Umi project.
|
||||||
|
* @doc https://umijs.org/docs/max/data-flow#%E5%85%A8%E5%B1%80%E5%88%9D%E5%A7%8B%E7%8A%B6%E6%80%81
|
||||||
|
*/
|
||||||
|
initialState: {},
|
||||||
|
/**
|
||||||
|
* @name layout 插件
|
||||||
|
* @doc https://umijs.org/docs/max/layout-menu
|
||||||
|
*/
|
||||||
|
title: 'RiskGuard',
|
||||||
|
|
||||||
|
layout: {
|
||||||
|
locale: true,
|
||||||
|
...defaultSettings,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @name moment2dayjs plugin
|
||||||
|
* @description Replace moment with dayjs across the project
|
||||||
|
* @doc https://umijs.org/docs/max/moment2dayjs
|
||||||
|
*/
|
||||||
|
moment2dayjs: {
|
||||||
|
preset: 'antd',
|
||||||
|
plugins: ['duration'],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @name Internationalization plugin
|
||||||
|
* @doc https://umijs.org/docs/max/i18n
|
||||||
|
*/
|
||||||
|
locale: {
|
||||||
|
default: 'en_US',
|
||||||
|
baseNavigator: false,
|
||||||
|
baseSeparator: '_',
|
||||||
|
antd: true,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @name antd plugin
|
||||||
|
* @description Includes the babel import plugin by default
|
||||||
|
* @doc https://umijs.org/docs/max/antd#antd
|
||||||
|
*/
|
||||||
|
antd: {
|
||||||
|
configProvider: {
|
||||||
|
theme: {
|
||||||
|
cssVar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @name Request configuration
|
||||||
|
* @description Provides a unified network request and error handling solution based on axios and ahooks' useRequest.
|
||||||
|
* @doc https://umijs.org/docs/max/request
|
||||||
|
*/
|
||||||
|
request: {},
|
||||||
|
/**
|
||||||
|
* @name Access plugin
|
||||||
|
* @description Permission plugin based on initialState; initialState must be enabled first
|
||||||
|
* @doc https://umijs.org/docs/max/access
|
||||||
|
*/
|
||||||
|
access: {},
|
||||||
|
/**
|
||||||
|
* @name Extra scripts in <head>
|
||||||
|
* @description Configure additional scripts to inject into the <head>
|
||||||
|
*/
|
||||||
|
headScripts: [
|
||||||
|
// Solve the problem of white screen when loading for the first time
|
||||||
|
{ src: join(PUBLIC_PATH, 'scripts/loading.js'), async: true },
|
||||||
|
],
|
||||||
|
//================ pro plugin configurations =================
|
||||||
|
presets: ['umi-presets-pro'],
|
||||||
|
|
||||||
|
mock: {
|
||||||
|
include: ['mock/**/*', 'src/pages/**/_mock.ts'],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @name Enable mako
|
||||||
|
* @description Use mako for rapid development
|
||||||
|
* @doc https://umijs.org/docs/api/config#mako
|
||||||
|
*/
|
||||||
|
mako: isDev ? false : {},
|
||||||
|
helmet: false,
|
||||||
|
// polyfill: false,
|
||||||
|
mfsu: {
|
||||||
|
esbuild: true,
|
||||||
|
},
|
||||||
|
codeSplitting: {
|
||||||
|
jsStrategy: 'granularChunks',
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.UMI_APP_SENTRY_DSN': process.env.UMI_APP_SENTRY_DSN,
|
||||||
|
'process.env.UMI_APP_SENTRY_RELEASE': process.env.UMI_APP_SENTRY_RELEASE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
24
config/defaultSettings.ts
Normal file
24
config/defaultSettings.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ProLayoutProps } from '@ant-design/pro-components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name
|
||||||
|
*/
|
||||||
|
const Settings: ProLayoutProps & {
|
||||||
|
pwa?: boolean;
|
||||||
|
logo?: string;
|
||||||
|
} = {
|
||||||
|
navTheme: 'light',
|
||||||
|
colorPrimary: '#1890ff',
|
||||||
|
layout: 'mix',
|
||||||
|
contentWidth: 'Fluid',
|
||||||
|
fixedHeader: false,
|
||||||
|
fixSiderbar: true,
|
||||||
|
footerRender: false,
|
||||||
|
colorWeak: false,
|
||||||
|
title: 'RiskGuard',
|
||||||
|
pwa: false,
|
||||||
|
logo: '/logo.svg',
|
||||||
|
iconfontUrl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
53
config/proxy.ts
Normal file
53
config/proxy.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @name Proxy configuration
|
||||||
|
* @see In the production environment, the proxy cannot take effect, so there is no configuration of the production environment
|
||||||
|
* -------------------------------
|
||||||
|
* The agent cannot take effect in the production environment
|
||||||
|
* so there is no configuration of the production environment
|
||||||
|
* For details, please see
|
||||||
|
* https://pro.ant.design/docs/deploy
|
||||||
|
*
|
||||||
|
* @doc https://umijs.org/docs/guides/proxy
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
// If you need to customize the local development server, please uncomment and adjust as needed.
|
||||||
|
dev: {
|
||||||
|
'/api': {
|
||||||
|
// The address to proxy
|
||||||
|
// target: 'http://2333z061l7.51mypc.cn',
|
||||||
|
target: 'http://172.20.177.121:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: {
|
||||||
|
'^/api': '',
|
||||||
|
},
|
||||||
|
// xfwd: true,
|
||||||
|
onProxyRes: (proxyRes: any, req: { url: string | string[] }) => {
|
||||||
|
// Tell the browser/proxy not to cache streaming data
|
||||||
|
if (req.url?.includes('/sse')) {
|
||||||
|
// Key setting: instruct the browser/proxy not to cache streamed data
|
||||||
|
proxyRes.headers['Cache-Control'] = 'no-transform';
|
||||||
|
|
||||||
|
// For upstream proxies like Nginx, disable buffering (hard to verify effect; set as a precaution).
|
||||||
|
proxyRes.headers['X-Accel-Buffering'] = 'no';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @name Detailed proxy configuration
|
||||||
|
* @doc https://github.com/chimurai/http-proxy-middleware
|
||||||
|
*/
|
||||||
|
test: {
|
||||||
|
// localhost:8000/api/** -> https://preview.pro.ant.design/api/**
|
||||||
|
'/api': {
|
||||||
|
target: 'https://proapi.azurewebsites.net',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pre: {
|
||||||
|
'/api/': {
|
||||||
|
target: 'your pre url',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
218
config/routes.ts
Normal file
218
config/routes.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* @name umi routing configuration
|
||||||
|
* @description Only supports path, component, routes, redirect, wrappers, name, icon configuration
|
||||||
|
* @param path path only supports two placeholder configurations. The first is in the form of dynamic parameter :id, and the second is the *wildcard character. The wildcard character can only appear at the end of the routing string.
|
||||||
|
* @param component Configure the React component path used for rendering after location and path match. It can be an absolute path or a relative path. If it is a relative path, it will be searched starting from src/pages.
|
||||||
|
* @param routes Configure sub-routes, usually used when you need to add layout components to multiple paths.
|
||||||
|
* @param redirect Configure route jump
|
||||||
|
* @param wrappers Configure the packaging component of the routing component. Through the packaging component, you can combine more functions into the current routing component. For example, it can be used for routing-level permission verification.
|
||||||
|
* @param name Configure the title of the route. By default, the value of menu.xxxx in the internationalization file menu.ts is read. If the name is configured as login, the value of menu.login in menu.ts is read as the title.
|
||||||
|
* @param icon Configure the icon of the route. For the value, refer to https://ant.design/components/icon-cn. Pay attention to removing the style suffix and capitalization. If you want to configure the icon to be <StepBackwardOutlined />, the value should be stepBackward or StepBackward. If you want to configure the icon to be <UserOutlined />, the value should be user or User.
|
||||||
|
* @doc https://umijs.org/docs/guides/routes
|
||||||
|
*/
|
||||||
|
export const commonRoutes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
layout: false,
|
||||||
|
component: './login',
|
||||||
|
// wrappers: ['@/wrappers/authRedirect'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
layout: false,
|
||||||
|
component: './404',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const roleRoutes = [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
icon: '📊',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['dashboard:list'],
|
||||||
|
},
|
||||||
|
component: './dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'profile',
|
||||||
|
hideInMenu: true,
|
||||||
|
component: './profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/rules',
|
||||||
|
name: 'rules',
|
||||||
|
icon: '⚙️',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['rule:list'],
|
||||||
|
},
|
||||||
|
wrappers: ['@/wrappers/noBindDataSource'],
|
||||||
|
component: './rules',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: 'large-trade-lots',
|
||||||
|
name: 'largeTradeLots',
|
||||||
|
ruleType: '1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'large-trade-usd',
|
||||||
|
name: 'largeTradeUSD',
|
||||||
|
ruleType: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'liquidity-trade',
|
||||||
|
name: 'liquidityTrade',
|
||||||
|
ruleType: '3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'scalping',
|
||||||
|
name: 'scalping',
|
||||||
|
ruleType: '4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'exposure-alert',
|
||||||
|
name: 'exposureAlert',
|
||||||
|
ruleType: '5',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// path: 'pricing-volatility',
|
||||||
|
// name: 'pricingVolatility',
|
||||||
|
// ruleType: '6',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
path: 'pricing',
|
||||||
|
name: 'pricing',
|
||||||
|
ruleType: '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'volatility',
|
||||||
|
name: 'volatility',
|
||||||
|
ruleType: '11',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'nop-limit',
|
||||||
|
name: 'NOPLimit',
|
||||||
|
ruleType: '7',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'watch-list',
|
||||||
|
name: 'watchList',
|
||||||
|
ruleType: '8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reverse-positions',
|
||||||
|
name: 'reversePositions',
|
||||||
|
ruleType: '9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'deposit-withdrawal',
|
||||||
|
name: 'depositWithdrawal',
|
||||||
|
ruleType: '10',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/products',
|
||||||
|
name: 'products',
|
||||||
|
icon: '📦',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['product:list'],
|
||||||
|
},
|
||||||
|
wrappers: ['@/wrappers/noBindDataSource'],
|
||||||
|
component: './products/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/alert',
|
||||||
|
name: 'alert',
|
||||||
|
icon: '🔔',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['alert:list'],
|
||||||
|
},
|
||||||
|
component: './alert/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/account',
|
||||||
|
name: 'account',
|
||||||
|
icon: '👥',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['account:list'],
|
||||||
|
},
|
||||||
|
component: './account/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/company',
|
||||||
|
name: 'company',
|
||||||
|
icon: '🏢',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['company:list'],
|
||||||
|
},
|
||||||
|
component: './company/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/data-source',
|
||||||
|
name: 'dataSource',
|
||||||
|
icon: '🔌',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['data-source:list'],
|
||||||
|
},
|
||||||
|
component: './data-source/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user',
|
||||||
|
name: 'user',
|
||||||
|
icon: '👤',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['user:list'],
|
||||||
|
},
|
||||||
|
component: './user/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/role',
|
||||||
|
name: 'role',
|
||||||
|
icon: '🔐',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['role:list'],
|
||||||
|
},
|
||||||
|
component: './role/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/config',
|
||||||
|
name: 'config',
|
||||||
|
icon: '🔧',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['config:list'],
|
||||||
|
},
|
||||||
|
component: './config/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/email-services',
|
||||||
|
name: 'emailServices',
|
||||||
|
icon: '📧',
|
||||||
|
access: 'isGlobalCompany',
|
||||||
|
component: './email-services/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/oper-log',
|
||||||
|
name: 'operLog',
|
||||||
|
icon: '📜',
|
||||||
|
access: 'normalRouteFilter',
|
||||||
|
meta: {
|
||||||
|
permissions: ['oper:log:list'],
|
||||||
|
},
|
||||||
|
component: './oper-log/index',
|
||||||
|
},
|
||||||
|
];
|
||||||
171
deploy.js
Normal file
171
deploy.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
// --- 【配置区】 ---
|
||||||
|
const CONFIG = {
|
||||||
|
localProjectRoot: path.join(__dirname, '..'),
|
||||||
|
localReleasesDir: path.join(__dirname, '../releases'),
|
||||||
|
deployRemote: true,
|
||||||
|
remote: {
|
||||||
|
host: '1.2.3.4', // 服务器IP
|
||||||
|
user: 'root', // 用户名
|
||||||
|
path: '/www/wwwroot/my-app', // 服务器项目根目录
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 颜色工具
|
||||||
|
const log = {
|
||||||
|
info: (msg) => console.log(`\x1b[36m>>> ${msg}\x1b[0m`),
|
||||||
|
success: (msg) => console.log(`\x1b[32m✔ ${msg}\x1b[0m`),
|
||||||
|
warn: (msg) => console.log(`\x1b[33m!${msg}\x1b[0m`),
|
||||||
|
error: (msg) => console.log(`\x1b[31m✘ ${msg}\x1b[0m`),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
let version = '';
|
||||||
|
|
||||||
|
// 1. 获取 Git Tag
|
||||||
|
try {
|
||||||
|
version = execSync('git describe --tags --abbrev=0').toString().trim();
|
||||||
|
log.success(`读取到目标版本: ${version}`);
|
||||||
|
} catch (_e) {
|
||||||
|
log.error('无法获取 Git Tag。请确保本地有 tag (例如执行: git tag v1.0.0)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 执行编译
|
||||||
|
try {
|
||||||
|
log.info('开始执行构建 (npm run build)...');
|
||||||
|
execSync('npm run build', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, UMI_APP_SENTRY_RELEASE: version },
|
||||||
|
});
|
||||||
|
log.success('项目构建成功');
|
||||||
|
} catch (_e) {
|
||||||
|
log.error('项目构建失败,请检查代码语法或 Umi/Webpack 配置');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 上传 SourceMap
|
||||||
|
try {
|
||||||
|
log.info('正在尝试上传 SourceMap 到 GlitchTip...');
|
||||||
|
// 如果这里报错,通常不中断部署,因为可能是网络波动,以后可以补传
|
||||||
|
execSync(
|
||||||
|
`sentry-cli releases files "${version}" upload-sourcemaps ./dist --url-prefix "~/"`,
|
||||||
|
{ stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
log.success('SourceMap 上传成功');
|
||||||
|
} catch (_e) {
|
||||||
|
log.warn(
|
||||||
|
'SourceMap 上传失败!原因可能是:sentry-cli 未安装、环境变量未配置、.sentryclirc 文件未配置或网络不通。',
|
||||||
|
);
|
||||||
|
log.warn('您可以稍后手动补传,部署将继续进行...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 清理并本地归档
|
||||||
|
try {
|
||||||
|
log.info('正在清理.map 文件并执行本地归档...');
|
||||||
|
cleanSourceMaps(path.join(CONFIG.localProjectRoot, 'dist'));
|
||||||
|
deployToLocal(version);
|
||||||
|
log.success('本地归档与软链接更新完成');
|
||||||
|
} catch (_e) {
|
||||||
|
log.error(`本地归档失败: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 远程部署
|
||||||
|
if (CONFIG.deployRemote) {
|
||||||
|
try {
|
||||||
|
log.info(`准备向远程服务器部署: ${CONFIG.remote.host}...`);
|
||||||
|
deployToRemote(version);
|
||||||
|
log.success('远程部署任务全部完成');
|
||||||
|
} catch (_e) {
|
||||||
|
log.error(`远程部署失败!错误详情: ${e.message}`);
|
||||||
|
log.error(
|
||||||
|
'请检查:1.服务器IP/路径是否正确;2.是否配置了 SSH 免密登录;3.服务器磁盘空间是否充足。',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`\n🎉 所有任务执行完毕 [Version: ${version}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 逻辑封装函数 ---
|
||||||
|
|
||||||
|
function deployToLocal(version) {
|
||||||
|
const distDir = path.join(CONFIG.localProjectRoot, 'dist');
|
||||||
|
const targetDir = path.join(CONFIG.localReleasesDir, version);
|
||||||
|
const currentLink = path.join(CONFIG.localProjectRoot, 'current');
|
||||||
|
|
||||||
|
if (!fs.existsSync(CONFIG.localReleasesDir))
|
||||||
|
fs.mkdirSync(CONFIG.localReleasesDir);
|
||||||
|
if (fs.existsSync(targetDir))
|
||||||
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// 移动 dist 到 releases
|
||||||
|
fs.renameSync(distDir, targetDir);
|
||||||
|
|
||||||
|
// 更新本地软链接 (Junction)
|
||||||
|
if (fs.existsSync(currentLink)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(currentLink, { recursive: true, force: true });
|
||||||
|
} catch (_e) {
|
||||||
|
throw new Error(`无法删除旧的本地软链接: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
||||||
|
fs.symlinkSync(targetDir, currentLink, linkType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployToRemote(version) {
|
||||||
|
const { host, user, path: remotePath } = CONFIG.remote;
|
||||||
|
const localVersionDir = path.join(CONFIG.localReleasesDir, version);
|
||||||
|
const remoteVersionDir = `${remotePath}/releases/${version}`;
|
||||||
|
const remoteCurrentLink = `${remotePath}/current`;
|
||||||
|
|
||||||
|
// A. 在服务器创建目录
|
||||||
|
try {
|
||||||
|
execSync(`ssh ${user}@${host} "mkdir -p ${remotePath}/releases"`, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
} catch (_e) {
|
||||||
|
throw new Error(`无法连接服务器或创建目录: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. 使用 scp 上传文件夹
|
||||||
|
try {
|
||||||
|
log.info(` 正在上传至: ${remoteVersionDir} (请耐心等待...)`);
|
||||||
|
execSync(
|
||||||
|
`scp -r "${localVersionDir}" ${user}@${host}:${remotePath}/releases/`,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
} catch (_e) {
|
||||||
|
throw new Error(`文件传输失败 (SCP): ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 更新远程软链接
|
||||||
|
try {
|
||||||
|
const sshCmd = `ln -snf ${remoteVersionDir} ${remoteCurrentLink}`;
|
||||||
|
execSync(`ssh ${user}@${host} "${sshCmd}"`);
|
||||||
|
} catch (_e) {
|
||||||
|
throw new Error(`远程软链接更新失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSourceMaps(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
if (fs.statSync(fullPath).isDirectory()) {
|
||||||
|
cleanSourceMaps(fullPath);
|
||||||
|
} else if (file.endsWith('.map')) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动执行
|
||||||
|
start();
|
||||||
332
mock/user.ts
Normal file
332
mock/user.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
// import userInfo from './userInfo.json';
|
||||||
|
const waitTime = (time: number = 100) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(true);
|
||||||
|
}, time);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const userInfo = {}
|
||||||
|
|
||||||
|
const { ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION } = process.env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions of the current user. If empty, it means not logged in.
|
||||||
|
* current user access, if is '', user need login
|
||||||
|
* If it is a pro preview, it has permission by default.
|
||||||
|
*/
|
||||||
|
let access =
|
||||||
|
ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION === 'site' ? 'admin' : '';
|
||||||
|
|
||||||
|
const getAccess = () => {
|
||||||
|
return access;
|
||||||
|
};
|
||||||
|
|
||||||
|
const successResponse = (data: any = null) => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'ok',
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The code will be compatible with local service mocks and static data of the deployment site
|
||||||
|
export default {
|
||||||
|
// Supported values are Object and Array
|
||||||
|
'GET /api/auth/info': (_req: Request, res: Response) => {
|
||||||
|
const token = _req.headers.authorization?.split(' ')[1];
|
||||||
|
// res.status(401).send({
|
||||||
|
// data: {
|
||||||
|
// isLogin: false,
|
||||||
|
// },
|
||||||
|
// code: 401,
|
||||||
|
// message: 'Please log in first!',
|
||||||
|
// success: false,
|
||||||
|
// });
|
||||||
|
// return
|
||||||
|
if (!token) {
|
||||||
|
res.send({
|
||||||
|
data: {
|
||||||
|
isLogin: false,
|
||||||
|
},
|
||||||
|
code: 401,
|
||||||
|
message: 'Please log in first!',
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.send(successResponse(userInfo));
|
||||||
|
},
|
||||||
|
'POST /api/auth/login': async (req: Request, res: Response) => {
|
||||||
|
const { password, username, type } = req.body;
|
||||||
|
await waitTime(2000);
|
||||||
|
if (password === '123456' && username === 'superadmin') {
|
||||||
|
res.send(successResponse(userInfo));
|
||||||
|
access = 'admin';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password === '123456' && username === 'user') {
|
||||||
|
res.send({
|
||||||
|
"token-prefix": "Bearer",
|
||||||
|
"token": "user111111"
|
||||||
|
});
|
||||||
|
access = 'user';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
data: {
|
||||||
|
status: 'error',
|
||||||
|
type,
|
||||||
|
currentAuthority: 'guest',
|
||||||
|
},
|
||||||
|
code: 400,
|
||||||
|
message: 'Incorrect username or password!',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
access = 'guest';
|
||||||
|
},
|
||||||
|
'POST /api/login/outLogin': (_req: Request, res: Response) => {
|
||||||
|
access = '';
|
||||||
|
res.send(successResponse());
|
||||||
|
},
|
||||||
|
'GET /api/company/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{
|
||||||
|
id: "C001",
|
||||||
|
name: 'Alpha Broker',
|
||||||
|
email: 'admin@alpha.com',
|
||||||
|
sourceNum: 5,
|
||||||
|
userNum: 10,
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "C002",
|
||||||
|
name: 'Beta Trading',
|
||||||
|
email: 'admin@beta.com',
|
||||||
|
sourceNum: 3,
|
||||||
|
userNum: 5,
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "C003",
|
||||||
|
name: 'Gamma FXr',
|
||||||
|
email: 'admin@gamma.com',
|
||||||
|
sourceNum: 1,
|
||||||
|
userNum: 5,
|
||||||
|
status: 0,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/user/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{
|
||||||
|
id: "U001",
|
||||||
|
username: 'superadmin',
|
||||||
|
nameStr: 'Super Administrator',
|
||||||
|
email: 'sa@system.com',
|
||||||
|
role: 'Super Administrator',
|
||||||
|
companyName: 'Global',
|
||||||
|
dataSource: '-',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U002",
|
||||||
|
username: 'alpha_admin',
|
||||||
|
nameStr: 'Alpha Broker Administrator',
|
||||||
|
email: 'admin@alpha.com',
|
||||||
|
role: 'Company Administrator',
|
||||||
|
companyName: 'Alpha Broker',
|
||||||
|
dataSource: 'Alpha MT4, Alpha MT5',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U003",
|
||||||
|
username: 'alpha_user',
|
||||||
|
nameStr: 'Alpha Broker User',
|
||||||
|
email: 'user@alpha.com',
|
||||||
|
role: 'Company User',
|
||||||
|
companyName: 'Alpha Broker',
|
||||||
|
dataSource: 'Alpha MT4',
|
||||||
|
status: 0,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U004",
|
||||||
|
username: 'alpha_viewer',
|
||||||
|
nameStr: 'Alpha Broker Viewer',
|
||||||
|
email: 'viewer@alpha.com',
|
||||||
|
role: 'Company Viewer',
|
||||||
|
companyName: 'Alpha Broker',
|
||||||
|
dataSource: 'Alpha MT4, Alpha MT5',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U005",
|
||||||
|
username: 'beta_admin',
|
||||||
|
nameStr: 'Beta Trading Administrator',
|
||||||
|
email: 'admin@beta.com',
|
||||||
|
role: 'Company Administrator',
|
||||||
|
companyName: 'Beta Trading',
|
||||||
|
dataSource: 'Beta MT5',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "U006",
|
||||||
|
username: 'beta_viewer',
|
||||||
|
nameStr: 'Beta Trading Viewer',
|
||||||
|
email: 'viewer@beta.com',
|
||||||
|
role: 'Company Viewer',
|
||||||
|
companyName: 'Beta Trading',
|
||||||
|
dataSource: 'Beta MT5',
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/data-source/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{
|
||||||
|
id: "DS001",
|
||||||
|
name: 'Alpha MT4',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
platformType: '4',
|
||||||
|
companyName: 'Alpha Broker',
|
||||||
|
ruleNum: 11,
|
||||||
|
alertNum: 2,
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "DS002",
|
||||||
|
name: 'Alpha MT5',
|
||||||
|
ip: '192.168.1.101',
|
||||||
|
platformType: '5',
|
||||||
|
companyName: 'Alpha Broker',
|
||||||
|
ruleNum: 1,
|
||||||
|
alertNum: 1,
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "DS003",
|
||||||
|
name: 'Beta MT5',
|
||||||
|
ip: '192.168.1.102',
|
||||||
|
platformType: '5',
|
||||||
|
companyName: 'Beta Trading',
|
||||||
|
ruleNum: 8,
|
||||||
|
alertNum: 5,
|
||||||
|
status: 1,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "DS004",
|
||||||
|
name: 'Gamma MT4',
|
||||||
|
ip: '192.168.1.103',
|
||||||
|
platformType: '4',
|
||||||
|
companyName: 'Gamma FX',
|
||||||
|
ruleNum: 1,
|
||||||
|
alertNum: 0,
|
||||||
|
status: 0,
|
||||||
|
createTime: '2023-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/roles/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{ role_id: 'R001', role_key: 'super_admin', role_name: 'Super Administrator', color: 'danger', level: 100, is_system: true, permissions: ['view_dashboard', 'manage_rules', 'manage_products', 'view_alerts', 'manage_accounts', 'manage_companies', 'manage_datasources', 'manage_users', 'manage_roles', 'manage_settings'], created_at: '2023-01-01' },
|
||||||
|
{ role_id: 'R002', role_key: 'company_admin', role_name: 'Company Administrator', color: 'warning', level: 80, is_system: true, permissions: ['view_dashboard', 'manage_rules', 'manage_products', 'view_alerts', 'manage_accounts', 'manage_datasources', 'manage_users', 'manage_roles'], created_at: '2023-01-01' },
|
||||||
|
{ role_id: 'R003', role_key: 'company_user', role_name: 'Company User', color: 'info', level: 60, is_system: true, permissions: ['view_dashboard', 'manage_rules', 'manage_products', 'view_alerts'], created_at: '2023-01-01' },
|
||||||
|
{ role_id: 'R004', role_key: 'viewer', role_name: 'Company Viewer', color: 'secondary', level: 20, is_system: true, permissions: ['view_dashboard', 'view_alerts'], created_at: '2023-01-01' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/account/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{ account_id: 'ACC001', source_id: 'DS001', platform: 'MT4', account_currency: 'USD', balance: 125000, equity: 128500, margin_level: 450, status: 'active', risk_level: 'high', created_at: '2023-06-15', alert_count: 2 },
|
||||||
|
{ account_id: 'ACC002', source_id: 'DS002', platform: 'MT5', account_currency: 'EUR', balance: 85000, equity: 82300, margin_level: 320, status: 'active', risk_level: 'medium', created_at: '2023-08-20', alert_count: 0 },
|
||||||
|
{ account_id: 'ACC003', source_id: 'DS001', platform: 'MT4', account_currency: 'JPY', balance: 15000000, equity: 15250000, margin_level: 580, status: 'active', risk_level: 'high', created_at: '2023-04-10', alert_count: 1 },
|
||||||
|
{ account_id: 'ACC004', source_id: 'DS003', platform: 'MT5', account_currency: 'GBP', balance: 45000, equity: 44200, margin_level: 280, status: 'active', risk_level: 'low', created_at: '2023-11-05', alert_count: 1 },
|
||||||
|
{ account_id: 'ACC005', source_id: 'DS003', platform: 'MT5', account_currency: 'USD', balance: 520000, equity: 535000, margin_level: 620, status: 'active', risk_level: 'high', created_at: '2023-02-28', alert_count: 1 }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/alert/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{ alert_id: 'A001', source_id: 'DS001', rule_type: 'large_trade', account_id: 'ACC001', product: 'EURUSD', trigger_time: '2024-01-15 10:35:00', trigger_value: 150000, status: 'new', platform: 'MT4' },
|
||||||
|
{ alert_id: 'A002', source_id: 'DS001', rule_type: 'scalping', account_id: 'ACC003', product: 'USDJPY', trigger_time: '2024-01-15 12:00:45', trigger_value: 45, status: 'new', platform: 'MT4' },
|
||||||
|
{ alert_id: 'A003', source_id: 'DS002', rule_type: 'large_trade', account_id: 'ACC001', product: 'XAUUSD', trigger_time: '2024-01-15 14:15:00', trigger_value: 180000, status: 'reviewed', platform: 'MT5' },
|
||||||
|
{ alert_id: 'A004', source_id: 'DS003', rule_type: 'large_trade', account_id: 'ACC005', product: 'BTCUSD', trigger_time: '2024-01-15 15:30:00', trigger_value: 320000, status: 'new', platform: 'MT5' },
|
||||||
|
{ alert_id: 'A005', source_id: 'DS003', rule_type: 'scalping', account_id: 'ACC004', product: 'EURUSD', trigger_time: '2024-01-15 14:00:25', trigger_value: 25, status: 'new', platform: 'MT5' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/product/list': (_req: Request, res: Response) => {
|
||||||
|
res.send(
|
||||||
|
successResponse([
|
||||||
|
{ id: 1, source_id: 'DS001', platform: 'MT4', raw_product_name: 'EURUSD', unified_product_code: 'EURUSD', product_category: 'Forex Major', enabled: true },
|
||||||
|
{ id: 2, source_id: 'DS002', platform: 'MT5', raw_product_name: 'EURUSD.', unified_product_code: 'EURUSD', product_category: 'Forex Major', enabled: true },
|
||||||
|
{ id: 3, source_id: 'DS001', platform: 'MT4', raw_product_name: 'XAUUSD', unified_product_code: 'XAUUSD', product_category: 'Commodities', enabled: true },
|
||||||
|
{ id: 4, source_id: 'DS002', platform: 'MT5', raw_product_name: 'GOLD', unified_product_code: 'XAUUSD', product_category: 'Commodities', enabled: true },
|
||||||
|
{ id: 5, source_id: 'DS003', platform: 'MT5', raw_product_name: 'EURUSD.', unified_product_code: 'EURUSD', product_category: 'Forex Major', enabled: true },
|
||||||
|
{ id: 6, source_id: 'DS003', platform: 'MT5', raw_product_name: 'BTCUSD', unified_product_code: 'BTCUSD', product_category: 'Crypto', enabled: true }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'GET /api/500': (_req: Request, res: Response) => {
|
||||||
|
res.status(500).send({
|
||||||
|
timestamp: 1513932555104,
|
||||||
|
status: 500,
|
||||||
|
error: 'error',
|
||||||
|
message: 'error',
|
||||||
|
path: '/base/category/list',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'GET /api/404': (_req: Request, res: Response) => {
|
||||||
|
res.status(404).send({
|
||||||
|
timestamp: 1513932643431,
|
||||||
|
status: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'No message available',
|
||||||
|
path: '/base/category/list/2121212',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'GET /api/403': (_req: Request, res: Response) => {
|
||||||
|
res.status(403).send({
|
||||||
|
timestamp: 1513932555104,
|
||||||
|
status: 403,
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Forbidden',
|
||||||
|
path: '/base/category/list',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'GET /api/401': (_req: Request, res: Response) => {
|
||||||
|
res.status(401).send({
|
||||||
|
timestamp: 1513932555104,
|
||||||
|
status: 401,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
path: '/base/category/list',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
78
package.json
Normal file
78
package.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"name": "risk-guard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "",
|
||||||
|
"repository": "",
|
||||||
|
"scripts": {
|
||||||
|
"analyze": "cross-env ANALYZE=1 max build",
|
||||||
|
"build": "max build",
|
||||||
|
"dev": "npm run start",
|
||||||
|
"dev:mock": "npm run start:mock",
|
||||||
|
"postinstall": "max setup",
|
||||||
|
"lint": "npm run biome:lint && npm run tsc",
|
||||||
|
"lint-staged": "lint-staged",
|
||||||
|
"biome:lint": "npx @biomejs/biome lint",
|
||||||
|
"prepare": "husky",
|
||||||
|
"preview": "max preview --port 8000",
|
||||||
|
"start": "cross-env UMI_ENV=dev MOCK=none max dev",
|
||||||
|
"start:mock": "cross-env UMI_ENV=dev max dev",
|
||||||
|
"tsc": "tsc --noEmit",
|
||||||
|
"dpl": "node deploy.js"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"defaults"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@ant-design/plots": "^2.6.8",
|
||||||
|
"@ant-design/pro-components": "^2.7.19",
|
||||||
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
|
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
"@sentry/react": "^10.42.0",
|
||||||
|
"antd": "^5.25.4",
|
||||||
|
"antd-style": "^3.7.0",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"localforage": "^1.10.0",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"query-string": "^9.3.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ant-design/pro-cli": "^3.3.0",
|
||||||
|
"@biomejs/biome": "^2.0.6",
|
||||||
|
"@commitlint/cli": "^19.5.0",
|
||||||
|
"@commitlint/config-conventional": "^19.5.0",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^24.0.10",
|
||||||
|
"@types/react": "^19.1.5",
|
||||||
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"@types/react-helmet": "^6.1.11",
|
||||||
|
"@umijs/max": "^4.3.24",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.1.2",
|
||||||
|
"mockjs": "^1.1.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"umi-presets-pro": "^2.0.3",
|
||||||
|
"umi-serve": "^1.9.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"commitlint": {
|
||||||
|
"extends": [
|
||||||
|
"@commitlint/config-conventional"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"**/*.{js,jsx,tsx,ts,md,css,less,json}": [
|
||||||
|
"pnpm exec biome check --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26638
pnpm-lock.yaml
generated
Normal file
26638
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
1
public/logo.svg
Normal file
1
public/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1766318220539" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7296" width="200" height="200" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M581.818182 572.509091m-328.610909 0a328.610909 328.610909 0 1 0 657.221818 0 328.610909 328.610909 0 1 0-657.221818 0Z" fill="#FB8339" p-id="7297"></path><path d="M899.258182 470.109091c19.549091 0 35.374545-15.825455 35.374545-35.374546V158.254545c0-15.825455-11.170909-29.789091-26.996363-33.512727-1.861818-0.930909-3.723636-1.861818-6.516364-2.792727l-380.741818-83.781818c-3.723636-0.930909-6.516364-0.930909-9.309091 0h-13.032727l-374.225455 83.781818c-2.792727 0.930909-4.654545 1.861818-6.516364 2.792727-15.825455 3.723636-26.996364 17.687273-26.996363 33.512727v393.774546c0 2.792727 0.930909 4.654545 0.930909 5.585454 11.170909 243.898182 216.901818 400.290909 415.185454 429.149091h10.24c177.803636-25.134545 383.534545-159.185455 413.323637-391.912727 2.792727-19.549091-11.170909-37.236364-30.72-39.098182-9.309091-0.930909-18.618182 0.930909-26.065455 7.447273S861.090909 577.163636 860.16 586.472727c-25.134545 194.56-197.352727 307.2-349.090909 330.472728-175.010909-26.996364-351.883636-165.701818-351.883636-378.88V185.250909l349.090909-78.196364 354.676363 78.196364v249.483636c0.930909 19.549091 16.756364 35.374545 36.305455 35.374546z" p-id="7298"></path><path d="M692.596364 413.323636c-5.585455-81.92-57.716364-124.741818-156.392728-127.534545H336.058182V716.8h80.989091V545.512727h80.058182L623.709091 715.869091h104.261818L585.541818 538.065455c70.749091-13.032727 107.054545-54.923636 107.054546-124.741819zM417.047273 353.745455h105.192727c76.334545 0 84.712727 34.443636 84.712727 61.44 0.930909 17.687273-3.723636 30.72-13.032727 40.02909-13.032727 13.963636-37.236364 20.48-71.68 20.48H417.047273V353.745455z" p-id="7299"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
18
public/manifest.json
Normal file
18
public/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "RiskGuard",
|
||||||
|
"short_name": "RiskGuard",
|
||||||
|
"display": "standalone",
|
||||||
|
"start_url": "./?utm_source=homescreen",
|
||||||
|
"theme_color": "#002140",
|
||||||
|
"background_color": "#001529",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/pdf/en_US/Lark_push_configuration.pdf
Normal file
BIN
public/pdf/en_US/Lark_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/en_US/Slack_push_configuration.pdf
Normal file
BIN
public/pdf/en_US/Slack_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/en_US/Teams_push_configuration.pdf
Normal file
BIN
public/pdf/en_US/Teams_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/en_US/Telegram_push_configuration.pdf
Normal file
BIN
public/pdf/en_US/Telegram_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/zh_CN/Lark_push_configuration.pdf
Normal file
BIN
public/pdf/zh_CN/Lark_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/zh_CN/Slack_push_configuration.pdf
Normal file
BIN
public/pdf/zh_CN/Slack_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/zh_CN/Teams_push_configuration.pdf
Normal file
BIN
public/pdf/zh_CN/Teams_push_configuration.pdf
Normal file
Binary file not shown.
BIN
public/pdf/zh_CN/Telegram_push_configuration.pdf
Normal file
BIN
public/pdf/zh_CN/Telegram_push_configuration.pdf
Normal file
Binary file not shown.
208
public/scripts/loading.js
Normal file
208
public/scripts/loading.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* loading placeholder
|
||||||
|
* Solve the problem that the white background will be displayed first when refreshing the page under the dark theme
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
const _root = document.querySelector('#root');
|
||||||
|
const theme = localStorage.getItem('navTheme');
|
||||||
|
|
||||||
|
// Solve the problem that the white background will be displayed first when refreshing the page under the dark theme
|
||||||
|
try {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if(theme === 'realDark') {
|
||||||
|
html.dataset.theme = 'dark';
|
||||||
|
}else {
|
||||||
|
html.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
if (_root && _root.innerHTML === '') {
|
||||||
|
_root.innerHTML = `
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-sub-title {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading-warp {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 26px;
|
||||||
|
}
|
||||||
|
.ant-spin {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant: tabular-nums;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
list-style: none;
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: -webkit-transform 0.3s
|
||||||
|
cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
transition: -webkit-transform 0.3s
|
||||||
|
cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
|
||||||
|
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
-webkit-font-feature-settings: "tnum";
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-spinning {
|
||||||
|
position: static;
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background-color: #1890ff;
|
||||||
|
border-radius: 100%;
|
||||||
|
-webkit-transform: scale(0.75);
|
||||||
|
-ms-transform: scale(0.75);
|
||||||
|
transform: scale(0.75);
|
||||||
|
-webkit-transform-origin: 50% 50%;
|
||||||
|
-ms-transform-origin: 50% 50%;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
opacity: 0.3;
|
||||||
|
-webkit-animation: antspinmove 1s infinite linear alternate;
|
||||||
|
animation: antSpinMove 1s infinite linear alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(1) {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(2) {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
-webkit-animation-delay: 0.4s;
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(3) {
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
-webkit-animation-delay: 0.8s;
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(4) {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
-webkit-animation-delay: 1.2s;
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-spin {
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
-webkit-animation: antrotate 1.2s infinite linear;
|
||||||
|
animation: antRotate 1.2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-lg .ant-spin-dot {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-lg .ant-spin-dot i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.ant-spin-blur {
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes antSpinMove {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antSpinMove {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes antRotate {
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(405deg);
|
||||||
|
transform: rotate(405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antRotate {
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(405deg);
|
||||||
|
transform: rotate(405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div style="
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 362px;
|
||||||
|
">
|
||||||
|
<div class="page-loading-warp">
|
||||||
|
<div class="ant-spin ant-spin-lg ant-spin-spinning">
|
||||||
|
<span class="ant-spin-dot ant-spin-dot-spin">
|
||||||
|
<i class="ant-spin-dot-item"></i>
|
||||||
|
<i class="ant-spin-dot-item"></i>
|
||||||
|
<i class="ant-spin-dot-item"></i>
|
||||||
|
<i class="ant-spin-dot-item"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
BIN
public/sounds/notification.mp3
Normal file
BIN
public/sounds/notification.mp3
Normal file
Binary file not shown.
101
src/access.ts
Normal file
101
src/access.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { history as umiHistory } from '@umijs/max';
|
||||||
|
import { outLogin } from './services/api';
|
||||||
|
import { matchPermission } from './utils/permission';
|
||||||
|
import { storage } from './utils/storage';
|
||||||
|
/**
|
||||||
|
* @see https://umijs.org/docs/max/access#access
|
||||||
|
* */
|
||||||
|
export const SUPER_ADMIN_ROLE = 'ROLE_SUPER_ADMIN';
|
||||||
|
export const COMPANY_ADMIN_ROLE = 'ROLE_COMPANY_ADMIN';
|
||||||
|
export default function access({
|
||||||
|
currentUser,
|
||||||
|
}: {
|
||||||
|
currentUser?: API.CurrentUser | undefined;
|
||||||
|
}) {
|
||||||
|
const roleList = currentUser?.roleList?.map((item) => item.mark) ?? [];
|
||||||
|
const permissionList = currentUser?.permissionList ?? [];
|
||||||
|
const userCompanyInfo = currentUser?.company;
|
||||||
|
|
||||||
|
const hasPerms = (perm: string) => {
|
||||||
|
return matchPermission(permissionList, perm);
|
||||||
|
};
|
||||||
|
const normalRouteFilter = (route: any) => {
|
||||||
|
const permissions = route?.meta?.permissions;
|
||||||
|
return hasPerms(permissions);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Is it the ID of the super administrator?
|
||||||
|
* The default usage is to use useModel('@@initialState') in the page and then get initialState.currentUser and then judge. It is too troublesome.
|
||||||
|
* Determine it directly in access. It is most convenient to obtain it in useAccess on the page.
|
||||||
|
*/
|
||||||
|
isSuperAdmin: roleList.includes(SUPER_ADMIN_ROLE),
|
||||||
|
// isGlobalCompany: userCompanyInfo?.special === 0,
|
||||||
|
isGlobalCompany:
|
||||||
|
currentUser?.companyId === 0 || userCompanyInfo?.special === 0,
|
||||||
|
|
||||||
|
isCompanyAdmin: roleList.includes(COMPANY_ADMIN_ROLE),
|
||||||
|
companyMailStatus: userCompanyInfo?.companyMailHostConfig?.mailStatus,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID of the current user
|
||||||
|
* The reason is the same as above
|
||||||
|
*/
|
||||||
|
userId: currentUser?.userId,
|
||||||
|
|
||||||
|
hasPerms,
|
||||||
|
normalRouteFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'access_token';
|
||||||
|
export const LOGIN_URL = '/login';
|
||||||
|
|
||||||
|
// Set token
|
||||||
|
export async function setAccessToken(access_token: string) {
|
||||||
|
storage.set(TOKEN_KEY, access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get token
|
||||||
|
export async function getAccessToken() {
|
||||||
|
return storage.get(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear token
|
||||||
|
export async function clearAccessToken() {
|
||||||
|
storage.remove(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication failed to clear token and jump to login page
|
||||||
|
export async function authFaiedToLoginPage() {
|
||||||
|
await clearAccessToken();
|
||||||
|
toLoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log out and jump to login page
|
||||||
|
export async function logoutAndToLoginPage() {
|
||||||
|
await outLogin();
|
||||||
|
await clearAccessToken();
|
||||||
|
toLoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login page with parameters and current path
|
||||||
|
export function toLoginPage() {
|
||||||
|
const { search, pathname } = window.location;
|
||||||
|
const urlParams = new URL(window.location.href).searchParams;
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
redirect: pathname + search,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* If the current path is not the login page and the redirect parameter is not empty,
|
||||||
|
* redirect to the redirect parameter location
|
||||||
|
*/
|
||||||
|
const redirect = urlParams.get('redirect');
|
||||||
|
|
||||||
|
if (!pathname.includes(LOGIN_URL) && !redirect) {
|
||||||
|
umiHistory.replace({
|
||||||
|
pathname: LOGIN_URL,
|
||||||
|
search: searchParams.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/app.tsx
Normal file
218
src/app.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
|
||||||
|
import { PageLoading } from '@ant-design/pro-components';
|
||||||
|
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
|
||||||
|
import { history, Link, useModel } from '@umijs/max';
|
||||||
|
import '@ant-design/v5-patch-for-react-19';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import { AvatarDropdown, AvatarName, SelectLang } from '@/components';
|
||||||
|
import { currentUser as queryCurrentUser } from '@/services/api';
|
||||||
|
import defaultSettings from '../config/defaultSettings';
|
||||||
|
import { getAccessToken, LOGIN_URL, toLoginPage } from './access';
|
||||||
|
import AlertCountInMenuItem from './components/AlertCountInMenuItem';
|
||||||
|
import ChangePassTips from './components/NeedChangePassTips';
|
||||||
|
// import ThemeSwitch from './components/RightContent/ThemeSwitch';
|
||||||
|
import WebNoticeStatus from './components/WebNoticeStatus';
|
||||||
|
import NoFoundPage from './pages/404';
|
||||||
|
import ServerErrorPage from './pages/500';
|
||||||
|
import { errorConfig } from './requestErrorConfig';
|
||||||
|
import { getDeviceId } from './utils/fp';
|
||||||
|
import './utils/sentry';
|
||||||
|
import GlobalErrorBoundary from './components/GlobalErrorBoundary';
|
||||||
|
import SSEController from './components/SSEController';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||||
|
* */
|
||||||
|
export async function getInitialState(): Promise<{
|
||||||
|
settings?: Partial<LayoutSettings>;
|
||||||
|
currentUser?: API.CurrentUser | undefined;
|
||||||
|
loading?: boolean;
|
||||||
|
fetchError?: boolean;
|
||||||
|
fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
|
||||||
|
}> {
|
||||||
|
// Modify whether to run in dark mode
|
||||||
|
const localTheme = (localStorage.getItem('navTheme') as NavTheme) || 'light';
|
||||||
|
if (localTheme !== defaultSettings.navTheme) {
|
||||||
|
defaultSettings.navTheme = localTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnObj = {
|
||||||
|
settings: defaultSettings as Partial<LayoutSettings>,
|
||||||
|
currentUser: undefined as API.CurrentUser | undefined,
|
||||||
|
fetchError: false,
|
||||||
|
fetchUserInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
const { code, data: info } =
|
||||||
|
(await queryCurrentUser()) as API.BaseResponse<API.CurrentUser>;
|
||||||
|
if (code !== 200) {
|
||||||
|
throw new Error('Failed to fetch current user info');
|
||||||
|
}
|
||||||
|
return info?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
toLoginPage();
|
||||||
|
return returnObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await fetchUserInfo().catch((error) => {
|
||||||
|
returnObj.fetchError = true;
|
||||||
|
console.log('Failed to fetch current user info', error);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
returnObj.currentUser = currentUser as API.CurrentUser | undefined;
|
||||||
|
return returnObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(oldRender: () => void) {
|
||||||
|
// Initialize FingerprintJS
|
||||||
|
try {
|
||||||
|
await getDeviceId();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to initialize FingerprintJS', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const { pathname } = window.location; // Note that the history may not be fully initialized at this point, so it is recommended to use window.location
|
||||||
|
// If already logged in and trying to access the login page, redirect to the home page
|
||||||
|
if (token && pathname === LOGIN_URL) {
|
||||||
|
history.push('/');
|
||||||
|
}
|
||||||
|
// If not logged in and not on the login page (and not a redirect/registration/etc. public page)
|
||||||
|
else if (!token && pathname !== LOGIN_URL) {
|
||||||
|
toLoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the original render logic
|
||||||
|
oldRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a root container function
|
||||||
|
export function rootContainer(container: React.ReactNode) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/** biome-ignore lint/a11y/useMediaCaption: <ignore> */}
|
||||||
|
<audio src="/sounds/notification.mp3" id="notificationAudio" />
|
||||||
|
<App>{container}</App>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProLayout support API https://procomponents.ant.design/components/layout
|
||||||
|
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||||
|
const { fetchUnread, initialized } = useModel('useMenuNoticeNum');
|
||||||
|
|
||||||
|
const isDark = initialState?.settings?.navTheme === 'realDark';
|
||||||
|
const currentUser = initialState?.currentUser;
|
||||||
|
const userConfig = currentUser?.userConfig;
|
||||||
|
const needChangePass = currentUser?.editPassword === 0;
|
||||||
|
const themeToken = isDark
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
sider: {
|
||||||
|
colorMenuBackground: '#fff',
|
||||||
|
},
|
||||||
|
// header: {
|
||||||
|
// colorBgHeader: '#fff',
|
||||||
|
// },
|
||||||
|
bgLayout: '#f8fafc',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentUser && !initialized) {
|
||||||
|
fetchUnread();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unAccessible: initialState?.fetchError ? (
|
||||||
|
<ServerErrorPage />
|
||||||
|
) : (
|
||||||
|
<NoFoundPage />
|
||||||
|
),
|
||||||
|
token: {
|
||||||
|
...themeToken,
|
||||||
|
},
|
||||||
|
actionsRender: (_layoutProps) => {
|
||||||
|
return [
|
||||||
|
needChangePass && <ChangePassTips key="ChangePassTips" />,
|
||||||
|
// <ThemeSwitch
|
||||||
|
// isMobile={layoutProps.isMobile}
|
||||||
|
// collapsed={layoutProps.collapsed}
|
||||||
|
// navTheme={layoutProps.navTheme}
|
||||||
|
// key="ThemeSwitch"
|
||||||
|
// />,
|
||||||
|
<SelectLang key="SelectLang" />,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
avatarProps: {
|
||||||
|
src: null,
|
||||||
|
title: <AvatarName />,
|
||||||
|
render: (_, avatarChildren) => {
|
||||||
|
return <AvatarDropdown>{avatarChildren}</AvatarDropdown>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
menuFooterRender: (menuProps) => {
|
||||||
|
return userConfig?.desktopPush ? (
|
||||||
|
<WebNoticeStatus collapsed={!!menuProps?.collapsed} />
|
||||||
|
) : null;
|
||||||
|
},
|
||||||
|
menuItemRender: (menuItemProps, defaultDom) => {
|
||||||
|
if (menuItemProps.path === '/alert') {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={menuItemProps.path}
|
||||||
|
className="flex align-center justify-between"
|
||||||
|
>
|
||||||
|
{defaultDom}
|
||||||
|
<AlertCountInMenuItem />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default return (need to wrap Link to ensure normal jump)
|
||||||
|
if (menuItemProps.isUrl || !menuItemProps.path) {
|
||||||
|
return defaultDom;
|
||||||
|
}
|
||||||
|
return <Link to={menuItemProps.path}>{defaultDom}</Link>;
|
||||||
|
},
|
||||||
|
onPageChange: async (location) => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
// 1. If there is a token and it is on the login page, jump to the home page directly
|
||||||
|
if (token && location?.pathname === LOGIN_URL) {
|
||||||
|
history.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If not logged in and not on the login page (and not a redirect/registration/etc. public page), jump to the login page
|
||||||
|
if (!token && location?.pathname !== LOGIN_URL) {
|
||||||
|
toLoginPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Add a loading state
|
||||||
|
childrenRender: (children) => {
|
||||||
|
if (initialState?.loading) return <PageLoading />;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentUser && <SSEController />}
|
||||||
|
|
||||||
|
<GlobalErrorBoundary>{children}</GlobalErrorBoundary>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// ErrorBoundary: false,
|
||||||
|
...initialState?.settings,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name request Configuration, you can configure error handling
|
||||||
|
* It provides a unified network request and error handling solution based on useRequest of axios and ahooks.
|
||||||
|
* @doc https://umijs.org/docs/max/request#Configuration
|
||||||
|
*/
|
||||||
|
export const request: RequestConfig = {
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 25000,
|
||||||
|
...errorConfig,
|
||||||
|
};
|
||||||
BIN
src/assets/imgs/login_bg.jpg
Normal file
BIN
src/assets/imgs/login_bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
src/assets/imgs/unnamed.png
Normal file
BIN
src/assets/imgs/unnamed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
348
src/assets/normalize.css
vendored
Normal file
348
src/assets/normalize.css
vendored
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||||
|
|
||||||
|
/* Document
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the line height in all browsers.
|
||||||
|
* 2. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html {
|
||||||
|
line-height: 1.15; /* 1 */
|
||||||
|
-webkit-text-size-adjust: 100%; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the margin in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the `main` element consistently in IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the font size and margin on `h1` elements within `section` and
|
||||||
|
* `article` contexts in Chrome, Firefox, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0.67em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grouping content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Add the correct box sizing in Firefox.
|
||||||
|
* 2. Show the overflow in Edge and IE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box; /* 1 */
|
||||||
|
height: 0; /* 1 */
|
||||||
|
overflow: visible; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||||
|
* 2. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: monospace; /* 1 */
|
||||||
|
font-size: 1em; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text-level semantics
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the gray background on active links in IE 10.
|
||||||
|
*/
|
||||||
|
|
||||||
|
a {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Remove the bottom border in Chrome 57-
|
||||||
|
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
border-bottom: none; /* 1 */
|
||||||
|
text-decoration: underline; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||||
|
* 2. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: monospace; /* 1 */
|
||||||
|
font-size: 1em; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||||
|
* all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Embedded content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the border on images inside links in IE 10.
|
||||||
|
*/
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Change the font styles in all browsers.
|
||||||
|
* 2. Remove the margin in Firefox and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit; /* 1 */
|
||||||
|
font-size: 100%; /* 1 */
|
||||||
|
line-height: 1.15; /* 1 */
|
||||||
|
margin: 0; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the overflow in IE.
|
||||||
|
* 1. Show the overflow in Edge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input { /* 1 */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||||
|
* 1. Remove the inheritance of text transform in Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
select { /* 1 */
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type="button"],
|
||||||
|
[type="reset"],
|
||||||
|
[type="submit"] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inner border and padding in Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button::-moz-focus-inner,
|
||||||
|
[type="button"]::-moz-focus-inner,
|
||||||
|
[type="reset"]::-moz-focus-inner,
|
||||||
|
[type="submit"]::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the focus styles unset by the previous rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button:-moz-focusring,
|
||||||
|
[type="button"]:-moz-focusring,
|
||||||
|
[type="reset"]:-moz-focusring,
|
||||||
|
[type="submit"]:-moz-focusring {
|
||||||
|
outline: 1px dotted ButtonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the padding in Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
padding: 0.35em 0.75em 0.625em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the text wrapping in Edge and IE.
|
||||||
|
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||||
|
* 3. Remove the padding so developers are not caught out when they zero out
|
||||||
|
* `fieldset` elements in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
legend {
|
||||||
|
box-sizing: border-box; /* 1 */
|
||||||
|
color: inherit; /* 2 */
|
||||||
|
display: table; /* 1 */
|
||||||
|
max-width: 100%; /* 1 */
|
||||||
|
padding: 0; /* 3 */
|
||||||
|
white-space: normal; /* 1 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the default vertical scrollbar in IE 10+.
|
||||||
|
*/
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Add the correct box sizing in IE 10.
|
||||||
|
* 2. Remove the padding in IE 10.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="checkbox"],
|
||||||
|
[type="radio"] {
|
||||||
|
box-sizing: border-box; /* 1 */
|
||||||
|
padding: 0; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="number"]::-webkit-inner-spin-button,
|
||||||
|
[type="number"]::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
* 2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="search"] {
|
||||||
|
-webkit-appearance: textfield; /* 1 */
|
||||||
|
outline-offset: -2px; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inner padding in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type="search"]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
* 2. Change font properties to `inherit` in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button; /* 1 */
|
||||||
|
font: inherit; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interactive
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the correct display in Edge, IE 10+, and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
details {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the correct display in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Misc
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 10+.
|
||||||
|
*/
|
||||||
|
|
||||||
|
template {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct display in IE 10.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
11
src/components/AlertCountInMenuItem/index.tsx
Normal file
11
src/components/AlertCountInMenuItem/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Badge } from 'antd';
|
||||||
|
// This component is used to display the unread alert count in the menu item.
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
const AlertCountInMenuItem = memo(() => {
|
||||||
|
const { unreadCount } = useModel('useMenuNoticeNum');
|
||||||
|
return <Badge count={unreadCount} overflowCount={9999} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AlertCountInMenuItem;
|
||||||
83
src/components/AsyncSwitch/index.tsx
Normal file
83
src/components/AsyncSwitch/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Switch, type SwitchProps } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface AsyncSwitchProps extends Omit<SwitchProps, 'onChange'> {
|
||||||
|
// Execute the function asynchronously. If successful, the state will be switched. If failed, the state will remain unchanged.
|
||||||
|
onAsyncChange: (checked: boolean) => Promise<any>;
|
||||||
|
// Standard onChange, automatically injected in Form mode
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
// Form.Item binds the value attribute to value by default, and the value attribute of Switch is checked. You can modify the bound value property through valuePropName.
|
||||||
|
/**
|
||||||
|
<Form.Item name="fieldA" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
*/
|
||||||
|
value?: boolean;
|
||||||
|
defaultChecked?: boolean;
|
||||||
|
// Successfully/unsuccessfully hook
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (err: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsyncSwitch: React.FC<AsyncSwitchProps> = (props) => {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
defaultChecked,
|
||||||
|
onChange,
|
||||||
|
onAsyncChange,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
// 1. Internal maintenance status, the initial value is checked first, followed by defaultChecked
|
||||||
|
const [innerChecked, setInnerChecked] = useState<boolean>(
|
||||||
|
!!(value ?? defaultChecked),
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 2. When the external checked changes (such as Form reset or external manual modification), synchronize the internal state
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== void 0) {
|
||||||
|
setInnerChecked(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = async (val: boolean) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Execute asynchronous logic
|
||||||
|
await onAsyncChange(val);
|
||||||
|
|
||||||
|
// Processing after success:
|
||||||
|
// If checked is not passed externally (uncontrolled mode), the internal state is updated
|
||||||
|
if (value === void 0) {
|
||||||
|
setInnerChecked(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger external onChange (if it is Form, Form takes over the status update)
|
||||||
|
onChange?.(val);
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('AsyncSwitch Error:', error);
|
||||||
|
onError?.(error);
|
||||||
|
// Failure does nothing and leaves the Switch as is
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
{...rest}
|
||||||
|
loading={loading}
|
||||||
|
// Always use innerChecked in preference as it is the final representation after synchronizing with the outside
|
||||||
|
checked={innerChecked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AsyncSwitch;
|
||||||
57
src/components/GlobalErrorBoundary/index.tsx
Normal file
57
src/components/GlobalErrorBoundary/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
type CustomErrorComponentProps = { error: Error };
|
||||||
|
const CustomErrorComponent = ({ error }: CustomErrorComponentProps) => {
|
||||||
|
const isChunkLoadFailed = error?.message?.includes('Loading chunk');
|
||||||
|
console.dir(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '100px 0', textAlign: 'center' }}>
|
||||||
|
<Result
|
||||||
|
status="500"
|
||||||
|
title={$t('pages.error.title')}
|
||||||
|
subTitle={
|
||||||
|
isChunkLoadFailed
|
||||||
|
? $t('pages.error.subTitle')
|
||||||
|
: $t('pages.error.subTitle2')
|
||||||
|
}
|
||||||
|
extra={[
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="refresh"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
{$t('pages.refresh')}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<pre style={{ marginTop: 24, color: isDev ? '#ccc' : 'transparent' }}>
|
||||||
|
{`${error.name}: ${error.message}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorBoundaryProps = { children: React.ReactNode };
|
||||||
|
type ErrorBoundaryState = { hasError: boolean; error: Error };
|
||||||
|
|
||||||
|
class GlobalErrorBoundary extends React.Component<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state = { hasError: false, error: new Error('') };
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <CustomErrorComponent error={this.state.error} />;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalErrorBoundary;
|
||||||
49
src/components/HeadStatisticCard/index.tsx
Normal file
49
src/components/HeadStatisticCard/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Card } from 'antd';
|
||||||
|
|
||||||
|
export type CardInfo = {
|
||||||
|
count: number;
|
||||||
|
icon: string;
|
||||||
|
iconColor: string;
|
||||||
|
iconBgColor: string;
|
||||||
|
leftColor: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
type Props = {
|
||||||
|
cardInfo: CardInfo;
|
||||||
|
};
|
||||||
|
export default ({ cardInfo }: Props) => (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: cardInfo.leftColor,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftStyle: 'solid',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: cardInfo.iconColor,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex align-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: cardInfo.iconBgColor,
|
||||||
|
// padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardInfo.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 34, fontWeight: 'bold' }}>{cardInfo.count}</div>
|
||||||
|
<div>{cardInfo.description}</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
41
src/components/HeaderDropdown/index.tsx
Normal file
41
src/components/HeaderDropdown/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import type { DropDownProps } from 'antd/es/dropdown';
|
||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ token }) => {
|
||||||
|
return {
|
||||||
|
dropdown: {
|
||||||
|
[`@media screen and (max-width: ${token.screenXS}px)`]: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HeaderDropdownProps = {
|
||||||
|
overlayClassName?: string;
|
||||||
|
placement?:
|
||||||
|
| 'bottomLeft'
|
||||||
|
| 'bottomRight'
|
||||||
|
| 'topLeft'
|
||||||
|
| 'topCenter'
|
||||||
|
| 'topRight'
|
||||||
|
| 'bottomCenter';
|
||||||
|
} & Omit<DropDownProps, 'overlay'>;
|
||||||
|
|
||||||
|
const HeaderDropdown: React.FC<HeaderDropdownProps> = ({
|
||||||
|
overlayClassName: cls,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
const { styles } = useStyles();
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
overlayClassName={classNames(styles.dropdown, cls)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderDropdown;
|
||||||
34
src/components/MyTag/index.tsx
Normal file
34
src/components/MyTag/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Tag, type TagProps } from 'antd';
|
||||||
|
import { hexToRgba } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tag component
|
||||||
|
* @param color - Tag color
|
||||||
|
* @param bordered - Whether to display a border
|
||||||
|
* @param round - Whether to display a rounded tag
|
||||||
|
* @param children - Tag content
|
||||||
|
* @param restProps - Other props
|
||||||
|
*/
|
||||||
|
export default ({
|
||||||
|
color = '#3b82f6',
|
||||||
|
bordered = false,
|
||||||
|
round = true,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: TagProps & { round?: boolean }) => (
|
||||||
|
<>
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
borderRadius: round ? '12px' : 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
color,
|
||||||
|
padding: '2px 10px',
|
||||||
|
backgroundColor: hexToRgba(color as string, 0.25),
|
||||||
|
}}
|
||||||
|
bordered={bordered}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
</>
|
||||||
|
);
|
||||||
76
src/components/NeedChangePassTips/index.tsx
Normal file
76
src/components/NeedChangePassTips/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { BellOutlined } from '@ant-design/icons';
|
||||||
|
import { history, Link } from '@umijs/max';
|
||||||
|
import { Alert, Badge, Modal, Popover } from 'antd';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const DEFAULT_DELAY_TIME = 6 * 60 * 60 * 1000;
|
||||||
|
const ChangePassTips: React.FC = () => {
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
const editPassword = userInfo?.editPassword;
|
||||||
|
const changePassTimestamp = localStorage.getItem('changePassTimestamp');
|
||||||
|
const isProfilePage = window.location.pathname.indexOf('/profile') !== -1;
|
||||||
|
const isDelayOutTime = changePassTimestamp
|
||||||
|
? Date.now() - Number(changePassTimestamp) > 0
|
||||||
|
: false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editPassword === 0 && (!changePassTimestamp || isDelayOutTime)) {
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
}, [editPassword, changePassTimestamp, isDelayOutTime]);
|
||||||
|
|
||||||
|
const showModal = useCallback(() => {
|
||||||
|
modal.confirm({
|
||||||
|
title: $t('pages.changePassTips'),
|
||||||
|
okText: $t('pages.profile.changePassword'),
|
||||||
|
cancelText: $t('pages.laterHandle'),
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: () => {
|
||||||
|
if (!isProfilePage) {
|
||||||
|
history.push('/profile');
|
||||||
|
}
|
||||||
|
localStorage.setItem(
|
||||||
|
'changePassTimestamp',
|
||||||
|
String(Date.now() + DEFAULT_DELAY_TIME),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCancel: (close) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'changePassTimestamp',
|
||||||
|
String(Date.now() + DEFAULT_DELAY_TIME),
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tipsNode = (
|
||||||
|
<Link to="/profile">
|
||||||
|
<Alert
|
||||||
|
message={$t('pages.changePassTips')}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ width: 375 }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
content={tipsNode}
|
||||||
|
styles={{
|
||||||
|
body: { padding: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge dot={true} offset={[-5, 5]}>
|
||||||
|
<BellOutlined style={{ fontSize: 20 }} />
|
||||||
|
</Badge>
|
||||||
|
</Popover>
|
||||||
|
{contextHolder}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ChangePassTips;
|
||||||
98
src/components/PasswordLevel/index.tsx
Normal file
98
src/components/PasswordLevel/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Form, Progress } from 'antd';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
type ScoreItem = {
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
percent: number;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
type PasswordLevelProps = {
|
||||||
|
passwordStr?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password strength component
|
||||||
|
* @param passwordStr - The name of the password field in the form, default is 'password'
|
||||||
|
*/
|
||||||
|
const PasswordLevel: React.FC<PasswordLevelProps> = ({
|
||||||
|
passwordStr = 'password',
|
||||||
|
}) => {
|
||||||
|
const tips = useMemo(
|
||||||
|
() => (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||||
|
{$t('form.password.tips')}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkPasswordStrength = (value: string) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
// 1. Less than 6 digits: directly judged as extremely weak (gear 0)
|
||||||
|
if (value.length < 6) {
|
||||||
|
return {
|
||||||
|
level: 0,
|
||||||
|
text: $t('form.password.level0'),
|
||||||
|
color: '#ff4d4f',
|
||||||
|
percent: 25,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Statistic character types
|
||||||
|
let typeCount = 0;
|
||||||
|
if (/\d/.test(value)) typeCount++; // Contains numbers
|
||||||
|
if (/[a-zA-Z]/.test(value)) typeCount++; // Contains letters
|
||||||
|
if (/[^A-Za-z0-9]/.test(value)) typeCount++; // Contains special characters
|
||||||
|
|
||||||
|
// 3. Based on the number of character types, judge the password strength
|
||||||
|
if (typeCount <= 1) {
|
||||||
|
// Contains only numbers or letters
|
||||||
|
return {
|
||||||
|
level: 1,
|
||||||
|
text: $t('form.password.level1'),
|
||||||
|
color: '#ff7a45',
|
||||||
|
percent: 50,
|
||||||
|
};
|
||||||
|
} else if (typeCount === 2) {
|
||||||
|
// Two types of combinations
|
||||||
|
return {
|
||||||
|
level: 2,
|
||||||
|
text: $t('form.password.level2'),
|
||||||
|
color: '#ffec3d',
|
||||||
|
percent: 75,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Three types of combinations or more
|
||||||
|
return {
|
||||||
|
level: 3,
|
||||||
|
text: $t('form.password.level3'),
|
||||||
|
color: '#52c41a',
|
||||||
|
percent: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Listen for changes in the password field
|
||||||
|
const value = Form.useWatch(passwordStr);
|
||||||
|
if (!value || value.length === 0) return tips;
|
||||||
|
|
||||||
|
const { text, color, percent } = checkPasswordStrength(value) as ScoreItem;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{$t('form.password.strength')}:<span style={{ color }}>{text}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
strokeColor={color}
|
||||||
|
showInfo={false}
|
||||||
|
steps={4}
|
||||||
|
/>
|
||||||
|
{tips}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default PasswordLevel;
|
||||||
93
src/components/RightContent/AvatarDropdown.tsx
Normal file
93
src/components/RightContent/AvatarDropdown.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { history, useModel } from '@umijs/max';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { logoutAndToLoginPage } from '@/access';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
// import { closeSSEWithApi } from '@/services/api';
|
||||||
|
import HeaderDropdown from '../HeaderDropdown';
|
||||||
|
|
||||||
|
export type GlobalHeaderRightProps = {
|
||||||
|
menu?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AvatarName = () => {
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const name = userInfo?.nickName || '';
|
||||||
|
return <span className="anticon">{name}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar dropdown component in the global header
|
||||||
|
* @param children - The content to be displayed inside the dropdown trigger
|
||||||
|
*/
|
||||||
|
export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { setUserInfo, userInfo } = useUserInfo();
|
||||||
|
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
|
||||||
|
// const { status } = useModel('useSSE');
|
||||||
|
const loginOut = async () => {
|
||||||
|
try {
|
||||||
|
setUnreadCountWithSign(0);
|
||||||
|
// if (status === 'open') {
|
||||||
|
// await closeSSEWithApi();
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to close SSE connection:', error);
|
||||||
|
}
|
||||||
|
await logoutAndToLoginPage();
|
||||||
|
setUserInfo(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMenuClick: MenuProps['onClick'] = (event) => {
|
||||||
|
const { key } = event;
|
||||||
|
if (key === 'logout') {
|
||||||
|
loginOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
history.push(`/${key}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = (
|
||||||
|
<Spin
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userInfo) {
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: $t('pages.profile'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: $t('pages.logout'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderDropdown
|
||||||
|
menu={{
|
||||||
|
selectedKeys: [],
|
||||||
|
onClick: onMenuClick,
|
||||||
|
items: menuItems,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</HeaderDropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
src/components/RightContent/ThemeSwitch.tsx
Normal file
56
src/components/RightContent/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Segmented } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ThemeDarkProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
navTheme?: NavTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** * Theme switch component for toggling between light and dark themes
|
||||||
|
* @param props - Component props including collapsed state, mobile state, and current theme
|
||||||
|
*/
|
||||||
|
const ThemeSwitch: React.FC<ThemeDarkProps> = (props: ThemeDarkProps) => {
|
||||||
|
const { initialState, setInitialState } = useModel('@@initialState');
|
||||||
|
const changeTheme = (value: NavTheme) => {
|
||||||
|
const settings = initialState?.settings || {};
|
||||||
|
|
||||||
|
localStorage.setItem('navTheme', value);
|
||||||
|
|
||||||
|
setInitialState((s) => ({
|
||||||
|
...s,
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
navTheme: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (value === 'realDark') {
|
||||||
|
html.dataset.theme = 'dark';
|
||||||
|
} else {
|
||||||
|
html.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Segmented
|
||||||
|
size="small"
|
||||||
|
defaultValue={props.navTheme}
|
||||||
|
shape="round"
|
||||||
|
onChange={changeTheme}
|
||||||
|
// vertical={!props.isMobile && props.collapsed}
|
||||||
|
options={[
|
||||||
|
{ value: 'light', icon: <SunOutlined /> },
|
||||||
|
{ value: 'realDark', icon: <MoonOutlined /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeSwitch;
|
||||||
52
src/components/RightContent/index.tsx
Normal file
52
src/components/RightContent/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { DownOutlined, GlobalOutlined } from '@ant-design/icons';
|
||||||
|
import { getLocale, setLocale } from '@umijs/max';
|
||||||
|
import { Button, Dropdown, type MenuProps, Space } from 'antd';
|
||||||
|
import { getAccessToken } from '@/access';
|
||||||
|
import { userUpdateLang } from '@/services/api/api';
|
||||||
|
|
||||||
|
export type SiderTheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
const localMap = [
|
||||||
|
{
|
||||||
|
key: 'en_US',
|
||||||
|
lang: 'en_US',
|
||||||
|
label: 'English',
|
||||||
|
title: 'Language',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zh_CN',
|
||||||
|
lang: 'zh_CN',
|
||||||
|
label: '简体中文',
|
||||||
|
title: '语言',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const langLabels: Record<string, string> = {
|
||||||
|
zh_CN: '简体中文',
|
||||||
|
en_US: 'English',
|
||||||
|
zh_TW: '繁体中文',
|
||||||
|
};
|
||||||
|
const onClick: MenuProps['onClick'] = async ({ key }) => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
await userUpdateLang({ lang: key });
|
||||||
|
}
|
||||||
|
setLocale(key);
|
||||||
|
};
|
||||||
|
export const SelectLang: React.FC = () => {
|
||||||
|
const selectedLang = getLocale();
|
||||||
|
return (
|
||||||
|
<Dropdown menu={{ items: localMap, onClick }} arrow>
|
||||||
|
<Button
|
||||||
|
color="default"
|
||||||
|
variant="filled"
|
||||||
|
style={{ paddingInline: '14px' }}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<GlobalOutlined />
|
||||||
|
<span>{langLabels?.[selectedLang]}</span>
|
||||||
|
<DownOutlined />
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
src/components/SSEController/index.tsx
Normal file
32
src/components/SSEController/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// src/components/SSEController/index.tsx
|
||||||
|
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useMessageBuffer } from '@/hooks/useMessage';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { isMobileDevice } from '@/utils';
|
||||||
|
|
||||||
|
const SSEController: React.FC = () => {
|
||||||
|
const { disconnectSSE } = useModel('useSSE');
|
||||||
|
const { startConnection } = useMessageBuffer();
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const userConfig = userInfo?.userConfig;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isMobile = isMobileDevice();
|
||||||
|
if (
|
||||||
|
!isMobile &&
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
userConfig?.desktopPush
|
||||||
|
) {
|
||||||
|
startConnection();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
disconnectSSE();
|
||||||
|
};
|
||||||
|
}, [userInfo?.userId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SSEController;
|
||||||
26
src/components/ThemeWrapper/index.tsx
Normal file
26
src/components/ThemeWrapper/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ConfigProvider, Layout, type LayoutProps, theme } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const { Content } = Layout;
|
||||||
|
|
||||||
|
/** * ThemeWrapper component that wraps the application with Ant Design's ConfigProvider to apply the selected theme
|
||||||
|
* @param children - The content to be wrapped by the theme provider
|
||||||
|
*/
|
||||||
|
const ThemeWrapper: React.FC<LayoutProps> = ({ children, ...restProps }) => {
|
||||||
|
const localTheme = localStorage.getItem('navTheme');
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm:
|
||||||
|
localTheme === 'realDark'
|
||||||
|
? theme.darkAlgorithm
|
||||||
|
: theme.defaultAlgorithm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layout>
|
||||||
|
<Content {...restProps}>{children}</Content>
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ThemeWrapper;
|
||||||
154
src/components/WebNoticeStatus/index copy.tsx
Normal file
154
src/components/WebNoticeStatus/index copy.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { getLocale, useModel } from '@umijs/max';
|
||||||
|
import { Alert, App, Badge, Button, Popover, Space } from 'antd';
|
||||||
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { getAccessToken } from '@/access';
|
||||||
|
import { useMessageBuffer } from '@/hooks/useMessage';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { isMobileDevice } from '@/utils';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
interface WebNotificationProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebNotification component for displaying web notifications
|
||||||
|
* @param collapsed - Whether the menu is collapsed
|
||||||
|
*/
|
||||||
|
const WebNotification: React.FC<WebNotificationProps> = ({ collapsed }) => {
|
||||||
|
const { status, retryCount, connectSSE, disconnectSSE } = useModel('useSSE');
|
||||||
|
const { addMessage } = useMessageBuffer({ wait: 2000 });
|
||||||
|
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const userConfig = userInfo?.userConfig;
|
||||||
|
|
||||||
|
const startConnection = useCallback(async () => {
|
||||||
|
const token = (await getAccessToken()) as string;
|
||||||
|
if (!token) {
|
||||||
|
message.error($t('pages.loginFirst'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// const url = 'http://localhost:5000/api/sse';
|
||||||
|
// const url = '/api/sse';
|
||||||
|
const url = '/api/sse/connect';
|
||||||
|
connectSSE(url, {
|
||||||
|
maxRetries: 5,
|
||||||
|
retryInterval: 5000,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
language: getLocale(),
|
||||||
|
},
|
||||||
|
onMessage: (data) => {
|
||||||
|
addMessage(data);
|
||||||
|
setUnreadCountWithSign(1, '+');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error($t('pages.notice.onError'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [connectSSE, addMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isMobile = isMobileDevice();
|
||||||
|
if (
|
||||||
|
!isMobile &&
|
||||||
|
// process.env.NODE_ENV === 'production' &&
|
||||||
|
userConfig?.desktopPush
|
||||||
|
) {
|
||||||
|
startConnection();
|
||||||
|
}
|
||||||
|
return () => disconnectSSE();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const test = useCallback(() => {
|
||||||
|
// addMessage(
|
||||||
|
// '{"id":28556,"ruleId":7,"ruleType":10,"ruleTypeName":"Deposit & Withdrawal","platform":5,"dataSourceId":30,"dataSourceName":"Alpha MT5","companyId":14,"companyName":"Alpha Broker","tradeAccount":"10102","product":"","details":"{\\"CcyDigits\\":2,\\"Cmd\\":2,\\"Comment\\":\\"Deposit\\",\\"Commission\\":0.0,\\"Digits\\":2,\\"Entry\\":0,\\"ExpertID\\":0,\\"Fee\\":0.0,\\"Position\\":0,\\"Price\\":0.0,\\"Profit\\":89845.0,\\"RateProfit\\":0.0,\\"Reason\\":2,\\"SL\\":0.0,\\"Swap\\":0.0,\\"Symbol\\":\\"\\",\\"TP\\":0.0,\\"Ticket\\":128764,\\"Time\\":1772709002,\\"TimeMsc\\":1772709002778,\\"Volume\\":0}","trigger":"$89845 | Deposit","triggerCn":"$89845 | 入金","summary":"Deposit","summaryCn":"入金","triggerTime":"2026-03-05T09:10:02","status":1,"createTime":"2026-03-05T09:10:03","updateTime":"2026-03-05T09:10:03","ticket":null,"deals":null}',
|
||||||
|
// );
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// State mapping configuration
|
||||||
|
const statusConfig = useMemo(() => {
|
||||||
|
const map = {
|
||||||
|
idle: {
|
||||||
|
color: 'default',
|
||||||
|
text: $t('pages.notice.status.default'),
|
||||||
|
icon: null,
|
||||||
|
},
|
||||||
|
connecting: {
|
||||||
|
color: 'processing',
|
||||||
|
text: $t('pages.notice.status.processing', { retryCount }),
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
color: 'success',
|
||||||
|
text: $t('pages.notice.status.success'),
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: 'error',
|
||||||
|
text: $t('pages.notice.status.error'),
|
||||||
|
icon: <CloseCircleOutlined />,
|
||||||
|
},
|
||||||
|
closed: {
|
||||||
|
color: 'default',
|
||||||
|
text: $t('pages.notice.status.closed'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
|
replaced: {
|
||||||
|
color: 'default',
|
||||||
|
text: $t('pages.notice.status.replaced'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map[status];
|
||||||
|
}, [status, retryCount]);
|
||||||
|
|
||||||
|
const alertNode = (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<Space onClick={test}>
|
||||||
|
<Badge
|
||||||
|
status={statusConfig.color as any}
|
||||||
|
text={statusConfig.text}
|
||||||
|
/>
|
||||||
|
{statusConfig.icon}
|
||||||
|
</Space>
|
||||||
|
{statusConfig.color !== 'success' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
disabled={status === 'open' || status === 'connecting'}
|
||||||
|
onClick={startConnection}
|
||||||
|
style={{ float: 'right' }}
|
||||||
|
>
|
||||||
|
{$t('pages.notice.button.reconnect')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Popover content={alertNode} styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<InfoCircleOutlined style={{ fontSize: 24, color: 'orange' }} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
alertNode
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebNotification;
|
||||||
222
src/components/WebNoticeStatus/index.tsx
Normal file
222
src/components/WebNoticeStatus/index.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Alert, Badge, Modal, Popover, Space, Typography } from 'antd';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useMessageBuffer } from '@/hooks/useMessage';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
// import { testSSESend } from '@/services/api';
|
||||||
|
|
||||||
|
interface WebNotificationProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
let hasOpened = false;
|
||||||
|
const { Title } = Typography;
|
||||||
|
/**
|
||||||
|
* WebNotification component for displaying web notifications
|
||||||
|
* @param collapsed - Whether the menu is collapsed
|
||||||
|
*/
|
||||||
|
const WebNotification: React.FC<WebNotificationProps> = ({ collapsed }) => {
|
||||||
|
const { status, retryCount } = useModel('useSSE');
|
||||||
|
const {
|
||||||
|
startConnection,
|
||||||
|
// addMessage
|
||||||
|
} = useMessageBuffer();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const test = useCallback(() => {
|
||||||
|
// const message = JSON.stringify({
|
||||||
|
// id: 29492,
|
||||||
|
// ruleId: 55,
|
||||||
|
// ruleType: 1,
|
||||||
|
// ruleTypeName: 'Large Trade (Lots)',
|
||||||
|
// platform: 5,
|
||||||
|
// dataSourceId: 84,
|
||||||
|
// dataSourceName: '47.84.118.19(second test platform)',
|
||||||
|
// companyId: 25,
|
||||||
|
// companyName: 'PM一人测试公司',
|
||||||
|
// tradeAccount: '60001',
|
||||||
|
// product: 'XAGUSD',
|
||||||
|
// trigger: '0.2 lots | BUY',
|
||||||
|
// triggerCn: '0.2 手 | BUY',
|
||||||
|
// summary: 'BUY',
|
||||||
|
// summaryCn: 'BUY',
|
||||||
|
// triggerTime: '2026-03-18T09:19:51',
|
||||||
|
// status: 1,
|
||||||
|
// createTime: '2026-03-18T09:19:51',
|
||||||
|
// updateTime: '2026-03-18T09:19:51',
|
||||||
|
// ticket: null,
|
||||||
|
// deals: null,
|
||||||
|
// operatorAction: null,
|
||||||
|
// operatorNickName: null,
|
||||||
|
// operatorId: null,
|
||||||
|
// operatorTime: null,
|
||||||
|
// tradeAccountId: 105105,
|
||||||
|
// });
|
||||||
|
// const message = JSON.stringify({
|
||||||
|
// id: '5egGkvlBLoDVK4X',
|
||||||
|
// ruleId: 'JREwzv6q4O7Y0y9',
|
||||||
|
// ruleName: 'testname',
|
||||||
|
// ruleType: 1,
|
||||||
|
// ruleTypeName: 'Large Trade (Lots)',
|
||||||
|
// platform: 5,
|
||||||
|
// dataSourceId: 'Xb6V0OkLyvE2jky',
|
||||||
|
// dataSourceName: 'Make Money',
|
||||||
|
// companyId: 'KJREwzv6G37Y0y9',
|
||||||
|
// companyName: 'Alpha Broker',
|
||||||
|
// tradeAccount: '10102',
|
||||||
|
// product: 'XAUUSD',
|
||||||
|
// trigger: '0.5 lots | SELL',
|
||||||
|
// triggerCn: '0.5 手 | SELL',
|
||||||
|
// summary: 'SELL',
|
||||||
|
// summaryCn: 'SELL',
|
||||||
|
// triggerTime: '2026-03-30T13:12:10',
|
||||||
|
// status: 1,
|
||||||
|
// createTime: '2026-03-30T21:12:13',
|
||||||
|
// updateTime: '2026-03-30T21:12:13',
|
||||||
|
// ticket: null,
|
||||||
|
// deals: null,
|
||||||
|
// operatorAction: null,
|
||||||
|
// operatorNickName: null,
|
||||||
|
// operatorId: null,
|
||||||
|
// operatorTime: null,
|
||||||
|
// tradeAccountId: 'PjaQ3NEAG0vV60o',
|
||||||
|
// timeZone: 'UTC+3',
|
||||||
|
// dataSourceServerTime: '2026-03-30T16:12:10',
|
||||||
|
// });
|
||||||
|
// addMessage(message);
|
||||||
|
// testSSESend({ message });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// State mapping configuration
|
||||||
|
const statusConfig = useMemo(() => {
|
||||||
|
const map = {
|
||||||
|
idle: {
|
||||||
|
color: 'default',
|
||||||
|
text: $t('pages.notice.status.default'),
|
||||||
|
title: $t('pages.notice.status.default.title'),
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
|
connecting: {
|
||||||
|
color: 'processing',
|
||||||
|
text: $t('pages.notice.status.processing', { retryCount }),
|
||||||
|
title: $t('pages.notice.status.processing.title'),
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
color: 'success',
|
||||||
|
text: $t('pages.notice.status.success'),
|
||||||
|
title: $t('pages.notice.status.success.title'),
|
||||||
|
icon: null,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: 'error',
|
||||||
|
text: $t('pages.notice.status.error'),
|
||||||
|
title: $t('pages.notice.status.error.title'),
|
||||||
|
icon: <CloseCircleOutlined style={{ color: 'red' }} />,
|
||||||
|
},
|
||||||
|
closed: {
|
||||||
|
color: 'default',
|
||||||
|
text: $t('pages.notice.status.closed'),
|
||||||
|
title: $t('pages.notice.status.closed.title'),
|
||||||
|
icon: <ExclamationCircleOutlined style={{ color: 'orange' }} />,
|
||||||
|
},
|
||||||
|
replaced: {
|
||||||
|
color: 'default',
|
||||||
|
text: $t('pages.notice.status.replaced'),
|
||||||
|
title: $t('pages.notice.status.replaced.title'),
|
||||||
|
icon: <ExclamationCircleOutlined style={{ color: 'orange' }} />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map[status];
|
||||||
|
}, [status, retryCount]);
|
||||||
|
|
||||||
|
const alertNode = (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
message={
|
||||||
|
<Space
|
||||||
|
onClick={() => {
|
||||||
|
if (status === 'open') return;
|
||||||
|
setIsModalOpen(!isModalOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge status={statusConfig.color as any} text={statusConfig.text} />
|
||||||
|
{statusConfig.icon}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModalVisible = useCallback(() => {
|
||||||
|
if (status === 'open') return;
|
||||||
|
setIsModalOpen(!isModalOpen);
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (['error', 'replaced'].includes(status) && !hasOpened) {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
hasOpened = true;
|
||||||
|
} else if (status === 'open') {
|
||||||
|
hasOpened = false;
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
okText={$t('pages.notice.button.reconnect')}
|
||||||
|
open={isModalOpen}
|
||||||
|
cancelButtonProps={{
|
||||||
|
style: { display: 'none' },
|
||||||
|
}}
|
||||||
|
onCancel={handleModalVisible}
|
||||||
|
onOk={startConnection}
|
||||||
|
okButtonProps={{
|
||||||
|
style: { display: status === 'open' ? 'none' : 'inline-flex' },
|
||||||
|
}}
|
||||||
|
mask={false}
|
||||||
|
maskClosable={false}
|
||||||
|
getContainer=".ant-pro-layout-container"
|
||||||
|
style={{ top: 20, marginLeft: 20 }}
|
||||||
|
styles={{
|
||||||
|
wrapper: { pointerEvents: 'none' },
|
||||||
|
content: { width: '400px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space align="start" onClick={test}>
|
||||||
|
<Title level={4}>{statusConfig.icon}</Title>
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ marginTop: 3 }}>
|
||||||
|
{statusConfig.title}
|
||||||
|
</Title>
|
||||||
|
<div>{statusConfig.text}</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
{collapsed ? (
|
||||||
|
<Popover content={alertNode} styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<InfoCircleOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: status === 'open' ? '#10b981' : 'orange',
|
||||||
|
}}
|
||||||
|
onClick={handleModalVisible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
alertNode
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebNotification;
|
||||||
11
src/components/index.ts
Normal file
11
src/components/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* This file serves as the directory for the component
|
||||||
|
* The purpose is to uniformly manage external output components and facilitate classification.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* layout component
|
||||||
|
*/
|
||||||
|
import { SelectLang } from './RightContent';
|
||||||
|
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
|
||||||
|
|
||||||
|
export { AvatarDropdown, AvatarName, SelectLang };
|
||||||
155
src/global.less
Normal file
155
src/global.less
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
|
||||||
|
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] {
|
||||||
|
background-color: #141414;
|
||||||
|
.bg-gray {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible,
|
||||||
|
:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorWeak {
|
||||||
|
filter: invert(80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pro-sider.ant-layout-sider.ant-pro-sider-fixed {
|
||||||
|
left: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pro-query-filter-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abs-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.text-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-100 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-0 {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-guard-notification {
|
||||||
|
.ant-notification-notice-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn-color-cyan.ant-btn-variant-solid:not(:disabled):not(.ant-btn-disabled) {
|
||||||
|
background: #10b981;
|
||||||
|
&:hover {
|
||||||
|
background: #10b981;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ant-pro-global-header-header-actions-avatar {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ant-table {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
&-thead>tr,
|
||||||
|
&-tbody>tr {
|
||||||
|
|
||||||
|
>th,
|
||||||
|
>td {
|
||||||
|
white-space: pre;
|
||||||
|
|
||||||
|
>span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ant-pro-page-container-children-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.ant-pro-page-container .ant-pro-page-container-warp-page-header {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/hooks/useMessage.ts
Normal file
236
src/hooks/useMessage.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { getLocale, history, useModel } from '@umijs/max';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { getAccessToken } from '@/access';
|
||||||
|
import bus from '@/utils/eventBus';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
import { formatToUserTimezone } from '@/utils/timeFormat';
|
||||||
|
import { useQParams } from './useQParams';
|
||||||
|
import useUserInfo from './useUserInfo';
|
||||||
|
|
||||||
|
// import { useWebNotification } from './useWebNotification';
|
||||||
|
|
||||||
|
interface BufferOptions {
|
||||||
|
/** Throttling window time (ms), how often to aggregate messages */
|
||||||
|
wait?: number;
|
||||||
|
/** Release lock required silence time (ms), during high frequency sending, the lock will not be released */
|
||||||
|
silenceWait?: number;
|
||||||
|
/** Notification title */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageInfo = {
|
||||||
|
title: string;
|
||||||
|
ruleTypeName: string;
|
||||||
|
content: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMessageBuffer = (options: BufferOptions = {}) => {
|
||||||
|
const { connectSSE } = useModel('useSSE');
|
||||||
|
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
|
||||||
|
const {
|
||||||
|
wait = 3000,
|
||||||
|
silenceWait = 2000,
|
||||||
|
title = $t('notice.title1'),
|
||||||
|
} = options;
|
||||||
|
const soundEle = document.getElementById(
|
||||||
|
'notificationAudio',
|
||||||
|
) as HTMLAudioElement;
|
||||||
|
const bufferRef = useRef<MessageInfo[]>([]);
|
||||||
|
const isThrottlingRef = useRef<boolean>(false);
|
||||||
|
const { notification } = App.useApp();
|
||||||
|
const { getQUrl } = useQParams();
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const userConfig = userInfo?.userConfig;
|
||||||
|
|
||||||
|
// Periodically aggregate pushed messages
|
||||||
|
const intervalTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
// Responsible for opening the throttling lock's silence timer (debounce)
|
||||||
|
const silenceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// const { sendBrowserNotification } = useWebNotification();
|
||||||
|
|
||||||
|
const showNotification = useCallback(
|
||||||
|
(infoArr: MessageInfo[]) => {
|
||||||
|
// const isBackground = document.visibilityState === 'hidden';
|
||||||
|
// const localStorageEnabled = localStorage.getItem('desktopPushEnabled');
|
||||||
|
|
||||||
|
const count = infoArr.length;
|
||||||
|
const infoData = infoArr[0];
|
||||||
|
const title =
|
||||||
|
count > 1 ? `🔔 ${$t('notice.title2', { count })}` : infoData.title;
|
||||||
|
|
||||||
|
const content =
|
||||||
|
count > 1 ? aggregationArrayContent(infoArr) : infoData.content;
|
||||||
|
|
||||||
|
const url = count > 1 ? '/alert' : infoData.url;
|
||||||
|
|
||||||
|
// if (isBackground && localStorageEnabled === 'true') {
|
||||||
|
// // Page not visible and user enabled desktop notifications, trigger desktop notifications
|
||||||
|
// sendBrowserNotification(title, content, url);
|
||||||
|
// } else {
|
||||||
|
// // Normal page notification
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (userConfig?.desktopPush) {
|
||||||
|
notification.warning({
|
||||||
|
message: title,
|
||||||
|
className: 'risk-guard-notification',
|
||||||
|
description: content,
|
||||||
|
placement: 'topRight',
|
||||||
|
duration: 4.5,
|
||||||
|
showProgress: true,
|
||||||
|
onClick: () => {
|
||||||
|
const { pathname } = window.location;
|
||||||
|
if (pathname.includes('/alert')) {
|
||||||
|
// history.push(url);
|
||||||
|
bus.emit('alert-refresh', true);
|
||||||
|
} else {
|
||||||
|
history.push(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userConfig?.voicePrompt) {
|
||||||
|
// Play notification sound
|
||||||
|
try {
|
||||||
|
soundEle?.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Play notification sound failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUnreadCountWithSign(count, '+');
|
||||||
|
bus.emit('alert-new');
|
||||||
|
},
|
||||||
|
[title, soundEle],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Only responsible for flushing the current buffer */
|
||||||
|
const flushBuffer = useCallback(() => {
|
||||||
|
if (bufferRef.current.length > 0) {
|
||||||
|
// const combinedMessage = bufferRef.current.join(' | ');
|
||||||
|
showNotification(bufferRef.current);
|
||||||
|
bufferRef.current = [];
|
||||||
|
}
|
||||||
|
}, [showNotification]);
|
||||||
|
|
||||||
|
/** Completely reset all states, opening the next round of "immediate first" opportunity */
|
||||||
|
const releaseLock = useCallback(() => {
|
||||||
|
flushBuffer(); // Finally flush the buffer
|
||||||
|
|
||||||
|
// Clear all timers
|
||||||
|
if (intervalTimerRef.current) clearInterval(intervalTimerRef.current);
|
||||||
|
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||||
|
|
||||||
|
intervalTimerRef.current = null;
|
||||||
|
silenceTimerRef.current = null;
|
||||||
|
isThrottlingRef.current = false; // True release the lock
|
||||||
|
}, [flushBuffer]);
|
||||||
|
|
||||||
|
const addMessage = useCallback(
|
||||||
|
(msg: string) => {
|
||||||
|
// 1. Every time a message is received, reset the "quiet timer" (debounce)
|
||||||
|
// Only release the lock if silenceWait milliseconds have passed without receiving new messages
|
||||||
|
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||||
|
silenceTimerRef.current = setTimeout(releaseLock, silenceWait);
|
||||||
|
|
||||||
|
if (!isThrottlingRef.current) {
|
||||||
|
// ---Case A: In silent period, first message received ---
|
||||||
|
isThrottlingRef.current = true;
|
||||||
|
showNotification([analysisMessage(msg)]); // Immediately trigger
|
||||||
|
|
||||||
|
// Start a fixed interval timer to ensure users can also see updates at regular intervals during high frequency
|
||||||
|
intervalTimerRef.current = setInterval(() => {
|
||||||
|
flushBuffer();
|
||||||
|
}, wait);
|
||||||
|
} else {
|
||||||
|
// ---Case B: In throttling/high frequency period, continue buffering ---
|
||||||
|
bufferRef.current.push(analysisMessage(msg));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flushBuffer, releaseLock, showNotification, silenceWait, wait],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (intervalTimerRef.current) clearInterval(intervalTimerRef.current);
|
||||||
|
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||||
|
bufferRef.current = [];
|
||||||
|
isThrottlingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Parse the message content and generate a MessageInfo object
|
||||||
|
const analysisMessage = useCallback((msg: string) => {
|
||||||
|
const info = {} as MessageInfo;
|
||||||
|
try {
|
||||||
|
const tmp = JSON.parse(msg) as API.AlertListItem;
|
||||||
|
info.title = `🔔 ${$t('notice.title1')}: ${tmp.ruleTypeName}`;
|
||||||
|
info.ruleTypeName = tmp.ruleTypeName || '';
|
||||||
|
const tradeAccountStr = tmp.tradeAccount
|
||||||
|
? `${$t('table.account')}: ${tmp.tradeAccount}\n`
|
||||||
|
: '';
|
||||||
|
const dataSourceTime = tmp.dataSourceServerTime
|
||||||
|
? `${$t('table.dataSourceTime')}: ${formatToUserTimezone(tmp.dataSourceServerTime)} (${tmp.timeZone || '-'})\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ruleName = tmp.ruleName
|
||||||
|
? `${$t('pages.rules.ruleName')}: ${tmp.ruleName}\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ruleId = tmp.ruleId
|
||||||
|
? `${$t('table.alertId')}: ${tmp.ruleId}\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
info.content = `${ruleId}${ruleName}${$t('table.dataSource')}: ${tmp.dataSourceName}\n${$t('table.triggerTime')} (UTC+0): ${formatToUserTimezone(tmp.triggerTime)}\n${dataSourceTime}${tradeAccountStr}${$t('table.trigger')}: ${tmp.trigger}\n${$t('table.summary')}: ${tmp.summary}`;
|
||||||
|
info.url = `/alert${getQUrl({
|
||||||
|
dataSourceId: tmp.dataSourceId,
|
||||||
|
ruleType: tmp.ruleType,
|
||||||
|
})}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Parse message failed:', error);
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Merge array content, group by ruleTypeName and count the number of occurrences
|
||||||
|
const aggregationArrayContent = useCallback((info: MessageInfo[]) => {
|
||||||
|
const tmp = {} as Record<string, number>;
|
||||||
|
info.forEach((item) => {
|
||||||
|
if (!tmp[item.ruleTypeName]) {
|
||||||
|
tmp[item.ruleTypeName] = 1;
|
||||||
|
} else {
|
||||||
|
tmp[item.ruleTypeName] = (tmp[item.ruleTypeName] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return `${Object.keys(tmp)
|
||||||
|
.map((key) => `${key}: ${tmp[key]}`)
|
||||||
|
.join('\n')}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startConnection = useCallback(async () => {
|
||||||
|
const token = (await getAccessToken()) as string;
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// const url = 'http://localhost:5000/api/sse';
|
||||||
|
// const url = '/api/sse';
|
||||||
|
const url = '/api/sse/connect';
|
||||||
|
const deviceId = (await storage.get('device_id')) as string;
|
||||||
|
connectSSE(url, {
|
||||||
|
maxRetries: 10,
|
||||||
|
retryInterval: 3000,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
language: getLocale(),
|
||||||
|
'X-Device-Id': deviceId || '',
|
||||||
|
},
|
||||||
|
onMessage: (data) => {
|
||||||
|
addMessage(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [connectSSE, addMessage]);
|
||||||
|
|
||||||
|
return { addMessage, clearAll, startConnection };
|
||||||
|
};
|
||||||
39
src/hooks/useQParams.ts
Normal file
39
src/hooks/useQParams.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// src/hooks/useQParams.ts
|
||||||
|
import { useNavigate, useSearchParams } from '@umijs/max';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { decodeQ, encodeQ } from '@/utils/urlUtils';
|
||||||
|
|
||||||
|
// Define generic T with constraints as objects
|
||||||
|
export const useQParams = <T extends Record<string, any>>() => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Parse query parameters from URL and decode them into type T
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const q = searchParams.get('q');
|
||||||
|
return decodeQ<T>(q);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setQParamsAndNavigate = (
|
||||||
|
pathname: string,
|
||||||
|
params: Partial<T>,
|
||||||
|
replace = false,
|
||||||
|
) => {
|
||||||
|
// Filter out empty values
|
||||||
|
const cleanParams = Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([_, v]) => v !== void 0 && v !== ''),
|
||||||
|
);
|
||||||
|
const q = encodeQ(cleanParams);
|
||||||
|
const newSearch = q ? `?q=${q}` : '';
|
||||||
|
navigate(`${pathname}${newSearch}`, { replace });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a URL with query parameters: for use with <Link> or <a> tags
|
||||||
|
*/
|
||||||
|
const getQUrl = (params: Record<string, any>) => {
|
||||||
|
const q = encodeQ(params);
|
||||||
|
return `?q=${q}`;
|
||||||
|
};
|
||||||
|
return { queryParams, setQParamsAndNavigate, getQUrl };
|
||||||
|
};
|
||||||
61
src/hooks/useRichI18n.tsx
Normal file
61
src/hooks/useRichI18n.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import type { JSX } from 'react';
|
||||||
|
|
||||||
|
type Node = React.ReactNode;
|
||||||
|
type RichTextInfo = {
|
||||||
|
tag: keyof JSX.IntrinsicElements;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
function html(chunks: Node, richTextInfo: RichTextInfo) {
|
||||||
|
const { tag: Tag, style } = richTextInfo;
|
||||||
|
return <Tag style={style}>{chunks}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const richTextMap: Record<string, RichTextInfo> = {
|
||||||
|
b: {
|
||||||
|
tag: 'b',
|
||||||
|
style: {},
|
||||||
|
},
|
||||||
|
i: {
|
||||||
|
tag: 'i',
|
||||||
|
style: {},
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
tag: 'b',
|
||||||
|
style: {
|
||||||
|
color: '#10b981',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
tag: 'b',
|
||||||
|
style: {
|
||||||
|
color: '#f59e0b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
tag: 'b',
|
||||||
|
style: {
|
||||||
|
color: '#818cf8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mapKeys = Object.keys(richTextMap);
|
||||||
|
const collection = {} as Record<string, (chunks: Node) => Node>;
|
||||||
|
mapKeys.forEach((key) => {
|
||||||
|
collection[key] = (chunks: Node) => html(chunks, richTextMap[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return {
|
||||||
|
richFormat: (id: string, data?: Record<string, any>) =>
|
||||||
|
intl.formatMessage(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
...collection,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
18
src/hooks/useUserInfo.ts
Normal file
18
src/hooks/useUserInfo.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
|
||||||
|
export default function useUserInfo() {
|
||||||
|
const { initialState, setInitialState } = useModel('@@initialState');
|
||||||
|
const userInfo = initialState?.currentUser as API.CurrentUser | undefined;
|
||||||
|
const fetchUserInfo = initialState?.fetchUserInfo;
|
||||||
|
|
||||||
|
const setUserInfo = async (userInfo: API.CurrentUser | undefined) => {
|
||||||
|
setInitialState((s) => ({ ...s, currentUser: userInfo }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUserInfo = async () => {
|
||||||
|
const info = await fetchUserInfo?.();
|
||||||
|
setUserInfo(info);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { userInfo, refreshUserInfo, setUserInfo };
|
||||||
|
}
|
||||||
55
src/hooks/useWebNotification.ts
Normal file
55
src/hooks/useWebNotification.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { history } from '@umijs/max';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import bus from '@/utils/eventBus';
|
||||||
|
|
||||||
|
type NoticeOptions = NotificationOptions & {
|
||||||
|
renotify?: boolean;
|
||||||
|
};
|
||||||
|
export const useWebNotification = () => {
|
||||||
|
const [permission, setPermission] = useState<NotificationPermission>(
|
||||||
|
typeof window !== 'undefined' ? Notification.permission : 'default',
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async () => {
|
||||||
|
const res = await Notification.requestPermission();
|
||||||
|
setPermission(res);
|
||||||
|
return res;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendBrowserNotification = useCallback(
|
||||||
|
(title: string, body: string, url: string) => {
|
||||||
|
if (Notification.permission !== 'granted') return;
|
||||||
|
const options = {
|
||||||
|
body,
|
||||||
|
icon: '/logo.png',
|
||||||
|
badge: '/logo.png',
|
||||||
|
data: {
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
tag: 'sse-notification', // Same tag will overwrite old notifications to prevent stacking
|
||||||
|
renotify: true, // Specifies whether the user should be notified after a new notification replaces an old notification (this is an experimental technology, the default value is false; setting to true will cause the notification to re-notify the user)
|
||||||
|
} as NoticeOptions;
|
||||||
|
|
||||||
|
const notification = new Notification(title, options);
|
||||||
|
notification.onclick = (event) => {
|
||||||
|
event.preventDefault(); // Prevents the browser's default click behavior
|
||||||
|
const url = (event.target as Notification).data?.url;
|
||||||
|
// Open the application or jump to the specified page
|
||||||
|
window.focus();
|
||||||
|
|
||||||
|
const { pathname } = window.location;
|
||||||
|
if (pathname.includes('/alert')) {
|
||||||
|
bus.emit('alert-refresh', true);
|
||||||
|
// history.push(url);
|
||||||
|
} else {
|
||||||
|
history.push(url);
|
||||||
|
}
|
||||||
|
// Close the notification
|
||||||
|
notification.close();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { permission, requestPermission, sendBrowserNotification };
|
||||||
|
};
|
||||||
10
src/loading.tsx
Normal file
10
src/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Skeleton } from 'antd';
|
||||||
|
|
||||||
|
type LoadingProps = {
|
||||||
|
padding?: string;
|
||||||
|
};
|
||||||
|
const Loading: React.FC<LoadingProps> = ({ padding = '24px 40px' }) => (
|
||||||
|
<Skeleton style={{ padding }} active />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
7
src/locales/en_US.ts
Normal file
7
src/locales/en_US.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import menu from './en_US/menu';
|
||||||
|
import pages from './en_US/pages';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...menu,
|
||||||
|
...pages,
|
||||||
|
};
|
||||||
60
src/locales/en_US/menu.ts
Normal file
60
src/locales/en_US/menu.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export default {
|
||||||
|
'menu.welcome': 'Welcome',
|
||||||
|
'menu.home': 'Home',
|
||||||
|
'menu.login': 'Login',
|
||||||
|
'menu.dashboard': 'Dashboard',
|
||||||
|
'menu.dashboard.desc': 'View Dashboard',
|
||||||
|
'menu.rules': 'Rules',
|
||||||
|
'menu.rule': 'Rule',
|
||||||
|
'menu.rules.desc': 'Manage Rules',
|
||||||
|
'menu.rules.largeTradeLots': 'Large Trade (Lots)',
|
||||||
|
'menu.rules.largeTradeLots.desc':
|
||||||
|
'Monitor trades with lots exceeding threshold',
|
||||||
|
'menu.rules.largeTradeUSD': 'Large Trade (USD)',
|
||||||
|
'menu.rules.largeTradeUSD.desc':
|
||||||
|
'Monitor trades with USD value exceeding threshold',
|
||||||
|
'menu.rules.liquidityTrade': 'Liquidity Trade',
|
||||||
|
'menu.rules.liquidityTrade.desc':
|
||||||
|
'Monitor frequent small trades in short time',
|
||||||
|
'menu.rules.scalping': 'Scalping',
|
||||||
|
'menu.rules.scalping.desc': 'Monitor short-duration high-frequency trades',
|
||||||
|
'menu.rules.exposureAlert': 'Exposure Alert',
|
||||||
|
'menu.rules.exposureAlert.desc': 'Monitor currency net exposure limits',
|
||||||
|
'menu.rules.pricingVolatility': 'Pricing & Volatility',
|
||||||
|
'menu.rules.pricingVolatility.desc':
|
||||||
|
'Monitor price gaps and extreme volatility',
|
||||||
|
'menu.rules.pricing': 'Pricing',
|
||||||
|
'menu.rules.pricing.desc': 'Monitor price gaps',
|
||||||
|
'menu.rules.volatility': 'Volatility',
|
||||||
|
'menu.rules.volatility.desc': 'Monitor extreme price fluctuations',
|
||||||
|
|
||||||
|
'menu.rules.NOPLimit': 'NOP Limit',
|
||||||
|
'menu.rules.NOPLimit.desc': 'Monitor net open position limits',
|
||||||
|
'menu.rules.watchList': 'Watch List',
|
||||||
|
'menu.rules.watchList.desc': 'Monitor specific high-priority accounts',
|
||||||
|
'menu.rules.reversePositions': 'Reverse Positions',
|
||||||
|
'menu.rules.reversePositions.desc': 'Monitor quick reversals after closing',
|
||||||
|
'menu.rules.depositWithdrawal': 'Deposit & Withdrawal',
|
||||||
|
'menu.rules.depositWithdrawal.desc': 'Monitor large fund movements',
|
||||||
|
'menu.products': 'Product List',
|
||||||
|
'menu.products.desc': 'View Products',
|
||||||
|
'menu.alert': 'Alerts',
|
||||||
|
'menu.alert.desc': 'View Alerts',
|
||||||
|
'menu.account': 'Trade Account',
|
||||||
|
'menu.account.desc': 'Manage Trade Accounts',
|
||||||
|
'menu.company': 'Companies',
|
||||||
|
'menu.company.desc': 'Manage Companies',
|
||||||
|
'menu.dataSource': 'Data Sources',
|
||||||
|
'menu.dataSource.desc': 'Manage Data Sources',
|
||||||
|
'menu.user': 'Users',
|
||||||
|
'menu.user.desc': 'Manage Users',
|
||||||
|
'menu.role': 'Roles',
|
||||||
|
'menu.role.desc': 'Manage Roles',
|
||||||
|
'menu.config': 'Global Settings',
|
||||||
|
'menu.config.desc': 'Manage Global Settings',
|
||||||
|
'menu.profile': 'Profile',
|
||||||
|
'menu.emailServices': 'Email Services',
|
||||||
|
'menu.emailServices.desc': 'Manage Email Services',
|
||||||
|
'menu.operLog': 'Audit Logs',
|
||||||
|
'menu.operLog.desc': 'View Operation Logs',
|
||||||
|
};
|
||||||
619
src/locales/en_US/pages.ts
Normal file
619
src/locales/en_US/pages.ts
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
export default {
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.content.noRules': 'No rules available',
|
||||||
|
'common.content.noData': 'No data available',
|
||||||
|
'table.group': 'Group',
|
||||||
|
'table.operatorRecords': 'Operator Records',
|
||||||
|
'table.operatorAction': 'Operator Action',
|
||||||
|
'table.operatorTime': 'Operator Time',
|
||||||
|
'table.os': 'Operating System',
|
||||||
|
'table.browser': 'Browser',
|
||||||
|
'table.timerange': 'Time Range',
|
||||||
|
'table.volume': 'Lots',
|
||||||
|
'table.cmd': 'Trade Type',
|
||||||
|
'table.openTime': 'Open Time',
|
||||||
|
'table.profit': 'Profit',
|
||||||
|
'table.unit.datasource': 'unit(s)',
|
||||||
|
'table.unit.users': 'person(s)',
|
||||||
|
'table.time': 'Time',
|
||||||
|
'table.account': 'Trade Account',
|
||||||
|
'table.symbol': 'Symbol',
|
||||||
|
'table.trigger': 'Value',
|
||||||
|
'table.triggerValue': 'Triggered Value',
|
||||||
|
'table.status': 'Status',
|
||||||
|
'table.id': 'ID',
|
||||||
|
'table.alertId': 'Alert ID',
|
||||||
|
'table.accountId': 'Account ID',
|
||||||
|
'table.companyId': 'Company ID',
|
||||||
|
'table.dataSourceId': 'Data Source ID',
|
||||||
|
'table.dataSource': 'Data Source',
|
||||||
|
'table.dataSourceTime': 'Data Source Server Time',
|
||||||
|
'table.userId': 'User ID',
|
||||||
|
'table.roleId': 'Role ID',
|
||||||
|
'table.company': 'Company',
|
||||||
|
'table.ruleType': 'Rule Type',
|
||||||
|
'table.platform': 'Platform',
|
||||||
|
'table.detail': 'Details',
|
||||||
|
'table.summary': 'Summary',
|
||||||
|
'table.triggerTime': 'Trigger Time',
|
||||||
|
'table.action': 'Actions',
|
||||||
|
'table.currency': 'Currency',
|
||||||
|
'table.balance': 'Balance',
|
||||||
|
'table.equity': 'Equity',
|
||||||
|
'table.marginLevel': 'Margin Level',
|
||||||
|
'table.alertCount': 'Alerts',
|
||||||
|
'table.companyName': 'Company Name',
|
||||||
|
'table.contactEmail': 'Contact Email',
|
||||||
|
'table.dataSourceCount': 'Data Sources',
|
||||||
|
'table.userCount': 'Users',
|
||||||
|
'table.createTime': 'Create Time',
|
||||||
|
'table.name': 'Name',
|
||||||
|
'table.ipAddress': 'IP Address',
|
||||||
|
'table.ruleCount': 'Rules',
|
||||||
|
'table.username': 'Username',
|
||||||
|
'table.displayName': 'Display Name',
|
||||||
|
'table.role': 'Role',
|
||||||
|
'table.roleIdentifier': 'Role Key',
|
||||||
|
'table.roleName': 'Role Name',
|
||||||
|
'table.permissionLevel': 'Permission Level',
|
||||||
|
'table.type': 'Type',
|
||||||
|
'table.permissionCount': 'Permissions',
|
||||||
|
'table.operator': 'Operator',
|
||||||
|
'table.actionType': 'Action',
|
||||||
|
'table.description': 'Detail Desc',
|
||||||
|
'table.all': 'All',
|
||||||
|
'table.new': 'Pending',
|
||||||
|
'table.viewed': 'Viewed',
|
||||||
|
'table.ignored': 'Ignored',
|
||||||
|
'table.ignored2': 'Ignore',
|
||||||
|
'table.action.add': 'Add ',
|
||||||
|
'table.action.view': 'View ',
|
||||||
|
'table.action.delete': 'Delete ',
|
||||||
|
'table.action.other': 'Other Actions',
|
||||||
|
'table.action.deleteDone': 'Deleted',
|
||||||
|
'table.action.edit': 'Edit ',
|
||||||
|
'table.action.export': 'Export',
|
||||||
|
'table.action.allRead': 'Mark All Read',
|
||||||
|
'table.dataSource.status0': 'Connecting',
|
||||||
|
'table.dataSource.status1': 'Running',
|
||||||
|
'table.dataSource.status2': 'Connection Failed',
|
||||||
|
'table.dataSource.status3': 'Deleting',
|
||||||
|
'table.user.status0': 'Disabled',
|
||||||
|
'table.user.status1': 'Active',
|
||||||
|
'table.role.type0': 'System',
|
||||||
|
'table.role.type1': 'Custom',
|
||||||
|
|
||||||
|
'pages.totalUserCount': 'Total Accounts',
|
||||||
|
'pages.totalAlertCount': 'Total Alerts',
|
||||||
|
'pages.totalCompanyCount': 'Total Companies',
|
||||||
|
'pages.activeCompanyCount': 'Active Companies',
|
||||||
|
'pages.totalDataSourceCount': 'Total Data Sources',
|
||||||
|
'pages.activeDataSourceCount': 'Active Data Sources',
|
||||||
|
'pages.mt4DataSourceCount': 'MT4 Data Sources',
|
||||||
|
'pages.mt5DataSourceCount': 'MT5 Data Sources',
|
||||||
|
'pages.adminCount': 'Admins',
|
||||||
|
'pages.operatorCount': 'Operators',
|
||||||
|
'pages.readOnlyUserCount': 'Read-Only Users',
|
||||||
|
'pages.totalRoleCount': 'Total Roles',
|
||||||
|
'pages.systemRoleCount': 'System Roles',
|
||||||
|
'pages.customRoleCount': 'Custom Roles',
|
||||||
|
'pages.availablePermissionList': 'Available Permissions',
|
||||||
|
|
||||||
|
'pages.dashboard.todayAlertCount': 'Today Alerts',
|
||||||
|
'pages.dashboard.activeRuleCount': 'Active Rules',
|
||||||
|
'pages.dashboard.monitoringAccountCount': 'Alerted Accounts',
|
||||||
|
'pages.dashboard.alertProcessingRate': 'Resolution Rate',
|
||||||
|
'pages.dashboard.todayTransactionCount': 'Today Trades',
|
||||||
|
'pages.dashboard.alertTrend': 'Alert Trend',
|
||||||
|
'pages.dashboard.globalView': 'Global View',
|
||||||
|
'pages.dashboard.ruleTriggerDistribution': 'Rule Trigger Distribution',
|
||||||
|
'pages.dashboard.latestAlert': 'Latest Alerts',
|
||||||
|
'pages.dashboard.platformTransactionVolume': 'Platform Volume',
|
||||||
|
'pages.dashboard.viewAll': 'View All',
|
||||||
|
'pages.dashboard.recentAlerts': 'Recent Alerts',
|
||||||
|
|
||||||
|
'pages.user.roleDesc.superAdmin': 'Can manage all companies, users and data',
|
||||||
|
'pages.user.roleDesc.companyAdmin':
|
||||||
|
'Manage current company data sources and users',
|
||||||
|
'pages.user.roleDesc.companyOperator':
|
||||||
|
'Configure rules for allocated data sources',
|
||||||
|
'pages.user.roleDesc.companyViewer': 'View alert records only',
|
||||||
|
'pages.user.roleDesc.header': 'Role Permissions Description',
|
||||||
|
|
||||||
|
'pages.role.system.superAdmin': 'Super Admin',
|
||||||
|
'pages.role.system.companyAdmin': 'Company Admin',
|
||||||
|
'pages.role.system.companyOperator': 'Company User',
|
||||||
|
'pages.role.system.companyViewer': 'Viewer',
|
||||||
|
|
||||||
|
'pages.tips.emptyIsAll': 'Empty for all',
|
||||||
|
|
||||||
|
'pages.account.pop.title': 'Account Details',
|
||||||
|
|
||||||
|
'pages.alert.pop.title': 'Alert Details',
|
||||||
|
'pages.alert.pop.title2': 'Basic Info',
|
||||||
|
'pages.alert.pop.title3': 'Trigger Rule Snapshot',
|
||||||
|
'pages.alert.pop.title4': 'Associated Trade Records',
|
||||||
|
'pages.alert.newAlertTips': 'You have new alerts, click "Reset" to refresh',
|
||||||
|
|
||||||
|
'pages.rules.seconds': 'Seconds',
|
||||||
|
'pages.rules.lots': 'Lots',
|
||||||
|
'pages.rules.triggerValue': 'Lot Threshold',
|
||||||
|
'pages.rules.triggerValueUSD': 'USD Threshold',
|
||||||
|
'pages.rules.monitoredSymbols': 'Monitored Symbols',
|
||||||
|
'pages.rules.monitoredGroups': 'Monitored Groups',
|
||||||
|
'pages.rules.allGroups': 'All Groups',
|
||||||
|
'pages.rules.allSymbols': 'All Symbols',
|
||||||
|
'pages.rules.allAssets': 'All Assets',
|
||||||
|
'pages.rules.trigger': 'Triggered',
|
||||||
|
'pages.rules.triggeredCount': 'Times',
|
||||||
|
'pages.rules.disable': 'Disable',
|
||||||
|
'pages.rules.enable': 'Enable',
|
||||||
|
'pages.rules.status0': 'Pending',
|
||||||
|
'pages.rules.status1': 'Running',
|
||||||
|
'pages.rules.status2': 'Disabled',
|
||||||
|
'pages.rules.status3': 'Edit Pending',
|
||||||
|
'pages.rules.status4': 'Delete Pending',
|
||||||
|
'pages.rules.status5': 'Deleted',
|
||||||
|
'pages.rules.reportInterval': 'Report Interval',
|
||||||
|
'pages.rules.ruleName': 'Rule Name',
|
||||||
|
'pages.rules.clone': 'Clone',
|
||||||
|
'pages.rules.cloneConfirm': 'Confirm Clone',
|
||||||
|
'pages.rules.cloneRule': 'Clone Rule',
|
||||||
|
'pages.rules.originalRule': 'Original Rule (Read-Only)',
|
||||||
|
'pages.rules.cloneRulePreview': 'Clone Rule Preview (Editable)',
|
||||||
|
|
||||||
|
'pages.rule3.timeWindow': 'Time Window',
|
||||||
|
'pages.rule3.minOrderCount': 'Min Order Count',
|
||||||
|
'pages.rule3.minOrderCount.tips': 'Orders',
|
||||||
|
'pages.rule3.totalLotsThreshold': 'Total Lot Threshold',
|
||||||
|
'pages.rule3.aggregationLogic': 'Aggregation Logic',
|
||||||
|
'pages.rule3.option1': 'By Virtual Category',
|
||||||
|
'pages.rule3.option2': 'By Individual Variety',
|
||||||
|
'form.rule3.tips':
|
||||||
|
'Tip: System automatically groups orders by direction (BUY/SELL). Only same-direction orders are counted together.',
|
||||||
|
|
||||||
|
'pages.rule4.item1': 'Duration Threshold',
|
||||||
|
'pages.rule4.item2': 'Min Close Profit',
|
||||||
|
'pages.rule4.item3': 'Min Close Lot',
|
||||||
|
'pages.rule4.item4': 'Min Close USD Value',
|
||||||
|
'form.rule4.tips': 'Tip: Monitor short-duration high-frequency trades.',
|
||||||
|
|
||||||
|
'pages.rule5.item1': 'Target Currency',
|
||||||
|
'pages.rule5.item2': 'Exposure Threshold',
|
||||||
|
'pages.rule5.item3': 'Time Interval',
|
||||||
|
'pages.rule5.item4': 'Max Remind Count',
|
||||||
|
'form.rule5.tips': 'Tip: Monitor currency net exposure limits.',
|
||||||
|
|
||||||
|
'pages.rule5_1.item1': 'Monitored Assets',
|
||||||
|
'pages.rule5_1.item2': 'Upper Threshold',
|
||||||
|
'pages.rule5_1.item4': 'Lower Threshold',
|
||||||
|
'pages.rule5_1.item5': 'Conversion Currency',
|
||||||
|
'pages.rule5_1.validateError2':
|
||||||
|
'Upper threshold cannot be less than lower threshold',
|
||||||
|
'pages.rule5_1.validateError4':
|
||||||
|
'Lower threshold cannot be greater than upper threshold',
|
||||||
|
|
||||||
|
// 'pages.rule6.item1': 'Stop Pricing Duration',
|
||||||
|
// 'pages.rule6.item2': 'Volatility Threshold',
|
||||||
|
// 'pages.rule6.item3': 'Volatility Mode',
|
||||||
|
// 'pages.rule6.option1': 'Points (Points)',
|
||||||
|
// 'pages.rule6.option2': 'Percentage (%)',
|
||||||
|
// 'form.rule6.tips': 'Tip: Monitor price gaps and extreme volatility.',
|
||||||
|
'pages.rule6_1.item1': 'Stop Pricing Duration',
|
||||||
|
'form.rule6_1.tips': 'Tip: Monitor price gaps.',
|
||||||
|
|
||||||
|
'pages.rule11.item1': 'Volatility Mode',
|
||||||
|
'pages.rule11.option1': 'Points',
|
||||||
|
'pages.rule11.option2': 'Percentage (%)',
|
||||||
|
'pages.rule11.item2': 'Time Window',
|
||||||
|
'pages.rule11.item3': 'Max Fluctuation',
|
||||||
|
'form.rule11.tips': 'Tip: Monitor extreme price fluctuations.',
|
||||||
|
|
||||||
|
'pages.rule7.item1': 'Platform Type',
|
||||||
|
'pages.rule7.option1': 'MT4 (Coefficient: 100)',
|
||||||
|
'pages.rule7.option2': 'MT5 (Coefficient: 10000)',
|
||||||
|
'pages.rule7.item2': 'NOP Threshold',
|
||||||
|
'pages.rule7.item3': 'Calculation Frequency',
|
||||||
|
'pages.rule7.item4': 'Alert Cooldown',
|
||||||
|
'form.rule7.tips': 'Tip: Monitor net open position limits.',
|
||||||
|
|
||||||
|
'pages.rule8.item1': 'Monitored Accounts',
|
||||||
|
'pages.rule8.item1.tips': 'IDs, comma separated',
|
||||||
|
'pages.rule8.item2': 'Monitoring Actions',
|
||||||
|
'pages.rule8.option1': 'Open Trade',
|
||||||
|
'pages.rule8.option2': 'Pending Order',
|
||||||
|
'pages.rule8.item3': 'Min Lots Limit',
|
||||||
|
'form.rule8.tips': 'Tip: Monitor specific high-priority accounts.',
|
||||||
|
|
||||||
|
'pages.rule9.item1': 'Max Reverse Interval',
|
||||||
|
'pages.rule9.item2': 'Min Reverse Lot',
|
||||||
|
'pages.rule9.item3': 'Min Reverse USD Value',
|
||||||
|
'form.rule9.tips': 'Tip: Monitor quick reversals after closing.',
|
||||||
|
|
||||||
|
'pages.rule10.item1': 'Deposit Threshold',
|
||||||
|
'pages.rule10.item2': 'Withdraw Threshold',
|
||||||
|
'pages.rule10.item3': 'Include Keywords',
|
||||||
|
'pages.rule10.item3.tips': 'Comma-separated',
|
||||||
|
'form.rule10.tips': 'Tip: Monitor large fund movements.',
|
||||||
|
|
||||||
|
'form.rules.header': 'Current rule logic:',
|
||||||
|
'form.rules.header.content1':
|
||||||
|
'If open lots ≥ <yellow>{lots}</yellow>, for symbols: <green>{symbols}</green>, trigger alert.',
|
||||||
|
'form.rules.header.content2':
|
||||||
|
'If open amount ≥ <yellow>{amount}</yellow>, for symbols: <green>{symbols}</green>, trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content3':
|
||||||
|
'Within <yellow>{seconds}</yellow> seconds, if orders ≥ <yellow>{orderCount}</yellow> AND total lots ≥ <yellow>{totalLots}</yellow>, monitoring <green>{symbols}</green> by <yellow>{aggregationLogic}</yellow> trigger alert.',
|
||||||
|
'form.rules.header.content4':
|
||||||
|
'Upon closing (incl. partial close), <blue>Duration < </blue> <yellow>{seconds}</yellow> <blue>seconds</blue> AND <blue>Close Lots ≥ </blue> <yellow>{lots}</yellow> AND <blue>Close Profit ≥ </blue> <yellow>{profitValue}</yellow> for <green>{symbols}</green> trades trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content5':
|
||||||
|
'If <yellow>{currency}</yellow> exposure ≥ <yellow>{exposureValue}</yellow>, triggered every <yellow>{interval}</yellow> seconds.',
|
||||||
|
'form.rules.header.content5_1':
|
||||||
|
'If <green>{assets}</green> exposure ≥ <yellow>$ {upperThreshold}</yellow> or ≤ <yellow>$ {lowerThreshold}</yellow>, triggered every <yellow>{interval}</yellow> seconds.',
|
||||||
|
|
||||||
|
'form.rules.header.content6':
|
||||||
|
'For symbols <green>{symbols}</green>, if pricing stops ≥ <yellow>{stopTime}</yellow> seconds, OR volatility ≥ <yellow>{volatility}</yellow> (<yellow>{volatilityUnit}</yellow>), triggered every <yellow>{interval}</yellow> seconds.',
|
||||||
|
'form.rules.header.content6_1':
|
||||||
|
'For symbols <green>{symbols}</green>, if pricing stops ≥ <yellow>{stopTime}</yellow> seconds, trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content11':
|
||||||
|
'For symbols <green>{symbols}</green>, if price fluctuates ≥ <yellow>{volatility}</yellow> (<yellow>{volatilityUnit}</yellow> mode) within <yellow>{timeWindow}</yellow>, trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content7':
|
||||||
|
'If NOP for <green>{symbols}</green> ≥ <yellow>{nopLimit}</yellow>, trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content8':
|
||||||
|
'Monitoring account(s) <yellow>{tradeAccounts}</yellow> for <yellow>{action}</yellow>, if lots ≥ <yellow>{lots}</yellow>, trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content9':
|
||||||
|
'Within <yellow>{seconds}</yellow> seconds after closing, reverse trade lots ≥ <yellow>{lots}</yellow> OR value ≥ <yellow>{usdValue}</yellow>, monitoring <green>{symbols}</green> trigger alert.',
|
||||||
|
|
||||||
|
'form.rules.header.content10':
|
||||||
|
'Monitoring deposit ≥ <yellow>{depositValue}</yellow> OR withdrawal ≥ <yellow>{withdrawValue}</yellow> (incl. keywords: <yellow>{keywords}</yellow>), trigger alert.',
|
||||||
|
|
||||||
|
'form.rule1.ignoreSimulatedAccount': 'Ignore Demo Accounts',
|
||||||
|
'form.rule1.tips': 'Tip: Monitor trades exceeding lot threshold.',
|
||||||
|
'form.rule1.tips2': 'Click to search, or category title to select all.',
|
||||||
|
'form.rule2.keyword': 'Cent Account Groups Keywords',
|
||||||
|
'form.rule2.keyword.placeholder': 'e.g. *CENT*,*MICRO*',
|
||||||
|
'form.rule2.tips': 'Tip: Monitor trades exceeding USD value threshold.',
|
||||||
|
'form.selectOrSearch': 'Plese Select or Search',
|
||||||
|
'form.required': 'This field is required',
|
||||||
|
'form.password': 'Password',
|
||||||
|
'form.example': 'Example',
|
||||||
|
'form.range': 'Range',
|
||||||
|
'form.placeholder.ipAddress':
|
||||||
|
'Please input a valid IPv4 address (e.g. 192.168.1.1)',
|
||||||
|
'form.dataSource.name': 'Data Source Name',
|
||||||
|
'form.dataSource.type': 'Platform Type',
|
||||||
|
'form.dataSource.connectionConfig': 'Connection Config',
|
||||||
|
'form.dataSource.ipAddress': 'IP Address',
|
||||||
|
'form.dataSource.port': 'Port',
|
||||||
|
'form.dataSource.tradeAccount': 'Trade Account',
|
||||||
|
|
||||||
|
'form.user.title': 'User',
|
||||||
|
'form.user.dataSourceIds': 'Bound Data Sources',
|
||||||
|
'form.user.dataSourceEmptyTips':
|
||||||
|
'Optional data sources are empty. Please add if needed.',
|
||||||
|
'form.user.displayName': 'Display Name',
|
||||||
|
'form.user.email': 'Email',
|
||||||
|
'form.email.placeholder': 'Please input a valid email address',
|
||||||
|
'form.password.repeatPassword': 'Repeat Password',
|
||||||
|
'form.password.passwordNotMatch':
|
||||||
|
'This input does not match the first password input.',
|
||||||
|
'form.password.placeholder': 'Password must be at least 6 characters',
|
||||||
|
'form.password.tips':
|
||||||
|
'Tip: Use letters, numbers, and symbols for stronger passwords.',
|
||||||
|
'form.password.strength': 'Strength',
|
||||||
|
'form.password.level0': 'Very Weak',
|
||||||
|
'form.password.level1': 'Weak',
|
||||||
|
'form.password.level2': 'Medium',
|
||||||
|
'form.password.level3': 'Strong',
|
||||||
|
|
||||||
|
'form.role.title': 'Role',
|
||||||
|
'form.role.preview': 'Preview',
|
||||||
|
'form.role.roleKey': 'Role Key',
|
||||||
|
'form.role.roleKey.placeholder':
|
||||||
|
'e.g. risk_analyst (only letters, numbers, and underscores are allowed)',
|
||||||
|
'form.role.roleKey.placeholder2':
|
||||||
|
'Only letters, numbers, and underscores are allowed.',
|
||||||
|
'form.role.roleName': 'Role Name',
|
||||||
|
'form.role.roleName.placeholder': 'e.g. Risk Analyst',
|
||||||
|
'form.role.level': 'Permission Level',
|
||||||
|
'form.role.level.placeholder':
|
||||||
|
'1-{level}, higher value means higher permission, can modify low permission user information',
|
||||||
|
'form.role.placeholder2': 'System roles cannot modify permission levels',
|
||||||
|
'form.role.badgeColor': 'Badge Color',
|
||||||
|
'form.role.menuIds': 'Bound Permissions (Menus)',
|
||||||
|
|
||||||
|
'form.config.timezone': 'Timezone Settings',
|
||||||
|
'form.config.timezone.title': 'Default Timezone',
|
||||||
|
'form.config.dataSet': 'Data Settings',
|
||||||
|
'form.config.dataSet.backDays': 'Alert Retention (Days)',
|
||||||
|
'form.config.dataSet.backDays.tips':
|
||||||
|
'Alerts older than this will be auto-archived',
|
||||||
|
'form.config.dataSet.refresh': 'Auto Refresh Interval (Sec)',
|
||||||
|
'form.config.notification': 'Notification Settings',
|
||||||
|
'form.config.emailNotification': 'Email Notification',
|
||||||
|
'form.config.emailNotification.tips': 'Send email on new alert',
|
||||||
|
'form.config.soundNotification': 'Sound Alert',
|
||||||
|
'form.config.soundNotification.tips': 'Play sound for high-risk alerts',
|
||||||
|
'form.config.frontNotice': 'Frontend Push',
|
||||||
|
'form.config.frontNotice.tips': 'Enable frontend push and related settings',
|
||||||
|
'form.config.desktopNotification': 'Desktop Notification',
|
||||||
|
'form.config.desktopNotification.notSupport':
|
||||||
|
'Browser does not support desktop notifications',
|
||||||
|
'form.config.desktopNotification.tips': 'Browser desktop notifications',
|
||||||
|
'form.config.desktopNoticeRefuseTips':
|
||||||
|
'Desktop notification permission denied',
|
||||||
|
'form.config.desktopNoticeRefuseTips2':
|
||||||
|
'Desktop notification permission once denied, cannot be re-prompted. To enable, please grant permission in browser settings or reset.',
|
||||||
|
'form.config.noticeOnButNotWorkTips':
|
||||||
|
'If you have enabled desktop notifications but are not receiving any, please check the following:',
|
||||||
|
'form.config.noticeOnButNotWorkTips2':
|
||||||
|
'Check if the system is in "Do Not Disturb" or "Focus" mode (most common)',
|
||||||
|
'form.config.noticeOnButNotWorkTips3': 'Settings → System → Notifications',
|
||||||
|
'form.config.noticeOnButNotWorkTips4':
|
||||||
|
'Ensure "Get notifications from applications and other senders" is enabled',
|
||||||
|
'form.config.noticeOnButNotWorkTips5':
|
||||||
|
'Find your browser in the list and ensure its switch is also enabled',
|
||||||
|
'form.config.noticeOnButNotWorkTips6':
|
||||||
|
'Check if "Focus Assistant" is disabled',
|
||||||
|
'form.config.noticeOnButNotWorkTips7':
|
||||||
|
'System Settings → Notifications → Focus Mode',
|
||||||
|
'form.config.noticeOnButNotWorkTips8': 'Ensure "Do Not Disturb" is disabled',
|
||||||
|
'form.config.noticeOnButNotWorkTips9':
|
||||||
|
'Find your browser in the list and ensure "Allow notifications" is checked',
|
||||||
|
'form.config.webhook': 'Webhook Notifications',
|
||||||
|
'form.config.webhook.telegram.title': 'Telegram Notifications',
|
||||||
|
'form.config.webhook.telegramBotToken': 'Bot Token',
|
||||||
|
'form.config.webhook.telegramBotToken.tips':
|
||||||
|
'From @BotFather to get the API Token.',
|
||||||
|
'form.config.webhook.telegramChatId': 'Chat ID',
|
||||||
|
'form.config.webhook.telegramChatId.tips':
|
||||||
|
'Add the bot to the group and get the Chat ID.',
|
||||||
|
'form.config.webhook.teams.title': 'Teams Notifications',
|
||||||
|
'form.config.webhook.teams.tips': 'Paste your Teams Webhook URL here.',
|
||||||
|
'form.config.webhook.teams': 'Teams Webhook',
|
||||||
|
'form.config.webhook.slack.title': 'Slack Notifications',
|
||||||
|
'form.config.webhook.slack.tips': 'Paste your Slack Webhook URL here.',
|
||||||
|
'form.config.webhook.slack': 'Slack Webhook',
|
||||||
|
'form.config.webhook.lark.title': 'Lark Notifications',
|
||||||
|
'form.config.webhook.lark.tips': 'Paste your Lark Bot Webhook URL here.',
|
||||||
|
'form.config.webhook.lark': 'Lark Webhook',
|
||||||
|
'form.config.updateSuccess': 'Configuration updated successfully',
|
||||||
|
'form.config.inputURLTips': 'Please input a valid URL format',
|
||||||
|
|
||||||
|
'pages.layouts.userLayout.title': 'Trading Risk Control System',
|
||||||
|
'pages.login.failure': 'Login failed, please try again later!',
|
||||||
|
'pages.login.success': 'Login successful!',
|
||||||
|
'pages.login.username': 'Email',
|
||||||
|
'pages.login.username.required': 'Please input your username!',
|
||||||
|
'pages.login.password': 'Password',
|
||||||
|
'pages.login.password.required': 'Please input your password!',
|
||||||
|
'pages.login.submit': 'Login',
|
||||||
|
'pages.404.subTitle': 'Sorry, the page you visited does not exist.',
|
||||||
|
'pages.404.buttonText': 'Back Home',
|
||||||
|
'pages.500.title': 'Service Exception',
|
||||||
|
'pages.500.subTitle':
|
||||||
|
'Failed to get menu or permission information, please try to refresh the page later.',
|
||||||
|
'pages.refresh': 'Refresh Page',
|
||||||
|
|
||||||
|
'pages.logout': 'Logout',
|
||||||
|
'pages.profile': 'Profile',
|
||||||
|
|
||||||
|
'pages.profile.basicInfo': 'Basic Information',
|
||||||
|
'pages.profile.changePassword': 'Change Password',
|
||||||
|
'pages.systemDirectly': 'System Directly',
|
||||||
|
'pages.confirmModify': 'Confirm Modify',
|
||||||
|
'pages.oldPassword': 'Old Password',
|
||||||
|
'pages.oldPassword.tips': 'Please input current login password',
|
||||||
|
'pages.newPassword': 'New Password',
|
||||||
|
'pages.confirmPassword': 'Confirm New Password',
|
||||||
|
'pages.changePasswordSuccess':
|
||||||
|
'Modification successful. You will be redirected to log in again shortly.',
|
||||||
|
'pages.changePassTips':
|
||||||
|
'You are using the system initial password. For your account privacy and operation security, it is recommended that you change your password now.',
|
||||||
|
'pages.goLogin': 'Go to login',
|
||||||
|
'pages.laterHandle': 'Handle later',
|
||||||
|
|
||||||
|
'pages.loginFirst': 'Please log in first',
|
||||||
|
'pages.notice.retryMaxTips': 'Max retry count reached, connection failed',
|
||||||
|
'pages.notice.onError':
|
||||||
|
'Connection interrupted, please check network and retry manually',
|
||||||
|
'pages.notice.status.processing': 'Connecting (Retry:{retryCount})',
|
||||||
|
'pages.notice.status.success': 'Web Notification Service Online',
|
||||||
|
'pages.notice.status.error':
|
||||||
|
'Web Notification Service Connection Failed, Please Check Network and Retry Manually',
|
||||||
|
'pages.notice.status.closed': 'Web Notification Service Disconnected',
|
||||||
|
'pages.notice.status.default': 'Web Notification Service Not Connected',
|
||||||
|
'pages.notice.status.replaced':
|
||||||
|
'Other End Connected, Please Reconnect to Continue',
|
||||||
|
'pages.notice.status.processing.title': 'Connecting',
|
||||||
|
'pages.notice.status.success.title': 'Online',
|
||||||
|
'pages.notice.status.error.title': 'Connection Failed',
|
||||||
|
'pages.notice.status.closed.title': 'Disconnected',
|
||||||
|
'pages.notice.status.default.title': 'Not Connected',
|
||||||
|
'pages.notice.status.replaced.title': 'Replaced',
|
||||||
|
'pages.notice.button.reconnect': 'Reconnect',
|
||||||
|
|
||||||
|
'pages.tips.operationSuccess': 'Operation Successful',
|
||||||
|
'pages.tips.operationFailed': 'Operation Failed',
|
||||||
|
|
||||||
|
'notice.title': 'Alert Notification',
|
||||||
|
'notice.title1': 'Alert Type',
|
||||||
|
'notice.title2': 'You have {count} alert notifications',
|
||||||
|
|
||||||
|
'pages.model.delete': 'Delete Task',
|
||||||
|
'pages.model.delete.content':
|
||||||
|
'Are you sure you want to delete this task? This action cannot be undone.',
|
||||||
|
'pages.model.disable': 'Disable User',
|
||||||
|
'pages.model.disable.content':
|
||||||
|
'After disabling, this user will not be able to log in to the system. Are you sure you want to disable it?',
|
||||||
|
'pages.model.confirm': 'Confirm',
|
||||||
|
'pages.model.cancel': 'Cancel',
|
||||||
|
'pages.model.allRead.content':
|
||||||
|
'Are you sure you want to mark all notifications as read?',
|
||||||
|
|
||||||
|
'pages.export.noData': 'No data available for export',
|
||||||
|
'pages.export.exporting': 'Exporting, please wait...',
|
||||||
|
'pages.export.exportSuccess': 'Export Successful',
|
||||||
|
'pages.export.exportFailed': 'Export Failed, please try again',
|
||||||
|
|
||||||
|
'pages.products.noBindDataSource':
|
||||||
|
'The current user does not have a data source bound to their account. Please bind a data source first, or contact the company administrator to bind a data source before viewing.',
|
||||||
|
'pages.products.noBindDataSource.superadmin':
|
||||||
|
'No data source available, please add first',
|
||||||
|
'pages.products.bindDataSource': 'Bind Data Source',
|
||||||
|
'pages.products.goto': 'Go to',
|
||||||
|
'pages.products.directory': 'Product Directory',
|
||||||
|
'pages.products.directory.tips':
|
||||||
|
'Please select a directory or start searching',
|
||||||
|
'pages.products.search': 'Global Search Product Name',
|
||||||
|
'pages.products.searchResult': 'Global Search Result',
|
||||||
|
'pages.products.noResult': 'No product found containing "{searchText}"',
|
||||||
|
'pages.products.noProduct': 'No product found in this directory',
|
||||||
|
|
||||||
|
'pages.config.alert':
|
||||||
|
'Tips: After modifying the configuration, please be sure to click the submit button to save.',
|
||||||
|
'pages.config.testPush': 'Test Connection',
|
||||||
|
'pages.config.testPush.tips': 'Please configure complete information first',
|
||||||
|
'pages.config.testPush.success':
|
||||||
|
'Test push sent successfully, please check the corresponding software group or channel',
|
||||||
|
'pages.config.testPush.fail': 'Test push failed, please check configuration',
|
||||||
|
|
||||||
|
'pages.config.pop.telegram.title': 'Telegram Bot Setup Guide',
|
||||||
|
'pages.config.pop.viewPdf': 'View PDF Tutorial',
|
||||||
|
'pages.config.pop.telegram.content1':
|
||||||
|
'Search for <b>@BotFather</b> on Telegram.',
|
||||||
|
'pages.config.pop.telegram.content2':
|
||||||
|
'Send /newbot and follow steps to get your <b>API Token</b>.',
|
||||||
|
'pages.config.pop.telegram.content3':
|
||||||
|
'Create a group/channel and add your bot as admin.',
|
||||||
|
'pages.config.pop.telegram.content4':
|
||||||
|
'Get your <b>Chat ID</b> (use bots like @userinfobot or API).',
|
||||||
|
'pages.config.pop.telegram.tips': 'Official Telegram Bot Tutorial',
|
||||||
|
|
||||||
|
'pages.config.pop.teams.title': 'Microsoft Teams Webhook Guide',
|
||||||
|
'pages.config.pop.teams.content1': 'Open Teams, go to your channel.',
|
||||||
|
'pages.config.pop.teams.content2':
|
||||||
|
'Click ... <b>(More options)</b> -> <b>Workflows (Recommended)</b>.',
|
||||||
|
'pages.config.pop.teams.content3':
|
||||||
|
'Search for "<b>Post to a channel when a webhook request is received</b>".',
|
||||||
|
'pages.config.pop.teams.content4':
|
||||||
|
'Follow steps to create your unique <b>Webhook URL</b>.',
|
||||||
|
'pages.config.pop.teams.tips': 'Official Teams Webhook Guide',
|
||||||
|
'pages.config.pop.teams.url':
|
||||||
|
'https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook',
|
||||||
|
|
||||||
|
'pages.config.pop.slack.title': 'Slack Webhook Guide',
|
||||||
|
'pages.config.pop.slack.content1': 'Go to Slack API and create a new App.',
|
||||||
|
'pages.config.pop.slack.content2':
|
||||||
|
'Enable <b>Incoming Webhooks</b> in the settings.',
|
||||||
|
'pages.config.pop.slack.content3':
|
||||||
|
'Click <b>Add New Webhook to Workspace</b> and choose a channel.',
|
||||||
|
'pages.config.pop.slack.content4': 'Copy the generated <b>Webhook URL</b>.',
|
||||||
|
'pages.config.pop.slack.tips': 'Official Slack Webhook Guide',
|
||||||
|
|
||||||
|
'pages.config.pop.lark.title': 'Lark Webhook Guide',
|
||||||
|
'pages.config.pop.lark.content1':
|
||||||
|
'Open Lark group chat, click <b>Settings</b> -> <b>Bots</b>.',
|
||||||
|
'pages.config.pop.lark.content2':
|
||||||
|
'Choose <b>Custom Bot</b> and click <b>Add</b>.',
|
||||||
|
'pages.config.pop.lark.content3':
|
||||||
|
'Name your bot and copy the <b>Webhook URL</b>.',
|
||||||
|
'pages.config.pop.lark.content4':
|
||||||
|
'(Optional) Configure IP Whitelist or Signature for security.',
|
||||||
|
'pages.config.pop.lark.tips': 'Official Lark Bot Guide',
|
||||||
|
'pages.config.pop.lark.url':
|
||||||
|
'https://www.larksuite.com/hc/en-US/articles/360048487736-use-bots-in-groups',
|
||||||
|
|
||||||
|
'pages.clone.tips1': 'Rule Operation Failed',
|
||||||
|
'pages.clone.tips2':
|
||||||
|
'Rule added, but still processing, please check rule status later.',
|
||||||
|
'pages.clone.tips3':
|
||||||
|
'Rule added, but failed to get rule detail, please check rule status later.',
|
||||||
|
'pages.clone.tips4':
|
||||||
|
'Rule added, but rule status is invalid, please check parameters and try again.',
|
||||||
|
'pages.clones.title': 'Bulk Clone',
|
||||||
|
'pages.clones.step1.title': 'Select Target',
|
||||||
|
'pages.clones.step1.source': 'Source Server',
|
||||||
|
'pages.clones.step1.target': 'Target Server',
|
||||||
|
'pages.clones.step1.sourceRules':
|
||||||
|
'Current source has {count} "{ruleType}" rules',
|
||||||
|
'pages.clones.step1.dividerText': 'Will Clone To',
|
||||||
|
'pages.clones.step1.confirm': 'Next: Preview Rules →',
|
||||||
|
'pages.clones.step1.tips': 'Please select source server first',
|
||||||
|
'pages.clones.step2.title': 'Preview Rules',
|
||||||
|
'pages.clones.step2.summary':
|
||||||
|
'Total {count} rules, will clone {cloneCount} rules to "{target}"',
|
||||||
|
'pages.clones.step2.checkAll': 'Check All',
|
||||||
|
'pages.clones.step2.cardTitle1': 'Original (Read Only)',
|
||||||
|
'pages.clones.step2.cardTitle2': 'After Cloned',
|
||||||
|
'pages.clones.step2.confirm': 'Next: Confirm Clone →',
|
||||||
|
'pages.clones.step3.title': 'Confirm Clone',
|
||||||
|
'pages.clones.step3.confirmTitle':
|
||||||
|
'About to clone {cloneCount} rules to "{target}"',
|
||||||
|
'pages.clones.step3.confirmTips':
|
||||||
|
'Please enter target server name below to confirm:',
|
||||||
|
'pages.clones.step3.confirmTips2':
|
||||||
|
'* * Enter identical server name to enable confirm button',
|
||||||
|
'pages.clones.step3.confirm': 'Start Execute Clone',
|
||||||
|
'pages.clones.step3.progress': 'Bulk Clone Progress',
|
||||||
|
'pages.clones.step3.start': 'Start Clone',
|
||||||
|
'pages.clones.step3.status1': 'Processing',
|
||||||
|
'pages.clones.step3.status2': 'Success',
|
||||||
|
'pages.clones.step3.status3': 'Failed',
|
||||||
|
'pages.clones.step3.status4': 'Warning',
|
||||||
|
'pages.clones.step3.status5': 'Pending',
|
||||||
|
'pages.clones.step3.success': 'All rules cloned successfully',
|
||||||
|
'pages.clones.step3.warning':
|
||||||
|
'All rules cloned successfully, but some rules failed to get status, please check manually',
|
||||||
|
'pages.clones.step3.error':
|
||||||
|
'Some rules failed to clone successfully, please check manually',
|
||||||
|
'pages.clones.step3.understand': 'I See',
|
||||||
|
'pages.clones.back': 'Back',
|
||||||
|
'pages.clones.backPreview': 'Back to Preview',
|
||||||
|
|
||||||
|
'pages.email.desc': 'Manage SMTP / API sending services for all companies',
|
||||||
|
'pages.email.notAssigned': 'Unassigned',
|
||||||
|
'pages.email.serviceName': 'Service Name',
|
||||||
|
'pages.email.serviceName.tips': 'Example: SendGrid Production',
|
||||||
|
'pages.email.serviceProvider': 'Service Provider',
|
||||||
|
'pages.email.assignedTo': 'Assigned To',
|
||||||
|
'pages.email.availableEmails': 'Verified Sender Emails',
|
||||||
|
'pages.email.availableEmails.tips':
|
||||||
|
'Enter verified sender emails (comma separated)',
|
||||||
|
'pages.email.status.normal': 'Active',
|
||||||
|
'pages.email.status.disabled': 'Inactive',
|
||||||
|
'pages.email.notification': 'Email Notification Config',
|
||||||
|
'pages.email.notificationEmail': 'Notification Email',
|
||||||
|
'pages.email.notificationEmail.tips':
|
||||||
|
'Email address to receive alert notifications (default is login email)',
|
||||||
|
'pages.email.emailNotification': 'Email Alerts',
|
||||||
|
'pages.email.acceptEnable': 'Receive Email Alerts',
|
||||||
|
'pages.email.acceptEnable.tips':
|
||||||
|
'Turn on to receive email alerts when rules are triggered',
|
||||||
|
'pages.email.notificationEnable': 'Enable Email Alerts',
|
||||||
|
'pages.email.notificationEnable.tips':
|
||||||
|
'When enabled, alert emails will be sent to users who have turned on email notifications',
|
||||||
|
'pages.email.notificationService': 'Assign Sending Service',
|
||||||
|
'pages.email.notificationFromName': 'Sender Name',
|
||||||
|
'pages.email.notificationFromEmail': 'Sender Email',
|
||||||
|
'pages.email.testEmail.title': 'Receive test information via email',
|
||||||
|
'pages.email.testEmail.success':
|
||||||
|
'Test email sent, please check {email} inbox.',
|
||||||
|
|
||||||
|
'pages.email.companyEmailDisabled':
|
||||||
|
'The company has not enabled or has disabled email functionality, and it cannot be enabled at this time (Can be turned off if enabled).',
|
||||||
|
|
||||||
|
'pages.error.title': 'Page Resource Loading Error',
|
||||||
|
'pages.error.subTitle':
|
||||||
|
'Network environment is unstable or system version is updated, please check network or refresh page later.',
|
||||||
|
'pages.error.subTitle2': 'Sorry, current page content rendering error.',
|
||||||
|
|
||||||
|
'pages.save': 'Save',
|
||||||
|
'pages.rules.cloneRuleWarning':
|
||||||
|
'At least two data sources must be bound to use the clone function',
|
||||||
|
};
|
||||||
7
src/locales/zh_CN.ts
Normal file
7
src/locales/zh_CN.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import menu from './zh_CN/menu';
|
||||||
|
import pages from './zh_CN/pages';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...pages,
|
||||||
|
...menu,
|
||||||
|
};
|
||||||
56
src/locales/zh_CN/menu.ts
Normal file
56
src/locales/zh_CN/menu.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export default {
|
||||||
|
'menu.welcome': '欢迎',
|
||||||
|
'menu.home': '首页',
|
||||||
|
'menu.login': '登录',
|
||||||
|
'menu.dashboard': '仪表板',
|
||||||
|
'menu.dashboard.desc': '查看仪表板',
|
||||||
|
'menu.rules': '规则管理',
|
||||||
|
'menu.rule': '规则',
|
||||||
|
'menu.rules.desc': '管理规则',
|
||||||
|
'menu.rules.largeTradeLots': '大额交易(手数)',
|
||||||
|
'menu.rules.largeTradeLots.desc': '监控单笔开仓手数超过设定阈值的大额交易',
|
||||||
|
'menu.rules.largeTradeUSD': '大额交易(USD)',
|
||||||
|
'menu.rules.largeTradeUSD.desc': '监控单笔开仓USD等值金额超过设定阈值的交易',
|
||||||
|
'menu.rules.liquidityTrade': 'Liquidity Trade',
|
||||||
|
'menu.rules.liquidityTrade.desc': '监控短时间内多笔同向小单的拆单行为',
|
||||||
|
'menu.rules.scalping': 'Scalping',
|
||||||
|
'menu.rules.scalping.desc': '监控持仓时间过短的超短线交易',
|
||||||
|
'menu.rules.exposureAlert': '敞口告警',
|
||||||
|
'menu.rules.exposureAlert.desc': '监控货币净敞口超限',
|
||||||
|
'menu.rules.pricingVolatility': 'Pricing & Volatility',
|
||||||
|
'menu.rules.pricingVolatility.desc': '监控行情中断和剧烈波动',
|
||||||
|
'menu.rules.pricing': '报价监控',
|
||||||
|
'menu.rules.pricing.desc': '监控行情停滞',
|
||||||
|
'menu.rules.volatility': '波动监控',
|
||||||
|
'menu.rules.volatility.desc': '监控高频剧烈行情波动',
|
||||||
|
|
||||||
|
'menu.rules.NOPLimit': 'NOP Limit',
|
||||||
|
'menu.rules.NOPLimit.desc': '监控产品净头寸超限',
|
||||||
|
'menu.rules.watchList': 'Watch List',
|
||||||
|
'menu.rules.watchList.desc': '监控重点账户的交易行为',
|
||||||
|
'menu.rules.reversePositions': 'Reverse Positions',
|
||||||
|
'menu.rules.reversePositions.desc': '监控平仓后短时间内反向开仓的翻仓行为',
|
||||||
|
'menu.rules.depositWithdrawal': 'Deposit & Withdrawal',
|
||||||
|
'menu.rules.depositWithdrawal.desc': '监控大额外部出入金',
|
||||||
|
'menu.products': '产品列表',
|
||||||
|
'menu.products.desc': '查看产品',
|
||||||
|
'menu.alert': '告警记录',
|
||||||
|
'menu.alert.desc': '查看告警',
|
||||||
|
'menu.account': '交易账户',
|
||||||
|
'menu.account.desc': '管理交易账户',
|
||||||
|
'menu.company': '公司管理',
|
||||||
|
'menu.company.desc': '管理公司',
|
||||||
|
'menu.dataSource': '数据源管理',
|
||||||
|
'menu.dataSource.desc': '管理数据源',
|
||||||
|
'menu.user': '用户管理',
|
||||||
|
'menu.user.desc': '管理用户',
|
||||||
|
'menu.role': '角色管理',
|
||||||
|
'menu.role.desc': '管理角色',
|
||||||
|
'menu.config': '全局配置',
|
||||||
|
'menu.config.desc': '管理配置',
|
||||||
|
'menu.profile': '个人信息',
|
||||||
|
'menu.emailServices': '发件服务',
|
||||||
|
'menu.emailServices.desc': '管理发件服务',
|
||||||
|
'menu.operLog': '操作日志',
|
||||||
|
'menu.operLog.desc': '查看操作日志',
|
||||||
|
};
|
||||||
583
src/locales/zh_CN/pages.ts
Normal file
583
src/locales/zh_CN/pages.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
export default {
|
||||||
|
'common.loading': '加载中...',
|
||||||
|
'common.content.noRules': '暂无规则',
|
||||||
|
'common.content.noData': '暂无数据',
|
||||||
|
'table.group': '所属组',
|
||||||
|
'table.operatorRecords': '操作记录',
|
||||||
|
'table.operatorAction': '操作动作',
|
||||||
|
'table.operatorTime': '操作时间',
|
||||||
|
'table.os': '操作系统',
|
||||||
|
'table.browser': '浏览器',
|
||||||
|
'table.timerange': '数据范围',
|
||||||
|
'table.volume': '手数',
|
||||||
|
'table.cmd': '交易类型',
|
||||||
|
'table.openTime': '开仓时间',
|
||||||
|
'table.profit': '盈亏',
|
||||||
|
'table.unit.datasource': '个',
|
||||||
|
'table.unit.users': '人',
|
||||||
|
'table.time': '时间',
|
||||||
|
'table.account': '交易账户',
|
||||||
|
'table.symbol': '产品',
|
||||||
|
'table.trigger': '触发值',
|
||||||
|
'table.triggerValue': '触发值',
|
||||||
|
'table.status': '状态',
|
||||||
|
'table.id': 'ID',
|
||||||
|
'table.alertId': '告警ID',
|
||||||
|
'table.accountId': '账户ID',
|
||||||
|
'table.companyId': '公司ID',
|
||||||
|
'table.dataSourceId': '数据源ID',
|
||||||
|
'table.dataSource': '数据源',
|
||||||
|
'table.dataSourceTime': '数据源时间',
|
||||||
|
'table.userId': '用户ID',
|
||||||
|
'table.roleId': '角色ID',
|
||||||
|
'table.company': '所属公司',
|
||||||
|
'table.ruleType': '规则类型',
|
||||||
|
'table.platform': '平台',
|
||||||
|
'table.detail': '详情',
|
||||||
|
'table.summary': '概要',
|
||||||
|
'table.triggerTime': '触发时间',
|
||||||
|
'table.action': '操作',
|
||||||
|
'table.currency': '币种',
|
||||||
|
'table.balance': '余额',
|
||||||
|
'table.equity': '净值',
|
||||||
|
'table.marginLevel': '保证金水平',
|
||||||
|
'table.alertCount': '告警数',
|
||||||
|
'table.companyName': '公司名称',
|
||||||
|
'table.contactEmail': '联系邮箱',
|
||||||
|
'table.dataSourceCount': '数据源',
|
||||||
|
'table.userCount': '用户数',
|
||||||
|
'table.createTime': '创建时间',
|
||||||
|
'table.name': '名称',
|
||||||
|
'table.ipAddress': 'IP地址',
|
||||||
|
'table.ruleCount': '规则数',
|
||||||
|
'table.username': '用户名',
|
||||||
|
'table.displayName': '显示名称',
|
||||||
|
'table.role': '角色',
|
||||||
|
'table.roleIdentifier': '角色标识',
|
||||||
|
'table.roleName': '角色名称',
|
||||||
|
'table.permissionLevel': '权限级别',
|
||||||
|
'table.type': '类型',
|
||||||
|
'table.permissionCount': '权限数',
|
||||||
|
'table.operator': '操作人',
|
||||||
|
'table.actionType': '动作',
|
||||||
|
'table.description': '详细描述',
|
||||||
|
'table.all': '全部',
|
||||||
|
'table.new': '待处理',
|
||||||
|
'table.viewed': '已查看',
|
||||||
|
'table.ignored': '已忽略',
|
||||||
|
'table.ignored2': '忽略',
|
||||||
|
'table.action.add': '添加',
|
||||||
|
'table.action.view': '查看',
|
||||||
|
'table.action.delete': '删除',
|
||||||
|
'table.action.other': '其他操作',
|
||||||
|
'table.action.deleteDone': '已删除',
|
||||||
|
'table.action.edit': '编辑',
|
||||||
|
'table.action.export': '导出',
|
||||||
|
'table.action.allRead': '全部标记已读',
|
||||||
|
'table.dataSource.status0': '连接中',
|
||||||
|
'table.dataSource.status1': '运行中',
|
||||||
|
'table.dataSource.status2': '连接失败',
|
||||||
|
'table.dataSource.status3': '删除中',
|
||||||
|
'table.user.status0': '停用',
|
||||||
|
'table.user.status1': '活跃',
|
||||||
|
'table.role.type0': '系统角色',
|
||||||
|
'table.role.type1': '自定义角色',
|
||||||
|
|
||||||
|
'pages.totalUserCount': '总用户数',
|
||||||
|
'pages.totalAlertCount': '总告警数',
|
||||||
|
'pages.totalCompanyCount': '总公司数',
|
||||||
|
'pages.activeCompanyCount': '活跃公司数',
|
||||||
|
'pages.totalDataSourceCount': '数据源总数',
|
||||||
|
'pages.activeDataSourceCount': '活跃数据源',
|
||||||
|
'pages.mt4DataSourceCount': 'MT4 数据源',
|
||||||
|
'pages.mt5DataSourceCount': 'MT5 数据源',
|
||||||
|
'pages.adminCount': '管理员',
|
||||||
|
'pages.operatorCount': '操作员',
|
||||||
|
'pages.readOnlyUserCount': '只读用户',
|
||||||
|
'pages.totalRoleCount': '总角色数',
|
||||||
|
'pages.systemRoleCount': '系统角色',
|
||||||
|
'pages.customRoleCount': '自定义角色',
|
||||||
|
'pages.availablePermissionList': '可用权限列表',
|
||||||
|
|
||||||
|
'pages.dashboard.todayAlertCount': '今日告警',
|
||||||
|
'pages.dashboard.activeRuleCount': '活跃规则',
|
||||||
|
'pages.dashboard.monitoringAccountCount': '告警账户数',
|
||||||
|
'pages.dashboard.alertProcessingRate': '告警处理率',
|
||||||
|
'pages.dashboard.todayTransactionCount': '今日交易',
|
||||||
|
'pages.dashboard.alertTrend': '告警趋势',
|
||||||
|
'pages.dashboard.globalView': '全局视图',
|
||||||
|
'pages.dashboard.ruleTriggerDistribution': '规则触发分布',
|
||||||
|
'pages.dashboard.latestAlert': ' 最新告警',
|
||||||
|
'pages.dashboard.platformTransactionVolume': '平台交易量',
|
||||||
|
'pages.dashboard.viewAll': '查看全部',
|
||||||
|
'pages.dashboard.recentAlerts': '最近告警',
|
||||||
|
|
||||||
|
'pages.user.roleDesc.superAdmin': '可管理全部公司、用户和数据',
|
||||||
|
'pages.user.roleDesc.companyAdmin': '可管理所属公司的用户和数据',
|
||||||
|
'pages.user.roleDesc.companyOperator': '配置分配数据源的规则',
|
||||||
|
'pages.user.roleDesc.companyViewer': '仅查看告警记录',
|
||||||
|
'pages.user.roleDesc.header': '角色权限说明',
|
||||||
|
|
||||||
|
'pages.role.system.superAdmin': '超级管理员',
|
||||||
|
'pages.role.system.companyAdmin': '公司管理员',
|
||||||
|
'pages.role.system.companyOperator': '公司用户',
|
||||||
|
'pages.role.system.companyViewer': '只读用户',
|
||||||
|
|
||||||
|
'pages.tips.emptyIsAll': '留空表示全部',
|
||||||
|
|
||||||
|
'pages.account.pop.title': '账户详情',
|
||||||
|
|
||||||
|
'pages.alert.pop.title': '告警追踪详情',
|
||||||
|
'pages.alert.pop.title2': '基本信息',
|
||||||
|
'pages.alert.pop.title3': '触发规则快照',
|
||||||
|
'pages.alert.pop.title4': '关联交易记录',
|
||||||
|
'pages.alert.newAlertTips': '你有新的告警,点击"重置"刷新数据后查看',
|
||||||
|
|
||||||
|
'pages.rules.seconds': '秒',
|
||||||
|
'pages.rules.lots': '手',
|
||||||
|
'pages.rules.triggerValue': '手数阈值',
|
||||||
|
'pages.rules.triggerValueUSD': 'USD 阈值',
|
||||||
|
'pages.rules.monitoredSymbols': '监控品种',
|
||||||
|
'pages.rules.monitoredGroups': '监控组',
|
||||||
|
'pages.rules.allGroups': '全部组',
|
||||||
|
'pages.rules.allSymbols': '全部产品',
|
||||||
|
'pages.rules.allAssets': '全部资产',
|
||||||
|
'pages.rules.trigger': '触发',
|
||||||
|
'pages.rules.triggeredCount': '次数',
|
||||||
|
'pages.rules.disable': '禁用',
|
||||||
|
'pages.rules.enable': '启用',
|
||||||
|
'pages.rules.status0': '待生效',
|
||||||
|
'pages.rules.status1': '运行中',
|
||||||
|
'pages.rules.status2': '已禁用',
|
||||||
|
'pages.rules.status3': '编辑生效中',
|
||||||
|
'pages.rules.status4': '删除生效中',
|
||||||
|
'pages.rules.status5': '删除成功',
|
||||||
|
'pages.rules.reportInterval': '上报间隔',
|
||||||
|
'pages.rules.ruleName': '规则名称',
|
||||||
|
'pages.rules.clone': '克隆',
|
||||||
|
'pages.rules.cloneConfirm': '确认克隆',
|
||||||
|
'pages.rules.cloneRule': '克隆规则',
|
||||||
|
'pages.rules.originalRule': '原始规则(只读)',
|
||||||
|
'pages.rules.cloneRulePreview': '克隆预览(可编辑)',
|
||||||
|
|
||||||
|
'pages.rule3.timeWindow': '时间窗口',
|
||||||
|
'pages.rule3.minOrderCount': '最小订单数',
|
||||||
|
'pages.rule3.minOrderCount.tips': '单',
|
||||||
|
'pages.rule3.totalLotsThreshold': '总手数阈值',
|
||||||
|
'pages.rule3.aggregationLogic': '聚合逻辑',
|
||||||
|
'pages.rule3.option1': '按虚拟大类聚合',
|
||||||
|
'pages.rule3.option2': '按单个品种聚合',
|
||||||
|
'form.rule3.tips':
|
||||||
|
'提示:系统会自动区分BUY和SELL方向,相同方向的订单才会累加手数。例如:60秒内开5张BUY单和3张SELL单,不会累加为8张。',
|
||||||
|
|
||||||
|
'pages.rule4.item1': '持仓时间阈值',
|
||||||
|
'pages.rule4.item2': '最小平仓获利',
|
||||||
|
'pages.rule4.item3': '最小平仓手数',
|
||||||
|
'pages.rule4.item4': '最小平仓USD价值',
|
||||||
|
'form.rule4.tips': '提示:监控持仓时间过短且获利超过阈值的超短线交易。',
|
||||||
|
|
||||||
|
'pages.rule5.item1': '目标货币',
|
||||||
|
'pages.rule5.item2': '敞口阈值',
|
||||||
|
'pages.rule5.item3': '时间间隔',
|
||||||
|
'pages.rule5.item4': '最大提醒次数',
|
||||||
|
'form.rule5.tips': '提示:实时监控各货币对的净敞口,防止头寸过大。',
|
||||||
|
|
||||||
|
'pages.rule5_1.item1': '监控资产',
|
||||||
|
'pages.rule5_1.item2': '上限阈值',
|
||||||
|
'pages.rule5_1.item4': '下限阈值',
|
||||||
|
'pages.rule5_1.item5': '折算货币',
|
||||||
|
'pages.rule5_1.validateError2': '上限值不能小于下限值',
|
||||||
|
'pages.rule5_1.validateError4': '下限值不能大于上限值',
|
||||||
|
|
||||||
|
// 'pages.rule6.item1': '停价阈值',
|
||||||
|
// 'pages.rule6.item2': '波动阈值',
|
||||||
|
// 'pages.rule6.item3': '波动模式',
|
||||||
|
// 'pages.rule6.option1': '点数 (Points)',
|
||||||
|
// 'pages.rule6.option2': '百分比 (%)',
|
||||||
|
// 'form.rule6.tips': '提示:监控行情报价中断和短时间内的异常剧烈波动。',
|
||||||
|
'pages.rule6_1.item1': '报价停滞时间',
|
||||||
|
'form.rule6_1.tips': '提示:监控行情报价中断。',
|
||||||
|
|
||||||
|
'pages.rule11.item1': '波动模式',
|
||||||
|
'pages.rule11.option1': '点数 (Points)',
|
||||||
|
'pages.rule11.option2': '百分比 (%)',
|
||||||
|
'pages.rule11.item2': '时间窗口',
|
||||||
|
'pages.rule11.item3': '最大波幅',
|
||||||
|
'form.rule11.tips': '提示:监控异常高频剧烈行情波动。',
|
||||||
|
|
||||||
|
'pages.rule7.item1': '平台类型',
|
||||||
|
'pages.rule7.option1': 'MT4 (系数: 100)',
|
||||||
|
'pages.rule7.option2': 'MT5 (系数: 10000)',
|
||||||
|
'pages.rule7.item2': 'NOP 阈值',
|
||||||
|
'pages.rule7.item3': '计算频率',
|
||||||
|
'pages.rule7.item4': '报警冷却时间',
|
||||||
|
'form.rule7.tips': '提示:监控单一产品的敞口限额,支持MT5折算。',
|
||||||
|
|
||||||
|
'pages.rule8.item1': '监控账户',
|
||||||
|
'pages.rule8.item1.tips': 'ID, 逗号分隔',
|
||||||
|
'pages.rule8.item2': '监控动作',
|
||||||
|
'pages.rule8.option1': '开仓',
|
||||||
|
'pages.rule8.option2': '挂单',
|
||||||
|
'pages.rule8.item3': '最小手数限制',
|
||||||
|
'form.rule8.tips': '提示:对重点监控名单中的账户进行的任何交易行为实时告警。',
|
||||||
|
|
||||||
|
'pages.rule9.item1': '最大间隔',
|
||||||
|
'pages.rule9.item2': '最小手数',
|
||||||
|
'pages.rule9.item3': '最小反向开仓USD价值',
|
||||||
|
'form.rule9.tips': '提示:监控平仓后短时间内反向开仓的翻仓/反向刷单行为。',
|
||||||
|
|
||||||
|
'pages.rule10.item1': '入金阈值',
|
||||||
|
'pages.rule10.item2': '出金阈值',
|
||||||
|
'pages.rule10.item3': '识别关键词',
|
||||||
|
'pages.rule10.item3.tips': '逗号分隔',
|
||||||
|
'form.rule10.tips': '提示:监控账户大额出入金,通过关键词识别入金类型。',
|
||||||
|
|
||||||
|
'form.rules.header': '当前规则含义:',
|
||||||
|
'form.rules.header.content1':
|
||||||
|
'如果开仓手数 ≥ <yellow>{lots}</yellow>,针对品种 <green>{symbols}</green>,触发告警。',
|
||||||
|
'form.rules.header.content2':
|
||||||
|
'如果开仓金额 ≥ <yellow>{amount}</yellow>,针对品种 <green>{symbols}</green>,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content3':
|
||||||
|
'在 <yellow>{seconds}</yellow> 秒内,如果订单数 ≥ <yellow>{orderCount}</yellow> 且总手数 ≥ <yellow>{totalLots}</yellow>,针对 <green>{symbols}</green> 按 <yellow>{aggregationLogic}</yellow> 监控,触发告警。',
|
||||||
|
'form.rules.header.content4':
|
||||||
|
'平仓后(含部分平仓),<blue>持仓时间 < </blue> <yellow>{seconds}</yellow> <blue>秒</blue> 且 <blue>平仓手数 ≥ </blue> <yellow>{lots}</yellow> 且 <blue>平仓盈利 ≥ </blue> <yellow>{profitValue}</yellow> 的 <green>{symbols}</green> 交易触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content5':
|
||||||
|
'如果 <yellow>{currency}</yellow> 的敞口 ≥ <yellow>{exposureValue}</yellow>,每隔 <yellow>{interval}</yellow> 秒,触发告警。',
|
||||||
|
'form.rules.header.content5_1':
|
||||||
|
'如果 <green>{assets}</green> 的敞口 ≥ <yellow>$ {upperThreshold}</yellow> 或 ≤ <yellow>$ {lowerThreshold}</yellow>,每隔 <yellow>{interval}</yellow> 秒,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content6':
|
||||||
|
'针对 <green>{symbols}</green>,如果停止报价时长 ≥ <yellow>{stopTime}</yellow> 秒,或价格波动 ≥ <yellow>{volatility}</yellow> ({volatilityUnit}),每隔 <yellow>{interval}</yellow> 秒,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content6_1':
|
||||||
|
'针对 <green>{symbols}</green>,如果停止报价时长 ≥ <yellow>{stopTime}</yellow> 秒,触发告警。',
|
||||||
|
'form.rules.header.content11':
|
||||||
|
'针对 <green>{symbols}</green>,如果价格波动 ≥ <yellow>{volatility}</yellow> (<yellow>{volatilityUnit}</yellow> 模式),时间窗口:<yellow>{timeWindow}</yellow>,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content7':
|
||||||
|
'如果 <green>{symbols}</green> 的净头寸(NOP) ≥ <yellow>{nopLimit}</yellow>,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content8':
|
||||||
|
'监控账号 <yellow>{tradeAccounts}</yellow> 的 <yellow>{action}</yellow> 动作,如果单笔手数 ≥ <yellow>{lots}</yellow>,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content9':
|
||||||
|
'平仓后 <yellow>{seconds}</yellow> 秒内,反向开仓手数 ≥ <yellow>{lots}</yellow> 或金额 ≥ <yellow>$ {usdValue}</yellow>,监控品种 <green>{symbols}</green>,触发告警。',
|
||||||
|
|
||||||
|
'form.rules.header.content10':
|
||||||
|
'监控单笔入金 ≥ <yellow>{depositValue}</yellow> 或出金 ≥ <yellow>{withdrawValue}</yellow> (含关键字: <yellow>{keywords}</yellow>),触发告警。',
|
||||||
|
|
||||||
|
'form.rule1.ignoreSimulatedAccount': '忽略模拟账户',
|
||||||
|
'form.rule1.tips': '提示:此规则将对单笔开仓手数超过阈值的订单进行实时监控。',
|
||||||
|
'form.rule1.tips2': '点击输入框搜索,或点击分类标题一键全选。',
|
||||||
|
'form.rule2.keyword': '美分账户组关键词',
|
||||||
|
'form.rule2.keyword.placeholder': '例如: *CENT*,*MICRO*',
|
||||||
|
'form.rule2.tips': '提示:监控单笔开仓USD等值金额超过设定阈值的交易。',
|
||||||
|
'form.selectOrSearch': '请选择或搜索',
|
||||||
|
'form.required': '该项是必填项',
|
||||||
|
'form.password': '密码',
|
||||||
|
'form.example': '例如',
|
||||||
|
'form.range': '范围',
|
||||||
|
'form.placeholder.ipAddress': '请输入正确的 IPv4 地址 (例如: 192.168.1.1)',
|
||||||
|
'form.dataSource.name': '数据源名称',
|
||||||
|
'form.dataSource.type': '平台类型',
|
||||||
|
'form.dataSource.connectionConfig': '连接配置',
|
||||||
|
'form.dataSource.ipAddress': 'IP 地址',
|
||||||
|
'form.dataSource.port': '端口号',
|
||||||
|
'form.dataSource.tradeAccount': '账号',
|
||||||
|
|
||||||
|
'form.user.title': '用户',
|
||||||
|
'form.user.dataSourceIds': '绑定数据源',
|
||||||
|
'form.user.dataSourceEmptyTips': '可选数据源为空,如需绑定,请先去添加',
|
||||||
|
'form.user.displayName': '显示名称',
|
||||||
|
'form.user.email': '邮箱',
|
||||||
|
'form.email.placeholder': '请输入合法的邮箱地址',
|
||||||
|
'form.password.repeatPassword': '确认密码',
|
||||||
|
'form.password.passwordNotMatch': '两次输入的密码不一致!',
|
||||||
|
'form.password.placeholder': '密码至少6位',
|
||||||
|
'form.password.tips':
|
||||||
|
' 提示(非强制):使用字母、数字和符号组合能让密码更安全。',
|
||||||
|
'form.password.strength': '强度',
|
||||||
|
'form.password.level0': '极弱',
|
||||||
|
'form.password.level1': '弱',
|
||||||
|
'form.password.level2': '中',
|
||||||
|
'form.password.level3': '强',
|
||||||
|
|
||||||
|
'form.role.title': '角色',
|
||||||
|
'form.role.preview': '预览',
|
||||||
|
'form.role.roleKey': '角色标识',
|
||||||
|
'form.role.roleKey.placeholder':
|
||||||
|
'如:risk_analyst(只能使用英文、数字和下划线)',
|
||||||
|
'form.role.roleKey.placeholder2': '只能使用英文、数字和下划线',
|
||||||
|
'form.role.roleName': '角色名称',
|
||||||
|
'form.role.roleName.placeholder': '如:风险分析师',
|
||||||
|
'form.role.level': '权限级别',
|
||||||
|
'form.role.level.placeholder':
|
||||||
|
'1-{level},,数值越大权限越高,可以修改低权限用户信息',
|
||||||
|
'form.role.placeholder2': '系统角色权限级别不可修改',
|
||||||
|
'form.role.badgeColor': '徽章颜色',
|
||||||
|
'form.role.menuIds': '绑定权限(菜单)',
|
||||||
|
|
||||||
|
'form.config.timezone': '时区设置',
|
||||||
|
'form.config.timezone.title': '默认时区',
|
||||||
|
'form.config.dataSet': '数据设置',
|
||||||
|
'form.config.dataSet.backDays': '告警保留天数',
|
||||||
|
'form.config.dataSet.backDays.tips': '超过此天数的告警将被自动归档',
|
||||||
|
'form.config.dataSet.refresh': '自动刷新间隔(秒)',
|
||||||
|
'form.config.notification': '通知设置',
|
||||||
|
'form.config.emailNotification': '邮件通知',
|
||||||
|
'form.config.emailNotification.tips': '新告警时发送邮件通知',
|
||||||
|
'form.config.soundNotification': '声音提醒',
|
||||||
|
'form.config.soundNotification.tips': '高风险告警时播放提示音',
|
||||||
|
'form.config.frontNotice': '前端推送',
|
||||||
|
'form.config.frontNotice.tips': '是否开启前端推送及相关设置',
|
||||||
|
'form.config.desktopNotification': '桌面推送',
|
||||||
|
'form.config.desktopNotification.notSupport': '浏览器不支持桌面通知',
|
||||||
|
'form.config.desktopNotification.tips': '浏览器桌面通知',
|
||||||
|
'form.config.desktopNoticeRefuseTips': '通知权限被拒绝',
|
||||||
|
'form.config.desktopNoticeRefuseTips2':
|
||||||
|
'您已拒绝桌面通知授权,无法使用桌面通知功能,该授权一旦拒绝,无法重新弹出。如需启用,需手动在浏览器设置中授权或重置。',
|
||||||
|
'form.config.noticeOnButNotWorkTips':
|
||||||
|
'如已开启桌面通知权限却未收到通知,请做如下检查',
|
||||||
|
'form.config.noticeOnButNotWorkTips2':
|
||||||
|
'系统处于"勿扰模式"或"专注模式"(最常见)',
|
||||||
|
'form.config.noticeOnButNotWorkTips3': '设置 → 系统 → 通知',
|
||||||
|
'form.config.noticeOnButNotWorkTips4':
|
||||||
|
'确保"获取来自应用和其他发送者的通知"已开启',
|
||||||
|
'form.config.noticeOnButNotWorkTips5':
|
||||||
|
'在列表中找到您使用的浏览器,确保其开关也是开启状态',
|
||||||
|
'form.config.noticeOnButNotWorkTips6': '检查"专注助手"是否关闭',
|
||||||
|
'form.config.noticeOnButNotWorkTips7': '系统设置 → 通知 → 专注模式',
|
||||||
|
'form.config.noticeOnButNotWorkTips8': '确保"勿扰模式"关闭',
|
||||||
|
'form.config.noticeOnButNotWorkTips9':
|
||||||
|
'在左侧列表中找到您使用的浏览器,确保"允许通知"已勾选',
|
||||||
|
'form.config.webhook': 'Webhook 告警配置',
|
||||||
|
'form.config.webhook.telegram.title': 'Telegram 通知',
|
||||||
|
'form.config.webhook.telegramBotToken': '机器人 Token',
|
||||||
|
'form.config.webhook.telegramBotToken.tips':
|
||||||
|
'从 @BotFather 获取的 API Token。',
|
||||||
|
'form.config.webhook.telegramChatId': '会话 ID (Chat ID)',
|
||||||
|
'form.config.webhook.telegramChatId.tips': '将机器人加入群组后获取的 ID。',
|
||||||
|
'form.config.webhook.teams.title': 'Teams 通知',
|
||||||
|
'form.config.webhook.teams.tips':
|
||||||
|
'在此输入 Microsoft Teams 的 Webhook 地址。',
|
||||||
|
'form.config.webhook.teams': 'Teams Webhook',
|
||||||
|
'form.config.webhook.slack.title': 'Slack 通知',
|
||||||
|
'form.config.webhook.slack.tips': '在此输入 Slack 的 Webhook 地址。',
|
||||||
|
'form.config.webhook.slack': 'Slack Webhook',
|
||||||
|
'form.config.webhook.lark.title': 'Lark 通知',
|
||||||
|
'form.config.webhook.lark.tips': '在此输入Lark机器人的 Webhook 地址。',
|
||||||
|
'form.config.webhook.lark': 'Lark Webhook',
|
||||||
|
'form.config.updateSuccess': '配置更新成功',
|
||||||
|
'form.config.inputURLTips': '请输入正确的URL格式',
|
||||||
|
|
||||||
|
'pages.layouts.userLayout.title':
|
||||||
|
'RiskGuard 是一款专注于风险评估、监控和报告的软件产品',
|
||||||
|
'pages.login.failure': '登录失败,请稍后重试!',
|
||||||
|
'pages.login.success': '登录成功!',
|
||||||
|
'pages.login.username': '邮箱',
|
||||||
|
'pages.login.username.required': '用户名是必填项!',
|
||||||
|
'pages.login.password': '密码',
|
||||||
|
'pages.login.password.required': '密码是必填项!',
|
||||||
|
'pages.login.submit': '登录',
|
||||||
|
'pages.404.subTitle': '抱歉,您访问的页面不存在。',
|
||||||
|
'pages.404.buttonText': '返回首页',
|
||||||
|
'pages.500.title': '服务异常',
|
||||||
|
'pages.500.subTitle': '获取菜单或权限信息失败,请稍后尝试刷新页面。',
|
||||||
|
'pages.refresh': '刷新页面',
|
||||||
|
|
||||||
|
'pages.logout': '退出登录',
|
||||||
|
'pages.profile': '个人中心',
|
||||||
|
|
||||||
|
'pages.profile.basicInfo': '基本信息',
|
||||||
|
'pages.profile.changePassword': '修改密码',
|
||||||
|
'pages.systemDirectly': '系统直属',
|
||||||
|
'pages.confirmModify': '确认修改',
|
||||||
|
'pages.oldPassword': '原密码',
|
||||||
|
'pages.oldPassword.tips': '请输当前登录密码',
|
||||||
|
'pages.newPassword': '新密码',
|
||||||
|
'pages.confirmPassword': '确认新密码',
|
||||||
|
'pages.changePasswordSuccess': '修改成功,即将跳转重新登录',
|
||||||
|
'pages.changePassTips':
|
||||||
|
'您当前使用的是系统初始密码。为了您的账户隐私与操作安全,建议您现在前往个人中心修改密码。',
|
||||||
|
'pages.goLogin': '去登录',
|
||||||
|
'pages.laterHandle': '稍后处理',
|
||||||
|
|
||||||
|
'pages.loginFirst': '请先登录',
|
||||||
|
'pages.notice.title': '通知服务',
|
||||||
|
'pages.notice.retryMaxTips': '重试次数已达最大次数,连接失败',
|
||||||
|
'pages.notice.onError': '连接已中断,请检查网络后手动重试',
|
||||||
|
'pages.notice.status.processing': '正在连接中... (重试:{retryCount})',
|
||||||
|
'pages.notice.status.success': '页面通知服务在线',
|
||||||
|
'pages.notice.status.error': '页面通知服务连接失败, 请检查网络后后手动重试',
|
||||||
|
'pages.notice.status.closed': '页面通知服务已断开',
|
||||||
|
'pages.notice.status.default': '页面通知服务未连接',
|
||||||
|
'pages.notice.status.replaced': '其他端已连接, 如需继续使用请重新连接',
|
||||||
|
'pages.notice.status.processing.title': '连接中',
|
||||||
|
'pages.notice.status.success.title': '在线',
|
||||||
|
'pages.notice.status.error.title': '连接失败',
|
||||||
|
'pages.notice.status.closed.title': '已断开',
|
||||||
|
'pages.notice.status.default.title': '未连接',
|
||||||
|
'pages.notice.status.replaced.title': '已替换',
|
||||||
|
'pages.notice.button.reconnect': '重连',
|
||||||
|
'pages.tips.operationSuccess': '操作成功',
|
||||||
|
'pages.tips.operationFailed': '操作失败',
|
||||||
|
|
||||||
|
'notice.title': '告警通知',
|
||||||
|
'notice.title1': '告警类型',
|
||||||
|
'notice.title2': '您有{count}条告警通知',
|
||||||
|
|
||||||
|
'pages.model.delete': '删除任务',
|
||||||
|
'pages.model.delete.content': '该操作不可逆, 确定删除吗?',
|
||||||
|
'pages.model.disable': '禁用用户',
|
||||||
|
'pages.model.disable.content': '禁用后, 该用户将不可登录系统, 确定禁用吗?',
|
||||||
|
'pages.model.confirm': '确认',
|
||||||
|
'pages.model.cancel': '取消',
|
||||||
|
'pages.model.allRead.content': '确认将所有告警通知标记为已读吗?',
|
||||||
|
|
||||||
|
'pages.export.noData': '当前暂无可导出的数据',
|
||||||
|
'pages.export.exporting': '正在导出,请稍候...',
|
||||||
|
'pages.export.exportSuccess': '导出成功',
|
||||||
|
'pages.export.exportFailed': '导出失败,请重试',
|
||||||
|
|
||||||
|
'pages.products.noBindDataSource':
|
||||||
|
'当前用户暂无绑定的数据源,请先绑定或联系公司管理员进行绑定后查看',
|
||||||
|
'pages.products.noBindDataSource.superadmin': '暂无数据源,请先添加',
|
||||||
|
'pages.products.bindDataSource': '去绑定',
|
||||||
|
'pages.products.goto': '去处理',
|
||||||
|
'pages.products.directory': '产品目录',
|
||||||
|
'pages.products.directory.tips': '请先选择目录或开始搜索',
|
||||||
|
'pages.products.search': '全局搜索产品名称',
|
||||||
|
'pages.products.searchResult': '全局搜索结果',
|
||||||
|
'pages.products.noResult': '未找到包含 "{searchText}" 的产品',
|
||||||
|
'pages.products.noProduct': '该目录下暂无产品',
|
||||||
|
|
||||||
|
'pages.config.alert': '修改配置后请务必点击提交按钮保存',
|
||||||
|
'pages.config.testPush': '测试推送',
|
||||||
|
'pages.config.testPush.tips': '请先配置好完整信息',
|
||||||
|
'pages.config.testPush.success': '已发送测试推送, 请查看对应软件的群组或频道',
|
||||||
|
'pages.config.testPush.fail': '测试推送失败, 请检查配置',
|
||||||
|
'pages.config.pop.viewPdf': '查看图文教程',
|
||||||
|
|
||||||
|
'pages.config.pop.telegram.title': 'Telegram 机器人配置指南',
|
||||||
|
'pages.config.pop.telegram.content1':
|
||||||
|
'在 Telegram 中搜索 <b>@BotFather</b>。',
|
||||||
|
'pages.config.pop.telegram.content2':
|
||||||
|
'发送 /newbot 指令并按提示获取 <b>API Token</b>。',
|
||||||
|
'pages.config.pop.telegram.content3':
|
||||||
|
'创建群组或频道,并将您的机器人添加为管理员。',
|
||||||
|
'pages.config.pop.telegram.content4':
|
||||||
|
'获取 <b>Chat ID</b> (可通过 @userinfobot 或 API 获取)。',
|
||||||
|
'pages.config.pop.telegram.tips': 'Telegram 官方机器人教程',
|
||||||
|
|
||||||
|
'pages.config.pop.teams.title': 'Microsoft Teams Webhook 配置指南',
|
||||||
|
'pages.config.pop.teams.content1': '打开 Teams,进入目标频道。',
|
||||||
|
'pages.config.pop.teams.content2':
|
||||||
|
'点击 <b>... (更多)</b> -> <b>工作流 (推荐)</b>。',
|
||||||
|
'pages.config.pop.teams.content3':
|
||||||
|
'搜索 <b>"在接收到 Webhook 请求时发布到频道"</b>。',
|
||||||
|
'pages.config.pop.teams.content4':
|
||||||
|
'按提示创建并获取唯一的 <b>Webhook URL</b>。',
|
||||||
|
'pages.config.pop.teams.tips': 'Teams Webhook 官方配置指南',
|
||||||
|
'pages.config.pop.teams.url':
|
||||||
|
'https://learn.microsoft.com/zh-cn/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook',
|
||||||
|
|
||||||
|
'pages.config.pop.slack.title': 'Slack Webhook 配置指南',
|
||||||
|
'pages.config.pop.slack.content1': '进入 Slack API 界面并创建新 App。',
|
||||||
|
'pages.config.pop.slack.content2': '在设置中开启 <b>Incoming Webhooks</b>。',
|
||||||
|
'pages.config.pop.slack.content3':
|
||||||
|
'点击 <b>Add New Webhook to Workspace</b> 并选择接收频道。',
|
||||||
|
'pages.config.pop.slack.content4': '复制生成的 <b>Webhook URL</b>。',
|
||||||
|
'pages.config.pop.slack.tips': 'Slack Webhook 官方说明',
|
||||||
|
|
||||||
|
'pages.config.pop.lark.title': 'Lark Webhook 配置指南',
|
||||||
|
'pages.config.pop.lark.content1':
|
||||||
|
'打开Lark群聊,点击 <b>设置</b> -> <b>群机器人</b>。',
|
||||||
|
'pages.config.pop.lark.content2':
|
||||||
|
'点击 <b>添加机器人</b> -> <b>自定义机器人</b>。',
|
||||||
|
'pages.config.pop.lark.content3':
|
||||||
|
'设置机器人名称,并复制生成的 <b>Webhook 地址</b>。',
|
||||||
|
'pages.config.pop.lark.content4':
|
||||||
|
'(可选) 为了安全,建议配置 IP 白名单或签名校验。',
|
||||||
|
'pages.config.pop.lark.tips': 'Lark机器人官方指南',
|
||||||
|
'pages.config.pop.lark.url':
|
||||||
|
'https://www.larksuite.com/hc/zh-CN/articles/360048487736-%E5%9C%A8%E7%BE%A4%E7%BB%84%E4%B8%AD%E4%BD%BF%E7%94%A8%E6%9C%BA%E5%99%A8%E4%BA%BA',
|
||||||
|
|
||||||
|
'pages.clone.tips1': '规则操作失败',
|
||||||
|
'pages.clone.tips2': '规则已添加, 但仍在处理中, 请稍后手动查看规则状态。',
|
||||||
|
'pages.clone.tips3':
|
||||||
|
'规则已添加, 但获取规则详情失败, 请稍后手动查看规则状态。',
|
||||||
|
'pages.clone.tips4': '规则已添加, 但状态有误, 请检查参数后重试。',
|
||||||
|
'pages.clones.title': '批量克隆',
|
||||||
|
'pages.clones.step1.title': '选择目标',
|
||||||
|
'pages.clones.step1.source': '来源服务器',
|
||||||
|
'pages.clones.step1.target': '目标服务器',
|
||||||
|
'pages.clones.step1.sourceRules': '当前源下有 {count} 条「{ruleType}」规则',
|
||||||
|
'pages.clones.step1.dividerText': '将克隆至',
|
||||||
|
'pages.clones.step1.confirm': '下一步:预览规则 →',
|
||||||
|
'pages.clones.step1.tips': '请先选择源服务器',
|
||||||
|
'pages.clones.step2.title': '预览规则',
|
||||||
|
'pages.clones.step2.summary':
|
||||||
|
'总计 {count} 条规则, 将克隆 {cloneCount} 条规则至 "{target}"',
|
||||||
|
'pages.clones.step2.checkAll': '全选',
|
||||||
|
'pages.clones.step2.cardTitle1': '原始(只读)',
|
||||||
|
'pages.clones.step2.cardTitle2': '克隆后',
|
||||||
|
'pages.clones.step2.confirm': '下一步:确认执行 →',
|
||||||
|
'pages.clones.step3.title': '确认执行',
|
||||||
|
'pages.clones.step3.confirmTitle':
|
||||||
|
'即将克隆 {cloneCount} 条规则至 "{target}"',
|
||||||
|
'pages.clones.step3.confirmTips': '请输入下方的目标服务器名称以确认:',
|
||||||
|
'pages.clones.step3.confirmTips2': '* * 输入目标服务器名称以激活确认按钮',
|
||||||
|
'pages.clones.step3.confirm': '开始执行克隆',
|
||||||
|
'pages.clones.step3.progress': '批量克隆进度',
|
||||||
|
'pages.clones.step3.start': '开始克隆',
|
||||||
|
'pages.clones.step3.status1': '处理中',
|
||||||
|
'pages.clones.step3.status2': '成功',
|
||||||
|
'pages.clones.step3.status3': '失败',
|
||||||
|
'pages.clones.step3.status4': '警告',
|
||||||
|
'pages.clones.step3.status5': '等待中',
|
||||||
|
'pages.clones.step3.success': '所有规则克隆成功',
|
||||||
|
'pages.clones.step3.warning':
|
||||||
|
'规则已全部克隆, 但部分规则状态获取失败, 请手动查看处理',
|
||||||
|
'pages.clones.step3.error': '部分规则克隆失败, 请手动处理',
|
||||||
|
'pages.clones.step3.understand': '已了解',
|
||||||
|
'pages.clones.back': '返回',
|
||||||
|
'pages.clones.backPreview': '返回预览',
|
||||||
|
|
||||||
|
'pages.email.desc': '管理所有公司的 SMTP / API 发件渠道',
|
||||||
|
'pages.email.serviceName': '服务名称',
|
||||||
|
'pages.email.notAssigned': '尚未分配',
|
||||||
|
'pages.email.serviceName.tips': '如:SendGrid 生产环境',
|
||||||
|
'pages.email.serviceProvider': '服务商',
|
||||||
|
'pages.email.assignedTo': '已分配给',
|
||||||
|
'pages.email.availableEmails': '可用发件邮箱',
|
||||||
|
'pages.email.availableEmails.tips': '在服务商已验证的邮箱,多个用逗号分隔',
|
||||||
|
'pages.email.status.normal': '正常',
|
||||||
|
'pages.email.status.disabled': '停用',
|
||||||
|
'pages.email.notification': '邮件通知配置',
|
||||||
|
'pages.email.notificationEmail': '通知邮箱',
|
||||||
|
'pages.email.notificationEmail.tips':
|
||||||
|
'用于接收告警邮件的邮箱地址(默认同登录邮箱)',
|
||||||
|
'pages.email.emailNotification': '邮件告警',
|
||||||
|
'pages.email.acceptEnable': '接收邮件告警',
|
||||||
|
'pages.email.acceptEnable.tips': '开启后,规则触发时将向通知邮箱发送告警邮件',
|
||||||
|
'pages.email.notificationEnable': '开启邮件告警',
|
||||||
|
'pages.email.notificationEnable.tips':
|
||||||
|
'开启后,本公司中已开启邮件通知的用户将收到告警邮件',
|
||||||
|
'pages.email.notificationService': '分配发件服务',
|
||||||
|
'pages.email.notificationFromName': '发件人名称',
|
||||||
|
'pages.email.notificationFromEmail': '发件邮箱',
|
||||||
|
'pages.email.testEmail.title': '接收测试信息的邮件',
|
||||||
|
'pages.email.testEmail.success': '测试邮件已发送,请检查{email}收件箱。',
|
||||||
|
'pages.email.companyEmailDisabled':
|
||||||
|
'所属公司未开启/已禁用邮件功能,暂无法开启 (已开启的可关闭)',
|
||||||
|
|
||||||
|
'pages.error.title': '页面资源加载错误',
|
||||||
|
'pages.error.subTitle':
|
||||||
|
'网络环境不稳定或系统版本更新,导致部分资源加载失败, 请检查网络或稍后刷新页面重试。',
|
||||||
|
'pages.error.subTitle2': '抱歉,当前页面内容渲染时发生了错误。',
|
||||||
|
|
||||||
|
'pages.save': '保存',
|
||||||
|
'pages.rules.cloneRuleWarning': '至少需要绑定两个数据源才能使用克隆功能',
|
||||||
|
};
|
||||||
55
src/models/useMenuNoticeNum.ts
Normal file
55
src/models/useMenuNoticeNum.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { getAlertPages } from '@/services/api';
|
||||||
|
|
||||||
|
type signType = '-' | '+' | 'replace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu notice number management hook
|
||||||
|
* 1. Fetch unread count from the server and set it to the state
|
||||||
|
* 2. Provide a function to update the unread count with a sign (add, subtract, replace)
|
||||||
|
*/
|
||||||
|
export default () => {
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
const fetchUnread = useCallback(async () => {
|
||||||
|
if (initialized) return;
|
||||||
|
|
||||||
|
const { data } = await getAlertPages({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
});
|
||||||
|
setUnreadCount(data?.pending || 0);
|
||||||
|
setInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setUnreadCountWithSign = useCallback(
|
||||||
|
(count: number, sign: signType = 'replace') => {
|
||||||
|
setUnreadCount((prev) => {
|
||||||
|
let newCount = prev;
|
||||||
|
switch (sign) {
|
||||||
|
case '-':
|
||||||
|
newCount -= count;
|
||||||
|
break;
|
||||||
|
case '+':
|
||||||
|
newCount += count;
|
||||||
|
break;
|
||||||
|
case 'replace':
|
||||||
|
newCount = count;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return newCount;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[unreadCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
initialized,
|
||||||
|
setUnreadCount,
|
||||||
|
setUnreadCountWithSign,
|
||||||
|
fetchUnread,
|
||||||
|
setInitialized,
|
||||||
|
};
|
||||||
|
};
|
||||||
232
src/models/useSSE.ts
Normal file
232
src/models/useSSE.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from '@microsoft/fetch-event-source';
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { authFaiedToLoginPage } from '@/access';
|
||||||
|
export type SSEStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'connecting'
|
||||||
|
| 'open'
|
||||||
|
| 'error'
|
||||||
|
| 'closed'
|
||||||
|
| 'replaced';
|
||||||
|
interface SSEOptions {
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
maxRetries?: number;
|
||||||
|
retryInterval?: number;
|
||||||
|
openWhenHidden?: boolean;
|
||||||
|
onMessage?: (data: string) => void;
|
||||||
|
onError?: (error: any) => void;
|
||||||
|
onStatusChange?: (status: SSEStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [status, setStatus] = useState<SSEStatus>('idle');
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
|
// Use ref to synchronize internal logic to avoid logical judgment errors caused by status update delays.
|
||||||
|
const internalCountRef = useRef(0);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Save retry parameters for onclose use
|
||||||
|
const retryParamsRef = useRef<{
|
||||||
|
url: string;
|
||||||
|
options: SSEOptions;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const updateStatus = useCallback((s: SSEStatus) => {
|
||||||
|
setStatus(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute retry logic
|
||||||
|
* @returns true means you can continue to retry, false means the maximum number of retries has been reached
|
||||||
|
*/
|
||||||
|
const doRetry = useCallback(
|
||||||
|
(err?: any): boolean => {
|
||||||
|
const { maxRetries, options } = retryParamsRef.current || {};
|
||||||
|
|
||||||
|
if (!retryParamsRef.current) return false;
|
||||||
|
|
||||||
|
internalCountRef.current += 1;
|
||||||
|
setRetryCount(internalCountRef.current);
|
||||||
|
|
||||||
|
// Maximum number of retries reached
|
||||||
|
if (internalCountRef.current >= maxRetries!) {
|
||||||
|
updateStatus('error');
|
||||||
|
options?.onError?.(
|
||||||
|
err || new Error('SSE connection failed after max retries'),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Continue retrying
|
||||||
|
updateStatus('connecting');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[updateStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectSSE = useCallback(
|
||||||
|
(url: string, options: SSEOptions) => {
|
||||||
|
const {
|
||||||
|
method = 'GET',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
maxRetries = 3,
|
||||||
|
retryInterval = 3000,
|
||||||
|
openWhenHidden = true,
|
||||||
|
onMessage,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Save retry parameters
|
||||||
|
retryParamsRef.current = { url, options, maxRetries, retryInterval };
|
||||||
|
|
||||||
|
// Clean up old connections
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
// Reset count when first connecting (if not a retry scenario)
|
||||||
|
if (status === 'idle' || status === 'error' || status === 'closed') {
|
||||||
|
internalCountRef.current = 0;
|
||||||
|
setRetryCount(0);
|
||||||
|
}
|
||||||
|
updateStatus('connecting');
|
||||||
|
|
||||||
|
fetchEventSource(url, {
|
||||||
|
method,
|
||||||
|
headers: { ...headers },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
openWhenHidden,
|
||||||
|
async onopen(response) {
|
||||||
|
if (
|
||||||
|
response.ok &&
|
||||||
|
response.headers
|
||||||
|
.get('content-type')
|
||||||
|
?.includes(EventStreamContentType)
|
||||||
|
) {
|
||||||
|
internalCountRef.current = 0;
|
||||||
|
setRetryCount(0);
|
||||||
|
updateStatus('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 401 Handling: Clear token and redirect to login page
|
||||||
|
if (response.status === 401) {
|
||||||
|
await authFaiedToLoginPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Connection failed with status ${response.status}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
onmessage(msg) {
|
||||||
|
const event = msg.event.toLowerCase();
|
||||||
|
// Filter notification events
|
||||||
|
if (msg.data && event === 'data') {
|
||||||
|
onMessage?.(msg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other clients log in and connect to SSE. The current logic only makes one connection per user.
|
||||||
|
* The previous connection will wait for the default timeout in the background before disconnecting and will not trigger a disconnect event after testing.
|
||||||
|
* So it is agreed to actively disconnect the connection with this event and notify the user, allowing them to decide whether to reconnect.
|
||||||
|
*/
|
||||||
|
if (event === 'replaced') {
|
||||||
|
disconnectSSE('replaced');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'error') {
|
||||||
|
console.log('backend notice see closed');
|
||||||
|
const shouldRetry = doRetry();
|
||||||
|
if (shouldRetry) {
|
||||||
|
console.log(
|
||||||
|
`Retrying connection in ${retryInterval}ms... (attempt ${internalCountRef.current})`,
|
||||||
|
);
|
||||||
|
// Use setTimeout to delay retries
|
||||||
|
setTimeout(() => {
|
||||||
|
// Check if actively disconnected
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
connectSSE(url, options);
|
||||||
|
}
|
||||||
|
}, retryInterval);
|
||||||
|
} else {
|
||||||
|
updateStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onclose() {
|
||||||
|
console.log('SSE connection closed');
|
||||||
|
// Try to reconnect
|
||||||
|
const shouldRetry = doRetry();
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
console.log(
|
||||||
|
`Retrying connection in ${retryInterval}ms... (attempt ${internalCountRef.current})`,
|
||||||
|
);
|
||||||
|
// Use setTimeout to delay retries
|
||||||
|
setTimeout(() => {
|
||||||
|
// Check if actively disconnected
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
connectSSE(url, options);
|
||||||
|
}
|
||||||
|
}, retryInterval);
|
||||||
|
} else {
|
||||||
|
updateStatus('error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onerror(err) {
|
||||||
|
console.error('SSE connection error:');
|
||||||
|
|
||||||
|
// User actively disconnects, do not count
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
updateStatus('closed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRetry = doRetry(err);
|
||||||
|
|
||||||
|
if (!shouldRetry) {
|
||||||
|
throw err; // Maximum number of retries reached, throw exception to stop automatic reconnection
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus('connecting'); // Mark as reconnecting
|
||||||
|
return retryInterval;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateStatus, doRetry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const disconnectSSE = useCallback(
|
||||||
|
(status: SSEStatus = 'error') => {
|
||||||
|
console.log('disconnect ...');
|
||||||
|
|
||||||
|
// Clear the retry parameters to prevent continued retries in onclose
|
||||||
|
retryParamsRef.current = null;
|
||||||
|
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
updateStatus(status);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
retryCount,
|
||||||
|
connectSSE,
|
||||||
|
disconnectSSE,
|
||||||
|
};
|
||||||
|
};
|
||||||
31
src/pages/404.tsx
Normal file
31
src/pages/404.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { history } from '@umijs/max';
|
||||||
|
import { Button, Result, Space } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import ThemeWrapper from '@/components/ThemeWrapper';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const NoFoundPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<ThemeWrapper>
|
||||||
|
<Result
|
||||||
|
className="abs-center"
|
||||||
|
style={{ top: '40%' }}
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle={$t('pages.404.subTitle')}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => history.push('/')}>
|
||||||
|
{$t('pages.404.buttonText')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
{$t('pages.refresh')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ThemeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoFoundPage;
|
||||||
25
src/pages/500.tsx
Normal file
25
src/pages/500.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import ThemeWrapper from '@/components/ThemeWrapper';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const NoFoundPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<ThemeWrapper>
|
||||||
|
<Result
|
||||||
|
className="abs-center"
|
||||||
|
style={{ top: '40%' }}
|
||||||
|
status="500"
|
||||||
|
title={$t('pages.500.title')}
|
||||||
|
subTitle={$t('pages.500.subTitle')}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
{$t('pages.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ThemeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoFoundPage;
|
||||||
188
src/pages/account/Popup.tsx
Normal file
188
src/pages/account/Popup.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useAccess, useRequest } from '@umijs/max';
|
||||||
|
import { Badge, Descriptions, Modal, Table } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAlertPages } from '@/services/api';
|
||||||
|
import { formatNumber } from '@/utils';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { formatToUserTimezone } from '@/utils/timeFormat';
|
||||||
|
|
||||||
|
export default (props: {
|
||||||
|
visible: boolean;
|
||||||
|
rowData: Partial<API.TradeAccountListItem | undefined>;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { isGlobalCompany } = useAccess();
|
||||||
|
const { visible, rowData, setVisible } = props;
|
||||||
|
const [alertData, setAlertData] = useState<API.AlertListItem[]>([]);
|
||||||
|
|
||||||
|
const { loading, run } = useRequest(
|
||||||
|
(id) =>
|
||||||
|
getAlertPages({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
tradeAccount: id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
setAlertData(data.list || []);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && rowData?.account) {
|
||||||
|
run(rowData.account);
|
||||||
|
}
|
||||||
|
}, [visible, rowData?.account]);
|
||||||
|
|
||||||
|
const statusMap = [
|
||||||
|
{
|
||||||
|
color: '#f97316',
|
||||||
|
text: $t('table.new'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#10b981',
|
||||||
|
text: $t('table.viewed'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#64748b',
|
||||||
|
text: $t('table.ignored'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: $t('table.company'),
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
hidden: !isGlobalCompany,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSource'),
|
||||||
|
dataIndex: 'dataSourceName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.ruleType'),
|
||||||
|
dataIndex: 'ruleTypeName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.platform'),
|
||||||
|
dataIndex: 'platform',
|
||||||
|
render: (platform: number) => {
|
||||||
|
return `MT${platform}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.symbol'),
|
||||||
|
dataIndex: 'product',
|
||||||
|
render: (product: string) => {
|
||||||
|
return product || 'N/A';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.trigger'),
|
||||||
|
dataIndex: 'trigger',
|
||||||
|
width: 'auto',
|
||||||
|
fieldProps: {
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.summary'),
|
||||||
|
dataIndex: 'summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${$t('table.triggerTime')} (UTC+0)`,
|
||||||
|
dataIndex: 'triggerTime',
|
||||||
|
render: (triggerTime: string) => {
|
||||||
|
return formatToUserTimezone(triggerTime);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (status: number) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={statusMap[status - 1]?.color || '#64748b'}
|
||||||
|
text={statusMap[status - 1]?.text || '-'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: $t('table.platform'),
|
||||||
|
children: rowData?.platform ? `MT${rowData?.platform}` : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: $t('table.currency'),
|
||||||
|
children: rowData?.currency || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: $t('table.balance'),
|
||||||
|
children: formatNumber(
|
||||||
|
rowData?.balance || 0,
|
||||||
|
rowData?.currencyDigits || 2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4',
|
||||||
|
label: $t('table.equity'),
|
||||||
|
children: formatNumber(
|
||||||
|
rowData?.equity || 0,
|
||||||
|
rowData?.currencyDigits || 2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '5',
|
||||||
|
label: $t('table.marginLevel'),
|
||||||
|
children: `${rowData?.marginLevel?.toFixed(2)}%` || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '6',
|
||||||
|
label: $t('table.createTime'),
|
||||||
|
children: formatToUserTimezone(rowData?.createTime || ''),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`${$t('pages.account.pop.title')} - ${rowData?.account || '-'} (${rowData?.group || '-'})`}
|
||||||
|
width={1200}
|
||||||
|
centered
|
||||||
|
open={visible}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setVisible(false)}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: '85vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ margin: '0 -24px' }}>
|
||||||
|
<ProCard title={`ℹ️ ${$t('pages.alert.pop.title2')}`}>
|
||||||
|
<Descriptions layout="vertical" items={items} column={2} bordered />
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
<ProCard title={`🤝 ${$t('pages.dashboard.recentAlerts')}`}>
|
||||||
|
<Table
|
||||||
|
dataSource={alertData}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
291
src/pages/account/index.tsx
Normal file
291
src/pages/account/index.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import { Button, Col, Row } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import HeadStatisticCard, {
|
||||||
|
type CardInfo,
|
||||||
|
} from '@/components/HeadStatisticCard';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { getAccountPages, getCompanyList } from '@/services/api';
|
||||||
|
import { getAllDataSourceList } from '@/utils/dataCenter';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { formatNumber } from '@/utils/index';
|
||||||
|
import Popup from './Popup';
|
||||||
|
|
||||||
|
const maxAlertHideCount = 9999;
|
||||||
|
|
||||||
|
const Account: React.FC = () => {
|
||||||
|
const { isGlobalCompany } = useAccess();
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const companyInfo = userInfo?.company;
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [rowData, setRowData] = useState<API.TradeAccountListItem | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [companyList, setCompanyList] = useState<API.CompanyListItem[]>([]);
|
||||||
|
const [dataSourceList, setDataSourceList] = useState<
|
||||||
|
API.DataSourceListItem[]
|
||||||
|
>([]);
|
||||||
|
const [cardInfo, setCardInfo] = useState({
|
||||||
|
count: 0,
|
||||||
|
alertCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGlobalCompany) {
|
||||||
|
getCompanyList().then(({ data: { data } }) => {
|
||||||
|
setCompanyList(
|
||||||
|
data.filter((item: API.CompanyListItem) => item.id !== 0) || [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllDataSourceList(companyInfo, true).then((data) => {
|
||||||
|
setDataSourceList(data || []);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: ProColumns<API.TradeAccountListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.accountId'),
|
||||||
|
dataIndex: 'account',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.company'),
|
||||||
|
dataIndex: 'companyId',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...companyList.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
renderText: (_, { companyId }) => {
|
||||||
|
const foundCompany = companyList.find((item) => item.id === companyId);
|
||||||
|
return foundCompany?.name || '-';
|
||||||
|
},
|
||||||
|
hideInSearch: !isGlobalCompany,
|
||||||
|
hideInTable: !isGlobalCompany,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSource'),
|
||||||
|
dataIndex: 'dataSourceId',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...dataSourceList.map((item) => ({
|
||||||
|
label: item.sourceName,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, { platform, sourceName }) => {
|
||||||
|
return (
|
||||||
|
<MyTag color={platform === 4 ? '#818cf8' : '#10b981'}>
|
||||||
|
{sourceName || '-'}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.platform'),
|
||||||
|
dataIndex: 'platformId',
|
||||||
|
render: (_, { platform = 0 }) => {
|
||||||
|
return (
|
||||||
|
<MyTag
|
||||||
|
color={platform === 4 ? '#818cf8' : '#10b981'}
|
||||||
|
>{`MT${platform}`}</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: 'MT4',
|
||||||
|
// value: '4',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
label: 'MT5',
|
||||||
|
value: '5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.group'),
|
||||||
|
dataIndex: 'group',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.currency'),
|
||||||
|
dataIndex: 'currency',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.balance'),
|
||||||
|
dataIndex: 'balance',
|
||||||
|
renderText: (_, { balance = 0, currencyDigits = 2 }) =>
|
||||||
|
formatNumber(balance, currencyDigits),
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.equity'),
|
||||||
|
dataIndex: 'equity',
|
||||||
|
renderText: (_, { equity = 0, currencyDigits = 2 }) =>
|
||||||
|
formatNumber(equity, currencyDigits),
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.marginLevel'),
|
||||||
|
dataIndex: 'marginLevel',
|
||||||
|
renderText: (_, { marginLevel = 0, currencyDigits = 2 }) =>
|
||||||
|
`${marginLevel.toFixed(currencyDigits)}%`,
|
||||||
|
// render: (_, { marginLevel = 0, currencyDigits = 2 }) => {
|
||||||
|
// const color =
|
||||||
|
// marginLevel > 300
|
||||||
|
// ? marginLevel > 500
|
||||||
|
// ? '#10b981'
|
||||||
|
// : '#f59e0b'
|
||||||
|
// : '#ef4444';
|
||||||
|
// return (
|
||||||
|
// <MyTag
|
||||||
|
// color={color}
|
||||||
|
// >{`${marginLevel.toFixed(currencyDigits)}%`}</MyTag>
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.alertCount'),
|
||||||
|
dataIndex: 'alertCount',
|
||||||
|
render: (_, { alertCount = 0 }) => {
|
||||||
|
return (
|
||||||
|
<MyTag
|
||||||
|
color={alertCount ? '#ef4444' : '#64748b'}
|
||||||
|
title={`${alertCount}`}
|
||||||
|
>
|
||||||
|
{alertCount > maxAlertHideCount
|
||||||
|
? `${maxAlertHideCount}+`
|
||||||
|
: (alertCount ?? '-')}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.action'),
|
||||||
|
dataIndex: 'option',
|
||||||
|
width: '60px',
|
||||||
|
valueType: 'option',
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, item) => [
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
key="view"
|
||||||
|
onClick={() => {
|
||||||
|
setVisible(true);
|
||||||
|
setRowData(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.view')}
|
||||||
|
</Button>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type CardKey = keyof typeof cardInfo;
|
||||||
|
const headStatisticCardList: Record<CardKey, CardInfo> = {
|
||||||
|
count: {
|
||||||
|
count: 0,
|
||||||
|
icon: '👥',
|
||||||
|
iconColor: '#3b82f6',
|
||||||
|
iconBgColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
leftColor: '#3b82f6',
|
||||||
|
description: $t('pages.totalUserCount'),
|
||||||
|
},
|
||||||
|
alertCount: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🔔',
|
||||||
|
iconColor: '#fbbf24',
|
||||||
|
iconBgColor: 'rgba(245, 158, 11, 0.15)',
|
||||||
|
leftColor: '#fbbf24',
|
||||||
|
description: $t('pages.totalAlertCount'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.keys(headStatisticCardList).map((key) => {
|
||||||
|
const item = headStatisticCardList[key as CardKey];
|
||||||
|
item.count = cardInfo[key as CardKey];
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={12}
|
||||||
|
lg={24 / Object.keys(cardInfo).length}
|
||||||
|
key={item.description}
|
||||||
|
>
|
||||||
|
<HeadStatisticCard cardInfo={item} />
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
search={{
|
||||||
|
labelWidth: 'auto',
|
||||||
|
span: 6,
|
||||||
|
}}
|
||||||
|
// dataSource={dataSourceList}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getAccountPages({
|
||||||
|
pageNum: params.current,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
setCardInfo({
|
||||||
|
count: data.count,
|
||||||
|
alertCount: data.alertCount,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Popup visible={visible} rowData={rowData} setVisible={setVisible} />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
||||||
222
src/pages/alert/Popup.tsx
Normal file
222
src/pages/alert/Popup.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useRequest } from '@umijs/max';
|
||||||
|
import { Card, Descriptions, Modal, Table, Typography } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import RuleCardBody from '@/pages/rules/components/RuleCardBody';
|
||||||
|
import useRuleMeta from '@/pages/rules/hooks/useRuleMeta';
|
||||||
|
import { getRuleDetail } from '@/services/api/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { formatToUserTimezone } from '@/utils/timeFormat';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default (props: {
|
||||||
|
visible: boolean;
|
||||||
|
rowData: API.AlertListItem | undefined;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { visible, rowData, setVisible } = props;
|
||||||
|
const [rule, setRule] = useState<API.RuleListItem | undefined>(undefined);
|
||||||
|
const [tradeData, setTradeData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const tradeTableShowType = [1, 2, 3, 4, 9];
|
||||||
|
const { getAllMetaByType } = useRuleMeta();
|
||||||
|
const ruleMeta = getAllMetaByType(rowData?.ruleType || 0);
|
||||||
|
|
||||||
|
const { loading, run } = useRequest(
|
||||||
|
(id: number | string) => getRuleDetail(id),
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
data.sourceName = rowData?.dataSourceName || '-';
|
||||||
|
setRule(data || {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
if (rowData?.details) {
|
||||||
|
try {
|
||||||
|
const bindTrade = JSON.parse(rowData?.details || '{}');
|
||||||
|
setTradeData(bindTrade);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing details:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowData?.ruleTemp) {
|
||||||
|
try {
|
||||||
|
const temp = JSON.parse(rowData.ruleTemp || '{}');
|
||||||
|
temp.sourceName = rowData?.dataSourceName || '-';
|
||||||
|
setRule(temp || {});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing ruleTemp:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowData?.ruleId) {
|
||||||
|
run(rowData.ruleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible, rowData?.id, rowData?.ruleTemp]);
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'Ticket',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.symbol'),
|
||||||
|
dataIndex: 'Symbol',
|
||||||
|
render: (val: string) => val || 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.volume'),
|
||||||
|
dataIndex: 'Volume',
|
||||||
|
render: (Volume: number) => Volume / 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.cmd'),
|
||||||
|
dataIndex: 'Cmd',
|
||||||
|
hidden: tradeData?.Cmd === void 0,
|
||||||
|
render: () => {
|
||||||
|
const cmd = tradeData?.Cmd;
|
||||||
|
return cmd !== void 0 ? (cmd === 1 ? 'SELL' : 'BUY') : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSourceTime'),
|
||||||
|
dataIndex: 'TimeMsc',
|
||||||
|
render: (TimeMsc: string | number) => formatToUserTimezone(TimeMsc),
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: `${$t('table.profit')}(USD)`,
|
||||||
|
// dataIndex: 'Profit',
|
||||||
|
// valueType: 'money',
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: $t('table.alertId'),
|
||||||
|
children: rowData?.id || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: $t('table.company'),
|
||||||
|
children: rowData?.companyName || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: $t('table.ruleType'),
|
||||||
|
children: rowData?.ruleTypeName || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4',
|
||||||
|
label: `${$t('table.triggerTime')} (UTC+0)`,
|
||||||
|
children: rowData?.triggerTime
|
||||||
|
? formatToUserTimezone(rowData?.triggerTime)
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4_1',
|
||||||
|
label: `${$t('table.dataSourceTime')}`,
|
||||||
|
children: rowData?.dataSourceServerTime
|
||||||
|
? `${formatToUserTimezone(rowData?.dataSourceServerTime)} ${rowData?.timeZone || '-'}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '5',
|
||||||
|
label: $t('table.accountId'),
|
||||||
|
children: rowData?.tradeAccount || 'SYSTEM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '6',
|
||||||
|
label: $t('table.symbol'),
|
||||||
|
children: rowData?.product || 'N/A',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const items2 = [
|
||||||
|
{
|
||||||
|
key: '10',
|
||||||
|
label: $t('table.operator'),
|
||||||
|
children: rowData?.operatorNickName || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '20',
|
||||||
|
label: `${$t('table.operatorTime')}(UTC+0)`,
|
||||||
|
children: rowData?.operatorTime
|
||||||
|
? formatToUserTimezone(rowData?.operatorTime)
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '30',
|
||||||
|
label: $t('table.operatorAction'),
|
||||||
|
children: rowData?.operatorAction || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`${$t('pages.alert.pop.title')} - ${rowData?.id || '-'} `}
|
||||||
|
width={800}
|
||||||
|
centered
|
||||||
|
open={visible}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setVisible(false)}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: '85vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ margin: '0 -24px' }}>
|
||||||
|
<ProCard title={`ℹ️ ${$t('pages.alert.pop.title2')}`}>
|
||||||
|
<Descriptions layout="vertical" items={items} column={2} bordered />
|
||||||
|
</ProCard>
|
||||||
|
<ProCard title={$t('table.triggerValue')}>
|
||||||
|
<Card>
|
||||||
|
<Title level={3}>{rowData?.trigger || '-'}</Title>
|
||||||
|
<Text type="secondary">{rowData?.summary || '-'}</Text>
|
||||||
|
</Card>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
<ProCard title={`⚙️ ${$t('pages.alert.pop.title3')}`} loading={loading}>
|
||||||
|
<Card
|
||||||
|
title={`${ruleMeta?.icon || ''} ${ruleMeta?.title} / ${rowData?.ruleName || '-'}`}
|
||||||
|
>
|
||||||
|
{rule && <RuleCardBody ruleMeta={ruleMeta} rule={rule} />}
|
||||||
|
</Card>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
{tradeTableShowType.includes(rowData?.ruleType || 0) &&
|
||||||
|
rowData?.details && (
|
||||||
|
<ProCard title={`🤝 ${$t('pages.alert.pop.title4')}`}>
|
||||||
|
<Table
|
||||||
|
dataSource={[tradeData]}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="ExpertID"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rowData?.operatorId && (
|
||||||
|
<ProCard title={`ℹ️ ${$t('table.operatorRecords')}`}>
|
||||||
|
<Descriptions
|
||||||
|
layout="vertical"
|
||||||
|
items={items2}
|
||||||
|
column={3}
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
src/pages/alert/components/AccountId.tsx
Normal file
49
src/pages/alert/components/AccountId.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useRequest } from '@umijs/max';
|
||||||
|
import { App, Button } from 'antd';
|
||||||
|
import { getAccountInfo } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
type AccountIdProps = {
|
||||||
|
tradeAccountId: number | string;
|
||||||
|
text?: string;
|
||||||
|
onSuccess?: (data: any) => void;
|
||||||
|
};
|
||||||
|
const AccountId: React.FC<AccountIdProps> = ({
|
||||||
|
tradeAccountId,
|
||||||
|
text,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const { loading, run } = useRequest(() => getAccountInfo(tradeAccountId), {
|
||||||
|
refreshDeps: [tradeAccountId],
|
||||||
|
cacheKey: `accountInfo-${tradeAccountId}`,
|
||||||
|
cacheTime: 1000 * 60 * 5,
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
manual: true,
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data) {
|
||||||
|
onSuccess?.(data);
|
||||||
|
} else {
|
||||||
|
message.error($t('pages.tips.operationFailed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error($t('pages.tips.operationFailed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
// loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-0"
|
||||||
|
type="link"
|
||||||
|
onClick={run}
|
||||||
|
>
|
||||||
|
{text || '-'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountId;
|
||||||
69
src/pages/alert/components/StatusHandle.tsx
Normal file
69
src/pages/alert/components/StatusHandle.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { useRequest } from '@umijs/max';
|
||||||
|
import { App, Button, Space } from 'antd';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { alertStatusHandle, getAlertDetail } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
interface StatusHandleProps {
|
||||||
|
item: API.AlertListItem;
|
||||||
|
onSuccess?: (newItem: API.AlertListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusHandle: React.FC<StatusHandleProps> = ({ item, onSuccess }) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { run } = useRequest(
|
||||||
|
(params) => {
|
||||||
|
setLoading(true);
|
||||||
|
return alertStatusHandle(params);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
manual: true,
|
||||||
|
formatResult(res) {
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
onSuccess: async ({ code }) => {
|
||||||
|
if (code === 200) {
|
||||||
|
message.success($t('pages.tips.operationSuccess'));
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
code: detailCode,
|
||||||
|
data: { data },
|
||||||
|
} = await getAlertDetail(item.id).finally(() => setLoading(false));
|
||||||
|
if (detailCode === 200) {
|
||||||
|
onSuccess?.(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error($t('pages.tips.operationFailed'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ backgroundColor: '#10b981', color: '#fff' }}
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => run({ id: item.id, status: 2 })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ backgroundColor: '#999', color: '#fff' }}
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => run({ id: item.id, status: 3 })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusHandle;
|
||||||
637
src/pages/alert/index.tsx
Normal file
637
src/pages/alert/index.tsx
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
import {
|
||||||
|
type ActionType,
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
type ProFormInstance,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess, useModel } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Alert as AntdAlert,
|
||||||
|
App,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import { useQParams } from '@/hooks/useQParams';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import AccountPopup from '@/pages/account/Popup';
|
||||||
|
import useRuleMeta, { type RuleType } from '@/pages/rules/hooks/useRuleMeta';
|
||||||
|
import {
|
||||||
|
alertAllRead,
|
||||||
|
alertDataExport,
|
||||||
|
getAlertPages,
|
||||||
|
getCompanyList,
|
||||||
|
getRuleTypeList,
|
||||||
|
} from '@/services/api';
|
||||||
|
import { downloadFile, isMobileDevice } from '@/utils';
|
||||||
|
import { getAllDataSourceList } from '@/utils/dataCenter';
|
||||||
|
import bus from '@/utils/eventBus';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { utcInstance as dayjs, formatToUserTimezone } from '@/utils/timeFormat';
|
||||||
|
import AccountId from './components/AccountId';
|
||||||
|
import StatusHandle from './components/StatusHandle';
|
||||||
|
import Popup from './Popup';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const Alert: React.FC = () => {
|
||||||
|
const { fetchUnread, setInitialized } = useModel('useMenuNoticeNum');
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
|
const { isGlobalCompany } = useAccess();
|
||||||
|
const [newAlertTipsShow, setNewAlertTipsShow] = useState(false);
|
||||||
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [rowData, setRowData] = useState<API.AlertListItem | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [accountVisible, setAccountVisible] = useState(false);
|
||||||
|
const [accountRowData, setAccountRowData] = useState<
|
||||||
|
API.TradeAccountListItem | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const tableRef = useRef<ActionType>(null);
|
||||||
|
const formRef = useRef<ProFormInstance>(undefined);
|
||||||
|
const [companyList, setCompanyList] = useState<API.CompanyListItem[]>([]);
|
||||||
|
const [ruleTypeList, setRuleTypeList] = useState<RuleType[]>([]);
|
||||||
|
const [alertList, setAlertList] = useState<API.AlertListItem[]>([]);
|
||||||
|
const [dataSourceList, setDataSourceList] = useState<
|
||||||
|
API.DataSourceListItem[]
|
||||||
|
>([]);
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const userDataSourceList = userInfo?.userDataSourceList;
|
||||||
|
const companyInfo = userInfo?.company;
|
||||||
|
|
||||||
|
const { extendRulesMeta } = useRuleMeta();
|
||||||
|
const [totalData, setTotalData] = useState({
|
||||||
|
total: 0,
|
||||||
|
pending: 0,
|
||||||
|
});
|
||||||
|
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
|
||||||
|
const { queryParams } = useQParams();
|
||||||
|
|
||||||
|
const startDate = dayjs().subtract(3, 'month').startOf('day');
|
||||||
|
const endDate = dayjs().endOf('day');
|
||||||
|
// const startDateStr = startDate.format('YYYY-MM-DDTHH:mm:ss') || '';
|
||||||
|
// const endDateStr = endDate.format('YYYY-MM-DDTHH:mm:ss') || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGlobalCompany) {
|
||||||
|
getCompanyList().then(({ data: { data } }) => {
|
||||||
|
setCompanyList(
|
||||||
|
data.filter((item: API.CompanyListItem) => item.id !== 0) || [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getRuleTypeList().then(({ data: { data } }) => {
|
||||||
|
setRuleTypeList(extendRulesMeta(data || []));
|
||||||
|
});
|
||||||
|
getAllDataSourceList(companyInfo).then((dataSourceList) => {
|
||||||
|
const filteredDataSourceList = dataSourceList.filter((item) =>
|
||||||
|
userDataSourceList?.find(
|
||||||
|
(userItem) => userItem.dataSourceId === item.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDataSourceList(filteredDataSourceList);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableResetRefresh = useCallback((refresh = false) => {
|
||||||
|
if (refresh) {
|
||||||
|
tableRef.current?.reload?.(refresh);
|
||||||
|
setNewAlertTipsShow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newAlertTipsShow) {
|
||||||
|
setNewAlertTipsShow(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
bus.on('alert-refresh', tableResetRefresh);
|
||||||
|
bus.on('alert-new', tableResetRefresh);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bus.off('alert-refresh', tableResetRefresh);
|
||||||
|
bus.off('alert-new', tableResetRefresh);
|
||||||
|
};
|
||||||
|
}, [tableResetRefresh]);
|
||||||
|
|
||||||
|
const accoutPopShow = (accountInfo: API.TradeAccountListItem) => {
|
||||||
|
setAccountVisible(true);
|
||||||
|
setAccountRowData(accountInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMap = [
|
||||||
|
{
|
||||||
|
color: '#f97316',
|
||||||
|
text: $t('table.new'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#10b981',
|
||||||
|
text: $t('table.viewed'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#64748b',
|
||||||
|
text: $t('table.ignored'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ProColumns<API.AlertListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.alertId'),
|
||||||
|
dataIndex: 'id',
|
||||||
|
fieldProps: {
|
||||||
|
placeholder: $t('table.alertId'),
|
||||||
|
},
|
||||||
|
renderText: (_, { id }) => {
|
||||||
|
return <Text copyable>{id}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.company'),
|
||||||
|
dataIndex: 'companyId',
|
||||||
|
valueType: 'select',
|
||||||
|
sorter: true,
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
popupMatchSelectWidth: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...companyList.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
renderText: (_, { companyName }) => companyName || '-',
|
||||||
|
hideInSearch: !isGlobalCompany,
|
||||||
|
hideInTable: !isGlobalCompany,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSource'),
|
||||||
|
dataIndex: 'dataSourceId',
|
||||||
|
valueType: 'select',
|
||||||
|
sorter: true,
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
popupMatchSelectWidth: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...(dataSourceList?.map((item) => ({
|
||||||
|
label: item.sourceName,
|
||||||
|
value: item.id,
|
||||||
|
})) || []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, { platform, dataSourceName }) => {
|
||||||
|
return (
|
||||||
|
<MyTag
|
||||||
|
color={platform === 4 ? '#818cf8' : '#10b981'}
|
||||||
|
title={dataSourceName || '-'}
|
||||||
|
>
|
||||||
|
<Text style={{ maxWidth: 150, color: 'inherit' }} ellipsis={true}>
|
||||||
|
{dataSourceName || '-'}
|
||||||
|
</Text>
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.ruleType'),
|
||||||
|
dataIndex: 'ruleType',
|
||||||
|
valueType: 'select',
|
||||||
|
sorter: true,
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
popupMatchSelectWidth: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...ruleTypeList.map((item) => ({
|
||||||
|
label: `${item.meta?.icon || ''} ${item.meta?.title || ''}`,
|
||||||
|
value: item.value || '',
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('pages.rules.ruleName'),
|
||||||
|
dataIndex: 'ruleName',
|
||||||
|
// sorter: true,
|
||||||
|
fieldProps: {
|
||||||
|
placeholder: $t('pages.rules.ruleName'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.platform'),
|
||||||
|
dataIndex: 'platformId',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
popupMatchSelectWidth: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: 'MT4',
|
||||||
|
// value: 4,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
label: 'MT5',
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, { platform }) => {
|
||||||
|
return (
|
||||||
|
<MyTag
|
||||||
|
color={platform === 4 ? '#818cf8' : '#10b981'}
|
||||||
|
>{`MT${platform}`}</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.account'),
|
||||||
|
dataIndex: 'tradeAccount',
|
||||||
|
sorter: true,
|
||||||
|
fieldProps: {
|
||||||
|
placeholder: $t('table.account'),
|
||||||
|
},
|
||||||
|
render: (_, { tradeAccount, tradeAccountId }) => {
|
||||||
|
return tradeAccount ? (
|
||||||
|
<AccountId
|
||||||
|
text={tradeAccount}
|
||||||
|
tradeAccountId={tradeAccountId}
|
||||||
|
onSuccess={accoutPopShow}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'SYSTEM'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.symbol'),
|
||||||
|
dataIndex: 'product',
|
||||||
|
renderText: (_, { product }) => {
|
||||||
|
return product || 'N/A';
|
||||||
|
},
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.trigger'),
|
||||||
|
dataIndex: 'trigger',
|
||||||
|
width: 'auto',
|
||||||
|
className: 'text-bold',
|
||||||
|
fieldProps: {
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.summary'),
|
||||||
|
dataIndex: 'summary',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${$t('table.triggerTime')} (UTC+0)`,
|
||||||
|
dataIndex: 'triggerTime',
|
||||||
|
valueType: 'dateTime',
|
||||||
|
hideInSearch: true,
|
||||||
|
sorter: true,
|
||||||
|
// renderText: (_, { triggerTime }) => {
|
||||||
|
// return formatToUserTimezone(triggerTime);
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSourceTime'),
|
||||||
|
dataIndex: 'dataSourceServerTime',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, { dataSourceServerTime, timeZone }) => {
|
||||||
|
if (!dataSourceServerTime) return '-';
|
||||||
|
return (
|
||||||
|
<Space size={0} direction="vertical">
|
||||||
|
<Text>
|
||||||
|
{formatToUserTimezone(dataSourceServerTime as string) || '-'}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{timeZone || '-'}{' '}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.status'),
|
||||||
|
dataIndex: 'statusId',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
popupMatchSelectWidth: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('table.new'),
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('table.viewed'),
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('table.ignored'),
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, { status = 1 }) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={statusMap[status - 1]?.color || '#64748b'}
|
||||||
|
text={statusMap[status - 1]?.text || '-'}
|
||||||
|
className="text-nowrap"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${$t('table.timerange')} (UTC+0)`,
|
||||||
|
dataIndex: 'timeRange',
|
||||||
|
valueType: isMobileDevice() ? 'dateRange' : 'dateTimeRange',
|
||||||
|
// valueType: 'dateRange',
|
||||||
|
hideInTable: true,
|
||||||
|
colSize: 2,
|
||||||
|
initialValue: [startDate, endDate],
|
||||||
|
search: {
|
||||||
|
transform: (value) => {
|
||||||
|
return {
|
||||||
|
startDate: dayjs(value[0])
|
||||||
|
.startOf('day')
|
||||||
|
.format('YYYY-MM-DDTHH:mm:ss'),
|
||||||
|
endDate: dayjs(value[1]).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.action'),
|
||||||
|
dataIndex: 'option',
|
||||||
|
valueType: 'option',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 'auto',
|
||||||
|
render: (_, item) => {
|
||||||
|
const renderItem = [
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
key="companyEdit"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setVisible(true);
|
||||||
|
setRowData(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.view')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
if (item.status === 1 && userInfo?.companyId !== 0) {
|
||||||
|
renderItem.unshift(
|
||||||
|
<StatusHandle
|
||||||
|
item={item}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
key="actionHandle"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderItem;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (totalData.total === 0) {
|
||||||
|
message.error($t('pages.export.noData'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExportLoading(true);
|
||||||
|
const hide = message.loading($t('pages.export.exporting'), 0);
|
||||||
|
try {
|
||||||
|
const params = formRef.current?.getFieldsValue();
|
||||||
|
const startDate = params?.timeRange?.[0] || '';
|
||||||
|
const endDate = params?.timeRange?.[1] || '';
|
||||||
|
|
||||||
|
const res = await alertDataExport({
|
||||||
|
companyId: params?.companyId || '',
|
||||||
|
dataSourceId: params?.dataSourceId || '',
|
||||||
|
platformId: params?.platformId || '',
|
||||||
|
ruleType: params?.ruleType || '',
|
||||||
|
statusId: params?.statusId || '',
|
||||||
|
startDate: startDate
|
||||||
|
? dayjs(startDate).format('YYYY-MM-DDTHH:mm:ss')
|
||||||
|
: '',
|
||||||
|
endDate: endDate ? dayjs(endDate).format('YYYY-MM-DDTHH:mm:ss') : '',
|
||||||
|
tradeAccount: params?.tradeAccount || '',
|
||||||
|
id: params?.id || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadFile(res);
|
||||||
|
|
||||||
|
hide();
|
||||||
|
message.success($t('pages.export.exportSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
hide();
|
||||||
|
message.error($t('pages.export.exportFailed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setExportLoading(false);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuccess = async (newItem: API.AlertListItem) => {
|
||||||
|
setUnreadCountWithSign(1, '-');
|
||||||
|
setAlertList((prev) => {
|
||||||
|
return prev.map((item) => {
|
||||||
|
if (item.id === newItem.id) {
|
||||||
|
return { ...item, ...newItem };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllRead = async () => {
|
||||||
|
modal.confirm({
|
||||||
|
title: $t('table.action.allRead'),
|
||||||
|
content: $t('pages.model.allRead.content'),
|
||||||
|
okType: 'primary',
|
||||||
|
onOk: async () => {
|
||||||
|
await alertAllRead();
|
||||||
|
setUnreadCountWithSign(0);
|
||||||
|
tableRef.current?.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
extra={
|
||||||
|
newAlertTipsShow && (
|
||||||
|
<AntdAlert
|
||||||
|
key="newAlertTips"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={$t('pages.alert.newAlertTips')}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 12px',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
tableResetRefresh(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
form={{
|
||||||
|
initialValues: {
|
||||||
|
...queryParams,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
actionRef={tableRef}
|
||||||
|
formRef={formRef}
|
||||||
|
dataSource={alertList}
|
||||||
|
search={{
|
||||||
|
// layout: 'vertical',
|
||||||
|
collapsed: false,
|
||||||
|
collapseRender: false,
|
||||||
|
labelWidth: 'auto',
|
||||||
|
// span: 4,
|
||||||
|
span: {
|
||||||
|
xs: 24,
|
||||||
|
sm: 12,
|
||||||
|
md: 8,
|
||||||
|
lg: 6,
|
||||||
|
xl: 6,
|
||||||
|
xxl: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
request={async (params, sorts) => {
|
||||||
|
if (params.current === 1) {
|
||||||
|
setNewAlertTipsShow(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqParams: Record<string, any> = {
|
||||||
|
...params,
|
||||||
|
pageNum: params.current,
|
||||||
|
};
|
||||||
|
const sortKeys = Object.keys(sorts);
|
||||||
|
if (sortKeys.length) {
|
||||||
|
const sortInfo = [] as EX.SortInfo[];
|
||||||
|
sortKeys.forEach((key) => {
|
||||||
|
sortInfo.push({
|
||||||
|
field: key,
|
||||||
|
order: sorts[key] as EX.SortInfo['order'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
reqParams.sortJson = encodeURIComponent(
|
||||||
|
JSON.stringify(sortInfo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await getAlertPages(reqParams);
|
||||||
|
|
||||||
|
setTotalData({
|
||||||
|
total: data.total,
|
||||||
|
pending: data.pending,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
postData={(data: API.AlertListItem[]) => {
|
||||||
|
setAlertList(data);
|
||||||
|
return data;
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
toolbar={{
|
||||||
|
title: (
|
||||||
|
<Space>
|
||||||
|
<MyTag color="#ef4444">{`${totalData.pending || 0} ${$t('table.new')}`}</MyTag>
|
||||||
|
<MyTag color="#3b82f6">{`${totalData.total || 0} ${$t('table.all')}`}</MyTag>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
!isGlobalCompany && (
|
||||||
|
<Button
|
||||||
|
key="allRead"
|
||||||
|
variant="filled"
|
||||||
|
style={{ backgroundColor: '#10b981', color: '#fff' }}
|
||||||
|
onClick={handleAllRead}
|
||||||
|
>
|
||||||
|
{$t('table.action.allRead')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
<Button
|
||||||
|
key="export"
|
||||||
|
loading={exportLoading}
|
||||||
|
disabled={exportLoading}
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
📥 {$t('table.action.export')}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
onReset={() => {
|
||||||
|
tableRef.current?.reload?.(true);
|
||||||
|
|
||||||
|
setInitialized(false);
|
||||||
|
fetchUnread();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Popup visible={visible} rowData={rowData} setVisible={setVisible} />
|
||||||
|
<AccountPopup
|
||||||
|
visible={accountVisible}
|
||||||
|
rowData={accountRowData}
|
||||||
|
setVisible={setAccountVisible}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Alert;
|
||||||
201
src/pages/company/Popup.tsx
Normal file
201
src/pages/company/Popup.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormDependency,
|
||||||
|
type ProFormInstance,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormSwitch,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import { Divider, Space, Typography } from 'antd';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import {
|
||||||
|
getCompanyDetail,
|
||||||
|
getEmailConfigDetail,
|
||||||
|
getEmailConfigList,
|
||||||
|
} from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
type CompanyListItem = Partial<API.CompanyListItem>;
|
||||||
|
export default (props: {
|
||||||
|
modalVisit: boolean;
|
||||||
|
formValues: Partial<CompanyListItem | undefined>;
|
||||||
|
onSubmit: (values: CompanyListItem) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) => {
|
||||||
|
const { isGlobalCompany } = useAccess();
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const { modalVisit, formValues, onSubmit, onDone } = props;
|
||||||
|
|
||||||
|
const formRef = useRef<ProFormInstance>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
width="600px"
|
||||||
|
title={`${formValues?.id === void 0 ? $t('table.action.add') : $t('table.action.edit')}${$t('table.company')}`}
|
||||||
|
formRef={formRef}
|
||||||
|
open={modalVisit}
|
||||||
|
initialValues={formValues}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
values.status = values.id !== void 0 ? values.status : 1;
|
||||||
|
if (!isGlobalCompany) {
|
||||||
|
values.companyId = userInfo?.companyId;
|
||||||
|
}
|
||||||
|
return onSubmit(values);
|
||||||
|
}}
|
||||||
|
modalProps={{
|
||||||
|
onCancel: () => onDone(),
|
||||||
|
destroyOnHidden: true,
|
||||||
|
centered: true,
|
||||||
|
maskClosable: false,
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
maxHeight: '85vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
marginRight: -20,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
request={async (values) => {
|
||||||
|
if (formValues?.id) {
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
data: { companyMailHostConfig = {} },
|
||||||
|
},
|
||||||
|
} = await getCompanyDetail(formValues?.id);
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
...companyMailHostConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return values || {};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
required
|
||||||
|
name="name"
|
||||||
|
label={$t('table.companyName')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
required
|
||||||
|
name="email"
|
||||||
|
label={$t('table.contactEmail')}
|
||||||
|
placeholder="example@example.com"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: $t('form.required') },
|
||||||
|
{ type: 'email', message: $t('form.email.placeholder') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
hidden={formValues?.id === void 0}
|
||||||
|
name="status"
|
||||||
|
label={$t('table.status')}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: $t('table.user.status1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: $t('table.user.status0'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formValues?.id !== void 0 && (
|
||||||
|
<>
|
||||||
|
<Divider size="small" />
|
||||||
|
<Text strong style={{ lineHeight: '40px', fontSize: 16 }}>
|
||||||
|
📧 {$t('pages.email.notification')}
|
||||||
|
</Text>
|
||||||
|
<ProFormSwitch
|
||||||
|
name="mailStatus"
|
||||||
|
label={$t('pages.email.notificationEnable')}
|
||||||
|
extra={$t('pages.email.notificationEnable.tips')}
|
||||||
|
transform={(value) => +value}
|
||||||
|
/>
|
||||||
|
<ProFormDependency name={['mailStatus']}>
|
||||||
|
{({ mailStatus }) => {
|
||||||
|
return (
|
||||||
|
mailStatus === 1 && (
|
||||||
|
<>
|
||||||
|
<ProFormSelect
|
||||||
|
name="mailHostConfigId"
|
||||||
|
label={$t('pages.email.notificationService')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
allowClear={false}
|
||||||
|
request={async () => {
|
||||||
|
const {
|
||||||
|
data: { data: emailConfigList = [] },
|
||||||
|
} = await getEmailConfigList();
|
||||||
|
return emailConfigList.map(
|
||||||
|
(item: API.EmailConfigListItem) => ({
|
||||||
|
value: item.id,
|
||||||
|
label:
|
||||||
|
item.status !== 1 ? (
|
||||||
|
<Space>
|
||||||
|
{item.name}
|
||||||
|
<Text type="warning">
|
||||||
|
({$t('pages.email.status.disabled')})
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
item.name
|
||||||
|
),
|
||||||
|
disabled: item.status !== 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="fromName"
|
||||||
|
label={$t('pages.email.notificationFromName')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="from"
|
||||||
|
dependencies={['mailHostConfigId']}
|
||||||
|
label={$t('pages.email.notificationFromEmail')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
allowClear={false}
|
||||||
|
request={async ({ mailHostConfigId }) => {
|
||||||
|
if (!mailHostConfigId) {
|
||||||
|
formRef.current?.setFieldsValue({
|
||||||
|
from: undefined,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
data: { data: emailConfigList = {} },
|
||||||
|
} = await getEmailConfigDetail(
|
||||||
|
mailHostConfigId as string,
|
||||||
|
);
|
||||||
|
const from = emailConfigList?.from.split(',') || [];
|
||||||
|
if (from.length > 0 && !formValues?.from) {
|
||||||
|
formRef.current?.setFieldsValue({
|
||||||
|
from: from[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return from.map((item: string) => ({
|
||||||
|
value: item,
|
||||||
|
label: item,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ProFormDependency>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ProFormText hidden name="id" />
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
226
src/pages/company/index.tsx
Normal file
226
src/pages/company/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
type ActionType,
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { Badge, Button, Col, Row } from 'antd';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import HeadStatisticCard, {
|
||||||
|
type CardInfo,
|
||||||
|
} from '@/components/HeadStatisticCard';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import { companyHandle, getCompanyPages } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import PopUp from './Popup';
|
||||||
|
|
||||||
|
const Company: React.FC = () => {
|
||||||
|
const [modalVisit, setModalVisit] = useState(false);
|
||||||
|
|
||||||
|
const [cardInfo, setCardInfo] = useState({
|
||||||
|
datasource: 0,
|
||||||
|
count: 0,
|
||||||
|
active: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [formValues, setFormValues] = useState<
|
||||||
|
Partial<API.CompanyListItem> | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const tableRef = useRef<ActionType>(null);
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
setModalVisit(false);
|
||||||
|
setFormValues({});
|
||||||
|
};
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
// console.log(values);
|
||||||
|
// return;
|
||||||
|
const { code } = (await companyHandle(values)) as API.BaseResponse;
|
||||||
|
if (code === 200) {
|
||||||
|
setFormValues(undefined);
|
||||||
|
tableRef.current?.reload();
|
||||||
|
handleDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<API.CompanyListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.companyId'),
|
||||||
|
dataIndex: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.companyName'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.contactEmail'),
|
||||||
|
dataIndex: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSourceCount'),
|
||||||
|
dataIndex: 'dataSourceCount',
|
||||||
|
render: (_, { dataSourceCount }) => {
|
||||||
|
return (
|
||||||
|
<MyTag color="#3b82f6">
|
||||||
|
{dataSourceCount ?? '-'} {$t('table.unit.datasource')}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.userCount'),
|
||||||
|
dataIndex: 'userCount',
|
||||||
|
render: (_, { userCount }) => {
|
||||||
|
return (
|
||||||
|
<MyTag color="#818cf8">
|
||||||
|
{userCount ?? '-'} {$t('table.unit.users')}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (_, { status }) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={status ? '#10b981' : '#64748b'}
|
||||||
|
text={status ? $t('table.user.status1') : $t('table.user.status0')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.createTime'),
|
||||||
|
dataIndex: 'createTime',
|
||||||
|
valueType: 'date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('pages.email.emailNotification'),
|
||||||
|
dataIndex: 'emailNotice',
|
||||||
|
render: (_, { mailStatus }) => {
|
||||||
|
return (
|
||||||
|
<MyTag color={mailStatus === 1 ? '#10b981' : '#64748b'}>
|
||||||
|
{mailStatus === 1
|
||||||
|
? $t('pages.rules.enable')
|
||||||
|
: $t('pages.rules.disable')}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.action'),
|
||||||
|
dataIndex: 'option',
|
||||||
|
width: '60px',
|
||||||
|
valueType: 'option',
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, item) => [
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
key="edit"
|
||||||
|
onClick={() => {
|
||||||
|
setFormValues(item);
|
||||||
|
setModalVisit(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.edit')}
|
||||||
|
</Button>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type CardKey = keyof typeof cardInfo;
|
||||||
|
const headStatisticCardList: Record<CardKey, CardInfo> = {
|
||||||
|
count: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🏢',
|
||||||
|
iconColor: '#3b82f6',
|
||||||
|
iconBgColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
leftColor: '#3b82f6',
|
||||||
|
description: $t('pages.totalCompanyCount'),
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
count: 0,
|
||||||
|
icon: '✅',
|
||||||
|
iconColor: '#60a5fa',
|
||||||
|
iconBgColor: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
leftColor: '#60a5fa',
|
||||||
|
description: $t('pages.activeCompanyCount'),
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🔌',
|
||||||
|
iconColor: '#34d399',
|
||||||
|
iconBgColor: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
leftColor: '#34d399',
|
||||||
|
description: $t('pages.totalDataSourceCount'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.keys(headStatisticCardList).map((key) => {
|
||||||
|
const item = headStatisticCardList[key as CardKey];
|
||||||
|
item.count = cardInfo[key as CardKey];
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={12}
|
||||||
|
lg={24 / Object.keys(cardInfo).length}
|
||||||
|
key={item.description}
|
||||||
|
>
|
||||||
|
<HeadStatisticCard cardInfo={item} />
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
search={false}
|
||||||
|
actionRef={tableRef}
|
||||||
|
request={async (params: { pageSize: number; current: number }) => {
|
||||||
|
const { data } = await getCompanyPages({
|
||||||
|
pageNum: params.current,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
});
|
||||||
|
setCardInfo({
|
||||||
|
datasource: data.datasource,
|
||||||
|
count: data.count,
|
||||||
|
active: data.active,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="primary"
|
||||||
|
onClick={() => setModalVisit(true)}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
{`${$t('table.action.add')}${$t('table.company')}`}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<PopUp
|
||||||
|
formValues={formValues}
|
||||||
|
onDone={handleDone}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
modalVisit={modalVisit}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Company;
|
||||||
174
src/pages/config/components/NoticeSwitch.tsx
Normal file
174
src/pages/config/components/NoticeSwitch.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { App, Popover, Space, Switch, Typography } from 'antd';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export type NoticeSwitchRef = {
|
||||||
|
checkIsNeedRequestPermission: (desktopPush: boolean) => void;
|
||||||
|
};
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
const NoticeSwitch = () => {
|
||||||
|
const isNoticeSupported = 'Notification' in window;
|
||||||
|
const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1;
|
||||||
|
|
||||||
|
const [noticePermission, setNoticePermission] = useState(
|
||||||
|
getNoticePermission(),
|
||||||
|
);
|
||||||
|
const [noticeEnabled, setNoticeEnabled] = useState(false);
|
||||||
|
|
||||||
|
const initEnabled = useCallback(() => {
|
||||||
|
const localStorageEnabled = localStorage.getItem('desktopPushEnabled');
|
||||||
|
let defaultEnabled = false;
|
||||||
|
if (!localStorageEnabled) {
|
||||||
|
if (getNoticePermission() === 'granted') {
|
||||||
|
defaultEnabled = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const enabled = JSON.parse(localStorageEnabled);
|
||||||
|
if (typeof enabled === 'boolean') {
|
||||||
|
defaultEnabled = enabled;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
localStorage.removeItem('desktopPushEnabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (defaultEnabled) {
|
||||||
|
checkIsNeedRequestPermission();
|
||||||
|
setNoticeEnabled(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initEnabled();
|
||||||
|
}, [initEnabled]);
|
||||||
|
|
||||||
|
const { modal } = App.useApp();
|
||||||
|
const [noticeLoading, setNoticeLoading] = useState(false);
|
||||||
|
|
||||||
|
function setNoticeEnableAndSetInLocal(enabled: boolean) {
|
||||||
|
setNoticeEnabled(enabled);
|
||||||
|
localStorage.setItem('desktopPushEnabled', enabled.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoticePermission() {
|
||||||
|
return Notification?.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIsNeedRequestPermission() {
|
||||||
|
if (
|
||||||
|
isNoticeSupported &&
|
||||||
|
getNoticePermission() === 'default' &&
|
||||||
|
!isFirefox
|
||||||
|
) {
|
||||||
|
requestNoticePermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoticeChange(checked: boolean) {
|
||||||
|
setNoticeLoading(true);
|
||||||
|
const permission = getNoticePermission();
|
||||||
|
if (checked) {
|
||||||
|
if (permission === 'default') {
|
||||||
|
const result = await requestNoticePermission();
|
||||||
|
if (result === 'granted') {
|
||||||
|
setNoticeEnableAndSetInLocal(true);
|
||||||
|
}
|
||||||
|
} else if (permission === 'denied') {
|
||||||
|
deniedErrorShow();
|
||||||
|
} else if (permission === 'granted') {
|
||||||
|
setNoticeEnableAndSetInLocal(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNoticeEnableAndSetInLocal(false);
|
||||||
|
}
|
||||||
|
setNoticeLoading(false);
|
||||||
|
}
|
||||||
|
const deniedErrorStr = $t('form.config.desktopNoticeRefuseTips2');
|
||||||
|
function deniedErrorShow() {
|
||||||
|
modal.error({
|
||||||
|
title: $t('form.config.desktopNoticeRefuseTips'),
|
||||||
|
content: deniedErrorStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestNoticePermission() {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission === 'denied') {
|
||||||
|
deniedErrorShow();
|
||||||
|
}
|
||||||
|
setNoticePermission(permission);
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noticeTipsConetent = (
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
{$t('form.config.noticeOnButNotWorkTips')}
|
||||||
|
</Title>
|
||||||
|
<p> {$t('form.config.noticeOnButNotWorkTips2')} </p>
|
||||||
|
<div>Windows:</div>
|
||||||
|
<ul>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips3')} </li>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips4')} </li>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips5')} </li>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips6')} </li>
|
||||||
|
</ul>
|
||||||
|
<p>macOS:</p>
|
||||||
|
<ul>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips7')} </li>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips8')} </li>
|
||||||
|
<li> {$t('form.config.noticeOnButNotWorkTips9')} </li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Popover content={noticeTipsConetent}>
|
||||||
|
<Space>
|
||||||
|
<span> {$t('form.config.desktopNotification')} </span>
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</Space>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary">
|
||||||
|
{' '}
|
||||||
|
{$t('form.config.desktopNotification.tips')}{' '}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{isNoticeSupported ? (
|
||||||
|
<div className="flex align-center">
|
||||||
|
{noticePermission === 'denied' && (
|
||||||
|
<Popover content={deniedErrorStr} styles={{ body: { width: 400 } }}>
|
||||||
|
<ExclamationCircleOutlined
|
||||||
|
style={{
|
||||||
|
color: '#ff0000',
|
||||||
|
fontSize: 20,
|
||||||
|
marginRight: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
loading={noticeLoading}
|
||||||
|
disabled={noticeLoading}
|
||||||
|
checked={noticeEnabled}
|
||||||
|
onChange={handleNoticeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="danger">
|
||||||
|
{$t('form.config.desktopNotification.notSupport')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoticeSwitch;
|
||||||
42
src/pages/config/components/Popup.tsx
Normal file
42
src/pages/config/components/Popup.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Modal, Space, Typography } from 'antd';
|
||||||
|
import useRichI18n from '@/hooks/useRichI18n';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import usePopMeta from '../usePopMeta';
|
||||||
|
|
||||||
|
const { Text, Link } = Typography;
|
||||||
|
|
||||||
|
export default (props: {
|
||||||
|
visible: boolean;
|
||||||
|
popType: string;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { visible, popType, setVisible } = props;
|
||||||
|
const { popInfo = {} } = usePopMeta(popType);
|
||||||
|
const { richFormat } = useRichI18n();
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={popInfo.title}
|
||||||
|
open={visible}
|
||||||
|
footer={[
|
||||||
|
<Space key="footer-space" className="justify-between w-100">
|
||||||
|
<Link href={popInfo.pdfUrl} target="_blank">
|
||||||
|
{$t('pages.config.pop.viewPdf')}
|
||||||
|
</Link>
|
||||||
|
<Link href={popInfo?.tips?.url} target="_blank">
|
||||||
|
{popInfo?.tips?.name}
|
||||||
|
</Link>
|
||||||
|
</Space>,
|
||||||
|
]}
|
||||||
|
onCancel={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
<Space direction="vertical">
|
||||||
|
{popInfo?.content?.map((item: string, index: number) => (
|
||||||
|
<Text key={item}>
|
||||||
|
<span>{index + 1}. </span>
|
||||||
|
{richFormat(item)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
413
src/pages/config/index.tsx
Normal file
413
src/pages/config/index.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
FooterToolbar,
|
||||||
|
PageContainer,
|
||||||
|
ProForm,
|
||||||
|
ProFormDependency,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
type FormInstance,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import AsyncSwitch from '@/components/AsyncSwitch';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import {
|
||||||
|
getUserConfig,
|
||||||
|
userConfigUpdate,
|
||||||
|
userNoticeTest,
|
||||||
|
} from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
// import NoticeSwitch from './components/NoticeSwitch';
|
||||||
|
import Popup from './components/Popup';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const UserConfig: React.FC = () => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
const { setUserInfo, userInfo } = useUserInfo();
|
||||||
|
const { companyMailStatus } = useAccess();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [popType, setPopType] = useState('');
|
||||||
|
const [userConfig, setUserConfig] = useState<API.UserConfigType | null>(null);
|
||||||
|
|
||||||
|
const formRef = React.useRef<FormInstance>(null);
|
||||||
|
|
||||||
|
const handlePopup = (type: string) => {
|
||||||
|
setPopType(type);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = async (checked: boolean, field: string) => {
|
||||||
|
if (!userConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqData = {
|
||||||
|
...userConfig,
|
||||||
|
[field]: checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { code } = await userConfigUpdate(reqData);
|
||||||
|
if (code === 200) {
|
||||||
|
message.success($t('form.config.updateSuccess'));
|
||||||
|
|
||||||
|
setUserInfo({
|
||||||
|
...userInfo,
|
||||||
|
userConfig: reqData,
|
||||||
|
});
|
||||||
|
setUserConfig(reqData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestPush = async (type: string) => {
|
||||||
|
const params = formRef.current?.getFieldsValue();
|
||||||
|
let testCode: number;
|
||||||
|
if (type === 'telegram') {
|
||||||
|
if (!params?.telegramToken || !params?.telegramCharId) {
|
||||||
|
message.warning($t('pages.config.testPush.tips'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code } = await userNoticeTest({
|
||||||
|
telegramToken: params.telegramToken,
|
||||||
|
telegramChatId: params.telegramCharId,
|
||||||
|
});
|
||||||
|
testCode = code;
|
||||||
|
} else {
|
||||||
|
const key = `${type}Webhook`;
|
||||||
|
if (!params[key]) {
|
||||||
|
message.warning($t('pages.config.testPush.tips'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code } = await userNoticeTest({
|
||||||
|
[key]: params[key],
|
||||||
|
});
|
||||||
|
testCode = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testCode === 200) {
|
||||||
|
message.success($t('pages.config.testPush.success'));
|
||||||
|
} else {
|
||||||
|
message.error($t('pages.config.testPush.fail'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testPushEle = useMemo(() => {
|
||||||
|
return (type: string) => (
|
||||||
|
<Space
|
||||||
|
className="w-100"
|
||||||
|
style={{ justifyContent: 'flex-end', paddingRight: 24 }}
|
||||||
|
>
|
||||||
|
<Button key="telegram" onClick={() => handleTestPush(type)}>
|
||||||
|
⚡ {$t('pages.config.testPush')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ProForm
|
||||||
|
submitter={{
|
||||||
|
render: (_, dom) => <FooterToolbar>{dom}</FooterToolbar>,
|
||||||
|
}}
|
||||||
|
formRef={formRef}
|
||||||
|
onFinish={async (values: API.UserConfigType) => {
|
||||||
|
const reqData = {
|
||||||
|
...userConfig,
|
||||||
|
...values,
|
||||||
|
telegram: Number(values.telegram),
|
||||||
|
slack: Number(values.slack),
|
||||||
|
teams: Number(values.teams),
|
||||||
|
lark: Number(values.lark),
|
||||||
|
desktopPush: Boolean(values.desktopPush),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { code } = await userConfigUpdate(reqData);
|
||||||
|
if (code === 200) {
|
||||||
|
message.success($t('form.config.updateSuccess'));
|
||||||
|
setUserInfo({
|
||||||
|
...userInfo,
|
||||||
|
userConfig: reqData,
|
||||||
|
});
|
||||||
|
setUserConfig(reqData);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
request={async (values) => {
|
||||||
|
const {
|
||||||
|
data: { data },
|
||||||
|
} = await getUserConfig(values);
|
||||||
|
|
||||||
|
if (!data?.email) {
|
||||||
|
data.email = userInfo?.email;
|
||||||
|
}
|
||||||
|
setUserConfig(data || {});
|
||||||
|
return data || {};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={12} xl={8}>
|
||||||
|
<Card
|
||||||
|
title={`🔔 ${$t('form.config.notification')}`}
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '0 -10px',
|
||||||
|
padding: '10px',
|
||||||
|
}}
|
||||||
|
className="bg-gray"
|
||||||
|
>
|
||||||
|
<Space className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div>{$t('form.config.frontNotice')}</div>
|
||||||
|
<Text type="secondary">
|
||||||
|
{$t('form.config.frontNotice.tips')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="desktopPush" noStyle>
|
||||||
|
<AsyncSwitch
|
||||||
|
onAsyncChange={(checked) =>
|
||||||
|
handleChange(checked, 'desktopPush')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<ProFormDependency name={['desktopPush']}>
|
||||||
|
{({ desktopPush }) =>
|
||||||
|
desktopPush && (
|
||||||
|
<Space
|
||||||
|
style={{
|
||||||
|
padding: '0 10px',
|
||||||
|
marginTop: 10,
|
||||||
|
}}
|
||||||
|
className="w-100"
|
||||||
|
direction="vertical"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div>{$t('form.config.soundNotification')}</div>
|
||||||
|
<Text type="secondary">
|
||||||
|
{$t('form.config.soundNotification.tips')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="voicePrompt" noStyle>
|
||||||
|
<AsyncSwitch
|
||||||
|
onAsyncChange={(checked) =>
|
||||||
|
handleChange(checked, 'voicePrompt')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
{/* <NoticeSwitch /> */}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</ProFormDependency>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12} xl={8}>
|
||||||
|
<Card
|
||||||
|
title={`📧 ${$t('form.config.emailNotification')}`}
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<div>{$t('form.config.emailNotification')}</div>
|
||||||
|
<Text type="secondary">
|
||||||
|
{$t('form.config.emailNotification.tips')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="emailNotice" noStyle>
|
||||||
|
<Switch
|
||||||
|
disabled={
|
||||||
|
!userConfig?.emailNotice && companyMailStatus !== 1
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProFormText
|
||||||
|
name="email"
|
||||||
|
label={$t('pages.email.notificationEmail')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'email',
|
||||||
|
message: $t('form.email.placeholder'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
extra={$t('pages.email.notificationEmail.tips')}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space direction="vertical">
|
||||||
|
{companyMailStatus !== 1 && (
|
||||||
|
<Alert
|
||||||
|
message={$t('pages.email.companyEmailDisabled')}
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Flex justify="end">
|
||||||
|
<Button key="save" type="primary" htmlType="submit">
|
||||||
|
{$t('pages.save')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Card
|
||||||
|
title={`🔗 ${$t('form.config.webhook')}`}
|
||||||
|
variant="borderless"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
extra={<Alert message={$t('pages.config.alert')} type="info" />}
|
||||||
|
>
|
||||||
|
<Row gutter={[30, 30]}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card hoverable actions={[testPushEle('telegram')]}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div onClick={() => handlePopup('telegram')}>
|
||||||
|
✈️ {$t('form.config.webhook.telegram.title')}
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 5 }} />
|
||||||
|
</div>
|
||||||
|
<Form.Item name="telegram" label={null}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProFormText
|
||||||
|
name="telegramToken"
|
||||||
|
label={$t('form.config.webhook.telegramBotToken')}
|
||||||
|
placeholder={$t(
|
||||||
|
'form.config.webhook.telegramBotToken.tips',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="telegramCharId"
|
||||||
|
label={$t('form.config.webhook.telegramChatId')}
|
||||||
|
placeholder={$t(
|
||||||
|
'form.config.webhook.telegramChatId.tips',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
actions={[testPushEle('teams')]}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
className="flex justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div onClick={() => handlePopup('teams')}>
|
||||||
|
👥 {$t('form.config.webhook.teams.title')}
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 5 }} />
|
||||||
|
</div>
|
||||||
|
<Form.Item name="teams" label={null}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<ProFormText
|
||||||
|
name="teamsWebhook"
|
||||||
|
label="Teams Webhook"
|
||||||
|
placeholder={$t('form.config.webhook.teams.tips')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
message: $t('form.config.inputURLTips'),
|
||||||
|
type: 'url',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card hoverable actions={[testPushEle('slack')]}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div onClick={() => handlePopup('slack')}>
|
||||||
|
💬 {$t('form.config.webhook.slack.title')}
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 5 }} />
|
||||||
|
</div>
|
||||||
|
<Form.Item name="slack" label={null}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<ProFormText
|
||||||
|
name="slackWebhook"
|
||||||
|
label="Slack Webhook"
|
||||||
|
placeholder={$t('form.config.webhook.slack.tips')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
message: $t('form.config.inputURLTips'),
|
||||||
|
type: 'url',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card hoverable actions={[testPushEle('lark')]}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div onClick={() => handlePopup('lark')}>
|
||||||
|
🐦 {$t('form.config.webhook.lark.title')}
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 5 }} />
|
||||||
|
</div>
|
||||||
|
<Form.Item name="lark" label={null}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProFormText
|
||||||
|
name="larkWebhook"
|
||||||
|
label={$t('form.config.webhook.lark')}
|
||||||
|
placeholder={$t('form.config.webhook.lark.tips')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
message: $t('form.config.inputURLTips'),
|
||||||
|
type: 'url',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</ProForm>
|
||||||
|
<Popup visible={visible} popType={popType} setVisible={setVisible} />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserConfig;
|
||||||
67
src/pages/config/usePopMeta.ts
Normal file
67
src/pages/config/usePopMeta.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { getLocale } from '@umijs/max';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export default function usePopMeta(type: string) {
|
||||||
|
const lang = getLocale();
|
||||||
|
const popInfoMap: Record<string, any> = {
|
||||||
|
telegram: {
|
||||||
|
title: $t('pages.config.pop.telegram.title'),
|
||||||
|
content: [
|
||||||
|
'pages.config.pop.telegram.content1',
|
||||||
|
'pages.config.pop.telegram.content2',
|
||||||
|
'pages.config.pop.telegram.content3',
|
||||||
|
'pages.config.pop.telegram.content4',
|
||||||
|
],
|
||||||
|
pdfUrl: `/pdf/${lang}/Telegram_push_configuration.pdf`,
|
||||||
|
tips: {
|
||||||
|
name: `🔗 ${$t('pages.config.pop.telegram.tips')}`,
|
||||||
|
url: 'https://core.telegram.org/bots/tutorial',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
title: $t('pages.config.pop.teams.title'),
|
||||||
|
content: [
|
||||||
|
'pages.config.pop.teams.content1',
|
||||||
|
'pages.config.pop.teams.content2',
|
||||||
|
'pages.config.pop.teams.content3',
|
||||||
|
'pages.config.pop.teams.content4',
|
||||||
|
],
|
||||||
|
pdfUrl: `/pdf/${lang}/Teams_push_configuration.pdf`,
|
||||||
|
tips: {
|
||||||
|
name: `🔗 ${$t('pages.config.pop.teams.tips')}`,
|
||||||
|
url: $t('pages.config.pop.teams.url'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slack: {
|
||||||
|
title: $t('pages.config.pop.slack.title'),
|
||||||
|
content: [
|
||||||
|
'pages.config.pop.slack.content1',
|
||||||
|
'pages.config.pop.slack.content2',
|
||||||
|
'pages.config.pop.slack.content3',
|
||||||
|
'pages.config.pop.slack.content4',
|
||||||
|
],
|
||||||
|
pdfUrl: `/pdf/${lang}/Slack_push_configuration.pdf`,
|
||||||
|
tips: {
|
||||||
|
name: `🔗 ${$t('pages.config.pop.slack.tips')}`,
|
||||||
|
url: 'https://api.slack.com/messaging/webhooks',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lark: {
|
||||||
|
title: $t('pages.config.pop.lark.title'),
|
||||||
|
content: [
|
||||||
|
'pages.config.pop.lark.content1',
|
||||||
|
'pages.config.pop.lark.content2',
|
||||||
|
'pages.config.pop.lark.content3',
|
||||||
|
'pages.config.pop.lark.content4',
|
||||||
|
],
|
||||||
|
pdfUrl: `/pdf/${lang}/Lark_push_configuration.pdf`,
|
||||||
|
tips: {
|
||||||
|
name: `🔗 ${$t('pages.config.pop.lark.tips')}`,
|
||||||
|
url: $t('pages.config.pop.lark.url'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
popInfo: popInfoMap[type] || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
484
src/pages/dashboard/index.tsx
Normal file
484
src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
import { Area, Pie } from '@ant-design/plots';
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
ProTable,
|
||||||
|
useBreakpoint,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { Link, useRequest } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Empty,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
type TimeRangePickerProps,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import HeadStatisticCard, {
|
||||||
|
type CardInfo,
|
||||||
|
} from '@/components/HeadStatisticCard';
|
||||||
|
import {
|
||||||
|
getAlertPages,
|
||||||
|
getDashboardAlertTrend,
|
||||||
|
getDashboardRuleTrigger,
|
||||||
|
getDashboardTotal,
|
||||||
|
} from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { utcInstance as dayjs, formatToUserTimezone } from '@/utils/timeFormat';
|
||||||
|
|
||||||
|
type RuleTriggerItem = {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
_placeholder?: boolean;
|
||||||
|
};
|
||||||
|
type AlertTrendItem = {
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Text } = Typography;
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const [alertTrendData, setAlertTrendData] = useState<AlertTrendItem[]>([]);
|
||||||
|
const [ruleTriggerData, setRuleTriggerData] = useState<RuleTriggerItem[]>([]);
|
||||||
|
const [cardInfo, setCardInfo] = useState({
|
||||||
|
accountCount: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
todayAlertCount: 0,
|
||||||
|
tradeVolume: 0,
|
||||||
|
});
|
||||||
|
const instance = dayjs().utc();
|
||||||
|
const rangePresets: TimeRangePickerProps['presets'] = [
|
||||||
|
{
|
||||||
|
label: 'Last 7 Days',
|
||||||
|
value: [instance.add(-7, 'd'), instance],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 14 Days',
|
||||||
|
value: [instance.add(-14, 'd'), instance],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 30 Days',
|
||||||
|
value: [instance.add(-30, 'd'), instance],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 90 Days',
|
||||||
|
value: [instance.add(-90, 'd'), instance],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDashboardTotal().then(({ data: { list = [] } }) => {
|
||||||
|
const info = list[0] || {};
|
||||||
|
info.tradeVolume = `${+info.tradeVolume.toFixed(2)}%`;
|
||||||
|
setCardInfo(info);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { loading: alertTrendLoading, run: reqAlertTrend } = useRequest(
|
||||||
|
async (params) => {
|
||||||
|
const defaultValue = rangePresets[0].value as (Dayjs | null)[];
|
||||||
|
const reqParams = params || {
|
||||||
|
startDate: defaultValue[0]?.format('YYYY-MM-DD HH:mm:ss') || '',
|
||||||
|
endDate: defaultValue[1]?.format('YYYY-MM-DD HH:mm:ss') || '',
|
||||||
|
};
|
||||||
|
return getDashboardAlertTrend(reqParams);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: ({ list = [] }) => {
|
||||||
|
setAlertTrendData(list);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { loading: ruleTriggerLoading, run: reqRuleTrigger } = useRequest(
|
||||||
|
async (params) => {
|
||||||
|
const defaultValue = rangePresets[0].value as (Dayjs | null)[];
|
||||||
|
const reqParams = params || {
|
||||||
|
startDate: defaultValue[0]?.format('YYYY-MM-DD HH:mm:ss') || '',
|
||||||
|
endDate: defaultValue[1]?.format('YYYY-MM-DD HH:mm:ss') || '',
|
||||||
|
};
|
||||||
|
return getDashboardRuleTrigger(reqParams);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: ({ list = [] }) => {
|
||||||
|
const isAllZero = list.every((d: RuleTriggerItem) => d.count === 0);
|
||||||
|
const processedData = isAllZero
|
||||||
|
? list.map((d: RuleTriggerItem) => {
|
||||||
|
d._placeholder = true;
|
||||||
|
d.count = 1;
|
||||||
|
return d;
|
||||||
|
})
|
||||||
|
: list;
|
||||||
|
setRuleTriggerData(processedData);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const componentShow = (
|
||||||
|
loading: boolean,
|
||||||
|
list: any[],
|
||||||
|
component: React.ReactNode,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loading ? (
|
||||||
|
<Space
|
||||||
|
className="flex justify-center align-center"
|
||||||
|
style={{ width: '100%', height: '250px' }}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
</Space>
|
||||||
|
) : list.length ? (
|
||||||
|
component
|
||||||
|
) : (
|
||||||
|
<Space
|
||||||
|
className="flex justify-center align-center"
|
||||||
|
style={{ width: '100%', height: '250px' }}
|
||||||
|
>
|
||||||
|
<Empty description={$t('common.content.noData')} />
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqFunMap = {
|
||||||
|
reqAlertTrend,
|
||||||
|
reqRuleTrigger,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRangeChange = (reqFunStr: string, dateStrings: string[]) => {
|
||||||
|
if (reqFunStr in reqFunMap) {
|
||||||
|
reqFunMap[reqFunStr as keyof typeof reqFunMap]({
|
||||||
|
startDate: dateStrings[0],
|
||||||
|
endDate: dateStrings[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMap = [
|
||||||
|
{
|
||||||
|
color: '#f97316',
|
||||||
|
text: $t('table.new'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#10b981',
|
||||||
|
text: $t('table.viewed'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#64748b',
|
||||||
|
text: $t('table.ignored'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const columns: ProColumns<API.AlertListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.ruleType'),
|
||||||
|
dataIndex: 'ruleTypeName',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: $t('table.account'),
|
||||||
|
dataIndex: 'tradeAccount',
|
||||||
|
renderText: (_, { tradeAccount }) => {
|
||||||
|
return tradeAccount || 'SYSTEM';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSource'),
|
||||||
|
dataIndex: 'dataSourceName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.trigger'),
|
||||||
|
dataIndex: 'trigger',
|
||||||
|
width: 'auto',
|
||||||
|
className: 'text-bold',
|
||||||
|
fieldProps: {
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.summary'),
|
||||||
|
dataIndex: 'summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${$t('table.triggerTime')} (UTC+0)`,
|
||||||
|
dataIndex: 'triggerTime',
|
||||||
|
renderText: (_, { triggerTime = '' }) =>
|
||||||
|
formatToUserTimezone(triggerTime),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.dataSourceTime'),
|
||||||
|
dataIndex: 'dataSourceServerTime',
|
||||||
|
render: (_, { dataSourceServerTime, timeZone }) => {
|
||||||
|
if (!dataSourceServerTime) return '-';
|
||||||
|
return (
|
||||||
|
<Space wrap>
|
||||||
|
<Text>
|
||||||
|
{formatToUserTimezone(dataSourceServerTime as string) || '-'}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{timeZone || '-'}{' '}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (_: any, { status = 1 }) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={statusMap[status - 1]?.color || '#64748b'}
|
||||||
|
text={statusMap[status - 1]?.text || '-'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type CardKey = keyof typeof cardInfo;
|
||||||
|
const headStatisticCardList: Record<CardKey, CardInfo> = {
|
||||||
|
todayAlertCount: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🔔',
|
||||||
|
iconColor: '#3b82f6',
|
||||||
|
iconBgColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
leftColor: '#3b82f6',
|
||||||
|
description: $t('pages.dashboard.todayAlertCount'),
|
||||||
|
},
|
||||||
|
activeCount: {
|
||||||
|
count: 0,
|
||||||
|
icon: '⚙️',
|
||||||
|
iconColor: '#34d399',
|
||||||
|
iconBgColor: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
leftColor: '#34d399',
|
||||||
|
description: $t('pages.dashboard.activeRuleCount'),
|
||||||
|
},
|
||||||
|
accountCount: {
|
||||||
|
count: 0,
|
||||||
|
icon: '👥',
|
||||||
|
iconColor: '#f87171',
|
||||||
|
iconBgColor: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
leftColor: '#f87171',
|
||||||
|
description: $t('pages.dashboard.monitoringAccountCount'),
|
||||||
|
},
|
||||||
|
tradeVolume: {
|
||||||
|
count: 0,
|
||||||
|
icon: '📈',
|
||||||
|
iconColor: '#fbbf24',
|
||||||
|
iconBgColor: 'rgba(245, 158, 11, 0.15)',
|
||||||
|
leftColor: '#fbbf24',
|
||||||
|
description: $t('pages.dashboard.alertProcessingRate'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.keys(headStatisticCardList).map((key) => {
|
||||||
|
const item = headStatisticCardList[key as CardKey];
|
||||||
|
item.count = cardInfo[key as CardKey];
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={12}
|
||||||
|
lg={24 / Object.keys(cardInfo).length}
|
||||||
|
key={item.description}
|
||||||
|
>
|
||||||
|
<HeadStatisticCard cardInfo={item} />
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Col span={24}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={12} lg={12}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>📊 {$t('pages.dashboard.alertTrend')}</span>
|
||||||
|
<Tag color="error" bordered={false}>
|
||||||
|
{$t('pages.dashboard.globalView')}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<RangePicker
|
||||||
|
style={{ width: '220px' }}
|
||||||
|
maxDate={instance}
|
||||||
|
allowClear={false}
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
defaultValue={rangePresets[0].value as any}
|
||||||
|
presets={rangePresets}
|
||||||
|
onChange={(_, dateStrings) =>
|
||||||
|
onRangeChange('reqAlertTrend', dateStrings)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{componentShow(
|
||||||
|
alertTrendLoading,
|
||||||
|
alertTrendData,
|
||||||
|
<Area
|
||||||
|
xField="dateLabel"
|
||||||
|
yField="count"
|
||||||
|
shapeField="smooth"
|
||||||
|
height={380}
|
||||||
|
style={{
|
||||||
|
fill: 'linear-gradient(-90deg, white 0%, #975FE4 100%)',
|
||||||
|
fillOpacity: 0.6,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
axis={{
|
||||||
|
x: {
|
||||||
|
labelTransform: 'rotate(0)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
data={alertTrendData}
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={12}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
title={`🎯 ${$t('pages.dashboard.ruleTriggerDistribution')}`}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
extra={
|
||||||
|
<RangePicker
|
||||||
|
style={{ width: '220px' }}
|
||||||
|
maxDate={instance}
|
||||||
|
allowClear={false}
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
defaultValue={rangePresets[0].value as any}
|
||||||
|
presets={rangePresets}
|
||||||
|
onChange={(_, dateStrings) =>
|
||||||
|
onRangeChange('reqRuleTrigger', dateStrings)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{componentShow(
|
||||||
|
ruleTriggerLoading,
|
||||||
|
ruleTriggerData,
|
||||||
|
<Pie
|
||||||
|
height={380}
|
||||||
|
radius={0.8}
|
||||||
|
innerRadius={0.5}
|
||||||
|
angleField="count"
|
||||||
|
colorField="name"
|
||||||
|
data={ruleTriggerData}
|
||||||
|
paddingTop={30}
|
||||||
|
legend={{
|
||||||
|
color: {
|
||||||
|
title: false,
|
||||||
|
position: ['xs', 'sm', 'md'].includes(screens || '')
|
||||||
|
? 'bottom'
|
||||||
|
: 'right',
|
||||||
|
rowPadding: 5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
label={[
|
||||||
|
{
|
||||||
|
text: 'name',
|
||||||
|
position: 'outside',
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
type: 'overlapDodgeY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
style: {
|
||||||
|
opacity: (d: RuleTriggerItem) => (d.count ? 1 : 0),
|
||||||
|
},
|
||||||
|
connectorOpacity: (d: RuleTriggerItem) =>
|
||||||
|
d.count ? 1 : 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: (
|
||||||
|
d: RuleTriggerItem,
|
||||||
|
_: number,
|
||||||
|
data: RuleTriggerItem[],
|
||||||
|
) => {
|
||||||
|
if (d.count === 0) return '';
|
||||||
|
if (d._placeholder) return 0;
|
||||||
|
const total = data.reduce((a, b) => a + b.count, 0);
|
||||||
|
const percent = `${((d.count / total) * 100).toFixed(1)}%`;
|
||||||
|
return `${d.count}\n${percent}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tooltip={{
|
||||||
|
items: [
|
||||||
|
(d: RuleTriggerItem) => ({
|
||||||
|
name: d.name,
|
||||||
|
value: d._placeholder ? 0 : d.count,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
// coordinate={{
|
||||||
|
// type: 'theta',
|
||||||
|
// startAngle: -Math.PI,
|
||||||
|
// endAngle: Math.PI,
|
||||||
|
// }}
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
title={`🔔 ${$t('pages.dashboard.latestAlert')}`}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
extra={
|
||||||
|
<Link to="/alert">
|
||||||
|
<Button type="primary" key="primary">
|
||||||
|
{$t('pages.dashboard.viewAll')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
search={false}
|
||||||
|
options={false}
|
||||||
|
pagination={false}
|
||||||
|
request={async (params: {
|
||||||
|
pageSize: number;
|
||||||
|
current: number;
|
||||||
|
}) => {
|
||||||
|
const { data } = await getAlertPages({
|
||||||
|
pageNum: params.current,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
169
src/pages/data-source/Popup.tsx
Normal file
169
src/pages/data-source/Popup.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormDigit,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import { Divider, type FormInstance, Typography } from 'antd';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { preventScientificNotation } from '@/utils';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default (props: {
|
||||||
|
visible: boolean;
|
||||||
|
formValues: Partial<API.DataSourceListItem | undefined>;
|
||||||
|
companyList: API.CompanyListItem[];
|
||||||
|
onSubmit: (values: Partial<API.DataSourceListItem>) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) => {
|
||||||
|
const { visible, formValues, onSubmit, onDone, companyList } = props;
|
||||||
|
|
||||||
|
const filteredCompanyList = formValues?.id
|
||||||
|
? companyList
|
||||||
|
: companyList.filter((v) => v.id !== 0);
|
||||||
|
|
||||||
|
const isEdit = !!formValues?.id;
|
||||||
|
|
||||||
|
const { isGlobalCompany } = useAccess();
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const formRef = useRef<FormInstance>(null);
|
||||||
|
const ipv4Rule = [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
pattern:
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
|
||||||
|
message: $t('form.placeholder.ipAddress'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
width="600px"
|
||||||
|
disabled={isEdit}
|
||||||
|
title={`${isEdit ? $t('table.action.view') : $t('table.action.add')}${$t('table.dataSource')}`}
|
||||||
|
initialValues={
|
||||||
|
formValues?.id
|
||||||
|
? formValues
|
||||||
|
: {
|
||||||
|
sourceType: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
formRef={formRef}
|
||||||
|
onFinish={async (values: Partial<API.DataSourceListItem>) => {
|
||||||
|
if (!isGlobalCompany) {
|
||||||
|
values.companyId = userInfo?.companyId;
|
||||||
|
}
|
||||||
|
return onSubmit(values);
|
||||||
|
}}
|
||||||
|
modalProps={{
|
||||||
|
maskClosable: false,
|
||||||
|
onCancel: () => onDone(),
|
||||||
|
destroyOnHidden: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="sourceName"
|
||||||
|
label={$t('form.dataSource.name')}
|
||||||
|
placeholder="e.g. Company MT5"
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="sourceType"
|
||||||
|
label={$t('form.dataSource.type')}
|
||||||
|
allowClear={false}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
options={[
|
||||||
|
// {
|
||||||
|
// value: 4,
|
||||||
|
// label: 'MT4',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
value: 5,
|
||||||
|
label: 'MT5',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isGlobalCompany && (
|
||||||
|
<ProFormSelect
|
||||||
|
name="companyId"
|
||||||
|
label={$t('table.company')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
showSearch
|
||||||
|
options={filteredCompanyList || []}
|
||||||
|
fieldProps={{
|
||||||
|
fieldNames: {
|
||||||
|
label: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Divider size="small" />
|
||||||
|
<Text strong style={{ lineHeight: '40px', fontSize: 16 }}>
|
||||||
|
🔗 {$t('form.dataSource.connectionConfig')}
|
||||||
|
</Text>
|
||||||
|
<ProFormText
|
||||||
|
name="tradeIp"
|
||||||
|
label={$t('form.dataSource.ipAddress')}
|
||||||
|
rules={ipv4Rule}
|
||||||
|
fieldProps={{
|
||||||
|
onPaste: (e) => {
|
||||||
|
try {
|
||||||
|
const text = e.clipboardData.getData('text').trim();
|
||||||
|
const match = text.match(/^(.+):(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
e.preventDefault();
|
||||||
|
formRef?.current?.setFieldsValue({
|
||||||
|
tradeIp: match[1],
|
||||||
|
tradePort: match[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Paste IP address error:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
label={$t('form.dataSource.port')}
|
||||||
|
name="tradePort"
|
||||||
|
placeholder={`${$t('form.range')}: 0-65535'`}
|
||||||
|
min={0}
|
||||||
|
max={65535}
|
||||||
|
fieldProps={{
|
||||||
|
precision: 0,
|
||||||
|
type: 'number',
|
||||||
|
onKeyDown: preventScientificNotation,
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
label={$t('form.dataSource.tradeAccount')}
|
||||||
|
name="tradeAccount"
|
||||||
|
min={0}
|
||||||
|
fieldProps={{
|
||||||
|
precision: 0,
|
||||||
|
type: 'number',
|
||||||
|
onKeyDown: preventScientificNotation,
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
{formValues?.id ? null : (
|
||||||
|
<ProFormText.Password
|
||||||
|
name="tradePassword"
|
||||||
|
label={$t('form.password')}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: $t('form.required') },
|
||||||
|
{ min: 6, message: $t('form.password.placeholder') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ProFormText hidden name="id" label="ID" />
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
331
src/pages/data-source/index.tsx
Normal file
331
src/pages/data-source/index.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
type ActionType,
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import { App, Badge, Button, Col, Row } from 'antd';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import HeadStatisticCard, {
|
||||||
|
type CardInfo,
|
||||||
|
} from '@/components/HeadStatisticCard';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import {
|
||||||
|
dataSourceDelete,
|
||||||
|
dataSourceHandle,
|
||||||
|
getCompanyList,
|
||||||
|
getDataSourcePages,
|
||||||
|
} from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import Popup from './Popup';
|
||||||
|
|
||||||
|
type DataSourceListItem = Partial<API.DataSourceListItem>;
|
||||||
|
type CompanyMapType = Record<number | string, API.CompanyListItem>;
|
||||||
|
|
||||||
|
const maxAlertHideCount = 9999;
|
||||||
|
const DataSource: React.FC = () => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { isGlobalCompany } = useAccess();
|
||||||
|
const { refreshUserInfo } = useUserInfo();
|
||||||
|
const [formValues, setFormValues] = useState<DataSourceListItem | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const { modal } = App.useApp();
|
||||||
|
const tableRef = useRef<ActionType>(null);
|
||||||
|
const [companyList, setCompanyList] = useState<API.CompanyListItem[]>([]);
|
||||||
|
const [companyMap, setCompanyMap] = useState<CompanyMapType>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGlobalCompany) {
|
||||||
|
getCompanyList().then(({ data: { data } }) => {
|
||||||
|
const tmp = {} as CompanyMapType;
|
||||||
|
data.forEach((item: API.CompanyListItem) => {
|
||||||
|
tmp[item.id] = item;
|
||||||
|
});
|
||||||
|
setCompanyMap(tmp);
|
||||||
|
setCompanyList(data || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const [cardInfo, setCardInfo] = useState({
|
||||||
|
// mt4Count: 0,
|
||||||
|
mt5Count: 0,
|
||||||
|
count: 0,
|
||||||
|
active: 0,
|
||||||
|
});
|
||||||
|
const statusMap = [
|
||||||
|
{
|
||||||
|
color: '#64748b',
|
||||||
|
text: $t('table.dataSource.status0'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#10b981',
|
||||||
|
text: $t('table.dataSource.status1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#f59e0b',
|
||||||
|
text: $t('table.dataSource.status2'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#f97316',
|
||||||
|
text: $t('table.dataSource.status3'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#64748b',
|
||||||
|
text: $t('table.action.deleteDone'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
setVisible(false);
|
||||||
|
setFormValues({});
|
||||||
|
};
|
||||||
|
const handleSubmit = async (values: DataSourceListItem) => {
|
||||||
|
const { code } = (await dataSourceHandle(values)) as API.BaseResponse;
|
||||||
|
if (code === 200) {
|
||||||
|
tableRef.current?.reload();
|
||||||
|
handleDone();
|
||||||
|
|
||||||
|
if (isGlobalCompany) {
|
||||||
|
await refreshUserInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<API.DataSourceListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.dataSourceId'),
|
||||||
|
dataIndex: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.name'),
|
||||||
|
dataIndex: 'sourceName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.platform'),
|
||||||
|
dataIndex: 'sourceType',
|
||||||
|
render: (_, { sourceType = 0 }) => {
|
||||||
|
return (
|
||||||
|
<MyTag
|
||||||
|
color={sourceType === 4 ? '#818cf8' : '#10b981'}
|
||||||
|
>{`MT${sourceType}`}</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.ipAddress'),
|
||||||
|
dataIndex: 'tradeIp',
|
||||||
|
renderText: (_, { tradeIp = '', tradePort = '' }) => {
|
||||||
|
return `${tradeIp || '-'}:${tradePort || '-'} `;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.company'),
|
||||||
|
hideInTable: !isGlobalCompany,
|
||||||
|
dataIndex: 'companyId',
|
||||||
|
renderText: (_, { companyId = 0 }) => {
|
||||||
|
return companyMap[companyId]?.name || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.ruleCount'),
|
||||||
|
dataIndex: 'ruleCount',
|
||||||
|
render: (_, { ruleCount = 0 }) => {
|
||||||
|
return <MyTag>{ruleCount ?? '-'}</MyTag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.alertCount'),
|
||||||
|
dataIndex: 'alertCount',
|
||||||
|
render: (_, { alertCount = 0 }) => {
|
||||||
|
return (
|
||||||
|
<MyTag
|
||||||
|
color={alertCount ? '#ef4444' : '#64748b'}
|
||||||
|
title={`${alertCount}`}
|
||||||
|
>
|
||||||
|
{alertCount > maxAlertHideCount
|
||||||
|
? `${maxAlertHideCount}+`
|
||||||
|
: (alertCount ?? '-')}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (_, { status = 0 }) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={statusMap[status]?.color || '#64748b'}
|
||||||
|
text={statusMap[status]?.text || '-'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.createTime'),
|
||||||
|
dataIndex: 'createTime',
|
||||||
|
valueType: 'date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.action'),
|
||||||
|
dataIndex: 'option',
|
||||||
|
width: '60px',
|
||||||
|
valueType: 'option',
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, item) => {
|
||||||
|
const renderItem = [
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="link"
|
||||||
|
key="edit"
|
||||||
|
onClick={() => {
|
||||||
|
setFormValues(item);
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.view')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
if (item.status && item.status < 3) {
|
||||||
|
renderItem.push(
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
color="danger"
|
||||||
|
size="small"
|
||||||
|
key="delete"
|
||||||
|
onClick={() => {
|
||||||
|
modal.confirm({
|
||||||
|
title: $t('pages.model.delete'),
|
||||||
|
content: $t('pages.model.delete.content'),
|
||||||
|
okText: $t('pages.model.confirm'),
|
||||||
|
cancelText: $t('pages.model.cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code } = (await dataSourceDelete({
|
||||||
|
id: item.id,
|
||||||
|
})) as API.BaseResponse;
|
||||||
|
if (code === 200) {
|
||||||
|
tableRef.current?.reload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.delete')}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderItem;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type CardKey = keyof typeof cardInfo;
|
||||||
|
const headStatisticCardList: Record<CardKey, CardInfo> = {
|
||||||
|
count: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🔌',
|
||||||
|
iconColor: '#3b82f6',
|
||||||
|
iconBgColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
leftColor: '#3b82f6',
|
||||||
|
description: $t('pages.totalDataSourceCount'),
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
count: 0,
|
||||||
|
icon: '✅',
|
||||||
|
iconColor: '#60a5fa',
|
||||||
|
iconBgColor: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
leftColor: '#60a5fa',
|
||||||
|
description: $t('pages.activeDataSourceCount'),
|
||||||
|
},
|
||||||
|
mt5Count: {
|
||||||
|
count: 0,
|
||||||
|
icon: '📊',
|
||||||
|
iconColor: '#34d399',
|
||||||
|
iconBgColor: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
leftColor: '#34d399',
|
||||||
|
description: $t('pages.mt5DataSourceCount'),
|
||||||
|
},
|
||||||
|
// mt4Count: {
|
||||||
|
// count: 0,
|
||||||
|
// icon: '📈',
|
||||||
|
// iconColor: '#fbbf24',
|
||||||
|
// iconBgColor: 'rgba(245, 158, 11, 0.15)',
|
||||||
|
// leftColor: '#fbbf24',
|
||||||
|
// description: $t('pages.mt4DataSourceCount'),
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.keys(headStatisticCardList).map((key) => {
|
||||||
|
const item = headStatisticCardList[key as CardKey];
|
||||||
|
item.count = cardInfo[key as CardKey];
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={12}
|
||||||
|
lg={24 / Object.keys(cardInfo).length}
|
||||||
|
key={item.description}
|
||||||
|
>
|
||||||
|
<HeadStatisticCard cardInfo={item} />
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
search={false}
|
||||||
|
actionRef={tableRef}
|
||||||
|
request={async (params: { pageSize: number; current: number }) => {
|
||||||
|
const { data } = await getDataSourcePages({
|
||||||
|
pageNum: params.current,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
});
|
||||||
|
setCardInfo({
|
||||||
|
mt5Count: data.mt5Count,
|
||||||
|
// mt4Count: data.mt4Count,
|
||||||
|
count: data.count,
|
||||||
|
active: data.active,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="primary"
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
{`${$t('table.action.add')}${$t('table.dataSource')}`}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
companyList={companyList}
|
||||||
|
formValues={formValues}
|
||||||
|
onDone={handleDone}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataSource;
|
||||||
179
src/pages/email-services/Popup.tsx
Normal file
179
src/pages/email-services/Popup.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormDependency,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { getEmailConfigDetail } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export default (props: {
|
||||||
|
visible: boolean;
|
||||||
|
formValues: Partial<API.DataSourceListItem | undefined>;
|
||||||
|
onSubmit: (values: Partial<API.DataSourceListItem>) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) => {
|
||||||
|
const { visible, formValues, onSubmit, onDone } = props;
|
||||||
|
const isEdit = !!formValues?.id;
|
||||||
|
const formRef = useRef<FormInstance>(null);
|
||||||
|
|
||||||
|
const customSMTP = (
|
||||||
|
<>
|
||||||
|
<ProFormText
|
||||||
|
name="host"
|
||||||
|
label="SMTP Host"
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="port"
|
||||||
|
label="SMTP Port"
|
||||||
|
placeholder="587"
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="username"
|
||||||
|
label="SMTP Username"
|
||||||
|
placeholder="username@example.com"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'email',
|
||||||
|
message: $t('form.email.placeholder'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
label="SMTP Password"
|
||||||
|
placeholder="password"
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
width="600px"
|
||||||
|
title={`${isEdit ? $t('table.action.edit') : $t('table.action.add')}${$t('menu.emailServices')}`}
|
||||||
|
initialValues={formValues?.id ? formValues : { type: 1 }}
|
||||||
|
open={visible}
|
||||||
|
formRef={formRef}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
const from = values.from
|
||||||
|
.split(',')
|
||||||
|
.map((item: string) => item.trim())
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
for (const key in values) {
|
||||||
|
if (typeof values[key] === 'string') {
|
||||||
|
values[key] = values[key].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return onSubmit({ ...values, from });
|
||||||
|
}}
|
||||||
|
modalProps={{
|
||||||
|
maskClosable: false,
|
||||||
|
onCancel: () => onDone(),
|
||||||
|
destroyOnHidden: true,
|
||||||
|
centered: true,
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
maxHeight: '82vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
marginRight: -10,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
request={async () => {
|
||||||
|
if (formValues?.id) {
|
||||||
|
const {
|
||||||
|
data: { data },
|
||||||
|
} = await getEmailConfigDetail(formValues?.id);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label={$t('pages.email.serviceName')}
|
||||||
|
placeholder={$t('pages.email.serviceName.tips')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="type"
|
||||||
|
label={$t('pages.email.serviceProvider')}
|
||||||
|
allowClear={false}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
options={[
|
||||||
|
// {
|
||||||
|
// value: 'sendgrid',
|
||||||
|
// label: '📧 SendGrid',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// value: 'mailgun',
|
||||||
|
// label: '📬 Mailgun',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// value: 'aws-ses',
|
||||||
|
// label: '☁️ AWS SES',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// value: 'postmark',
|
||||||
|
// label: '📮 Postmark',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: '🔧 Custom SMTP',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormDependency name={['type']}>
|
||||||
|
{({ type }) => {
|
||||||
|
if (type === 1) {
|
||||||
|
return customSMTP;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ProFormText.Password
|
||||||
|
name="apiKey"
|
||||||
|
label="API Key"
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ProFormDependency>
|
||||||
|
|
||||||
|
<ProFormSelect
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: $t('pages.email.status.normal'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: $t('pages.email.status.disabled'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialValue={1}
|
||||||
|
name="status"
|
||||||
|
label={$t('table.status')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="from"
|
||||||
|
label={$t('pages.email.availableEmails')}
|
||||||
|
placeholder={$t('pages.email.availableEmails.tips')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormText hidden name="id" label="ID" />
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
232
src/pages/email-services/index.tsx
Normal file
232
src/pages/email-services/index.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
type ActionType,
|
||||||
|
ModalForm,
|
||||||
|
type ProColumns,
|
||||||
|
ProFormText,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { Badge, Button, Col, message, Row, Space, Typography } from 'antd';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import {
|
||||||
|
emailConfigHandle,
|
||||||
|
emailConfigTest,
|
||||||
|
getEmailConfigPages,
|
||||||
|
} from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import RuleHeader from '../rules/components/RuleHeader';
|
||||||
|
import Popup from './Popup';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const MAX_SHOWED_ITEM = 3;
|
||||||
|
const EmailServices: React.FC = () => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [formValues, setFormValues] = useState(undefined);
|
||||||
|
const tableRef = useRef<ActionType>(null);
|
||||||
|
const [open, setModalOpen] = useState(false);
|
||||||
|
const [currentItem, setCurrentItem] = useState<
|
||||||
|
API.EmailConfigListItem | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {}, []);
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
setVisible(false);
|
||||||
|
setFormValues(undefined);
|
||||||
|
};
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
// console.log(values);
|
||||||
|
// return;
|
||||||
|
const { code } = (await emailConfigHandle(values)) as API.BaseResponse;
|
||||||
|
if (code === 200) {
|
||||||
|
tableRef.current?.reload();
|
||||||
|
handleDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestEmail = async (values: any) => {
|
||||||
|
const { code } = await emailConfigTest({
|
||||||
|
email: values.email,
|
||||||
|
id: currentItem?.id || '',
|
||||||
|
}).finally(() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
});
|
||||||
|
if (code === 200) {
|
||||||
|
message.success(
|
||||||
|
$t('pages.email.testEmail.success', { email: values.email }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns[] = [
|
||||||
|
{
|
||||||
|
title: $t('pages.email.serviceName'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (text, { createTime }) => {
|
||||||
|
return (
|
||||||
|
<Space size={0} direction="vertical">
|
||||||
|
<Text strong>{text || '-'}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{createTime || '-'}{' '}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('pages.email.serviceProvider'),
|
||||||
|
dataIndex: 'type',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, { type }) => {
|
||||||
|
return type === 1 ? '🔧 Custom SMTP' : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, { status }) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="text-nowrap"
|
||||||
|
color={status ? '#10b981' : '#64748b'}
|
||||||
|
text={
|
||||||
|
status
|
||||||
|
? $t('pages.email.status.normal')
|
||||||
|
: $t('pages.email.status.disabled')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('pages.email.assignedTo'),
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, { companyName }) => {
|
||||||
|
if (!companyName) {
|
||||||
|
return <Text type="secondary">{$t('pages.email.notAssigned')}</Text>;
|
||||||
|
}
|
||||||
|
const names = companyName?.split(',');
|
||||||
|
const showedItem = names?.slice(0, MAX_SHOWED_ITEM);
|
||||||
|
return (
|
||||||
|
<Space wrap size={[0, 4]} title={names?.join('\n')}>
|
||||||
|
{showedItem?.map((name: string) => (
|
||||||
|
<MyTag key={name}>{name}</MyTag>
|
||||||
|
))}
|
||||||
|
{names?.length > MAX_SHOWED_ITEM && (
|
||||||
|
<Text>+{names?.length - MAX_SHOWED_ITEM}...</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.action'),
|
||||||
|
dataIndex: 'option',
|
||||||
|
valueType: 'option',
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, item) => {
|
||||||
|
const renderItem = [
|
||||||
|
<Button
|
||||||
|
key="test"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentItem(item);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡ {$t('pages.config.testPush')}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="link"
|
||||||
|
key="edit"
|
||||||
|
onClick={() => {
|
||||||
|
setFormValues(item);
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.edit')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
return renderItem;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space className="w-100 justify-between" style={{ marginBottom: 24 }}>
|
||||||
|
<RuleHeader
|
||||||
|
ruleMeta={{
|
||||||
|
title: $t('menu.emailServices'),
|
||||||
|
icon: '📧',
|
||||||
|
desc: $t('pages.email.desc'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space size={16}>
|
||||||
|
<Button key="1" type="primary" onClick={() => setVisible(true)}>
|
||||||
|
<PlusOutlined />
|
||||||
|
{`${$t('table.action.add')}${$t('menu.emailServices')}`}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
size="small"
|
||||||
|
actionRef={tableRef}
|
||||||
|
search={false}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getEmailConfigPages({
|
||||||
|
pageNum: params.current,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
formValues={formValues}
|
||||||
|
onDone={handleDone}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<ModalForm
|
||||||
|
title={$t('pages.email.testEmail.title')}
|
||||||
|
open={open}
|
||||||
|
width={400}
|
||||||
|
onFinish={handleTestEmail}
|
||||||
|
modalProps={{
|
||||||
|
maskClosable: false,
|
||||||
|
onCancel: () => setModalOpen(false),
|
||||||
|
centered: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="email"
|
||||||
|
placeholder="example@example.com"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
type: 'email',
|
||||||
|
message: $t('form.email.placeholder'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ModalForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailServices;
|
||||||
138
src/pages/login/index.tsx
Normal file
138
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { LoginForm, ProFormText } from '@ant-design/pro-components';
|
||||||
|
import { history, useModel, useSearchParams } from '@umijs/max';
|
||||||
|
import { App, Col, Flex, Row } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { setAccessToken } from '@/access';
|
||||||
|
import { SelectLang } from '@/components/RightContent';
|
||||||
|
import ThemeWrapper from '@/components/ThemeWrapper';
|
||||||
|
import { login } from '@/services/api/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import './login.less';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
|
||||||
|
const Lang = () => {
|
||||||
|
return <div data-lang>{SelectLang && <SelectLang />}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const { setUserInfo } = useUserInfo();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { setInitialized } = useModel('useMenuNoticeNum');
|
||||||
|
const setInfo = (userInfo: API.CurrentUser) => {
|
||||||
|
if (userInfo) {
|
||||||
|
flushSync(() => {
|
||||||
|
setInitialized(false);
|
||||||
|
setUserInfo(userInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: API.LoginParams) => {
|
||||||
|
try {
|
||||||
|
// Log in
|
||||||
|
const res = (await login(
|
||||||
|
{ ...values },
|
||||||
|
{ skipErrorHandler: true },
|
||||||
|
)) as API.BaseResponse<API.CurrentUser>;
|
||||||
|
if (res.code === 200) {
|
||||||
|
const userInfo = res.data?.data as API.CurrentUser;
|
||||||
|
const token = userInfo?.jwtToken
|
||||||
|
? `${userInfo.tokenPrefix} ${userInfo.jwtToken}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const successMessage = $t('pages.login.success');
|
||||||
|
// Save token and user info
|
||||||
|
await setAccessToken(token);
|
||||||
|
message.success(successMessage);
|
||||||
|
setInfo(userInfo);
|
||||||
|
|
||||||
|
const hasDashboadPage =
|
||||||
|
userInfo.permissionList?.includes('dashboard:list');
|
||||||
|
history.replace(
|
||||||
|
searchParams.get('redirect') ||
|
||||||
|
(hasDashboadPage ? '/' : '/profile'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const failureMessage = $t('pages.login.failure');
|
||||||
|
message.error(failureMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeWrapper style={{ height: '100vh' }} className="login-page">
|
||||||
|
<div className="lang-container">
|
||||||
|
<Lang />
|
||||||
|
</div>
|
||||||
|
<Row className="h-100" align="middle">
|
||||||
|
<Col xs={0} sm={0} lg={13} className="left-col h-100">
|
||||||
|
{/* <Row align="middle" justify="center" className="h-100">
|
||||||
|
<Flex className="left-content" vertical align="center">
|
||||||
|
<span className="title">RiskGuard</span>
|
||||||
|
<span className="sub-title">Trading Risk Control System</span>
|
||||||
|
</Flex>
|
||||||
|
</Row> */}
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} lg={11} className="right-col h-100">
|
||||||
|
<Row align="middle" justify="center" className="h-100">
|
||||||
|
<Flex align="center" justify="center">
|
||||||
|
<LoginForm
|
||||||
|
logo="/logo.svg"
|
||||||
|
title="RiskGuard"
|
||||||
|
subTitle={$t('pages.layouts.userLayout.title')}
|
||||||
|
initialValues={{
|
||||||
|
autoLogin: true,
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
await handleSubmit(values as API.LoginParams);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="username"
|
||||||
|
label={$t('table.username')}
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <UserOutlined />,
|
||||||
|
}}
|
||||||
|
placeholder={$t('pages.login.username')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: $t('pages.login.username.required'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
getValueFromEvent={(e) => e.target.value.trim()}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
label={$t('pages.login.password')}
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <LockOutlined />,
|
||||||
|
}}
|
||||||
|
placeholder={$t('pages.login.password')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: $t('pages.login.password.required'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
getValueFromEvent={(e) => e.target.value.trim()}
|
||||||
|
/>
|
||||||
|
</LoginForm>
|
||||||
|
</Flex>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</ThemeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
37
src/pages/login/login.less
Normal file
37
src/pages/login/login.less
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
.login-page {
|
||||||
|
position: relative;
|
||||||
|
.left-col {
|
||||||
|
background: url('@/assets/imgs/login_bg.jpg') no-repeat center center / cover;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.left-content {
|
||||||
|
padding: 10px 50px 30px 50px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
.title {
|
||||||
|
font-size: 50px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.sub-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.right-col {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.lang-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.ant-pro-form-login-container {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.h-100 {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
157
src/pages/oper-log/index.tsx
Normal file
157
src/pages/oper-log/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { Col, Row } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import useRuleMeta from '@/pages/rules/hooks/useRuleMeta';
|
||||||
|
import { getOperLogAction, getOperLogPages } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const OperLog: React.FC = () => {
|
||||||
|
const [actionList, setActionList] = useState([]);
|
||||||
|
const { ruleBaseMeta } = useRuleMeta();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOperLogAction().then(({ data: { data } }) => {
|
||||||
|
setActionList(data || []);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const alertActionType: Record<string, string> = {
|
||||||
|
'2': $t('table.viewed'),
|
||||||
|
'3': $t('table.ignored'),
|
||||||
|
};
|
||||||
|
const ruleActionType: Record<string, string> = {
|
||||||
|
'1': $t('pages.rules.enable'),
|
||||||
|
'2': $t('pages.rules.disable'),
|
||||||
|
'4': $t('table.action.delete'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionMap: Record<string, (item: API.OperLogListItem) => string> = {
|
||||||
|
'alert record edit': (item) => {
|
||||||
|
const matched = item.operParam.match(/status=(?<status>\d+)/);
|
||||||
|
const status = matched?.groups?.status || '';
|
||||||
|
return `Handle Alert Record : ${alertActionType[status] || 'unknown'}`;
|
||||||
|
},
|
||||||
|
login: (item) => {
|
||||||
|
return `'${item.operName || '-'}' Login`;
|
||||||
|
},
|
||||||
|
'alert record excel export': () => {
|
||||||
|
return `Export Alert Record`;
|
||||||
|
},
|
||||||
|
'user logout': (item) => {
|
||||||
|
return `'${item.operName || '-'}' Logout`;
|
||||||
|
},
|
||||||
|
'data-source insert': (item) => {
|
||||||
|
const operParam = JSON.parse(item.operParam || '{}');
|
||||||
|
return `Add Data Source: '${operParam.sourceName || '-'}'`;
|
||||||
|
},
|
||||||
|
'rule insert': (item) => {
|
||||||
|
const operParam = JSON.parse(item.operParam || '{}');
|
||||||
|
return `Add Rule: '${ruleBaseMeta[operParam.type].title || '-'}'`;
|
||||||
|
},
|
||||||
|
'rule update': (item) => {
|
||||||
|
const operParam = JSON.parse(item.operParam || '{}');
|
||||||
|
return `Update Rule: '${ruleBaseMeta[operParam.type].title || '-'}'`;
|
||||||
|
},
|
||||||
|
'rule update status': (item) => {
|
||||||
|
const matched = item.operParam.match(/status=(?<status>\d+)/);
|
||||||
|
const status = matched?.groups?.status || '';
|
||||||
|
return `Handle Rule : ${ruleActionType[status] || 'unknown'}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<API.OperLogListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.time'),
|
||||||
|
dataIndex: 'operTime',
|
||||||
|
valueType: 'dateTime',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.operator'),
|
||||||
|
dataIndex: 'operName',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.os'),
|
||||||
|
dataIndex: 'os',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.browser'),
|
||||||
|
dataIndex: 'browser',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.actionType'),
|
||||||
|
dataIndex: 'title',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
defaultValue: '',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...actionList.map((item: string) => ({
|
||||||
|
label: item?.toUpperCase() || '',
|
||||||
|
value: item,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
renderText: (_, { title }) => title?.toUpperCase() || '-',
|
||||||
|
search: {
|
||||||
|
transform: (value) => ({
|
||||||
|
titles: value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.description'),
|
||||||
|
dataIndex: 'title',
|
||||||
|
hideInSearch: true,
|
||||||
|
ellipsis: true,
|
||||||
|
width: 'auto',
|
||||||
|
renderText: (_, item) => actionMap[item.title]?.(item) || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.ipAddress'),
|
||||||
|
dataIndex: 'operIp',
|
||||||
|
hideInSearch: true,
|
||||||
|
renderText: (_, { operIp }) => operIp || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
size="small"
|
||||||
|
search={{
|
||||||
|
labelWidth: 'auto',
|
||||||
|
}}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getOperLogPages({
|
||||||
|
pageNum: params.current,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data?.data?.list || [],
|
||||||
|
success: true,
|
||||||
|
total: data?.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="operId"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperLog;
|
||||||
296
src/pages/products/index.tsx
Normal file
296
src/pages/products/index.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { RedoOutlined } from '@ant-design/icons';
|
||||||
|
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useRequest } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Flex,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tree,
|
||||||
|
} from 'antd';
|
||||||
|
import React, { type Key, useEffect, useMemo, useState } from 'react';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import Loading from '@/loading';
|
||||||
|
import { getAllDataSourceList, getSymbolTreeList } from '@/utils/dataCenter';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const Products: React.FC = () => {
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const userDataSourceList = userInfo?.userDataSourceList;
|
||||||
|
const companyInfo = userInfo?.company;
|
||||||
|
|
||||||
|
const [dataSourceList, setDataSourceList] = useState<
|
||||||
|
API.DataSourceListItem[]
|
||||||
|
>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | string>();
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('');
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllDataSourceList(companyInfo).then((dataSourceList) => {
|
||||||
|
const filteredDataSourceList = dataSourceList.filter((item) =>
|
||||||
|
userDataSourceList?.find(
|
||||||
|
(userItem) => userItem.dataSourceId === item.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDataSourceList(filteredDataSourceList);
|
||||||
|
setSelectedId(filteredDataSourceList[0]?.id || 0);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: treeData,
|
||||||
|
loading: loadingB,
|
||||||
|
run: refreshSymbolTreeList,
|
||||||
|
} = useRequest(
|
||||||
|
(isForceRefresh = false) =>
|
||||||
|
getSymbolTreeList(companyInfo, selectedId, isForceRefresh),
|
||||||
|
{
|
||||||
|
ready: !!selectedId,
|
||||||
|
formatResult: (result) => {
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
refreshDeps: [selectedId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const productMap = useMemo(() => {
|
||||||
|
const map = new Map<string, API.SymbolTreeNode[]>();
|
||||||
|
|
||||||
|
const traverse = (nodes: API.SymbolTreeNode[]) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
// Extract direct products (children without no children)
|
||||||
|
const directProducts = node.children.filter(
|
||||||
|
(c) => !c.children || c.children.length === 0,
|
||||||
|
);
|
||||||
|
if (directProducts.length > 0) {
|
||||||
|
map.set(node.key, directProducts);
|
||||||
|
}
|
||||||
|
traverse(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(treeData || []);
|
||||||
|
return map;
|
||||||
|
}, [treeData]);
|
||||||
|
|
||||||
|
// All products flattened array (for global search)
|
||||||
|
const allProducts = useMemo(() => {
|
||||||
|
const list: (API.SymbolTreeNode & { parentTitle: string })[] = [];
|
||||||
|
const traverse = (
|
||||||
|
nodes: API.SymbolTreeNode[],
|
||||||
|
parentTitle: string = '',
|
||||||
|
) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const isProduct = !node.children || node.children.length === 0;
|
||||||
|
if (isProduct) {
|
||||||
|
list.push({ ...node, parentTitle }); // Record parent title for path display
|
||||||
|
} else {
|
||||||
|
traverse(node.children || [], node.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(treeData || []);
|
||||||
|
return list;
|
||||||
|
}, [treeData]);
|
||||||
|
|
||||||
|
const directoryTree = useMemo(() => {
|
||||||
|
const getOnlyDirs = (nodes: API.SymbolTreeNode[]): API.SymbolTreeNode[] => {
|
||||||
|
return nodes
|
||||||
|
.filter((node) => node.children && node.children.length > 0)
|
||||||
|
.map((node) => ({
|
||||||
|
...node,
|
||||||
|
children: getOnlyDirs(node.children || []),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
return getOnlyDirs(treeData || []);
|
||||||
|
}, [treeData]);
|
||||||
|
|
||||||
|
const displayProducts = useMemo(() => {
|
||||||
|
if (!searchText) {
|
||||||
|
// Category mode
|
||||||
|
return productMap.get(activeCategory) || [];
|
||||||
|
}
|
||||||
|
// Global search mode
|
||||||
|
const lowerSearch = searchText.toLowerCase();
|
||||||
|
return allProducts.filter(
|
||||||
|
(p) =>
|
||||||
|
p.title.toLowerCase().indexOf(lowerSearch) !== -1 ||
|
||||||
|
p.value?.toLowerCase().indexOf(lowerSearch) !== -1,
|
||||||
|
);
|
||||||
|
// return productMap.get(activeCategory) || [];
|
||||||
|
}, [activeCategory, searchText, productMap, allProducts]);
|
||||||
|
|
||||||
|
const productSearch = (searchText: string) => {
|
||||||
|
if (searchText) {
|
||||||
|
setActiveCategory('');
|
||||||
|
}
|
||||||
|
setSearchText(searchText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickProduct = (_event: any, item: API.SymbolTreeNode) => {
|
||||||
|
if (searchText) {
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
if (item.children?.length) {
|
||||||
|
setExpandedKeys((prev) =>
|
||||||
|
prev.includes(item.key)
|
||||||
|
? prev.filter((key) => key !== item.key)
|
||||||
|
: [...prev, item.key],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (productMap.has(item.key)) {
|
||||||
|
setActiveCategory(item.key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
extra={
|
||||||
|
!!dataSourceList?.length && (
|
||||||
|
<Space size={16}>
|
||||||
|
{dataSourceList?.length ? (
|
||||||
|
<Space>
|
||||||
|
<span>{$t('table.dataSource')}: </span>
|
||||||
|
<Select
|
||||||
|
value={selectedId}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedId(value);
|
||||||
|
setActiveCategory('');
|
||||||
|
setSearchText('');
|
||||||
|
}}
|
||||||
|
options={dataSourceList}
|
||||||
|
fieldNames={{ label: 'sourceName', value: 'id' }}
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
loading={loadingB}
|
||||||
|
disabled={loadingB}
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
onClick={async () => {
|
||||||
|
refreshSymbolTreeList(true);
|
||||||
|
// getGroupTreeList(companyInfo, selectedId as number, true);
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
<Flex align="center">
|
||||||
|
{/* <CloudDownloadOutlined style={{ fontSize: 20 }} /> */}
|
||||||
|
<RedoOutlined style={{ fontSize: 20 }} />
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder={$t('pages.products.search')}
|
||||||
|
onSearch={productSearch}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingB ? (
|
||||||
|
<Loading padding="0" />
|
||||||
|
) : treeData?.length ? (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={10} xs={24} lg={10}>
|
||||||
|
<ProCard title={$t('pages.products.directory')}>
|
||||||
|
<Tree
|
||||||
|
treeData={directoryTree}
|
||||||
|
// onSelect={onClickProduct}
|
||||||
|
onClick={onClickProduct}
|
||||||
|
onExpand={setExpandedKeys}
|
||||||
|
selectedKeys={[activeCategory]}
|
||||||
|
blockNode
|
||||||
|
expandedKeys={expandedKeys}
|
||||||
|
// showLine
|
||||||
|
// selectable
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={14} xs={24} lg={14}>
|
||||||
|
<ProCard
|
||||||
|
title={
|
||||||
|
displayProducts.length > 0 && (
|
||||||
|
<Space>
|
||||||
|
{searchText && (
|
||||||
|
<span style={{ fontSize: 14 }}>
|
||||||
|
{$t('pages.products.searchResult')}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Tag color="blue">
|
||||||
|
{displayProducts.length} {$t('table.symbol')}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayProducts.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Symbol',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Path',
|
||||||
|
dataIndex: 'key',
|
||||||
|
key: 'key',
|
||||||
|
render: (text, { parentTitle }) =>
|
||||||
|
`${parentTitle || activeCategory}\\${text}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={displayProducts}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 15,
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
// virtual
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
searchText
|
||||||
|
? $t('pages.products.noResult', { searchText })
|
||||||
|
: activeCategory
|
||||||
|
? $t('pages.products.noProduct')
|
||||||
|
: $t('pages.products.directory.tips')
|
||||||
|
}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</ProCard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Space className="flex justify-center" style={{ width: '100%' }}>
|
||||||
|
<Empty description={$t('common.content.noData')} />
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Products;
|
||||||
192
src/pages/profile/index.tsx
Normal file
192
src/pages/profile/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
ProForm,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { history } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
type DescriptionsProps,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
} from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { clearAccessToken } from '@/access';
|
||||||
|
import PasswordLevel from '@/components/PasswordLevel';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { changeUserPass } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
const Profile: React.FC = () => {
|
||||||
|
const { userInfo, setUserInfo } = useUserInfo();
|
||||||
|
const userCompany = userInfo?.company;
|
||||||
|
const { message: antdMessage, modal } = App.useApp();
|
||||||
|
|
||||||
|
const roleList = () => {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
{userInfo?.roleList?.map((item) => (
|
||||||
|
<Tag color="blue" key={item?.id}>
|
||||||
|
{item?.mark || '-'}
|
||||||
|
</Tag>
|
||||||
|
)) || '-'}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const items: DescriptionsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
span: 'filled',
|
||||||
|
label: $t('form.user.displayName'),
|
||||||
|
children: userInfo?.nickName || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
span: 'filled',
|
||||||
|
label: $t('table.username'),
|
||||||
|
children: userInfo?.username || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
span: 'filled',
|
||||||
|
label: $t('form.user.email'),
|
||||||
|
children: userInfo?.email || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4',
|
||||||
|
span: 'filled',
|
||||||
|
label: $t('table.role'),
|
||||||
|
children: roleList(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '5',
|
||||||
|
span: 'filled',
|
||||||
|
label: $t('table.company'),
|
||||||
|
children: userCompany?.name || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const changePasswordSuccessHadle = async () => {
|
||||||
|
let countdown = 5;
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
const modalData = {
|
||||||
|
title: $t('pages.changePasswordSuccess'),
|
||||||
|
okText: `${$t('pages.goLogin')}(${countdown}S)`,
|
||||||
|
centered: true,
|
||||||
|
onOk: () => {
|
||||||
|
history.replace('/login');
|
||||||
|
instance.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const instance = modal.info({
|
||||||
|
...modalData,
|
||||||
|
});
|
||||||
|
timer = setInterval(() => {
|
||||||
|
countdown--;
|
||||||
|
instance.update({
|
||||||
|
okText: `${$t('pages.goLogin')}(${countdown}S)`,
|
||||||
|
});
|
||||||
|
if (countdown <= 0) {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
history.replace('/login');
|
||||||
|
instance.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
clearAccessToken();
|
||||||
|
setUserInfo(undefined);
|
||||||
|
localStorage.removeItem('changePassTimestamp');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title={`👤 ${$t('pages.profile.basicInfo')}`}
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Descriptions layout="vertical" items={items} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title={`🔐 ${$t('pages.profile.changePassword')}`}
|
||||||
|
hoverable
|
||||||
|
variant="borderless"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<ProForm
|
||||||
|
onFinish={async (values: API.ChangeUserPassParams) => {
|
||||||
|
const { code, message } = await changeUserPass(values);
|
||||||
|
if (code === 200) {
|
||||||
|
await changePasswordSuccessHadle();
|
||||||
|
} else {
|
||||||
|
antdMessage.error(message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
submitter={{
|
||||||
|
render: () => {
|
||||||
|
return [
|
||||||
|
<Button htmlType="submit" type="primary" key="edit" block>
|
||||||
|
{$t('pages.confirmModify')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
label={$t('pages.oldPassword')}
|
||||||
|
placeholder={$t('pages.oldPassword.tips')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
getValueFromEvent={(e) => e.target.value.trim()}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="newPassword"
|
||||||
|
label={$t('pages.newPassword')}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: $t('form.required') },
|
||||||
|
{ min: 6, message: $t('form.password.placeholder') },
|
||||||
|
]}
|
||||||
|
extra={<PasswordLevel passwordStr="newPassword" />}
|
||||||
|
getValueFromEvent={(e) => e.target.value.trim()}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="newPassword2"
|
||||||
|
label={$t('pages.confirmPassword')}
|
||||||
|
dependencies={['newPassword']}
|
||||||
|
getValueFromEvent={(e) => e.target.value.trim()}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: $t('form.required') },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('newPassword') === value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(
|
||||||
|
new Error($t('form.password.passwordNotMatch')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ProForm>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
137
src/pages/role/Popup.tsx
Normal file
137
src/pages/role/Popup.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProForm,
|
||||||
|
ProFormCheckbox,
|
||||||
|
ProFormDependency,
|
||||||
|
// ProFormDigit,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
// import { useAccess } from '@umijs/max';
|
||||||
|
import { ColorPicker, Form, Space } from 'antd';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { getRoleDetail } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
// import { preventScientificNotation } from '@/utils';
|
||||||
|
import useCardList from './cardList';
|
||||||
|
|
||||||
|
type RolesListItem = Partial<API.RolesListItem>;
|
||||||
|
export default (props: {
|
||||||
|
action: EX.Action;
|
||||||
|
visible: boolean;
|
||||||
|
formValues: Partial<RolesListItem | undefined>;
|
||||||
|
onSubmit: (values: RolesListItem) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) => {
|
||||||
|
const { visible, formValues, onSubmit, onDone, action } = props;
|
||||||
|
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
// const { isGlobalCompany } = useAccess();
|
||||||
|
// const userRoleInfo = userInfo?.roleList?.[0];
|
||||||
|
// const level = userRoleInfo?.level || (isGlobalCompany ? 100 : 80);
|
||||||
|
|
||||||
|
const { getMenuMetaByPermissions } = useCardList();
|
||||||
|
const menuList = getMenuMetaByPermissions(
|
||||||
|
userInfo?.menuList || ([] as API.MenuListItem[]),
|
||||||
|
);
|
||||||
|
const actionMap = {
|
||||||
|
add: $t('table.action.add'),
|
||||||
|
view: $t('table.action.view'),
|
||||||
|
edit: $t('table.action.edit'),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
disabled={action === 'view'}
|
||||||
|
title={`${actionMap[action]} ${$t('form.role.title')}`}
|
||||||
|
initialValues={formValues?.id ? formValues : { color: '#3b82f6' }}
|
||||||
|
open={visible}
|
||||||
|
onFinish={async (values: any) => {
|
||||||
|
values.menuIds = values.menuIds.join(',') || '';
|
||||||
|
return onSubmit(values);
|
||||||
|
}}
|
||||||
|
modalProps={{
|
||||||
|
maskClosable: action === 'view',
|
||||||
|
onCancel: () => onDone(),
|
||||||
|
destroyOnHidden: true,
|
||||||
|
}}
|
||||||
|
request={async () => {
|
||||||
|
if (formValues?.id) {
|
||||||
|
const {
|
||||||
|
data: { data },
|
||||||
|
} = await getRoleDetail({
|
||||||
|
id: formValues.id,
|
||||||
|
});
|
||||||
|
const menuIds = (data?.menuList || []).map(
|
||||||
|
(item: API.MenuListItem) => item.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...formValues,
|
||||||
|
menuIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return formValues || {};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label={$t('form.role.roleName')}
|
||||||
|
placeholder={$t('form.role.roleName.placeholder')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/>
|
||||||
|
{/* <ProFormDigit
|
||||||
|
min={1}
|
||||||
|
max={level - 1}
|
||||||
|
extra={$t('form.role.level.placeholder', { level: level - 1 })}
|
||||||
|
precision={0}
|
||||||
|
name="level"
|
||||||
|
label={$t('form.role.level')}
|
||||||
|
fieldProps={{
|
||||||
|
type: 'number',
|
||||||
|
onKeyDown: preventScientificNotation,
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
/> */}
|
||||||
|
<ProForm.Item label={$t('form.role.badgeColor')} required>
|
||||||
|
<Space>
|
||||||
|
<Form.Item
|
||||||
|
name="color"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
getValueFromEvent={(color: { toHexString: () => any }) =>
|
||||||
|
typeof color === 'string' ? color : color.toHexString()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ColorPicker format="hex" placement="top" showText disabledAlpha />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<ProFormDependency name={['color', 'name']}>
|
||||||
|
{({ color, name }) => (
|
||||||
|
<Space>
|
||||||
|
<span>{$t('form.role.preview')}: </span>
|
||||||
|
<MyTag color={color || '#3b82f6'}>{name || 'Example'}</MyTag>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</ProFormDependency>
|
||||||
|
</Space>
|
||||||
|
</ProForm.Item>
|
||||||
|
<ProFormCheckbox.Group
|
||||||
|
name="menuIds"
|
||||||
|
label={$t('form.role.menuIds')}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
options={menuList.map((item: API.MenuListItem) => ({
|
||||||
|
label: `${item.meta?.icon || ''} ${item.meta?.title}(${item?.meta?.describe})`,
|
||||||
|
value: item.id,
|
||||||
|
}))}
|
||||||
|
fieldProps={{
|
||||||
|
style: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '8px 16px',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProFormText hidden name="id" label="ID" />
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
108
src/pages/role/cardList.ts
Normal file
108
src/pages/role/cardList.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
type RoleCard = {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
describe: string;
|
||||||
|
permissions: string;
|
||||||
|
curUserShow?: boolean;
|
||||||
|
};
|
||||||
|
export default function useRoleCardList() {
|
||||||
|
const { hasPerms } = useAccess();
|
||||||
|
|
||||||
|
const roleCardList: RoleCard[] = [
|
||||||
|
{
|
||||||
|
icon: '📊',
|
||||||
|
title: $t('menu.dashboard'),
|
||||||
|
describe: $t('menu.dashboard.desc'),
|
||||||
|
permissions: 'dashboard:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '⚙️',
|
||||||
|
title: $t('menu.rules'),
|
||||||
|
describe: $t('menu.rules.desc'),
|
||||||
|
permissions: 'rule:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '📦',
|
||||||
|
title: $t('menu.products'),
|
||||||
|
describe: $t('menu.products.desc'),
|
||||||
|
permissions: 'product:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🔔',
|
||||||
|
title: $t('menu.alert'),
|
||||||
|
describe: $t('menu.alert.desc'),
|
||||||
|
permissions: 'alert:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '👥',
|
||||||
|
title: $t('menu.account'),
|
||||||
|
describe: $t('menu.account.desc'),
|
||||||
|
permissions: 'account:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🏢',
|
||||||
|
title: $t('menu.company'),
|
||||||
|
describe: $t('menu.company.desc'),
|
||||||
|
permissions: 'company:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🔌',
|
||||||
|
title: $t('menu.dataSource'),
|
||||||
|
describe: $t('menu.dataSource.desc'),
|
||||||
|
permissions: 'data-source:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '👤',
|
||||||
|
title: $t('menu.user'),
|
||||||
|
describe: $t('menu.user.desc'),
|
||||||
|
permissions: 'user:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🔐',
|
||||||
|
title: $t('menu.role'),
|
||||||
|
describe: $t('menu.role.desc'),
|
||||||
|
permissions: 'role:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '📧',
|
||||||
|
title: $t('menu.emailServices'),
|
||||||
|
describe: $t('menu.emailServices.desc'),
|
||||||
|
permissions: 'mail-host-config:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🔧',
|
||||||
|
title: $t('menu.config'),
|
||||||
|
describe: $t('menu.config.desc'),
|
||||||
|
permissions: 'config:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '📜',
|
||||||
|
title: $t('menu.operLog'),
|
||||||
|
describe: $t('menu.operLog.desc'),
|
||||||
|
permissions: 'oper:log:list',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const menuMap = {} as Record<string, RoleCard>;
|
||||||
|
roleCardList.forEach((item) => {
|
||||||
|
item.curUserShow = hasPerms(item.permissions);
|
||||||
|
menuMap[item.permissions] = item;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getMenuMetaByPermissions(userMenus: API.MenuListItem[]) {
|
||||||
|
return userMenus.map((item) => {
|
||||||
|
if (!item.permission && item.path === 'rule') {
|
||||||
|
item.permission = 'rule:list';
|
||||||
|
}
|
||||||
|
const menu = menuMap[item.permission];
|
||||||
|
if (menu) {
|
||||||
|
item.meta = menu;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { roleCardList, getMenuMetaByPermissions };
|
||||||
|
}
|
||||||
347
src/pages/role/index.tsx
Normal file
347
src/pages/role/index.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
type ActionType,
|
||||||
|
PageContainer,
|
||||||
|
type ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
|
import { Button, Card, Col, Row, Select, Space, Typography } from 'antd';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { SUPER_ADMIN_ROLE } from '@/access';
|
||||||
|
import HeadStatisticCard, {
|
||||||
|
type CardInfo,
|
||||||
|
} from '@/components/HeadStatisticCard';
|
||||||
|
import MyTag from '@/components/MyTag';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import { getCompanyList, getRoleList, roleHandle } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import useRoleCardList from './cardList';
|
||||||
|
import Popup from './Popup';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
type RolesListItem = Partial<API.RolesListItem>;
|
||||||
|
type CompanyMapType = Record<number | string, API.CompanyListItem>;
|
||||||
|
const Role: React.FC = () => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [action, setAction] = useState<EX.Action>('add');
|
||||||
|
const [formValues, setFormValues] = useState<RolesListItem | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const { isGlobalCompany, isSuperAdmin, isCompanyAdmin } = useAccess();
|
||||||
|
const globalCompanyId = isGlobalCompany ? userInfo?.companyId : 0;
|
||||||
|
const tableRef = useRef<ActionType>(null);
|
||||||
|
const { roleCardList } = useRoleCardList();
|
||||||
|
|
||||||
|
const [companyType, setCompanyType] = useState<number | ''>(
|
||||||
|
userInfo?.companyId ?? '',
|
||||||
|
);
|
||||||
|
const [companyMap, setCompanyMap] = useState<CompanyMapType>({});
|
||||||
|
const [companyList, setCompanyList] = useState<API.CompanyListItem[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGlobalCompany) {
|
||||||
|
getCompanyList().then(({ data: { data } }) => {
|
||||||
|
const tmp = {} as CompanyMapType;
|
||||||
|
data.forEach((item: API.CompanyListItem) => {
|
||||||
|
tmp[item.id] = item;
|
||||||
|
});
|
||||||
|
setCompanyMap(tmp);
|
||||||
|
setCompanyList(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [cardInfo, setCardInfo] = useState({
|
||||||
|
noSystemRoleCount: 0,
|
||||||
|
count: 0,
|
||||||
|
systemRoleCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
setVisible(false);
|
||||||
|
setFormValues({});
|
||||||
|
};
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
// console.log(values);
|
||||||
|
// return;
|
||||||
|
const { code } = (await roleHandle(values)) as API.BaseResponse;
|
||||||
|
if (code === 200) {
|
||||||
|
tableRef.current?.reload();
|
||||||
|
handleDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<API.RolesListItem>[] = [
|
||||||
|
{
|
||||||
|
title: $t('table.roleId'),
|
||||||
|
dataIndex: 'id',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: $t('table.roleIdentifier'),
|
||||||
|
// dataIndex: 'mark',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: $t('table.roleName'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (name, item) => {
|
||||||
|
return <MyTag color={item.color}>{name || '-'}</MyTag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.company'),
|
||||||
|
hideInTable: !isGlobalCompany,
|
||||||
|
hideInSearch: !isGlobalCompany,
|
||||||
|
dataIndex: 'companyId',
|
||||||
|
renderText: (id) => {
|
||||||
|
return companyMap[id]?.name || '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: $t('table.permissionLevel'),
|
||||||
|
// dataIndex: 'level',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: $t('table.type'),
|
||||||
|
dataIndex: 'type',
|
||||||
|
render: (type) => {
|
||||||
|
return (
|
||||||
|
<MyTag color={type === 0 ? '#64748b' : '#3b82f6'}>
|
||||||
|
{$t(`table.role.type${type}`)}
|
||||||
|
</MyTag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.permissionCount'),
|
||||||
|
dataIndex: 'menuCount',
|
||||||
|
render: (num) => {
|
||||||
|
return <MyTag>{num ?? '-'}</MyTag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.createTime'),
|
||||||
|
dataIndex: 'createTime',
|
||||||
|
valueType: 'date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('table.action'),
|
||||||
|
dataIndex: 'option',
|
||||||
|
width: '60px',
|
||||||
|
valueType: 'option',
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, item) => [
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
key="view"
|
||||||
|
color="primary"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => {
|
||||||
|
setAction('view');
|
||||||
|
setFormValues(item);
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.view')}
|
||||||
|
</Button>,
|
||||||
|
|
||||||
|
userInfo?.companyId === item.companyId &&
|
||||||
|
item.type === 1 &&
|
||||||
|
(isSuperAdmin || (!isGlobalCompany && isCompanyAdmin)) && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
key="edit"
|
||||||
|
onClick={() => {
|
||||||
|
setAction('edit');
|
||||||
|
setFormValues(item);
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('table.action.edit')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type CardKey = keyof typeof cardInfo;
|
||||||
|
const headStatisticCardList: Record<CardKey, CardInfo> = {
|
||||||
|
count: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🔐',
|
||||||
|
iconColor: '#3b82f6',
|
||||||
|
iconBgColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
leftColor: '#3b82f6',
|
||||||
|
description: $t('pages.totalRoleCount'),
|
||||||
|
},
|
||||||
|
systemRoleCount: {
|
||||||
|
count: 0,
|
||||||
|
icon: '🔒',
|
||||||
|
iconColor: '#60a5fa',
|
||||||
|
iconBgColor: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
leftColor: '#60a5fa',
|
||||||
|
description: $t('pages.systemRoleCount'),
|
||||||
|
},
|
||||||
|
noSystemRoleCount: {
|
||||||
|
count: 0,
|
||||||
|
icon: '✨',
|
||||||
|
iconColor: '#34d399',
|
||||||
|
iconBgColor: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
leftColor: '#34d399',
|
||||||
|
description: $t('pages.customRoleCount'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.keys(headStatisticCardList).map((key) => {
|
||||||
|
const item = headStatisticCardList[key as CardKey];
|
||||||
|
item.count = cardInfo[key as CardKey];
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={12}
|
||||||
|
lg={24 / Object.keys(cardInfo).length}
|
||||||
|
key={item.description}
|
||||||
|
>
|
||||||
|
<HeadStatisticCard cardInfo={item} />
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Col span={24}>
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
📋 {$t('pages.availablePermissionList')}
|
||||||
|
</Title>
|
||||||
|
</Col>
|
||||||
|
{roleCardList
|
||||||
|
.filter((item) => item.curUserShow)
|
||||||
|
.map((item) => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} key={item.title}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
styles={{
|
||||||
|
body: { padding: '10px 20px' },
|
||||||
|
}}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
variant="borderless"
|
||||||
|
>
|
||||||
|
<Space
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* left icon */}
|
||||||
|
<Text style={{ fontSize: '24px', paddingRight: '4px' }}>
|
||||||
|
{item.icon}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Text content on the right */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Title level={5} style={{ marginBottom: 4, marginTop: 0 }}>
|
||||||
|
{item.title}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{item.describe}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
<Col span={24}>
|
||||||
|
<ProTable
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={columns}
|
||||||
|
actionRef={tableRef}
|
||||||
|
search={false}
|
||||||
|
request={async (params) => {
|
||||||
|
const reqParams = {
|
||||||
|
...params,
|
||||||
|
pageNum: params.current,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { data },
|
||||||
|
} = await getRoleList(reqParams);
|
||||||
|
|
||||||
|
const list: API.RolesListItem[] = data || [];
|
||||||
|
const filteredList = list.filter((item) => {
|
||||||
|
if (item.companyId === globalCompanyId && item.type === 0) {
|
||||||
|
return item.mark === SUPER_ADMIN_ROLE;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCardInfo({
|
||||||
|
noSystemRoleCount:
|
||||||
|
filteredList.filter((item) => item.type === 1).length || 0,
|
||||||
|
count: filteredList.length || 0,
|
||||||
|
systemRoleCount:
|
||||||
|
filteredList.filter((item) => item.type === 0).length || 0,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: filteredList,
|
||||||
|
success: true,
|
||||||
|
total: filteredList.length || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
params={{
|
||||||
|
companyId: companyType,
|
||||||
|
}}
|
||||||
|
headerTitle={
|
||||||
|
isGlobalCompany && (
|
||||||
|
<Space size={16}>
|
||||||
|
<span>{$t('table.company')}:</span>
|
||||||
|
<Select
|
||||||
|
value={companyType}
|
||||||
|
onChange={(val) => setCompanyType(val)}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: $t('table.all'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
...companyList.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toolBarRender={() => [
|
||||||
|
(isSuperAdmin || (!isGlobalCompany && isCompanyAdmin)) && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAction('add');
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined />{' '}
|
||||||
|
{`${$t('table.action.add')}${$t('form.role.title')}`}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
action={action}
|
||||||
|
visible={visible}
|
||||||
|
formValues={formValues}
|
||||||
|
onDone={handleDone}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Role;
|
||||||
98
src/pages/rules/components/AssetsSelect.tsx
Normal file
98
src/pages/rules/components/AssetsSelect.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { ProFormTreeSelect } from '@ant-design/pro-components';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import useUserInfo from '@/hooks/useUserInfo';
|
||||||
|
import Loading from '@/loading';
|
||||||
|
import { getAssetsTreeList } from '@/utils/dataCenter';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
interface AssetsSelectProps {
|
||||||
|
dataSourceId: number;
|
||||||
|
value?: string; // Pattern string passed in from outside
|
||||||
|
onChange?: (val: string) => void; // Callback to pass the Pattern string back to the outside
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetsSelect: React.FC<AssetsSelectProps> = ({
|
||||||
|
dataSourceId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const { userInfo } = useUserInfo();
|
||||||
|
const companyInfo = userInfo?.company;
|
||||||
|
const [treeData, setTreeData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Internally maintains a keys array for TreeSelect display
|
||||||
|
const [checkedKeys, setCheckedKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const loadData = async (force: boolean = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getAssetsTreeList(companyInfo, dataSourceId, force);
|
||||||
|
setCheckedKeys([]);
|
||||||
|
setTreeData(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 1. Listen for changes to dataSourceId and request source data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSourceId) {
|
||||||
|
setTreeData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, [dataSourceId]);
|
||||||
|
|
||||||
|
// 2. When treeData is loaded and there is an initial Pattern string, convert it to internal keys
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setCheckedKeys([]);
|
||||||
|
} else if (treeData.length > 0 && typeof value === 'string') {
|
||||||
|
const keys = value.split(',');
|
||||||
|
setCheckedKeys(keys);
|
||||||
|
}
|
||||||
|
}, [value, treeData]);
|
||||||
|
|
||||||
|
// 3. When users manually check/uncheck items, convert internal keys back to string and trigger onChange
|
||||||
|
const handleTreeChange = (keys: any) => {
|
||||||
|
// Handle the object format that antd tree may return
|
||||||
|
const actualKeys = Array.isArray(keys) ? keys : keys.checked;
|
||||||
|
// Update internal UI state
|
||||||
|
setCheckedKeys(actualKeys);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(actualKeys.join(','));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!dataSourceId) return null;
|
||||||
|
if (loading) return <Loading padding="0" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="addon-select-wrapper">
|
||||||
|
<ProFormTreeSelect
|
||||||
|
label={`${$t('pages.rule5_1.item1')} (${$t('pages.tips.emptyIsAll')})`}
|
||||||
|
extra={$t('form.rule1.tips2')}
|
||||||
|
placeholder={`${$t('pages.rules.allAssets')}`}
|
||||||
|
fieldProps={{
|
||||||
|
listHeight: 410,
|
||||||
|
treeData: treeData,
|
||||||
|
treeCheckable: true,
|
||||||
|
value: checkedKeys, // Use the internally parsed array
|
||||||
|
onChange: handleTreeChange,
|
||||||
|
maxTagCount: 'responsive',
|
||||||
|
virtual: true,
|
||||||
|
fieldNames: {
|
||||||
|
label: 'title',
|
||||||
|
value: 'value',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
formItemProps={{
|
||||||
|
style: { marginBottom: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssetsSelect;
|
||||||
319
src/pages/rules/components/CopyRule/MultiCopyPopup.tsx
Normal file
319
src/pages/rules/components/CopyRule/MultiCopyPopup.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
type ProFormInstance,
|
||||||
|
StepsForm,
|
||||||
|
useBreakpoint,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { Button, Divider, Flex, Input, Modal, Space, Typography } from 'antd';
|
||||||
|
import { createContext, useMemo, useRef, useState } from 'react';
|
||||||
|
import useRichFormat from '@/hooks/useRichI18n';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import '../style.less';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { type SchemaType, schemaMap } from '../../ruleFormSchema';
|
||||||
|
import { handleEmptyValuesFunc } from '../../utils';
|
||||||
|
import BatchCloneModal from './comp/BatchCloneModal';
|
||||||
|
import Step1 from './comp/Step1';
|
||||||
|
import Step2 from './comp/Step2';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
type MultiCopyPopupContextValue = {
|
||||||
|
ruleMeta: Record<string, any>;
|
||||||
|
dataSourceList: API.DataSourceListItem[];
|
||||||
|
currentRuleType: number;
|
||||||
|
targetDataSourceId: string | number | null;
|
||||||
|
selectRules: API.RuleListItem[];
|
||||||
|
setSelectRules: (rules: API.RuleListItem[]) => void;
|
||||||
|
currentSchemaMap: Record<string, any>;
|
||||||
|
};
|
||||||
|
export const MultiCopyPopupContext = createContext<MultiCopyPopupContextValue>({
|
||||||
|
ruleMeta: {},
|
||||||
|
dataSourceList: [],
|
||||||
|
currentRuleType: 0,
|
||||||
|
targetDataSourceId: null,
|
||||||
|
selectRules: [],
|
||||||
|
setSelectRules: () => {},
|
||||||
|
currentSchemaMap: {},
|
||||||
|
});
|
||||||
|
type MultiCopyPopupProps = {
|
||||||
|
visible: boolean;
|
||||||
|
ruleMeta: Record<string, any>;
|
||||||
|
dataSourceList: API.DataSourceListItem[];
|
||||||
|
currentRuleType: number;
|
||||||
|
onDone: (refresh?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiCopyPopup = ({
|
||||||
|
visible,
|
||||||
|
ruleMeta,
|
||||||
|
onDone,
|
||||||
|
currentRuleType,
|
||||||
|
dataSourceList,
|
||||||
|
}: MultiCopyPopupProps) => {
|
||||||
|
const { richFormat } = useRichFormat();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
|
||||||
|
const currentSchemaMap =
|
||||||
|
schemaMap[currentRuleType as unknown as SchemaType]?.({ richFormat }) || {};
|
||||||
|
const formMapRef = useRef<
|
||||||
|
React.RefObject<ProFormInstance<any> | undefined>[]
|
||||||
|
>([]);
|
||||||
|
const [targetDataSourceId, setTargetDataSourceId] = useState<
|
||||||
|
string | number | null
|
||||||
|
>(null);
|
||||||
|
const [selectRules, setSelectRules] = useState<API.RuleListItem[]>([]);
|
||||||
|
const [checkedIdsList, setCheckedIdsList] = useState<Array<string | number>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [batchCloneModalVisible, setBatchCloneModalVisible] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Partial<API.RuleListItem>[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitRuleList = useMemo(() => {
|
||||||
|
return selectRules.filter((item) => checkedIdsList.includes(item.id));
|
||||||
|
}, [selectRules, checkedIdsList]);
|
||||||
|
|
||||||
|
const targetDataSource = useMemo(() => {
|
||||||
|
return dataSourceList.find((item) => item.id === targetDataSourceId);
|
||||||
|
}, [targetDataSourceId, dataSourceList]);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setBatchCloneModalVisible(false);
|
||||||
|
setSelectedRows([]);
|
||||||
|
setSelectRules([]);
|
||||||
|
setCheckedIdsList([]);
|
||||||
|
setInputValue('');
|
||||||
|
setTargetDataSourceId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
width={['xl', 'xxl'].includes(screens || '') ? '50%' : '95%'}
|
||||||
|
open={visible}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
onCancel={() => {
|
||||||
|
handleCancel();
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
title={$t('pages.clones.title')}
|
||||||
|
footer={null}
|
||||||
|
maskClosable={false}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
marginRight: -10,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MultiCopyPopupContext.Provider
|
||||||
|
value={{
|
||||||
|
ruleMeta,
|
||||||
|
dataSourceList,
|
||||||
|
currentRuleType,
|
||||||
|
targetDataSourceId,
|
||||||
|
selectRules,
|
||||||
|
setSelectRules,
|
||||||
|
currentSchemaMap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StepsForm
|
||||||
|
formMapRef={formMapRef}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
const editNameRules = values?.step2 || {};
|
||||||
|
|
||||||
|
const submitValues = submitRuleList.map((item) => {
|
||||||
|
const tmpValues = handleEmptyValuesFunc(item);
|
||||||
|
const tmpValues2 =
|
||||||
|
currentSchemaMap?.transformSubmitValue?.(tmpValues) ||
|
||||||
|
tmpValues;
|
||||||
|
const submitValues = {
|
||||||
|
...tmpValues2,
|
||||||
|
dataSourceId: targetDataSourceId,
|
||||||
|
name:
|
||||||
|
editNameRules[item.id] ||
|
||||||
|
`${item.name || ruleMeta?.title || ''} - ${targetDataSource?.sourceName || ''}`,
|
||||||
|
id: void 0,
|
||||||
|
groupFilter: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentSchemaMap?.symbolFormShow) {
|
||||||
|
submitValues.param2 = '';
|
||||||
|
}
|
||||||
|
if (currentSchemaMap?.assetFormShow) {
|
||||||
|
submitValues.param1 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp: Record<string, any> = {
|
||||||
|
companyId: submitValues.companyId,
|
||||||
|
dataSourceId: submitValues.dataSourceId,
|
||||||
|
groupFilter: submitValues.groupFilter,
|
||||||
|
name: submitValues.name,
|
||||||
|
type: submitValues.type,
|
||||||
|
};
|
||||||
|
for (const key in submitValues) {
|
||||||
|
if (key.indexOf('param') !== -1) {
|
||||||
|
tmp[key] = submitValues[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedRows(submitValues);
|
||||||
|
setBatchCloneModalVisible(true);
|
||||||
|
}}
|
||||||
|
submitter={{
|
||||||
|
render: (props) => {
|
||||||
|
if (props.step === 0) {
|
||||||
|
return [
|
||||||
|
<Button key="cancel" onClick={() => onDone()}>
|
||||||
|
{$t('pages.model.cancel')}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
disabled={selectRules.length === 0}
|
||||||
|
key="goToTree"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => props.onSubmit?.()}
|
||||||
|
>
|
||||||
|
{$t('pages.clones.step1.confirm')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.step === 1) {
|
||||||
|
return [
|
||||||
|
<Button key="pre" onClick={() => props.onPre?.()}>
|
||||||
|
← {$t('pages.clones.back')}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
disabled={checkedIdsList.length === 0}
|
||||||
|
type="primary"
|
||||||
|
key="goToTree"
|
||||||
|
onClick={() => props.onSubmit?.()}
|
||||||
|
>
|
||||||
|
{$t('pages.clones.step2.confirm')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Button
|
||||||
|
key="gotoTwo"
|
||||||
|
onClick={() => {
|
||||||
|
setInputValue('');
|
||||||
|
props.onPre?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← {$t('pages.clones.backPreview')}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
submitRuleList.length === 0 ||
|
||||||
|
inputValue !== targetDataSource?.sourceName
|
||||||
|
}
|
||||||
|
variant="solid"
|
||||||
|
color="danger"
|
||||||
|
key="goToTree"
|
||||||
|
onClick={() => props.onSubmit?.()}
|
||||||
|
>
|
||||||
|
{$t('pages.clones.step3.confirm')}
|
||||||
|
</Button>,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StepsForm.StepForm
|
||||||
|
name="step1"
|
||||||
|
title={$t('pages.clones.step1.title')}
|
||||||
|
onValuesChange={({ sourceDataSourceId }) => {
|
||||||
|
if (sourceDataSourceId) {
|
||||||
|
const curFormRef = formMapRef?.current?.[0]?.current;
|
||||||
|
const curTargetDataSourceId =
|
||||||
|
curFormRef?.getFieldValue('targetDataSourceId');
|
||||||
|
if (curTargetDataSourceId === sourceDataSourceId) {
|
||||||
|
let targetDataSourceId: string | number | null = null;
|
||||||
|
for (const item of dataSourceList) {
|
||||||
|
if (item.id !== sourceDataSourceId) {
|
||||||
|
targetDataSourceId = item.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetDataSourceId) {
|
||||||
|
curFormRef?.setFieldsValue({
|
||||||
|
targetDataSourceId,
|
||||||
|
});
|
||||||
|
setTargetDataSourceId(targetDataSourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Step1
|
||||||
|
formMapRef={formMapRef}
|
||||||
|
setTargetDataSourceId={setTargetDataSourceId}
|
||||||
|
/>
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
<StepsForm.StepForm
|
||||||
|
name="step2"
|
||||||
|
title={$t('pages.clones.step2.title')}
|
||||||
|
>
|
||||||
|
<Step2 setCheckedIdsList={setCheckedIdsList} />
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
<StepsForm.StepForm
|
||||||
|
name="time"
|
||||||
|
title={$t('pages.clones.step3.title')}
|
||||||
|
>
|
||||||
|
<div className="confirm-container">
|
||||||
|
<Flex className="confirm-space" align="center" justify="center">
|
||||||
|
<Space direction="vertical" align="center">
|
||||||
|
<Title level={5}>
|
||||||
|
{$t('pages.clones.step3.confirmTitle', {
|
||||||
|
cloneCount: checkedIdsList.length,
|
||||||
|
target: targetDataSource?.sourceName || '',
|
||||||
|
})}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
{$t('pages.clones.step3.confirmTips')}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder={targetDataSource?.sourceName || ''}
|
||||||
|
className="text-center"
|
||||||
|
size="large"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">
|
||||||
|
{$t('pages.clones.step3.confirmTips2')}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
</StepsForm>
|
||||||
|
</MultiCopyPopupContext.Provider>
|
||||||
|
|
||||||
|
<BatchCloneModal
|
||||||
|
visible={batchCloneModalVisible}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onClose={(cb) => {
|
||||||
|
setBatchCloneModalVisible(false);
|
||||||
|
cb?.();
|
||||||
|
}}
|
||||||
|
onFinish={(cb) => {
|
||||||
|
flushSync(() => {
|
||||||
|
setBatchCloneModalVisible(false);
|
||||||
|
});
|
||||||
|
cb?.();
|
||||||
|
handleCancel();
|
||||||
|
onDone?.(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default MultiCopyPopup;
|
||||||
245
src/pages/rules/components/CopyRule/SingleCopyPopup.tsx
Normal file
245
src/pages/rules/components/CopyRule/SingleCopyPopup.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { ArrowDownOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
BetaSchemaForm,
|
||||||
|
ProForm,
|
||||||
|
ProFormDependency,
|
||||||
|
ProFormSelect,
|
||||||
|
useBreakpoint,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
Col,
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
type FormInstance,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import useRichFormat from '@/hooks/useRichI18n';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { type SchemaType, schemaMap } from '../../ruleFormSchema';
|
||||||
|
import { handleEmptyValuesFunc, processSingleRule } from '../../utils';
|
||||||
|
import AssetsSelect from '../AssetsSelect';
|
||||||
|
import SymbolSelect from '../SymbolSelect';
|
||||||
|
|
||||||
|
type RuleListItem = Partial<API.RuleListItem>;
|
||||||
|
type CopyPopupProps = {
|
||||||
|
visible: boolean;
|
||||||
|
formValues: Partial<RuleListItem | undefined>;
|
||||||
|
dataSourceList: API.DataSourceListItem[];
|
||||||
|
currentRuleType: number;
|
||||||
|
onDone: (refresh?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const SingleCopyPopup = ({
|
||||||
|
visible,
|
||||||
|
onDone,
|
||||||
|
currentRuleType,
|
||||||
|
formValues,
|
||||||
|
dataSourceList,
|
||||||
|
}: CopyPopupProps) => {
|
||||||
|
const { richFormat } = useRichFormat();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
|
const formRef = useRef<FormInstance>(null);
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
const [errMsg, setErrMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setErrMsg('');
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
setErrMsg('');
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
leftForm,
|
||||||
|
symbolFormShow,
|
||||||
|
assetFormShow,
|
||||||
|
transformInitialValue,
|
||||||
|
transformSubmitValue,
|
||||||
|
} =
|
||||||
|
schemaMap[currentRuleType as unknown as SchemaType]?.({ richFormat }) || {};
|
||||||
|
|
||||||
|
const rightDataSourceList = dataSourceList.filter(
|
||||||
|
(item) => item.id !== formValues?.dataSourceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialValues = transformInitialValue?.(formValues) || formValues;
|
||||||
|
|
||||||
|
const rightFirstDataSource = rightDataSourceList[0];
|
||||||
|
const copyFormValues = {
|
||||||
|
...initialValues,
|
||||||
|
groupFilter: '',
|
||||||
|
id: void 0,
|
||||||
|
dataSourceId: rightFirstDataSource?.id || '',
|
||||||
|
};
|
||||||
|
if (symbolFormShow) {
|
||||||
|
copyFormValues.param2 = '';
|
||||||
|
}
|
||||||
|
if (assetFormShow) {
|
||||||
|
copyFormValues.param1 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formContent = ({
|
||||||
|
dataSourceList,
|
||||||
|
formAttr,
|
||||||
|
}: {
|
||||||
|
dataSourceList: API.DataSourceListItem[];
|
||||||
|
formAttr: Record<string, any>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ProForm {...formAttr} submitter={false}>
|
||||||
|
<ProFormSelect
|
||||||
|
name="dataSourceId"
|
||||||
|
label={$t('table.dataSource')}
|
||||||
|
options={dataSourceList}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
fieldProps={{
|
||||||
|
allowClear: false,
|
||||||
|
fieldNames: {
|
||||||
|
label: 'sourceName',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BetaSchemaForm layoutType="Embed" columns={leftForm} />
|
||||||
|
{(symbolFormShow || assetFormShow) && (
|
||||||
|
<ProFormDependency name={['dataSourceId']}>
|
||||||
|
{({ dataSourceId }) => (
|
||||||
|
<>
|
||||||
|
{symbolFormShow && (
|
||||||
|
<ProForm.Item name="param2" noStyle>
|
||||||
|
<SymbolSelect dataSourceId={dataSourceId} />
|
||||||
|
</ProForm.Item>
|
||||||
|
)}
|
||||||
|
{assetFormShow && (
|
||||||
|
<ProForm.Item name="param1" noStyle>
|
||||||
|
<AssetsSelect dataSourceId={dataSourceId} />
|
||||||
|
</ProForm.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ProFormDependency>
|
||||||
|
)}
|
||||||
|
</ProForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={$t('pages.rules.cloneRule')}
|
||||||
|
open={visible}
|
||||||
|
onCancel={() => onDone()}
|
||||||
|
destroyOnHidden
|
||||||
|
centered
|
||||||
|
width={['xl', 'xxl'].includes(screens || '') ? '60%' : '95%'}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
okText={$t('pages.rules.cloneConfirm')}
|
||||||
|
onOk={async () => {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
const values = formRef.current?.getFieldsValue();
|
||||||
|
|
||||||
|
const tmpValues = handleEmptyValuesFunc(values);
|
||||||
|
const submitValues = transformSubmitValue?.(tmpValues) || tmpValues;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processSingleRule({ payload: submitValues });
|
||||||
|
message.success($t('pages.tips.operationSuccess'));
|
||||||
|
onDone(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log('error', error);
|
||||||
|
switch (error?.errType) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
modal.warning({
|
||||||
|
content: error?.respMessage || $t(error?.errMsg),
|
||||||
|
centered: true,
|
||||||
|
onOk: () => {
|
||||||
|
setErrMsg('');
|
||||||
|
setConfirmLoading(false);
|
||||||
|
onDone(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 3:
|
||||||
|
setErrMsg($t(error?.errMsg));
|
||||||
|
formRef.current?.setFieldsValue({
|
||||||
|
...error.data,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: '82vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
marginRight: -10,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
<Col xs={24} sm={11}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>📋 {$t('pages.rules.originalRule')}</Text>
|
||||||
|
</div>
|
||||||
|
{formContent({
|
||||||
|
dataSourceList,
|
||||||
|
formAttr: { initialValues, disabled: true },
|
||||||
|
})}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={0} sm={1}>
|
||||||
|
<Flex vertical align="center" style={{ height: '100%' }}>
|
||||||
|
<ArrowRightOutlined style={{ marginTop: 2, fontSize: 18 }} />
|
||||||
|
<Divider type="vertical" style={{ flex: 1, marginTop: 16 }} />
|
||||||
|
</Flex>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={0}>
|
||||||
|
<div className="text-center" style={{ marginBottom: 16 }}>
|
||||||
|
<ArrowDownOutlined />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>✏️ {$t('pages.rules.cloneRulePreview')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formContent({
|
||||||
|
dataSourceList: rightDataSourceList,
|
||||||
|
formAttr: {
|
||||||
|
initialValues: copyFormValues,
|
||||||
|
formRef: formRef,
|
||||||
|
disabled: confirmLoading,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
{errMsg && (
|
||||||
|
<Alert
|
||||||
|
message={errMsg}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SingleCopyPopup;
|
||||||
205
src/pages/rules/components/CopyRule/comp/BatchCloneModal.tsx
Normal file
205
src/pages/rules/components/CopyRule/comp/BatchCloneModal.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
type AlertProps,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
List,
|
||||||
|
Modal,
|
||||||
|
Progress,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { processSingleRule } from '@/pages/rules/utils';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
|
||||||
|
type BatchCloneModalProps = {
|
||||||
|
visible: boolean;
|
||||||
|
selectedRows: Partial<API.RuleListItem>[];
|
||||||
|
onClose?: (cb?: () => void) => void;
|
||||||
|
onFinish?: (cb?: () => void) => void;
|
||||||
|
};
|
||||||
|
const BatchCloneModal = ({
|
||||||
|
visible,
|
||||||
|
selectedRows,
|
||||||
|
onClose,
|
||||||
|
onFinish,
|
||||||
|
}: BatchCloneModalProps) => {
|
||||||
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [currentRunning, setCurrentRunning] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialTasks = selectedRows.map((row) => ({
|
||||||
|
name: row.name,
|
||||||
|
status: 'waiting', // waiting, processing, success, error
|
||||||
|
errorMsg: '',
|
||||||
|
}));
|
||||||
|
setTasks(initialTasks);
|
||||||
|
}, [visible, selectedRows]);
|
||||||
|
|
||||||
|
const finishedTasks = useMemo(
|
||||||
|
() => tasks.filter((t) => ['success', 'warning'].includes(t.status)),
|
||||||
|
[tasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishedMessage = useMemo(() => {
|
||||||
|
const hasWarning = finishedTasks.find((t) => t.status === 'warning');
|
||||||
|
const type: AlertProps['type'] = hasWarning
|
||||||
|
? 'warning'
|
||||||
|
: finishedTasks.length === tasks.length
|
||||||
|
? 'success'
|
||||||
|
: 'error';
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message:
|
||||||
|
type === 'success'
|
||||||
|
? $t('pages.clones.step3.success')
|
||||||
|
: type === 'warning'
|
||||||
|
? $t('pages.clones.step3.warning')
|
||||||
|
: $t('pages.clones.step3.error'),
|
||||||
|
};
|
||||||
|
}, [finishedTasks, tasks]);
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
setIsRunning(true);
|
||||||
|
for (let i = 0; i < selectedRows.length; i++) {
|
||||||
|
setCurrentRunning(i + 1);
|
||||||
|
setTasks((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[i].status = 'processing';
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await processSingleRule({ payload: selectedRows[i] });
|
||||||
|
|
||||||
|
setTasks((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[i].status = 'success';
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[i].status = [1, 2, 3].includes(e.errType) ? 'warning' : 'error';
|
||||||
|
next[i].errorMsg = e.errMsg ? $t(e.errMsg) : e.respMessage;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsRunning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setTasks([]);
|
||||||
|
setIsRunning(false);
|
||||||
|
setCurrentRunning(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={$t('pages.clones.step3.progress')}
|
||||||
|
open={visible}
|
||||||
|
closable={false}
|
||||||
|
maskClosable={false}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round((finishedTasks.length / tasks.length) * 100)}
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
/>
|
||||||
|
<div style={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={tasks}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{item.name}</span>
|
||||||
|
{item.status === 'processing' && (
|
||||||
|
<Tag color="blue" icon={<SyncOutlined spin />}>
|
||||||
|
{$t('pages.clones.step3.status1')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{item.status === 'success' && (
|
||||||
|
<Tag color="green" icon={<CheckCircleOutlined />}>
|
||||||
|
{$t('pages.clones.step3.status2')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{item.status === 'error' && (
|
||||||
|
<Tooltip
|
||||||
|
title={item.errorMsg || $t('pages.clones.step3.status3')}
|
||||||
|
>
|
||||||
|
<Tag color="red" icon={<CloseCircleOutlined />}>
|
||||||
|
{$t('pages.clones.step3.status3')}
|
||||||
|
</Tag>
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{item.status === 'warning' && (
|
||||||
|
<Tooltip
|
||||||
|
title={item.errorMsg || $t('pages.clones.step3.status4')}
|
||||||
|
>
|
||||||
|
<Tag color="orange" icon={<CloseCircleOutlined />}>
|
||||||
|
{$t('pages.clones.step3.status4')}
|
||||||
|
</Tag>
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{item.status === 'waiting' && (
|
||||||
|
<Tag color="default">{$t('pages.clones.step3.status5')}</Tag>
|
||||||
|
)}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space direction="vertical" className="w-100">
|
||||||
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
|
{!isRunning && currentRunning === tasks.length && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
message={finishedMessage.message}
|
||||||
|
type={finishedMessage.type}
|
||||||
|
/>
|
||||||
|
<Flex justify="flex-end">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
onFinish?.(handleReset);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('pages.clones.step3.understand')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRunning && currentRunning === 0 && (
|
||||||
|
<Flex justify="flex-end" gap={10}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onClose?.(handleReset);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('pages.model.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={startBatch}>
|
||||||
|
{$t('pages.clones.step3.start')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BatchCloneModal;
|
||||||
88
src/pages/rules/components/CopyRule/comp/RuleCheckCard.tsx
Normal file
88
src/pages/rules/components/CopyRule/comp/RuleCheckCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { CheckCard, ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useRequest } from '@umijs/max';
|
||||||
|
import { Col, Row, Spin } from 'antd';
|
||||||
|
import { useContext, useState } from 'react';
|
||||||
|
import { getRuleList } from '@/services/api';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { RuleCardBodyInfo } from '../../RuleCardBody';
|
||||||
|
import { MultiCopyPopupContext } from '../MultiCopyPopup';
|
||||||
|
|
||||||
|
type RuleCheckCardProps = {
|
||||||
|
dataSourceId: string | number;
|
||||||
|
onChange?: (id: string | number) => void;
|
||||||
|
};
|
||||||
|
const RuleCheckCard = ({ dataSourceId }: RuleCheckCardProps) => {
|
||||||
|
const [ruleList, setRuleList] = useState<API.RuleListItem[]>([]);
|
||||||
|
|
||||||
|
const { ruleMeta, currentRuleType, setSelectRules } = useContext(
|
||||||
|
MultiCopyPopupContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loading } = useRequest(
|
||||||
|
() =>
|
||||||
|
getRuleList({
|
||||||
|
type: currentRuleType,
|
||||||
|
dataSourceId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
// manual: true,
|
||||||
|
refreshDeps: [dataSourceId, currentRuleType],
|
||||||
|
onSuccess: ({ data = [] }: { data: API.RuleListItem[] }) => {
|
||||||
|
setRuleList(data);
|
||||||
|
setSelectRules(data);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setRuleList([]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dataSourceId) return <div>{$t('pages.clones.step1.tips')}</div>;
|
||||||
|
if (loading) return <Spin />;
|
||||||
|
|
||||||
|
// const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProCard
|
||||||
|
title={$t('pages.clones.step1.sourceRules', {
|
||||||
|
count: ruleList?.length,
|
||||||
|
ruleType: ruleMeta?.title || '-',
|
||||||
|
})}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed
|
||||||
|
className="clone-rule-card"
|
||||||
|
>
|
||||||
|
<CheckCard.Group
|
||||||
|
multiple
|
||||||
|
onChange={(value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const selectedRules = value.map((item) =>
|
||||||
|
ruleList.find((rule) => rule.id === item),
|
||||||
|
) as API.RuleListItem[];
|
||||||
|
setSelectRules(selectedRules);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-100"
|
||||||
|
defaultValue={ruleList.map((item) => item.id)}
|
||||||
|
>
|
||||||
|
<Row gutter={12} style={{ maxHeight: '45vh', overflowY: 'auto' }}>
|
||||||
|
{ruleList.map((rule) => (
|
||||||
|
<Col span={8} key={rule.id}>
|
||||||
|
<CheckCard
|
||||||
|
title={$t('pages.rules.ruleName')}
|
||||||
|
extra={rule.name || 'N/A'}
|
||||||
|
value={rule.id}
|
||||||
|
description={
|
||||||
|
<RuleCardBodyInfo ruleMeta={ruleMeta} rule={rule} />
|
||||||
|
}
|
||||||
|
className="w-100"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</CheckCard.Group>
|
||||||
|
</ProCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleCheckCard;
|
||||||
91
src/pages/rules/components/CopyRule/comp/Step1.tsx
Normal file
91
src/pages/rules/components/CopyRule/comp/Step1.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { ArrowDownOutlined } from '@ant-design/icons';
|
||||||
|
import { ProFormDependency, ProFormSelect } from '@ant-design/pro-components';
|
||||||
|
import { Divider, Tag } from 'antd';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { MultiCopyPopupContext } from '../MultiCopyPopup';
|
||||||
|
import RuleCheckCard from './RuleCheckCard';
|
||||||
|
|
||||||
|
type Step1Props = {
|
||||||
|
formMapRef: any;
|
||||||
|
setTargetDataSourceId: (id: string | number | null) => void;
|
||||||
|
};
|
||||||
|
const Step1 = ({ formMapRef, setTargetDataSourceId }: Step1Props) => {
|
||||||
|
const { dataSourceList } = useContext(MultiCopyPopupContext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataSourceList?.length) {
|
||||||
|
// waitTime(1000).then(() => {
|
||||||
|
const sourceDataSourceId = dataSourceList[0]?.id || '';
|
||||||
|
const targetDataSourceId = dataSourceList[1]?.id || '';
|
||||||
|
const curFormRef = formMapRef?.current?.[0]?.current;
|
||||||
|
if (curFormRef) {
|
||||||
|
curFormRef?.setFieldsValue({
|
||||||
|
sourceDataSourceId,
|
||||||
|
targetDataSourceId,
|
||||||
|
});
|
||||||
|
setTargetDataSourceId(targetDataSourceId);
|
||||||
|
}
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
}, [dataSourceList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProFormSelect
|
||||||
|
name="sourceDataSourceId"
|
||||||
|
label={$t('pages.clones.step1.source')}
|
||||||
|
options={dataSourceList}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
fieldProps={{
|
||||||
|
allowClear: false,
|
||||||
|
fieldNames: {
|
||||||
|
label: 'sourceName',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProFormDependency name={['sourceDataSourceId']}>
|
||||||
|
{({ sourceDataSourceId }) => {
|
||||||
|
if (!sourceDataSourceId) return null;
|
||||||
|
return <RuleCheckCard dataSourceId={sourceDataSourceId} />;
|
||||||
|
}}
|
||||||
|
</ProFormDependency>
|
||||||
|
|
||||||
|
<Divider>
|
||||||
|
<Tag style={{ borderRadius: 30, padding: '4px 16px' }}>
|
||||||
|
<ArrowDownOutlined /> {$t('pages.clones.step1.dividerText')}
|
||||||
|
</Tag>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<ProFormDependency name={['sourceDataSourceId']}>
|
||||||
|
{({ sourceDataSourceId }) => {
|
||||||
|
if (!sourceDataSourceId) return null;
|
||||||
|
const dataSourceOptions = dataSourceList.filter(
|
||||||
|
(item) => item.id !== sourceDataSourceId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ProFormSelect
|
||||||
|
name="targetDataSourceId"
|
||||||
|
label={$t('pages.clones.step1.target')}
|
||||||
|
options={dataSourceOptions}
|
||||||
|
rules={[{ required: true, message: $t('form.required') }]}
|
||||||
|
fieldProps={{
|
||||||
|
allowClear: false,
|
||||||
|
fieldNames: {
|
||||||
|
label: 'sourceName',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={(value: string | number | null) => {
|
||||||
|
setTargetDataSourceId(value || null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ProFormDependency>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Step1;
|
||||||
193
src/pages/rules/components/CopyRule/comp/Step2.tsx
Normal file
193
src/pages/rules/components/CopyRule/comp/Step2.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { FormOutlined, SwapRightOutlined } from '@ant-design/icons';
|
||||||
|
import { ProCard } from '@ant-design/pro-components';
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
type CheckboxProps,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { $t } from '@/utils/i18n';
|
||||||
|
import { RuleCardBodyInfo } from '../../RuleCardBody';
|
||||||
|
import { MultiCopyPopupContext } from '../MultiCopyPopup';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type CollapseItemProps = {
|
||||||
|
leftItem: API.RuleListItem;
|
||||||
|
targetDataSource?: API.DataSourceListItem;
|
||||||
|
};
|
||||||
|
const CollapseItem = ({ leftItem, targetDataSource }: CollapseItemProps) => {
|
||||||
|
const { ruleMeta, currentSchemaMap } = useContext(MultiCopyPopupContext);
|
||||||
|
const rightForm = {
|
||||||
|
...leftItem,
|
||||||
|
groupFilter: '',
|
||||||
|
};
|
||||||
|
if (currentSchemaMap?.symbolFormShow) {
|
||||||
|
rightForm.param2 = '';
|
||||||
|
}
|
||||||
|
if (currentSchemaMap?.assetFormShow) {
|
||||||
|
rightForm.param1 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProCard bordered style={{ backgroundColor: '#f6f6f6' }}>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
className="w-100"
|
||||||
|
>
|
||||||
|
<Text type="secondary" strong>
|
||||||
|
{$t('pages.clones.step2.cardTitle1')}
|
||||||
|
</Text>
|
||||||
|
<Flex justify="space-between" align="center" style={{ height: 30 }}>
|
||||||
|
<Text type="secondary" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{$t('pages.rules.ruleName')}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">{leftItem.name || 'N/A'}</Text>
|
||||||
|
</Flex>
|
||||||
|
</Space>
|
||||||
|
<RuleCardBodyInfo ruleMeta={ruleMeta} rule={leftItem} />
|
||||||
|
</ProCard>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProCard bordered style={{ height: '100%' }}>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
className="w-100"
|
||||||
|
>
|
||||||
|
<Text strong>{$t('pages.clones.step2.cardTitle2')}</Text>
|
||||||
|
<Flex justify="space-between" align="center" style={{ height: 30 }}>
|
||||||
|
<Text style={{ marginRight: 8, whiteSpace: 'nowrap' }}>
|
||||||
|
{$t('pages.rules.ruleName')}
|
||||||
|
</Text>
|
||||||
|
<Form.Item
|
||||||
|
name={['step2', `${leftItem.id}`]}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Space.Compact>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
size="small"
|
||||||
|
defaultValue={`${leftItem.name || ruleMeta?.title || ''} - ${targetDataSource?.sourceName || ''}`}
|
||||||
|
variant="underlined"
|
||||||
|
style={{ textAlign: 'right' }}
|
||||||
|
/>
|
||||||
|
<FormOutlined />
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
|
</Space>
|
||||||
|
<RuleCardBodyInfo ruleMeta={ruleMeta} rule={rightForm} />
|
||||||
|
</ProCard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Step2Props = {
|
||||||
|
setCheckedIdsList: (list: string[]) => void;
|
||||||
|
};
|
||||||
|
const Step2 = ({ setCheckedIdsList }: Step2Props) => {
|
||||||
|
const { dataSourceList, ruleMeta, targetDataSourceId, selectRules } =
|
||||||
|
useContext(MultiCopyPopupContext);
|
||||||
|
const [checkedList, setCheckedList] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const targetDataSource = useMemo(() => {
|
||||||
|
return dataSourceList.find((item) => item.id === targetDataSourceId);
|
||||||
|
}, [targetDataSourceId, dataSourceList]);
|
||||||
|
|
||||||
|
const ruleList = useMemo(() => {
|
||||||
|
return selectRules.map((item) => ({
|
||||||
|
key: item.id,
|
||||||
|
// forceRender: true,
|
||||||
|
label: (
|
||||||
|
<Space align="center">
|
||||||
|
<Text>{item.name || 'N/A'} </Text>
|
||||||
|
<SwapRightOutlined />
|
||||||
|
<Text>
|
||||||
|
{item.name || ruleMeta?.title || ''} -{' '}
|
||||||
|
{targetDataSource?.sourceName || ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
extra: <Checkbox value={item.id as string}></Checkbox>,
|
||||||
|
children: (
|
||||||
|
<CollapseItem leftItem={item} targetDataSource={targetDataSource} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [selectRules, targetDataSource]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectRuleIds = selectRules.map((item) => item.id as string);
|
||||||
|
setCheckedList(selectRuleIds);
|
||||||
|
setCheckedIdsList(selectRuleIds);
|
||||||
|
}, [selectRules]);
|
||||||
|
|
||||||
|
const checkAll = selectRules.length === checkedList.length;
|
||||||
|
const indeterminate =
|
||||||
|
checkedList.length > 0 && checkedList.length < selectRules.length;
|
||||||
|
|
||||||
|
const onChange = (list: string[]) => {
|
||||||
|
setCheckedList(list);
|
||||||
|
setCheckedIdsList(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCheckAllChange: CheckboxProps['onChange'] = (e) => {
|
||||||
|
const checkedIdList = e.target.checked
|
||||||
|
? selectRules.map((item) => item.id as string)
|
||||||
|
: [];
|
||||||
|
setCheckedList(checkedIdList);
|
||||||
|
setCheckedIdsList(checkedIdList);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical>
|
||||||
|
<Flex justify="space-between">
|
||||||
|
<span>
|
||||||
|
{$t('pages.clones.step2.summary', {
|
||||||
|
count: selectRules.length,
|
||||||
|
cloneCount: checkedList.length,
|
||||||
|
target: targetDataSource?.sourceName || '-',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
onChange={onCheckAllChange}
|
||||||
|
checked={checkAll}
|
||||||
|
>
|
||||||
|
{$t('pages.clones.step2.checkAll')}
|
||||||
|
</Checkbox>
|
||||||
|
</Flex>
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', maxHeight: '70vh' }}>
|
||||||
|
<Checkbox.Group
|
||||||
|
className="w-100"
|
||||||
|
name="selectedRules"
|
||||||
|
onChange={onChange}
|
||||||
|
value={checkedList}
|
||||||
|
>
|
||||||
|
<Collapse
|
||||||
|
className="clone-rule-collapse w-100"
|
||||||
|
items={ruleList}
|
||||||
|
collapsible="header"
|
||||||
|
/>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Step2;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user