Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a9d2af00 | ||
|
|
59740d1601 | ||
|
|
d874e502e2 | ||
|
|
722b385bcb | ||
|
|
aa7247baaf | ||
|
|
73f3cfceb1 | ||
|
|
f2fe080f7b | ||
|
|
eee73be77e | ||
|
|
6e4dd30104 | ||
|
|
19b88f3375 | ||
|
|
94b3206111 | ||
|
|
ea4ea56e59 | ||
|
|
042f8fed47 | ||
|
|
f7c3b28cc8 | ||
|
|
1462768aa9 | ||
|
|
07fa3c8824 | ||
|
|
61f83f31d7 | ||
|
|
6f2c1daa41 |
@@ -0,0 +1 @@
|
|||||||
|
src/components/icon-font/iconfont/iconfont.js
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: require.resolve('@umijs/max/eslint'),
|
||||||
|
rules: {
|
||||||
|
'react/no-unstable-nested-components': 1,
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'no-undef': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/class-name-casing': 'off'
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
Global: 'readonly',
|
||||||
|
React: 'readonly',
|
||||||
|
JSX: 'readonly'
|
||||||
|
},
|
||||||
|
ignorePatterns: ['public/static/']
|
||||||
|
};
|
||||||
@@ -12,7 +12,4 @@
|
|||||||
/.mfsu
|
/.mfsu
|
||||||
.swc
|
.swc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.claude
|
|
||||||
/dist.zip
|
|
||||||
.cache
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"*.{md,json}": ["prettier --cache --write"],
|
"*.{md,json}": ["prettier --cache --write"],
|
||||||
"*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"],
|
"*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"],
|
||||||
"*.{css,less}": ["prettier --cache --write"],
|
"*.{css,less}": [
|
||||||
|
"max lint --fix --stylelint-only",
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
"!public/vs/**": [],
|
"!public/vs/**": [],
|
||||||
"*.ts?(x)": [
|
"*.ts?(x)": [
|
||||||
"max lint --fix --eslint-only",
|
"max lint --fix --eslint-only",
|
||||||
"prettier --cache --parser=typescript --write"
|
"prettier --cache --parser=typescript --write"
|
||||||
],
|
],
|
||||||
"src/locales/**/*.ts": ["node --import tsx src/locales/check.ts"]
|
"src/locales/**/*.ts": ["npx tsx src/locales/check.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ node_modules
|
|||||||
.umi-production
|
.umi-production
|
||||||
public/static/*.js
|
public/static/*.js
|
||||||
public/static/*.css
|
public/static/*.css
|
||||||
src/components/iconfont/
|
src/components/icon-font/iconfont/iconfont.js
|
||||||
|
src/components/icon-font/iconfont/*.css
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'selector-class-pattern': null
|
'selector-class-pattern': null
|
||||||
},
|
},
|
||||||
ignoreFiles: ['public/static/*.css', 'src/components/iconfont/iconfont.css']
|
ignoreFiles: ['public/static/*.css']
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
# React State and Request Patterns
|
|
||||||
|
|
||||||
These guidelines define preferred patterns for request handling, state updates, and side-effect management in React applications.
|
|
||||||
|
|
||||||
The primary goal is to keep data flow explicit, predictable, maintainable, and performant while avoiding unnecessary rerenders and effect-driven logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Avoid Effect-Driven Requests
|
|
||||||
|
|
||||||
Do not use request functions themselves as dependencies in `useEffect`.
|
|
||||||
|
|
||||||
Avoid patterns like:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Requests should be triggered explicitly by user actions or lifecycle entry points.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Form Requests Should Be Action-Driven
|
|
||||||
|
|
||||||
For form-related requests (such as loading `Select` options):
|
|
||||||
|
|
||||||
- Fetch data when the form is opened for the first time.
|
|
||||||
- If later requests depend on user interactions, trigger them directly inside the interaction handler.
|
|
||||||
- Do not rely on `useEffect` dependency changes to trigger requests.
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const handleOnChange = (value) => {
|
|
||||||
fetchData(value);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData(value);
|
|
||||||
}, [value]);
|
|
||||||
```
|
|
||||||
|
|
||||||
The action itself should control the request.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Update Related States Together
|
|
||||||
|
|
||||||
If a single action updates multiple related states:
|
|
||||||
|
|
||||||
- Do not synchronize them through `useEffect`
|
|
||||||
- Do not derive them indirectly through `useMemo`
|
|
||||||
|
|
||||||
Instead, update all related states directly inside the action handler.
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const handleOnChange = (value) => {
|
|
||||||
setState1(...);
|
|
||||||
setState2(...);
|
|
||||||
buildState(...);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Avoid implicit state synchronization chains.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Group Strongly Related State
|
|
||||||
|
|
||||||
If multiple states are always updated together:
|
|
||||||
|
|
||||||
- Do not split them into multiple `useState` calls.
|
|
||||||
- Prefer a single state object.
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const [state, setState] = useState({
|
|
||||||
state1: ...,
|
|
||||||
state2: ...,
|
|
||||||
state3: ...,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
This reduces unnecessary rerenders and keeps state transitions predictable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Prefer Explicit State Flow
|
|
||||||
|
|
||||||
Avoid chaining business logic through multiple `useEffect` hooks.
|
|
||||||
|
|
||||||
Keep:
|
|
||||||
|
|
||||||
- request execution
|
|
||||||
- state updates
|
|
||||||
- derived calculations
|
|
||||||
|
|
||||||
close to the triggering action whenever possible.
|
|
||||||
|
|
||||||
Prefer:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const handleAction = () => {
|
|
||||||
fetchData();
|
|
||||||
setTableData(...);
|
|
||||||
setSelectedRow(...);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Over:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
useEffect(() => {
|
|
||||||
buildTable();
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateSelection();
|
|
||||||
}, [tableData]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Avoid Premature Memoization
|
|
||||||
|
|
||||||
Do not use `useMemo` or `useCallback` unless there is a confirmed rendering or computation bottleneck.
|
|
||||||
|
|
||||||
Overusing memoization:
|
|
||||||
|
|
||||||
- increases complexity
|
|
||||||
- makes state flow harder to understand
|
|
||||||
- may introduce stale dependency issues
|
|
||||||
|
|
||||||
Prefer simple and explicit logic first.
|
|
||||||
|
|
||||||
Optimize only when necessary.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Keep Request Logic Predictable
|
|
||||||
|
|
||||||
A user interaction should clearly show:
|
|
||||||
|
|
||||||
- what request is triggered
|
|
||||||
- which states are updated
|
|
||||||
- how the UI changes
|
|
||||||
|
|
||||||
Avoid indirect update chains caused by dependency-driven effects.
|
|
||||||
|
|
||||||
The code should make the request and update flow easy to trace.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Prefer Action-Driven Architecture
|
|
||||||
|
|
||||||
Prefer:
|
|
||||||
|
|
||||||
- action-driven updates
|
|
||||||
- explicit handlers
|
|
||||||
- localized state transitions
|
|
||||||
|
|
||||||
Over:
|
|
||||||
|
|
||||||
- effect-driven synchronization
|
|
||||||
- cross-hook implicit updates
|
|
||||||
- reactive chains between states
|
|
||||||
|
|
||||||
The triggering action should remain the primary source of truth for UI updates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Form
|
|
||||||
|
|
||||||
Form-specific patterns that build on the rules above. The theme: keep cascading selections (pick A → derive B → write form) on a single, predictable path.
|
|
||||||
|
|
||||||
## 1. No Fallback for Derived Selection
|
|
||||||
|
|
||||||
When "pick A then auto-pick B", match by rule and return `undefined` if no match — let the corresponding form field stay empty.
|
|
||||||
|
|
||||||
Do not silently fall back to `list[0]` or another default. A fallback hides data issues and tricks the user into thinking they have a valid selection.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const findB = (key, list) =>
|
|
||||||
key ? list.find((x) => x.key === key) : undefined;
|
|
||||||
```
|
|
||||||
|
|
||||||
For form fields, prefer clearing with `undefined` over `''`. With Ant Design, `undefined` restores the placeholder; `''` is treated as a real value.
|
|
||||||
|
|
||||||
## 2. Async Race Protection
|
|
||||||
|
|
||||||
For fetches triggered by a lifecycle entry (e.g., modal open), tag each invocation with a session ref. Discard stale results if the session has rotated (the modal was closed and re-opened) by the time the response arrives.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const sessionRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
sessionRef.current += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const session = ++sessionRef.current;
|
|
||||||
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
|
||||||
if (sessionRef.current !== session) return;
|
|
||||||
applySelection(as.items[0], findB(as.items[0].key, bs.items));
|
|
||||||
});
|
|
||||||
}, [open]);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Reference Template
|
|
||||||
|
|
||||||
A typical form with two cascading selectors backed by a single shared state:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type Selection = { a?: string; b?: number };
|
|
||||||
|
|
||||||
const [selection, setSelection] = useState<Selection>({});
|
|
||||||
const sessionRef = useRef(0);
|
|
||||||
|
|
||||||
const findB = (key, list) =>
|
|
||||||
key ? list.find((x) => x.key === key) : undefined;
|
|
||||||
|
|
||||||
// Single atomic write: state + form together.
|
|
||||||
const applySelection = (a, b) => {
|
|
||||||
setSelection({ a: a.name, b: b?.id });
|
|
||||||
form.current?.setFieldsValue({
|
|
||||||
field: b?.field,
|
|
||||||
spec: { ...currentSpec, ...b?.spec }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trigger 1: modal opened
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
sessionRef.current++;
|
|
||||||
setSelection({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const session = ++sessionRef.current;
|
|
||||||
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
|
||||||
if (sessionRef.current !== session) return;
|
|
||||||
const first = as.items[0];
|
|
||||||
applySelection(first, findB(first.key, bs.items));
|
|
||||||
});
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Trigger 2: user picks A
|
|
||||||
const handleAChange = (a) => {
|
|
||||||
applySelection(a, findB(a.key, listB));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trigger 3: user picks B
|
|
||||||
const handleBChange = (b) => {
|
|
||||||
setSelection((prev) => ({ ...prev, b: b.id }));
|
|
||||||
form.current?.setFieldsValue({ ...b.fields });
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
## Create form table list
|
|
||||||
|
|
||||||
## Create a form
|
|
||||||
|
|
||||||
## StatusTag
|
|
||||||
|
|
||||||
Use `StatusTag` from `@gpustack/core-ui` for status display. Do not use `Tag` from `antd` directly.
|
|
||||||
|
|
||||||
1. Define the mapping from business status values to UI status values in the module's `config/index.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { StatusMaps } from '@/config';
|
|
||||||
import { StatusType } from '@/config/types';
|
|
||||||
|
|
||||||
export const XxxStatusValueMap = {
|
|
||||||
Running: 'running',
|
|
||||||
Pending: 'pending',
|
|
||||||
Failed: 'failed'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const XxxStatusLabelMap: Record<string, string> = {
|
|
||||||
[XxxStatusValueMap.Running]: 'Running',
|
|
||||||
[XxxStatusValueMap.Pending]: 'Pending',
|
|
||||||
[XxxStatusValueMap.Failed]: 'Failed'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const status: Record<string, StatusType> = {
|
|
||||||
[XxxStatusValueMap.Running]: StatusMaps.success,
|
|
||||||
[XxxStatusValueMap.Pending]: StatusMaps.transitioning,
|
|
||||||
[XxxStatusValueMap.Failed]: StatusMaps.error
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. In table columns, pass only the UI status value and display text required by `StatusTag`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<StatusTag
|
|
||||||
statusValue={{
|
|
||||||
status: status[value],
|
|
||||||
text: XxxStatusLabelMap[value] || value,
|
|
||||||
message: record.state_message
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
`statusValue.status` must be a value mapped from `StatusMaps`, such as `success`, `transitioning`, `warning`, `error`, or `inactive`. Do not pass business status values such as `running` or `pending` directly.
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineConfig } from '@umijs/max';
|
import { defineConfig } from '@umijs/max';
|
||||||
import keepAlive from './keep-alive';
|
import keepAlive from './keep-alive';
|
||||||
import { extraMfsuExclude } from './mfsu.extensions';
|
|
||||||
import { compressionPluginConfig, monacoPluginConfig } from './plugins';
|
import { compressionPluginConfig, monacoPluginConfig } from './plugins';
|
||||||
import proxy from './proxy';
|
import proxy from './proxy';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
@@ -20,9 +19,6 @@ export default defineConfig({
|
|||||||
history: {
|
history: {
|
||||||
type: 'hash'
|
type: 'hash'
|
||||||
},
|
},
|
||||||
define: {
|
|
||||||
'process.env.ENABLE_ENTERPRISE': process.env.ENABLE_ENTERPRISE
|
|
||||||
},
|
|
||||||
analyze: {
|
analyze: {
|
||||||
analyzerMode: 'server',
|
analyzerMode: 'server',
|
||||||
analyzerPort: 8888,
|
analyzerPort: 8888,
|
||||||
@@ -33,7 +29,7 @@ export default defineConfig({
|
|||||||
defaultSizes: 'parsed' // stat // gzip
|
defaultSizes: 'parsed' // stat // gzip
|
||||||
},
|
},
|
||||||
mfsu: {
|
mfsu: {
|
||||||
exclude: ['lodash', 'ml-pca', ...extraMfsuExclude]
|
exclude: ['lodash', 'ml-pca']
|
||||||
},
|
},
|
||||||
base: process.env.npm_config_base || '/',
|
base: process.env.npm_config_base || '/',
|
||||||
...(isProduction
|
...(isProduction
|
||||||
@@ -77,7 +73,6 @@ export default defineConfig({
|
|||||||
antd: {
|
antd: {
|
||||||
style: 'less'
|
style: 'less'
|
||||||
},
|
},
|
||||||
title: 'GPUStack',
|
|
||||||
hash: true,
|
hash: true,
|
||||||
access: {},
|
access: {},
|
||||||
model: {},
|
model: {},
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
// Identity hook for build-time mfsu.exclude extensions. Tooling may
|
|
||||||
// overwrite this file to add package names that must skip MFSU's
|
|
||||||
// pre-bundling; the original is restored on cleanup. Mirrors
|
|
||||||
// `src/request.extensions.ts` / `src/access.extensions.ts`.
|
|
||||||
//
|
|
||||||
// MFSU bundles node_modules into immutable chunks at dev startup, so
|
|
||||||
// workspace-linked packages whose source you edit during dev must be
|
|
||||||
// excluded here or HMR won't pick up changes.
|
|
||||||
export const extraMfsuExclude: string[] = [];
|
|
||||||
@@ -31,6 +31,5 @@ export default function createProxyTable(target?: string) {
|
|||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
return proxyTable;
|
return proxyTable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Identity hook for build-time route extensions. Tooling may overwrite
|
|
||||||
// this file to inject additional routes; the original is restored on cleanup.
|
|
||||||
export const applyRouteExtensions = <T>(base: T): T => base;
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { keepAliveRoutes } from './keep-alive';
|
import { keepAliveRoutes } from './keep-alive';
|
||||||
import { applyRouteExtensions } from './routes.extensions';
|
|
||||||
|
|
||||||
const baseRoutes = [
|
export default [
|
||||||
{
|
{
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
@@ -9,10 +8,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-dashboard',
|
icon: 'icon-dashboard',
|
||||||
selectedIcon: 'icon-dashboard-filled',
|
selectedIcon: 'icon-dashboard-filled',
|
||||||
defaultIcon: 'icon-dashboard',
|
defaultIcon: 'icon-dashboard',
|
||||||
// `canSeeOrgAdmin` widens to anyone the access seam grants
|
access: 'canSeeAdmin',
|
||||||
// admin-ish visibility — by default platform admin, plus
|
|
||||||
// whatever the routes extension chooses to allow.
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
component: './dashboard',
|
component: './dashboard',
|
||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
@@ -36,7 +32,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-chat',
|
icon: 'icon-chat',
|
||||||
selectedIcon: 'icon-chat-filled',
|
selectedIcon: 'icon-chat-filled',
|
||||||
defaultIcon: 'icon-chat',
|
defaultIcon: 'icon-chat',
|
||||||
component: './playground/chat/index'
|
component: './playground/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'embedding',
|
name: 'embedding',
|
||||||
@@ -46,7 +42,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-embedding',
|
icon: 'icon-embedding',
|
||||||
selectedIcon: 'icon-embedding-filled',
|
selectedIcon: 'icon-embedding-filled',
|
||||||
defaultIcon: 'icon-embedding',
|
defaultIcon: 'icon-embedding',
|
||||||
component: './playground/embedding/index'
|
component: './playground/embedding'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'rerank',
|
name: 'rerank',
|
||||||
@@ -56,7 +52,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-reranker',
|
icon: 'icon-reranker',
|
||||||
selectedIcon: 'icon-reranker-filled',
|
selectedIcon: 'icon-reranker-filled',
|
||||||
defaultIcon: 'icon-reranker',
|
defaultIcon: 'icon-reranker',
|
||||||
component: './playground/rerank/index'
|
component: './playground/rerank'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'text2images',
|
name: 'text2images',
|
||||||
@@ -66,7 +62,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-image1',
|
icon: 'icon-image1',
|
||||||
selectedIcon: 'icon-image-filled',
|
selectedIcon: 'icon-image-filled',
|
||||||
defaultIcon: 'icon-image1',
|
defaultIcon: 'icon-image1',
|
||||||
component: './playground/images/index'
|
component: './playground/images'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'speech',
|
name: 'speech',
|
||||||
@@ -76,19 +72,8 @@ const baseRoutes = [
|
|||||||
icon: 'icon-audio1',
|
icon: 'icon-audio1',
|
||||||
selectedIcon: 'icon-audio-filled',
|
selectedIcon: 'icon-audio-filled',
|
||||||
defaultIcon: 'icon-audio1',
|
defaultIcon: 'icon-audio1',
|
||||||
component: './playground/speech/index'
|
component: './playground/speech'
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// name: 'video',
|
|
||||||
// title: 'Video',
|
|
||||||
// path: '/playground/video',
|
|
||||||
// key: 'video',
|
|
||||||
// icon: 'icon-video-outline',
|
|
||||||
// hideInMenu: false,
|
|
||||||
// selectedIcon: 'icon-video-filled02',
|
|
||||||
// defaultIcon: 'icon-video-outline',
|
|
||||||
// component: './playground/video'
|
|
||||||
// }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -107,9 +92,40 @@ const baseRoutes = [
|
|||||||
icon: 'icon-layers',
|
icon: 'icon-layers',
|
||||||
selectedIcon: 'icon-layers-filled',
|
selectedIcon: 'icon-layers-filled',
|
||||||
defaultIcon: 'icon-layers',
|
defaultIcon: 'icon-layers',
|
||||||
access: 'canSeeOrgAdmin',
|
access: 'canSeeAdmin',
|
||||||
component: './llmodels/catalog'
|
component: './llmodels/catalog'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'deployment',
|
||||||
|
path: '/models/deployments',
|
||||||
|
key: 'modelDeployments',
|
||||||
|
icon: 'icon-rocket-launch1',
|
||||||
|
selectedIcon: 'icon-rocket-launch-fill',
|
||||||
|
defaultIcon: 'icon-rocket-launch1',
|
||||||
|
access: 'canSeeAdmin',
|
||||||
|
component: './llmodels/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'routes',
|
||||||
|
path: '/models/routes',
|
||||||
|
key: 'routes',
|
||||||
|
icon: 'icon-captive_portal',
|
||||||
|
selectedIcon: 'icon-captive_portal',
|
||||||
|
defaultIcon: 'icon-captive_portal',
|
||||||
|
access: 'canSeeAdmin',
|
||||||
|
component: './model-routes/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'providers',
|
||||||
|
path: '/models/providers',
|
||||||
|
key: 'modelProviders',
|
||||||
|
icon: 'icon-extension-outline',
|
||||||
|
selectedIcon: 'icon-extension-filled',
|
||||||
|
defaultIcon: 'icon-extension-outline',
|
||||||
|
access: 'canSeeAdmin',
|
||||||
|
component: './maas-provider/index'
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'userModels',
|
name: 'userModels',
|
||||||
path: '/models/user-models',
|
path: '/models/user-models',
|
||||||
@@ -120,36 +136,6 @@ const baseRoutes = [
|
|||||||
access: 'canSeeUser',
|
access: 'canSeeUser',
|
||||||
component: './llmodels/user-models'
|
component: './llmodels/user-models'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'deployment',
|
|
||||||
path: '/models/deployments',
|
|
||||||
key: 'modelDeployments',
|
|
||||||
icon: 'icon-rocket-launch1',
|
|
||||||
selectedIcon: 'icon-rocket-launch-fill',
|
|
||||||
defaultIcon: 'icon-rocket-launch1',
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
component: './llmodels/index'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'routes',
|
|
||||||
path: '/models/routes',
|
|
||||||
key: 'routes',
|
|
||||||
icon: 'icon-captive_portal',
|
|
||||||
selectedIcon: 'icon-captive_portal',
|
|
||||||
defaultIcon: 'icon-captive_portal',
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
component: './model-routes/index'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'providers',
|
|
||||||
path: '/models/providers',
|
|
||||||
key: 'modelProviders',
|
|
||||||
icon: 'icon-extension-outline',
|
|
||||||
selectedIcon: 'icon-extension-filled',
|
|
||||||
defaultIcon: 'icon-extension-outline',
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
component: './maas-provider/index'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'benchmark',
|
name: 'benchmark',
|
||||||
path: '/models/benchmark',
|
path: '/models/benchmark',
|
||||||
@@ -157,7 +143,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-speed',
|
icon: 'icon-speed',
|
||||||
selectedIcon: 'icon-speed-filled',
|
selectedIcon: 'icon-speed-filled',
|
||||||
defaultIcon: 'icon-speed',
|
defaultIcon: 'icon-speed',
|
||||||
access: 'canSeeOrgAdmin',
|
access: 'canSeeAdmin',
|
||||||
component: './benchmark/index'
|
component: './benchmark/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,92 +153,9 @@ const baseRoutes = [
|
|||||||
icon: 'icon-speed',
|
icon: 'icon-speed',
|
||||||
selectedIcon: 'icon-speed-filled',
|
selectedIcon: 'icon-speed-filled',
|
||||||
defaultIcon: 'icon-speed',
|
defaultIcon: 'icon-speed',
|
||||||
access: 'canSeeOrgAdmin',
|
access: 'canSeeAdmin',
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
component: './benchmark/details'
|
component: './benchmark/details'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'backendsList',
|
|
||||||
path: '/models/backends',
|
|
||||||
key: 'backendsList',
|
|
||||||
icon: 'icon-backend',
|
|
||||||
selectedIcon: 'icon-backend-filled',
|
|
||||||
defaultIcon: 'icon-backend',
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
component: './backends/index'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'modelfiles',
|
|
||||||
path: '/models/modelfiles',
|
|
||||||
key: 'modelfiles',
|
|
||||||
icon: 'icon-files',
|
|
||||||
selectedIcon: 'icon-files-filled',
|
|
||||||
defaultIcon: 'icon-files',
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
component: './resources/components/model-files'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'gpuService',
|
|
||||||
path: '/gpu-service',
|
|
||||||
key: 'gpuService',
|
|
||||||
access: 'canSeeGpuService',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/gpu-service',
|
|
||||||
redirect: '/gpu-service/instances'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'instances',
|
|
||||||
path: '/gpu-service/instances',
|
|
||||||
key: 'gpuServiceList',
|
|
||||||
icon: 'icon-cloud-outlined',
|
|
||||||
selectedIcon: 'icon-cloud-filled',
|
|
||||||
defaultIcon: 'icon-cloud-outlined',
|
|
||||||
component: './gpu-service/instances'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'templates',
|
|
||||||
path: '/gpu-service/templates',
|
|
||||||
key: 'gpuServiceTemplates',
|
|
||||||
icon: 'icon-instance-template-outlined',
|
|
||||||
selectedIcon: 'icon-instance-template-filled',
|
|
||||||
defaultIcon: 'icon-instance-template-outlined',
|
|
||||||
component: './gpu-service/templates'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'storage',
|
|
||||||
path: '/gpu-service/storage',
|
|
||||||
key: 'gpuServiceStorage',
|
|
||||||
icon: 'icon-database-outlined',
|
|
||||||
selectedIcon: 'icon-database-filled',
|
|
||||||
defaultIcon: 'icon-database-outlined',
|
|
||||||
component: './gpu-service/storage'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'storageTypes',
|
|
||||||
path: '/gpu-service/storage-types',
|
|
||||||
key: 'gpuServiceStorageTypes',
|
|
||||||
icon: 'icon-storage-outlined',
|
|
||||||
// Storage types are tenant-scoped on the backend (Org owners
|
|
||||||
// can create/list their own), so the menu shouldn't be
|
|
||||||
// platform-admin-only. ``canSeeOrgAdmin`` keeps the gate at
|
|
||||||
// "admin or current-org owner" — Org members still don't see
|
|
||||||
// it, which matches the read/write model in the route.
|
|
||||||
access: 'canSeeOrgAdmin',
|
|
||||||
selectedIcon: 'icon-storage-filled',
|
|
||||||
defaultIcon: 'icon-storage-outlined',
|
|
||||||
component: './gpu-service/storage-types'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'publicKeys',
|
|
||||||
path: '/gpu-service/public-keys',
|
|
||||||
key: 'gpuServicePublicKeys',
|
|
||||||
icon: 'icon-ssh-outlined',
|
|
||||||
selectedIcon: 'icon-ssh-filled',
|
|
||||||
defaultIcon: 'icon-ssh-outlined',
|
|
||||||
component: './gpu-service/public-keys'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -260,22 +163,12 @@ const baseRoutes = [
|
|||||||
name: 'resources',
|
name: 'resources',
|
||||||
path: '/resources',
|
path: '/resources',
|
||||||
key: 'resources',
|
key: 'resources',
|
||||||
access: 'canSeeOrgAdmin',
|
access: 'canSeeAdmin',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/resources',
|
path: '/resources',
|
||||||
redirect: '/resources/workers'
|
redirect: '/resources/workers'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'clusters',
|
|
||||||
path: '/resources/clusters/list',
|
|
||||||
key: 'clusters',
|
|
||||||
icon: 'icon-cluster2-outline',
|
|
||||||
selectedIcon: 'icon-cluster2-filled',
|
|
||||||
defaultIcon: 'icon-cluster2-outline',
|
|
||||||
component: './cluster-management/clusters',
|
|
||||||
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'workers',
|
name: 'workers',
|
||||||
path: '/resources/workers',
|
path: '/resources/workers',
|
||||||
@@ -295,63 +188,67 @@ const baseRoutes = [
|
|||||||
component: './resources/components/gpus'
|
component: './resources/components/gpus'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'credentials',
|
name: 'backendsList',
|
||||||
path: '/resources/credentials',
|
path: '/resources/backends',
|
||||||
key: 'credentials',
|
key: 'backendsList',
|
||||||
icon: 'icon-credential-outline',
|
icon: 'icon-backend',
|
||||||
selectedIcon: 'icon-credential-filled',
|
selectedIcon: 'icon-backend-filled',
|
||||||
defaultIcon: 'icon-credential-outline',
|
defaultIcon: 'icon-backend',
|
||||||
component: './cluster-management/credentials'
|
access: 'canSeeAdmin',
|
||||||
|
component: './backends/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'modelfiles',
|
||||||
|
path: '/resources/modelfiles',
|
||||||
|
key: 'modelfiles',
|
||||||
|
icon: 'icon-files',
|
||||||
|
selectedIcon: 'icon-files-filled',
|
||||||
|
defaultIcon: 'icon-files',
|
||||||
|
component: './resources/components/model-files'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clusterManagement',
|
||||||
|
path: '/cluster-management',
|
||||||
|
key: 'clusterManagement',
|
||||||
|
access: 'canSeeAdmin',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/cluster-management',
|
||||||
|
redirect: '/cluster-management/clusters/list'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clusters',
|
||||||
|
path: '/cluster-management/clusters/list',
|
||||||
|
key: 'clusters',
|
||||||
|
icon: 'icon-cluster2-outline',
|
||||||
|
selectedIcon: 'icon-cluster2-filled',
|
||||||
|
defaultIcon: 'icon-cluster2-outline',
|
||||||
|
component: './cluster-management/clusters',
|
||||||
|
subMenu: [
|
||||||
|
'/cluster-management/clusters/detail',
|
||||||
|
'/cluster-management/clusters/create'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'clusterDetail',
|
name: 'clusterDetail',
|
||||||
path: '/resources/clusters/detail',
|
path: '/cluster-management/clusters/detail',
|
||||||
key: 'clusterDetail',
|
key: 'clusterDetail',
|
||||||
icon: 'icon-cluster2-outline',
|
icon: 'icon-cluster2-outline',
|
||||||
selectedIcon: 'icon-cluster2-filled',
|
selectedIcon: 'icon-cluster2-filled',
|
||||||
defaultIcon: 'icon-cluster2-outline',
|
defaultIcon: 'icon-cluster2-outline',
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
component: './cluster-management/cluster-detail'
|
component: './cluster-management/cluster-detail'
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Cross-resource consumption (tokens + GPU/CPU instances + storage).
|
|
||||||
// A folder so it matches the other top-level groups; more usage views can
|
|
||||||
// graduate in here later.
|
|
||||||
name: 'billingAndUsage',
|
|
||||||
path: '/usage',
|
|
||||||
key: 'usageGroup',
|
|
||||||
icon: 'icon-usage-outlined',
|
|
||||||
selectedIcon: 'icon-usage-filled',
|
|
||||||
defaultIcon: 'icon-usage-outlined',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/usage',
|
|
||||||
redirect: '/usage/overview'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'usage',
|
name: 'credentials',
|
||||||
path: '/usage/overview',
|
path: '/cluster-management/credentials',
|
||||||
key: 'usage',
|
key: 'credentials',
|
||||||
icon: 'icon-usage-outlined',
|
icon: 'icon-credential-outline',
|
||||||
selectedIcon: 'icon-usage-filled',
|
selectedIcon: 'icon-credential-filled',
|
||||||
defaultIcon: 'icon-usage-outlined',
|
defaultIcon: 'icon-credential-outline',
|
||||||
component: './usage/index'
|
component: './cluster-management/credentials'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'billing',
|
|
||||||
path: '/usage/billing',
|
|
||||||
key: 'billing',
|
|
||||||
icon: 'icon-billing-outlined',
|
|
||||||
selectedIcon: 'icon-billing-filled',
|
|
||||||
defaultIcon: 'icon-billing-outlined',
|
|
||||||
hideInMenu: process.env.ENABLE_ENTERPRISE === 'true',
|
|
||||||
// OSS exposes the menu as a teaser for the enterprise billing
|
|
||||||
// module. The page itself just renders an upsell notice — the real
|
|
||||||
// billing UI lives in the enterprise plugin and shadows this route
|
|
||||||
// via `routes.extensions.ts`.
|
|
||||||
component: './billing'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -359,25 +256,12 @@ const baseRoutes = [
|
|||||||
name: 'accessControl',
|
name: 'accessControl',
|
||||||
path: '/access-control',
|
path: '/access-control',
|
||||||
key: 'accessControl',
|
key: 'accessControl',
|
||||||
|
access: 'canSeeAdmin',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/access-control',
|
path: '/access-control',
|
||||||
redirect: '/access-control/users'
|
redirect: '/access-control/users'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'organizations',
|
|
||||||
path: '/access-control/organizations',
|
|
||||||
key: 'organizations',
|
|
||||||
icon: 'icon-org-outlined',
|
|
||||||
selectedIcon: 'icon-org-filled',
|
|
||||||
defaultIcon: 'icon-org-outlined',
|
|
||||||
// OSS exposes the menu to platform admins as a teaser for the
|
|
||||||
// enterprise multi-tenancy module. The page itself just renders
|
|
||||||
// an upsell notice — the real CRUD UI lives in the enterprise
|
|
||||||
// plugin and shadows this route via `routes.extensions.ts`.
|
|
||||||
access: 'canSeeAdmin',
|
|
||||||
component: './organizations'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'users',
|
name: 'users',
|
||||||
path: '/access-control/users',
|
path: '/access-control/users',
|
||||||
@@ -385,17 +269,7 @@ const baseRoutes = [
|
|||||||
icon: 'icon-users',
|
icon: 'icon-users',
|
||||||
selectedIcon: 'icon-users-filled',
|
selectedIcon: 'icon-users-filled',
|
||||||
defaultIcon: 'icon-users',
|
defaultIcon: 'icon-users',
|
||||||
access: 'canSeeAdmin',
|
|
||||||
component: './users'
|
component: './users'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'apikeys',
|
|
||||||
path: '/access-control/api-keys',
|
|
||||||
key: 'apikeys',
|
|
||||||
selectedIcon: 'icon-key-filled',
|
|
||||||
icon: 'icon-key',
|
|
||||||
defaultIcon: 'icon-key',
|
|
||||||
component: './api-keys'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -411,8 +285,8 @@ const baseRoutes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'profile',
|
name: 'profile',
|
||||||
path: '/preferences',
|
path: '/profile',
|
||||||
key: 'preferences',
|
key: 'profile',
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
component: './profile',
|
component: './profile',
|
||||||
icon: 'User'
|
icon: 'User'
|
||||||
@@ -433,5 +307,3 @@ const baseRoutes = [
|
|||||||
component: './404'
|
component: './404'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default applyRouteExtensions(baseRoutes);
|
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import { execSync } from 'child_process';
|
const child_process = require('child_process');
|
||||||
|
|
||||||
export const getBranchInfo = () => {
|
export const getBranchInfo = () => {
|
||||||
// git may be absent (source archive, bare container) or this tree may
|
const latestCommit = child_process
|
||||||
// not be a git checkout. Swallow the failure and fall back to the env
|
.execSync('git rev-parse HEAD')
|
||||||
// overrides below — losing build info shouldn't fail the build.
|
.toString()
|
||||||
let latestCommit = '';
|
.trim();
|
||||||
let versionTag = '';
|
const versionTag = child_process
|
||||||
try {
|
.execSync(`git tag --contains ${latestCommit}`)
|
||||||
latestCommit = execSync('git rev-parse HEAD').toString().trim();
|
.toString()
|
||||||
versionTag = execSync(`git tag --contains ${latestCommit}`)
|
.trim();
|
||||||
.toString()
|
return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
|
||||||
.trim();
|
|
||||||
} catch {
|
|
||||||
// Not a git checkout / git unavailable; rely on env overrides.
|
|
||||||
}
|
|
||||||
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that
|
|
||||||
// checks this source tree out as a sub-package can stamp its own
|
|
||||||
// release tag and commit id onto the UI (otherwise the panel reports
|
|
||||||
// the host tree's git HEAD, which the wrapper doesn't control).
|
|
||||||
const overrideVersion = process.env.GPUSTACK_UI_VERSION?.trim();
|
|
||||||
const overrideCommitId = process.env.GPUSTACK_UI_COMMIT_ID?.trim();
|
|
||||||
return {
|
|
||||||
version: overrideVersion || versionTag || '',
|
|
||||||
commitId: overrideCommitId || latestCommit.slice(0, 7)
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import js from '@eslint/js';
|
|
||||||
import prettier from 'eslint-config-prettier';
|
|
||||||
import importPlugin from 'eslint-plugin-import';
|
|
||||||
import reactPlugin from 'eslint-plugin-react';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
|
||||||
import unusedImports from 'eslint-plugin-unused-imports';
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
||||||
import globals from 'globals';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores([
|
|
||||||
'public/static/',
|
|
||||||
'dist',
|
|
||||||
'src/.umi/',
|
|
||||||
'src/.umi-production/',
|
|
||||||
'src/.umi-test/',
|
|
||||||
'src/components/iconfont/'
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
prettier
|
|
||||||
],
|
|
||||||
plugins: {
|
|
||||||
react: reactPlugin,
|
|
||||||
import: importPlugin,
|
|
||||||
'unused-imports': unusedImports
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect'
|
|
||||||
},
|
|
||||||
'import/resolver': {
|
|
||||||
node: true,
|
|
||||||
typescript: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
Global: 'readonly',
|
|
||||||
React: 'readonly',
|
|
||||||
JSX: 'readonly'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react/no-unstable-nested-components': 'warn',
|
|
||||||
'no-unused-vars': 'off',
|
|
||||||
'no-undef': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
|
||||||
'@typescript-eslint/ban-types': 'off',
|
|
||||||
'@typescript-eslint/no-empty-object-type': 'off',
|
|
||||||
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
|
|
||||||
'unused-imports/no-unused-imports': 'error',
|
|
||||||
'unused-imports/no-unused-vars': 'off',
|
|
||||||
'import/no-unresolved': 'off',
|
|
||||||
'import/no-duplicates': 'error',
|
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
|
||||||
'react-hooks/preserve-manual-memoization': 'off',
|
|
||||||
'react-hooks/set-state-in-effect': 'off',
|
|
||||||
'react-hooks/refs': 'off',
|
|
||||||
'react-hooks/use-memo': 'off',
|
|
||||||
'react-hooks/immutability': 'off',
|
|
||||||
'no-unsafe-optional-chaining': 'off',
|
|
||||||
'no-empty': 'off',
|
|
||||||
'no-constant-condition': 'off',
|
|
||||||
'no-prototype-builtins': 'off',
|
|
||||||
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0, maxBOF: 0 }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "gpustack",
|
"author": "jialin",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "max build",
|
"build": "max build",
|
||||||
"check:locales": "node --import tsx ./src/locales/check.ts",
|
"check:locales": "npx tsx ./src/locales/check.ts",
|
||||||
"dev": "max dev",
|
"dev": "max dev",
|
||||||
"format": "prettier --cache --write .",
|
"format": "prettier --cache --write .",
|
||||||
"postinstall": "max setup",
|
"postinstall": "max setup",
|
||||||
@@ -12,12 +12,13 @@
|
|||||||
"setup": "max setup",
|
"setup": "max setup",
|
||||||
"start": "npm run dev"
|
"start": "npm run dev"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"immer": "^9.0.6"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@ant-design/pro-components": "3.1.0-0",
|
"@ant-design/pro-components": "3.1.0-0",
|
||||||
"@antv/g6": "^5.0.51",
|
|
||||||
"@braintree/sanitize-url": "^7.1.1",
|
"@braintree/sanitize-url": "^7.1.1",
|
||||||
"@gpustack/core-ui": "^1.0.27",
|
|
||||||
"@huggingface/gguf": "^0.1.7",
|
"@huggingface/gguf": "^0.1.7",
|
||||||
"@huggingface/hub": "^0.15.1",
|
"@huggingface/hub": "^0.15.1",
|
||||||
"@huggingface/tasks": "^0.11.6",
|
"@huggingface/tasks": "^0.11.6",
|
||||||
@@ -30,27 +31,29 @@
|
|||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"ahooks": "^3.8.5",
|
"ahooks": "^3.8.5",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"antd": "^6.3.3",
|
"antd": "^6.1.2",
|
||||||
"antd-style": "^3.6.2",
|
"antd-style": "^3.6.2",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "^2.0.11",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"culori": "^4.0.2",
|
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"driver.js": "^1.3.1",
|
"driver.js": "^1.3.1",
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
|
"epubjs": "^0.3.93",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"has-ansi": "^5.0.1",
|
"has-ansi": "^5.0.1",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
"jotai": "^2.8.4",
|
"jotai": "^2.8.4",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lamejs": "github:zhuker/lamejs",
|
"lamejs": "github:zhuker/lamejs",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"mammoth": "^1.8.0",
|
||||||
"marked": "^14.1.0",
|
"marked": "^14.1.0",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"ml-dataset-iris": "^1.2.1",
|
"ml-dataset-iris": "^1.2.1",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
"numeral": "^2.0.6",
|
"numeral": "^2.0.6",
|
||||||
"overlayscrollbars": "^2.10.0",
|
"overlayscrollbars": "^2.10.0",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
|
"pdfjs-dist": "^4.7.76",
|
||||||
"query-string": "^9.0.0",
|
"query-string": "^9.0.0",
|
||||||
"rc-resize-observer": "^1.4.3",
|
"rc-resize-observer": "^1.4.3",
|
||||||
"rc-virtual-list": "^3.14.8",
|
"rc-virtual-list": "^3.14.8",
|
||||||
@@ -69,7 +73,6 @@
|
|||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
"react-router-dom": "^6.30.3",
|
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@@ -77,33 +80,26 @@
|
|||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"simplebar-react": "^3.2.6",
|
"simplebar-react": "^3.2.6",
|
||||||
"styled-components": "^6.1.15",
|
"styled-components": "^6.1.15",
|
||||||
|
"tinycolor2": "^1.6.0",
|
||||||
"umi-presets-pro": "^2.0.3",
|
"umi-presets-pro": "^2.0.3",
|
||||||
"wavesurfer.js": "^7.8.8"
|
"wavesurfer.js": "^7.8.8",
|
||||||
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
|
||||||
"@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1",
|
"@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1",
|
||||||
"@umijs/plugins": "^4.4.11",
|
"@umijs/plugins": "^4.4.11",
|
||||||
"babel-plugin-named-asset-import": "^0.3.8",
|
"babel-plugin-named-asset-import": "^0.3.8",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||||
"compression-webpack-plugin": "^11.1.0",
|
"compression-webpack-plugin": "^11.1.0",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-plugin-unused-imports": "^3.2.0",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-react": "^7.37.5",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
|
||||||
"extract-css-loader": "^0.0.1",
|
"extract-css-loader": "^0.0.1",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"globals": "^17.4.0",
|
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"less-loader": "^12.2.0",
|
"less-loader": "^12.2.0",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^15.2.2",
|
||||||
@@ -115,12 +111,10 @@
|
|||||||
"prettier-plugin-two-style-order": "^1.0.1",
|
"prettier-plugin-two-style-order": "^1.0.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"typescript-eslint": "^8.58.0",
|
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack-bundle-analyzer": "^4.10.2",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"worker-loader": "^3.0.8"
|
"worker-loader": "^3.0.8"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.3.0",
|
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"elliptic": "^6.6.1"
|
"elliptic": "^6.6.1"
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export default (api: IApi) => {
|
|||||||
const info = JSON.parse(process.env.VERSION || '{}');
|
const info = JSON.parse(process.env.VERSION || '{}');
|
||||||
const env = process.env.NODE_ENV;
|
const env = process.env.NODE_ENV;
|
||||||
|
|
||||||
$('html').attr('lang', 'en');
|
|
||||||
|
|
||||||
$('html').attr('data-env', env);
|
$('html').attr('data-env', env);
|
||||||
|
|
||||||
$('html').attr(
|
$('html').attr(
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { spawnSync } = require('child_process');
|
|
||||||
|
|
||||||
const pnpmDir = path.resolve(__dirname, '../../../node_modules/.pnpm');
|
|
||||||
|
|
||||||
function findDir(prefix) {
|
|
||||||
return fs.readdirSync(pnpmDir).find((name) => name.startsWith(prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
const stylelintDir = findDir('stylelint@14.8.2');
|
|
||||||
if (!stylelintDir) {
|
|
||||||
console.error('Compatible stylelint@14.8.2 not found in workspace node_modules/.pnpm');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stylelintBin = path.join(
|
|
||||||
pnpmDir,
|
|
||||||
stylelintDir,
|
|
||||||
'node_modules/stylelint/bin/stylelint.js'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = spawnSync(process.execPath, [stylelintBin, ...process.argv.slice(2)], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: process.cwd(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.error(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Identity hook for build-time access-predicate extensions. Tooling may
|
|
||||||
// overwrite this file to widen predicates; the original is restored on
|
|
||||||
// cleanup. Mirrors `config/routes.extensions.ts`.
|
|
||||||
export type AccessPredicates = {
|
|
||||||
canSeeAdmin: boolean;
|
|
||||||
canSeeOrgAdmin: boolean;
|
|
||||||
canManageCurrentOrg: boolean;
|
|
||||||
canSeeUser: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
canLogin: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const applyAccessExtensions = <T extends AccessPredicates>(base: T): T =>
|
|
||||||
base;
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { applyAccessExtensions } from './access.extensions';
|
export default (initialState: { currentUser?: Global.UserInfo }) => {
|
||||||
|
const canSeeAdmin = !!(
|
||||||
export default (initialState: {
|
|
||||||
currentUser?: Global.UserInfo;
|
|
||||||
hasKubernetesCluster?: boolean;
|
|
||||||
hasResourceEvents?: boolean;
|
|
||||||
}) => {
|
|
||||||
const isPlatformAdmin = !!(
|
|
||||||
initialState &&
|
initialState &&
|
||||||
initialState.currentUser &&
|
initialState.currentUser &&
|
||||||
initialState.currentUser.is_admin
|
initialState.currentUser.is_admin
|
||||||
@@ -15,42 +9,11 @@ export default (initialState: {
|
|||||||
initialState.currentUser &&
|
initialState.currentUser &&
|
||||||
!initialState.currentUser.is_admin
|
!initialState.currentUser.is_admin
|
||||||
);
|
);
|
||||||
// GPU Service is Kubernetes-only. We only gate visibility down when
|
|
||||||
// the probe in `getInitialState` came back with a definitive answer;
|
|
||||||
// `undefined` (probe failed / not yet ready) collapses to the
|
|
||||||
// role-based default so a transient network blip can't lock anyone
|
|
||||||
// out of the menu.
|
|
||||||
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
|
|
||||||
// Having run GPU/CPU instances or storage (any resource_events) also unlocks
|
|
||||||
// GPU Service / the full Usage page — a user who used it keeps seeing it even
|
|
||||||
// without a current cluster. MaaS-only users (no cluster, no events) don't.
|
|
||||||
const hasResourceEvents = !!initialState?.hasResourceEvents;
|
|
||||||
|
|
||||||
// Predicate roles, top-down by strictness:
|
return {
|
||||||
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
|
canSeeAdmin,
|
||||||
// Gates Users.
|
|
||||||
// * `canSeeOrgAdmin` — admin-style menus that work cross-org
|
|
||||||
// (Dashboard, Resources, Models, Cluster Management). Defaults
|
|
||||||
// to platform admin; extensions widen to include org admins.
|
|
||||||
// * `canSeeGpuService` — GPU Service menu. Anyone allowed to
|
|
||||||
// manage clusters (admins, Org owners) sees it; non-admins fall
|
|
||||||
// through to "show only if a Kubernetes cluster is actually
|
|
||||||
// reachable" so Org members without scheduling access don't see
|
|
||||||
// a dead-end menu item.
|
|
||||||
// * `canManageCurrentOrg` — pages that only make sense inside a
|
|
||||||
// specific org context (member / group management). Defaults to
|
|
||||||
// `false`; extensions widen when both an org is selected AND
|
|
||||||
// the caller is admin of it.
|
|
||||||
// Pass through `applyAccessExtensions` so build-time tooling can
|
|
||||||
// widen these without editing this file. Default is a no-op.
|
|
||||||
return applyAccessExtensions({
|
|
||||||
canSeeAdmin: isPlatformAdmin,
|
|
||||||
canSeeOrgAdmin: isPlatformAdmin,
|
|
||||||
canSeeGpuService:
|
|
||||||
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
|
|
||||||
canManageCurrentOrg: false,
|
|
||||||
canSeeUser,
|
canSeeUser,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
canLogin: true
|
canLogin: true
|
||||||
});
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { userSettingsHelperAtom } from '@/atoms/settings';
|
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
|
||||||
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
|
||||||
import { setAtomStorage } from '@/atoms/utils';
|
import { setAtomStorage } from '@/atoms/utils';
|
||||||
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
||||||
import { COLOR_PRIMARY } from '@/config/theme/constants';
|
|
||||||
import { queryClusterList } from '@/pages/cluster-management/apis';
|
|
||||||
import { ProviderValueMap } from '@/pages/cluster-management/config';
|
|
||||||
import { queryResourceEvents } from '@/pages/usage/apis/resource';
|
|
||||||
import { getGPUStackPlugin } from '@/plugins';
|
|
||||||
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
|
||||||
import { GPUStackPluginManager } from '@/plugins/manager';
|
|
||||||
import { requestConfig } from '@/request-config';
|
import { requestConfig } from '@/request-config';
|
||||||
import {
|
import {
|
||||||
queryCurrentUserState,
|
queryCurrentUserState,
|
||||||
@@ -17,18 +9,14 @@ import {
|
|||||||
} from '@/services/profile/apis';
|
} from '@/services/profile/apis';
|
||||||
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
||||||
import { isOnline } from '@/utils';
|
import { isOnline } from '@/utils';
|
||||||
import { installTenantFetch } from '@/utils/install-fetch';
|
|
||||||
import {
|
import {
|
||||||
IS_FIRST_LOGIN,
|
IS_FIRST_LOGIN,
|
||||||
readState,
|
readState,
|
||||||
writeState
|
writeState
|
||||||
} from '@/utils/localstore/index';
|
} from '@/utils/localstore/index';
|
||||||
import '@gpustack/core-ui/style.css';
|
import { RequestConfig, history } from '@umijs/max';
|
||||||
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
|
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
|
||||||
installTenantFetch();
|
|
||||||
|
|
||||||
// only for the first login and access from http://localhost
|
// only for the first login and access from http://localhost
|
||||||
|
|
||||||
const checkDefaultPage = async (userInfo: any) => {
|
const checkDefaultPage = async (userInfo: any) => {
|
||||||
@@ -41,107 +29,13 @@ const checkDefaultPage = async (userInfo: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Probes the caller's cluster list once so access predicates can gate
|
|
||||||
// GPU Service (Kubernetes-only). Cheap (one list request) and never
|
|
||||||
// blocks login — any failure just falls back to `undefined`, which
|
|
||||||
// the predicate treats as "unknown / don't restrict beyond role".
|
|
||||||
// The result is also mirrored into sessionStorage so access extensions
|
|
||||||
// that run without the initialState argument can read it (e.g. to
|
|
||||||
// override the admin shortcut in scopes where the menu shouldn't
|
|
||||||
// show even for admins).
|
|
||||||
const HAS_K8S_CLUSTER_KEY = 'hasKubernetesCluster';
|
|
||||||
const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
|
|
||||||
try {
|
|
||||||
const res = await queryClusterList(
|
|
||||||
{ page: -1 },
|
|
||||||
{
|
|
||||||
skipErrorHandler: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const value = (res?.items ?? []).some(
|
|
||||||
(c) => c?.provider === ProviderValueMap.Kubernetes
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
window.sessionStorage.setItem(HAS_K8S_CLUSTER_KEY, JSON.stringify(value));
|
|
||||||
} catch {
|
|
||||||
// sessionStorage may be unavailable (Safari private mode); the
|
|
||||||
// access predicate already handles a missing value as "unknown".
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('probeHasKubernetesCluster error', error);
|
|
||||||
try {
|
|
||||||
window.sessionStorage.removeItem(HAS_K8S_CLUSTER_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Probes whether the caller has ANY resource-usage events (GPU/CPU instance or
|
|
||||||
// storage lifecycle). Used alongside the cluster probe so a user who has run
|
|
||||||
// GPU instances still sees GPU Service / the full Usage page even if they
|
|
||||||
// currently have no Kubernetes cluster. Mirrored into sessionStorage for the
|
|
||||||
// access extensions; any failure → undefined ("unknown — don't restrict").
|
|
||||||
const HAS_RESOURCE_EVENTS_KEY = 'hasResourceEvents';
|
|
||||||
const probeHasResourceEvents = async (): Promise<boolean | undefined> => {
|
|
||||||
try {
|
|
||||||
// No date range = "ever"; scope is clamped to the caller server-side.
|
|
||||||
const res = await queryResourceEvents(
|
|
||||||
{ perPage: 1 },
|
|
||||||
{
|
|
||||||
skipErrorHandler: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const value = (res?.pagination?.total ?? 0) > 0;
|
|
||||||
try {
|
|
||||||
window.sessionStorage.setItem(
|
|
||||||
HAS_RESOURCE_EVENTS_KEY,
|
|
||||||
JSON.stringify(value)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// sessionStorage may be unavailable; predicate treats missing as unknown.
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('probeHasResourceEvents error', error);
|
|
||||||
try {
|
|
||||||
window.sessionStorage.removeItem(HAS_RESOURCE_EVENTS_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// runtime configuration
|
// runtime configuration
|
||||||
export async function getInitialState(): Promise<{
|
export async function getInitialState(): Promise<{
|
||||||
fetchUserInfo: () => Promise<Global.UserInfo>;
|
fetchUserInfo: () => Promise<Global.UserInfo>;
|
||||||
currentUser?: Global.UserInfo;
|
currentUser?: Global.UserInfo;
|
||||||
pluginData?: Record<string, any>;
|
|
||||||
hasKubernetesCluster?: boolean;
|
|
||||||
hasResourceEvents?: boolean;
|
|
||||||
}> {
|
}> {
|
||||||
const { location } = history;
|
const { location } = history;
|
||||||
|
|
||||||
// In open-source builds the promise resolves immediately.
|
|
||||||
await enterprisePluginReady;
|
|
||||||
|
|
||||||
// initialize plugins and merge enterprise locales
|
|
||||||
let pluginData = {};
|
|
||||||
try {
|
|
||||||
pluginData = await GPUStackPluginManager.initialize({
|
|
||||||
request: umiRequest,
|
|
||||||
setUserSettings: (value) => setAtomStorage(userSettingsHelperAtom, value),
|
|
||||||
setStorageUserSettings: (value) =>
|
|
||||||
setAtomStorage(userSettingsHelperAtom, value),
|
|
||||||
defaultColorPrimary: COLOR_PRIMARY
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize plugins:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getUpdateCheck = async () => {
|
const getUpdateCheck = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await updateCheck();
|
const data = await updateCheck();
|
||||||
@@ -166,36 +60,6 @@ export async function getInitialState(): Promise<{
|
|||||||
getUpdateCheck();
|
getUpdateCheck();
|
||||||
fetchSystemConfig();
|
fetchSystemConfig();
|
||||||
}
|
}
|
||||||
// Only commit a substantive user object. A truthy-but-empty
|
|
||||||
// `data` (e.g. server responded 200 with an empty body) would
|
|
||||||
// otherwise look like "logged in" to every `currentUser`
|
|
||||||
// reader and the access seam — break out instead and let the
|
|
||||||
// caller treat the request as failed.
|
|
||||||
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
|
|
||||||
// Commit the identity to atom storage (and so to localStorage)
|
|
||||||
// before returning. The access function — memoized on
|
|
||||||
// `initialState` and run once per commit — reads identity from
|
|
||||||
// localStorage; without this preemptive write the predicate
|
|
||||||
// sees the prior session's identity on its first evaluation
|
|
||||||
// after login, and stays stale until the next identity change
|
|
||||||
// (which usually doesn't come without a manual refresh).
|
|
||||||
try {
|
|
||||||
setAtomStorage(userAtom, data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('userAtom commit error:', err);
|
|
||||||
}
|
|
||||||
// Fire `onUserFetched` so plugins maintaining identity-scoped
|
|
||||||
// caches can seed them under the new identity before any
|
|
||||||
// caller commits this user to `initialState`. Errors here are
|
|
||||||
// swallowed and logged — fetchUserInfo must still return.
|
|
||||||
try {
|
|
||||||
await getGPUStackPlugin()?.login?.onUserFetched?.(data, {
|
|
||||||
request: umiRequest
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('onUserFetched plugin hook error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const data = error?.response?.data;
|
const data = error?.response?.data;
|
||||||
@@ -235,24 +99,15 @@ export async function getInitialState(): Promise<{
|
|||||||
getAppVersionInfo();
|
getAppVersionInfo();
|
||||||
|
|
||||||
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
||||||
const [userInfo, hasKubernetesCluster, hasResourceEvents] =
|
const userInfo = await fetchUserInfo();
|
||||||
await Promise.all([
|
|
||||||
fetchUserInfo(),
|
|
||||||
probeHasKubernetesCluster(),
|
|
||||||
probeHasResourceEvents()
|
|
||||||
]);
|
|
||||||
checkDefaultPage(userInfo);
|
checkDefaultPage(userInfo);
|
||||||
return {
|
return {
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
currentUser: userInfo,
|
currentUser: userInfo
|
||||||
pluginData,
|
|
||||||
hasKubernetesCluster,
|
|
||||||
hasResourceEvents
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetchUserInfo,
|
fetchUserInfo
|
||||||
pluginData
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 286 64"><g><g><defs><path id="SVGID_1_" d="M47.5 17.6L25 4.8v52.6l9-5.2V37.4l6.8 3.9-.1-10.1-6.7-3.9v-5.9l13.5 7.9z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_2_)"><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1.6" y1="335.05" x2="53.6" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.6 4.6h55.2v52.9H-1.6V4.6z" fill="url(#SVGID_3_)"/></g></g></g><g><g><defs><path id="SVGID_4_" d="M.5 17.6L23 4.8v52.6l-9-5.2V21.4L.5 29.3z"/></defs><clipPath id="SVGID_5_"><use xlink:href="#SVGID_4_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_5_)"><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1.9" y1="335.05" x2="53.3" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.9 4.6h55.2v52.9H-1.9V4.6z" fill="url(#SVGID_6_)"/></g></g></g><path style="fill:#425066" d="M88.2 21.1h-10v27.7h-5.6V21.1h-10v-4.5h25.6v4.5z"/><path style="fill:#425066" d="M94.9 49.2c-3.4 0-6.2-1.1-8.3-3.2-2.1-2.1-3.2-5-3.2-8.6v-.7c0-2.2.4-4.4 1.4-6.4.9-1.8 2.2-3.3 3.9-4.4 1.7-1.1 3.6-1.6 5.6-1.6 3.3 0 5.8 1 7.6 3.1s2.7 5 2.7 8.8v2.2H88.9c.1 1.8.8 3.4 2 4.7 1.2 1.2 2.7 1.8 4.4 1.7 2.4.1 4.6-1.1 6-3l2.9 2.8c-1 1.4-2.3 2.6-3.8 3.3-1.8 1-3.6 1.4-5.5 1.3zm-.6-20.5c-1.4-.1-2.7.5-3.6 1.5-1 1.2-1.6 2.7-1.7 4.3h10.3v-.4c-.1-1.8-.6-3.2-1.4-4.1-1-.8-2.3-1.4-3.6-1.3zm19.4-3.9l.2 2.8c1.7-2.1 4.3-3.3 7-3.2 5 0 7.5 2.9 7.6 8.6v15.8h-5.4V33.3c0-1.5-.3-2.6-1-3.4-.7-.7-1.7-1.1-3.2-1.1-2.1-.1-4 1.1-4.9 2.9v17h-5.4v-24l5.1.1zm32.2 17.5c0-.9-.4-1.7-1.2-2.2-1.2-.7-2.6-1.1-3.9-1.3-1.6-.3-3.1-.8-4.6-1.5-2.7-1.3-4-3.2-4-5.6 0-2 1-4 2.6-5.2 1.7-1.4 4-2.1 6.6-2.1 2.9 0 5.2.7 6.9 2.1 1.7 1.3 2.7 3.4 2.6 5.5h-5.4c0-1-.4-1.9-1.2-2.6-.9-.7-1.9-1.1-3.1-1-1 0-2 .2-2.9.8-.7.5-1.1 1.3-1.1 2.2 0 .8.4 1.5 1 1.9.7.5 2.1.9 4.2 1.4 1.7.3 3.4.9 5 1.7 1.1.5 2 1.3 2.7 2.3.6 1 .9 2.1.9 3.3 0 2.1-1 4-2.7 5.2-1.8 1.3-4.1 2-7 2-1.8 0-3.6-.3-5.2-1.1-1.4-.6-2.7-1.6-3.6-2.9-.8-1.2-1.3-2.6-1.3-4h5.2c0 1.1.5 2.2 1.4 2.9 1 .7 2.3 1.1 3.5 1 1.4 0 2.5-.3 3.2-.8 1-.4 1.4-1.2 1.4-2zm8.1-5.7c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.4 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3-5.2-3.1-9l.1-.3zm5.3.5c0 2.5.5 4.4 1.5 5.8 1.8 2.3 5.1 2.8 7.5 1 .4-.3.7-.6 1-1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.8 1.4-1.4 3.5-1.4 6.3zm33.1-7.3c-.7-.1-1.5-.2-2.2-.2-2.5 0-4.1.9-5 2.8v16.4h-5.4v-24h5.1l.1 2.7c1.3-2.1 3.1-3.1 5.4-3.1.6 0 1.3.1 1.9.3l.1 5.1zm22.5 5.3h-13v13.7h-5.6V16.6h20.5v4.5h-14.9v9.6h13v4.4zm10.7 13.7h-5.4V16.5h5.4v32.3zm3.9-12.2c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.3 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3.1-5.2-3.1-9v-.3zm5.4.5c0 2.5.5 4.4 1.5 5.8 1 1.4 2.6 2.2 4.3 2.1 1.7.1 3.3-.7 4.2-2.1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.9 1.4-1.4 3.5-1.4 6.3zm41.2 4.3l3.8-16.5h5.2l-6.5 24h-4.4l-5.1-16.5-5.1 16.5h-4.4l-6.6-24h5.3l3.9 16.4 4.9-16.4h4.1l4.9 16.5z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AlibabaCloud</title><path d="M14.752 4.64h5.274C22.242 4.64 24 6.475 24 8.691V15.8a3.947 3.947 0 01-3.974 3.975h-5.274l1.299-1.835 3.822-1.222c.688-.23 1.146-.918 1.146-1.605v-5.81c0-.687-.458-1.375-1.146-1.605L16.05 6.475l-1.3-1.835zM2.98 15.111c0 .688.46 1.376 1.147 1.606l3.822 1.146 1.3 1.835H3.974A3.947 3.947 0 010 15.723V8.69c0-2.216 1.758-4.05 3.975-4.05h5.273L7.95 6.474 4.127 7.697c-.688.23-1.146.918-1.146 1.606v5.808z" fill="#FF6A00"></path><path d="M16.051 11.213H8.025v1.835h8.026v-1.835z" fill="#FF6A00"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 656 B |
@@ -1 +0,0 @@
|
|||||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill="#1C54E3"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill="#AA9AFF"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z" fill="#00EAD1"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill="#00CEC9"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z" fill="#00EAD1"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill="#7347FF"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill="#0423DA"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -293,10 +293,6 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-white-tertiary {
|
.color-white-tertiary {
|
||||||
color: var(--color-white-tertiary);
|
color: var(--color-white-tertiary);
|
||||||
}
|
}
|
||||||
@@ -372,15 +368,3 @@ textarea:hover {
|
|||||||
.line-6 {
|
.line-6 {
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.align-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
.ant-layout-sider-children {
|
.ant-layout-sider-children {
|
||||||
border-inline: none;
|
border-inline: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding-inline-end: 0;
|
|
||||||
padding-block-end: 8px;
|
padding-block-end: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-table {
|
|
||||||
.ant-table {
|
|
||||||
.ant-table-container {
|
|
||||||
.ant-table-body,
|
|
||||||
.ant-table-content {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--color-scrollbar-thumb) transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -63,13 +63,6 @@ export const fromClusterCreationAtom = atom(false);
|
|||||||
export const clusterSessionAtom = atom<{
|
export const clusterSessionAtom = atom<{
|
||||||
firstAddWorker: boolean;
|
firstAddWorker: boolean;
|
||||||
firstAddCluster: boolean;
|
firstAddCluster: boolean;
|
||||||
presetClusterType?: 'model' | 'gpu';
|
|
||||||
// Provider to preselect when the create flow opens — set by the
|
|
||||||
// empty-state CTA on feature pages that need a specific provider
|
|
||||||
// (e.g. GPU Service can only schedule on Kubernetes, so its
|
|
||||||
// "Add Cluster" button skips provider catalog and lands on the
|
|
||||||
// K8s configure step). Consumed once by ClusterCreate on mount.
|
|
||||||
providerHint?: string;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const clusterDetailAtom = atom<ClusterListItem | null>(null);
|
export const clusterDetailAtom = atom<ClusterListItem | null>(null);
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export const activeModelsAtom = atom<any[]>([]);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { getDefaultStore } from 'jotai';
|
|
||||||
import { atomWithStorage } from 'jotai/utils';
|
|
||||||
|
|
||||||
export interface PaginationState {
|
|
||||||
perPage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const paginationAtom = atomWithStorage<Record<string, any>>(
|
|
||||||
'paginationStatus',
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{ getOnInit: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getPaginationStatus = (key: string) => {
|
|
||||||
if (!key) return {};
|
|
||||||
const store = getDefaultStore();
|
|
||||||
const cache = store.get(paginationAtom);
|
|
||||||
return cache[key] || {};
|
|
||||||
};
|
|
||||||
@@ -55,10 +55,3 @@ export const userSettingsHelperAtom = atom(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
export const hideModalTemporarilyAtom = atom<boolean>(false);
|
export const hideModalTemporarilyAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const collapsedMenuGroupsAtom = atomWithStorage<string[]>(
|
|
||||||
'collapsedMenuGroups',
|
|
||||||
[],
|
|
||||||
undefined,
|
|
||||||
{ getOnInit: true }
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export interface UsageTableData {
|
|
||||||
dataList: any[];
|
|
||||||
total: number;
|
|
||||||
loadend: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const apiKeysTableDataAtom = atom<UsageTableData>({
|
|
||||||
dataList: [],
|
|
||||||
total: 0,
|
|
||||||
loadend: false
|
|
||||||
});
|
|
||||||
|
|
||||||
export const usersTableDataAtom = atom<UsageTableData>({
|
|
||||||
dataList: [],
|
|
||||||
total: 0,
|
|
||||||
loadend: false
|
|
||||||
});
|
|
||||||
|
|
||||||
export const modelsTableDataAtom = atom<UsageTableData>({
|
|
||||||
dataList: [],
|
|
||||||
total: 0,
|
|
||||||
loadend: false
|
|
||||||
});
|
|
||||||
@@ -3,15 +3,6 @@ import { atomWithStorage } from 'jotai/utils';
|
|||||||
|
|
||||||
export const userAtom = atomWithStorage<any>('userInfo', null);
|
export const userAtom = atomWithStorage<any>('userInfo', null);
|
||||||
|
|
||||||
// Backs the `currentOrganizationId` localStorage key. Stays null in
|
|
||||||
// builds with no Org context (single-tenant), and is shared with any
|
|
||||||
// extension that persists the same key so both sides stay in sync
|
|
||||||
// without one side having to import from the other.
|
|
||||||
export const currentOrganizationIdAtom = atomWithStorage<number | null>(
|
|
||||||
'currentOrganizationId',
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
export const GPUStackVersionAtom = atom<{
|
export const GPUStackVersionAtom = atom<{
|
||||||
version: string;
|
version: string;
|
||||||
git_commit: string;
|
git_commit: string;
|
||||||
@@ -32,112 +23,7 @@ export const UpdateCheckAtom = atom<{
|
|||||||
latest_version: ''
|
latest_version: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
export const initialPasswordAtom = atom<string>('');
|
export const initialPasswordAtom = atomWithStorage<string>(
|
||||||
|
'initialPassword',
|
||||||
// Namespace the server creates for an Org's resources on each Kubernetes
|
''
|
||||||
// cluster. The format must match the backend's ``get_namespace_name``
|
);
|
||||||
// helper — ``gpustack-{name}`` — because the GPU-instance / storage CRDs
|
|
||||||
// (worker.gpustack.ai/v1) are namespaced and the server-side admission
|
|
||||||
// keys off this exact name. The identifier column on the unified
|
|
||||||
// Principal table is now ``name`` (post identity-consolidation rename
|
|
||||||
// of the legacy ``slug``); the namespace prefix is unchanged.
|
|
||||||
//
|
|
||||||
// Resolution path:
|
|
||||||
// 1. The Org the caller is currently acting under — the enterprise
|
|
||||||
// plugin persists ``currentOrganizationId`` (numeric) when the user
|
|
||||||
// picks an Org via OrgSwitcher.
|
|
||||||
// 2. The cluster's own owner Org — used in the platform-admin "All"
|
|
||||||
// view, where the caller has no Org context but the resource still
|
|
||||||
// has to land in *some* Org's namespace.
|
|
||||||
// 3. ``gpustack-default`` as a last resort (first load before any
|
|
||||||
// cache is hydrated, or a cluster whose owner Org is missing from
|
|
||||||
// both caches).
|
|
||||||
//
|
|
||||||
// Called outside React (umi page utilities) so it reads localStorage
|
|
||||||
// directly rather than going through a Jotai hook. The org caches are
|
|
||||||
// kept fresh by the enterprise plugin's atomWithStorage atoms, and the
|
|
||||||
// OrgSwitcher reloads the page on switch so we don't need in-process
|
|
||||||
// reactivity here.
|
|
||||||
export const getCurrentOrgNamespace = (
|
|
||||||
clusterOwnerPrincipalId?: number | null
|
|
||||||
): string => {
|
|
||||||
return (
|
|
||||||
lookupOrgNamespace(getStoredCurrentOrgId()) ??
|
|
||||||
lookupOrgNamespace(clusterOwnerPrincipalId ?? null) ??
|
|
||||||
'gpustack-default'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStoredCurrentOrgId = (): number | null => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('currentOrganizationId');
|
|
||||||
if (!raw) return null;
|
|
||||||
const value = JSON.parse(raw);
|
|
||||||
return typeof value === 'number' ? value : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Org caches the enterprise plugin persists. ``organizationList`` is
|
|
||||||
// the caller's member orgs; ``allOrganizations`` is admin-only (every
|
|
||||||
// Org on the platform) so admin sessions can resolve any owner Org id.
|
|
||||||
// Both are checked because ``currentOrganizationId`` is null in the
|
|
||||||
// admin "All" view but a member org's ``name`` might still cover the
|
|
||||||
// cluster-owner fallback.
|
|
||||||
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
|
|
||||||
|
|
||||||
export interface CachedOrg {
|
|
||||||
id: number;
|
|
||||||
name?: string;
|
|
||||||
// The platform Org (single global tenant). Its models are NOT
|
|
||||||
// namespaced in ``/v1/models`` — they appear under their bare name.
|
|
||||||
is_platform?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the cached Org record for an owner/principal id by scanning both
|
|
||||||
// org caches. ``organizationList`` (the caller's member orgs) is checked
|
|
||||||
// alongside the admin-only ``allOrganizations`` so member sessions resolve
|
|
||||||
// too. Id types vary between localStorage payloads (some writers stringify,
|
|
||||||
// others persist as a JSON number), so compare as strings — strict equality
|
|
||||||
// would silently miss those cases.
|
|
||||||
export const getOrgById = (
|
|
||||||
id: number | string | null | undefined
|
|
||||||
): CachedOrg | null => {
|
|
||||||
if (id == null) return null;
|
|
||||||
const target = String(id);
|
|
||||||
for (const key of ORG_CACHE_KEYS) {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (!raw) continue;
|
|
||||||
const list = JSON.parse(raw) as CachedOrg[];
|
|
||||||
if (!Array.isArray(list)) continue;
|
|
||||||
const match = list.find((item) => String(item?.id) === target);
|
|
||||||
if (match) return match;
|
|
||||||
} catch {
|
|
||||||
// ignore malformed cache; continue checking other keys
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bare Org *name* (e.g. ``org1``) for an owner/principal id, or null.
|
|
||||||
export const getOrgNameById = (
|
|
||||||
id: number | string | null | undefined
|
|
||||||
): string | null => {
|
|
||||||
return getOrgById(id)?.name ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lookupOrgNamespace = (id: number | null): string | null => {
|
|
||||||
const name = getOrgNameById(id);
|
|
||||||
return name ? `gpustack-${name}` : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The Org the caller is currently acting under, or null in the admin-"All"
|
|
||||||
// context. The org-switcher reloads the page on switch, so the list pages
|
|
||||||
// always show this org's resources — which is how ``/v1/models`` namespaces
|
|
||||||
// their model ids (``{org}/{name}``). Callers reconstructing that id use this
|
|
||||||
// as the fallback owner when a row carries no explicit ``owner_principal_id``.
|
|
||||||
export const getCurrentOrg = (): CachedOrg | null => {
|
|
||||||
return getOrgById(getStoredCurrentOrgId());
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -6,17 +6,12 @@ export const clearStorageUserSettings = () => {
|
|||||||
const savedSettings = JSON.parse(
|
const savedSettings = JSON.parse(
|
||||||
localStorage.getItem('userSettings') || '{}'
|
localStorage.getItem('userSettings') || '{}'
|
||||||
);
|
);
|
||||||
// colorPrimary is an enterprise-wide branding setting (set by admins
|
|
||||||
// and applied by `onAppInit` from /enterprise/settings), not a per-user
|
|
||||||
// preference. Preserve it across login — otherwise the next layout
|
|
||||||
// mount triggers `atomWithStorage.onMount`, re-reads localStorage,
|
|
||||||
// and falls back to the default color until a full page refresh
|
|
||||||
// re-runs `applyEnterpriseSettings`.
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'userSettings',
|
'userSettings',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...savedSettings,
|
...savedSettings,
|
||||||
hideAddResourceModal: false
|
hideAddResourceModal: false,
|
||||||
|
colorPrimary: undefined
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleFilled,
|
||||||
|
LoadingOutlined,
|
||||||
|
WarningFilled
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import OverlayScroller, { OverlayScrollerOptions } from '../overlay-scroller';
|
||||||
|
interface AlertInfoProps {
|
||||||
|
type: Global.MessageType;
|
||||||
|
message: React.ReactNode;
|
||||||
|
rows?: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
ellipsis?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
contentStyle?: React.CSSProperties;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
maxHeight?: number;
|
||||||
|
overlayScrollerProps?: OverlayScrollerOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ token, css }) => {
|
||||||
|
return {
|
||||||
|
alertBlockInfo: css`
|
||||||
|
padding-block: 6px;
|
||||||
|
padding-inline: 10px 16px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 32px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: ${token.borderRadius}px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
.ant-typography {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
border-color: ${token.colorErrorBorder};
|
||||||
|
background-color: ${token.colorErrorBg};
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
border-color: ${token.colorWarningBorder};
|
||||||
|
background-color: ${token.colorWarningBg};
|
||||||
|
}
|
||||||
|
|
||||||
|
&.transition {
|
||||||
|
color: ${token.geekblue7};
|
||||||
|
background: ${token.geekblue1};
|
||||||
|
border-color: ${token.geekblue3};
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
border: 1px solid ${token.colorSuccess};
|
||||||
|
color: ${token.colorSuccessText};
|
||||||
|
background: ${token.colorSuccessBg};
|
||||||
|
|
||||||
|
.content.success {
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
height: 32px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: ${token.borderRadius}px ${token.borderRadius}px 0 0;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
&.danger {
|
||||||
|
color: ${token.colorErrorText};
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: ${token.colorWarningText};
|
||||||
|
}
|
||||||
|
|
||||||
|
&.transition {
|
||||||
|
color: ${token.geekblue7};
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: ${token.colorSuccessText};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const TitleWrapper = styled.div`
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ContentWrapper = styled.div<{ $hasTitle: boolean }>`
|
||||||
|
word-break: break-word;
|
||||||
|
color: ${(props) =>
|
||||||
|
props.$hasTitle
|
||||||
|
? 'var(--ant-color-text-secondary)'
|
||||||
|
: 'var(--ant-color-text)'};
|
||||||
|
font-weight: var(--font-weight-500);
|
||||||
|
white-space: pre-line;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AlertInfo: React.FC<AlertInfoProps> = (props) => {
|
||||||
|
const {
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
rows = 1,
|
||||||
|
ellipsis,
|
||||||
|
style,
|
||||||
|
title,
|
||||||
|
contentStyle,
|
||||||
|
icon,
|
||||||
|
maxHeight = 86,
|
||||||
|
overlayScrollerProps = {}
|
||||||
|
} = props;
|
||||||
|
const { styles } = useStyles();
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (type === 'transition') {
|
||||||
|
return <LoadingOutlined />;
|
||||||
|
}
|
||||||
|
if (type === 'success') {
|
||||||
|
return <CheckCircleFilled />;
|
||||||
|
}
|
||||||
|
return <WarningFilled />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{message ? (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.alertBlockInfo, type)}
|
||||||
|
style={{ ...style }}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph
|
||||||
|
ellipsis={
|
||||||
|
ellipsis ?? {
|
||||||
|
rows: rows,
|
||||||
|
tooltip: message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={classNames('title', type)}>
|
||||||
|
<span className={classNames('info-icon', type)}>
|
||||||
|
{icon ?? renderIcon()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{title && (
|
||||||
|
<TitleWrapper className="title-text">{title}</TitleWrapper>
|
||||||
|
)}
|
||||||
|
<OverlayScroller
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
style={{ ...contentStyle }}
|
||||||
|
{...overlayScrollerProps}
|
||||||
|
>
|
||||||
|
<ContentWrapper
|
||||||
|
$hasTitle={!!title}
|
||||||
|
className={classNames('content', type)}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</ContentWrapper>
|
||||||
|
</OverlayScroller>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlertInfo;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { WarningOutlined } from '@ant-design/icons';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AlertInfoProps {
|
||||||
|
type: 'danger' | 'warning';
|
||||||
|
message: string;
|
||||||
|
rows?: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
ellipsis?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertInfo: React.FC<AlertInfoProps> = (props) => {
|
||||||
|
const { message, type, rows = 1, ellipsis, style } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{message ? (
|
||||||
|
<Typography.Paragraph
|
||||||
|
type={type}
|
||||||
|
ellipsis={
|
||||||
|
ellipsis !== undefined
|
||||||
|
? ellipsis
|
||||||
|
: {
|
||||||
|
rows: rows,
|
||||||
|
tooltip: message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontWeight: 400,
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '2px 5px',
|
||||||
|
borderRadius: 'var(--border-radius-base)',
|
||||||
|
margin: 0,
|
||||||
|
backgroundColor: 'var(--ant-color-error-bg)',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningOutlined className="m-r-8" />
|
||||||
|
{message}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(AlertInfo);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.canvas-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import useResizeObserver from '@/components/logs-viewer/use-size';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
interface AudioAnimationProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
scaleFactor?: number;
|
||||||
|
maxBarCount?: number;
|
||||||
|
amplitude?: number;
|
||||||
|
fixedHeight?: boolean;
|
||||||
|
analyserData: {
|
||||||
|
data: Uint8Array;
|
||||||
|
analyser: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioAnimation: React.FC<AudioAnimationProps> = (props) => {
|
||||||
|
const {
|
||||||
|
scaleFactor = 1.2,
|
||||||
|
maxBarCount = 128,
|
||||||
|
amplitude = 40,
|
||||||
|
maxWidth,
|
||||||
|
fixedHeight = true,
|
||||||
|
analyserData,
|
||||||
|
width: initialWidth,
|
||||||
|
height: initialHeight
|
||||||
|
} = props;
|
||||||
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationId = React.useRef<number>(0);
|
||||||
|
const isScaled = React.useRef<boolean>(false);
|
||||||
|
const oscillationOffset = React.useRef(0);
|
||||||
|
const direction = React.useRef(1);
|
||||||
|
const scrollerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const [height, setHeight] = useState(initialHeight);
|
||||||
|
const containerRef = React.useRef<any>(null);
|
||||||
|
|
||||||
|
const size = useResizeObserver(scrollerRef);
|
||||||
|
|
||||||
|
const calculateJitter = (
|
||||||
|
i: number,
|
||||||
|
timestamp: number,
|
||||||
|
baseHeight: number,
|
||||||
|
minJitter: number,
|
||||||
|
jitterAmplitude: number
|
||||||
|
) => {
|
||||||
|
//
|
||||||
|
const jitterFactor = Math.sin(timestamp / 200 + i) * 0.5 + 0.5;
|
||||||
|
const jitter =
|
||||||
|
minJitter +
|
||||||
|
jitterFactor * (jitterAmplitude - minJitter) * (baseHeight / maxBarCount);
|
||||||
|
return jitter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAudioVisualization = () => {
|
||||||
|
if (!canvasRef.current || !analyserData.data?.length) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const canvasCtx = canvas.getContext('2d');
|
||||||
|
if (!canvasCtx) return;
|
||||||
|
|
||||||
|
const WIDTH = (canvas.width = width * 2);
|
||||||
|
const HEIGHT = (canvas.height = height * 2);
|
||||||
|
|
||||||
|
if (!isScaled.current) {
|
||||||
|
canvasCtx.scale(2, 2);
|
||||||
|
isScaled.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const barWidth = 4;
|
||||||
|
const barSpacing = 6;
|
||||||
|
const centerLine = Math.floor(HEIGHT / 2);
|
||||||
|
|
||||||
|
const jitterAmplitude = amplitude;
|
||||||
|
const minJitter = 10;
|
||||||
|
|
||||||
|
let lastFrameTime = 0;
|
||||||
|
|
||||||
|
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
|
||||||
|
gradient.addColorStop(0, '#007BFF');
|
||||||
|
gradient.addColorStop(1, '#0069DA');
|
||||||
|
canvasCtx.fillStyle = gradient;
|
||||||
|
|
||||||
|
const draw = (timestamp: number) => {
|
||||||
|
const elapsed = timestamp - lastFrameTime;
|
||||||
|
if (elapsed < 16) {
|
||||||
|
animationId.current = requestAnimationFrame(draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastFrameTime = timestamp;
|
||||||
|
|
||||||
|
analyserData.analyser?.current?.getByteFrequencyData(analyserData.data);
|
||||||
|
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
const barCount = Math.min(maxBarCount, analyserData.data.length);
|
||||||
|
const totalWidth = barCount * (barWidth + barSpacing) - barSpacing;
|
||||||
|
let x = WIDTH / 2 - totalWidth / 2 + oscillationOffset.current;
|
||||||
|
|
||||||
|
oscillationOffset.current += direction.current;
|
||||||
|
if (Math.abs(oscillationOffset.current) > 20) {
|
||||||
|
direction.current *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < barCount; i++) {
|
||||||
|
const baseHeight = Math.floor(analyserData.data[i] / 2) * scaleFactor;
|
||||||
|
|
||||||
|
const jitter = calculateJitter(
|
||||||
|
i,
|
||||||
|
timestamp,
|
||||||
|
baseHeight,
|
||||||
|
minJitter,
|
||||||
|
jitterAmplitude
|
||||||
|
);
|
||||||
|
const barHeight = baseHeight + Math.round(jitter);
|
||||||
|
|
||||||
|
const topY = Math.round(centerLine - barHeight / 2);
|
||||||
|
const bottomY = Math.round(centerLine + barHeight / 2);
|
||||||
|
|
||||||
|
canvasCtx.beginPath();
|
||||||
|
canvasCtx.moveTo(x, bottomY);
|
||||||
|
canvasCtx.lineTo(x, topY + 2);
|
||||||
|
canvasCtx.arcTo(x + barWidth, topY + 2, x + barWidth, bottomY, 2);
|
||||||
|
canvasCtx.lineTo(x + barWidth, bottomY);
|
||||||
|
canvasCtx.closePath();
|
||||||
|
canvasCtx.fill();
|
||||||
|
|
||||||
|
x += barWidth + barSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationId.current = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw(performance.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (size) {
|
||||||
|
if (maxWidth) {
|
||||||
|
setWidth(Math.min(size.width, maxWidth));
|
||||||
|
} else {
|
||||||
|
setWidth(size?.width || 0);
|
||||||
|
}
|
||||||
|
if (!fixedHeight) {
|
||||||
|
setHeight(size?.height || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [size, maxWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
const clearCanvas = () => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const ctx = canvasRef.current.getContext('2d');
|
||||||
|
if (ctx) ctx.clearRect(0, 0, width * 2, height * 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!analyserData.data?.length || !analyserData.analyser?.current) {
|
||||||
|
clearCanvas();
|
||||||
|
cancelAnimationFrame(animationId.current);
|
||||||
|
animationId.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startAudioVisualization();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationId.current);
|
||||||
|
clearCanvas();
|
||||||
|
};
|
||||||
|
}, [analyserData, width, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="scroller-wrapper"
|
||||||
|
ref={scrollerRef}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="canvas-wrap"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} style={{ display: 'block' }}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(AudioAnimation);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const AudioWrapper = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AudioElement: React.FC<any> = (props) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AudioWrapper>
|
||||||
|
<audio {...props} controls></audio>
|
||||||
|
</AudioWrapper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioElement;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type AudioEvent =
|
||||||
|
| 'play'
|
||||||
|
| 'playing'
|
||||||
|
| 'pause'
|
||||||
|
| 'timeupdate'
|
||||||
|
| 'ended'
|
||||||
|
| 'loadedmetadata'
|
||||||
|
| 'audioprocess'
|
||||||
|
| 'canplay'
|
||||||
|
| 'ended'
|
||||||
|
| 'loadeddata'
|
||||||
|
| 'seeked'
|
||||||
|
| 'seeking'
|
||||||
|
| 'volumechange';
|
||||||
|
|
||||||
|
export interface AudioPlayerProps {
|
||||||
|
controls?: boolean;
|
||||||
|
autoplay?: boolean;
|
||||||
|
url: string;
|
||||||
|
speed?: number;
|
||||||
|
ref?: any;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
duration?: number;
|
||||||
|
onPlay?: () => void;
|
||||||
|
onPlaying?: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onTimeUpdate?: () => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
onLoadedMetadata?: (duration: number) => void;
|
||||||
|
onAudioProcess?: (current: number) => void;
|
||||||
|
onCanPlay?: () => void;
|
||||||
|
onLoadedData?: () => void;
|
||||||
|
onSeeked?: () => void;
|
||||||
|
onSeeking?: () => void;
|
||||||
|
onVolumeChange?: () => void;
|
||||||
|
onReady?: (duration: number) => void;
|
||||||
|
onAnalyse?: (analyseData: any, frequencyBinCount: any) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
.player-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
// background-color: var(--ant-color-fill-quaternary);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.player-ui {
|
||||||
|
padding: 8px 16px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
margin-inline: 30px;
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backward,
|
||||||
|
.forward {
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.slider-inner {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
width: 52px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
margin-inline: 10px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-horizontal {
|
||||||
|
margin-block: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker {
|
||||||
|
margin-left: 10px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { formatTime } from '@/utils/index';
|
||||||
|
import {
|
||||||
|
FastBackwardOutlined,
|
||||||
|
FastForwardOutlined,
|
||||||
|
PauseCircleFilled,
|
||||||
|
PlayCircleFilled
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Slider, Tooltip } from 'antd';
|
||||||
|
import { round } from 'lodash';
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle
|
||||||
|
} from 'react';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
autoplay?: boolean;
|
||||||
|
url: string;
|
||||||
|
speed?: number;
|
||||||
|
ref?: any;
|
||||||
|
name: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
duration?: number;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedOptions = [
|
||||||
|
{ label: '1x', value: 1 },
|
||||||
|
{ label: '2x', value: 2 },
|
||||||
|
{ label: '3x', value: 3 },
|
||||||
|
{ label: '4x', value: 4 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const speedConfig = {
|
||||||
|
min: 0.5,
|
||||||
|
max: 2,
|
||||||
|
step: 0.25
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { autoplay = false, speed: defaultSpeed = 1, extra } = props;
|
||||||
|
const audioRef = React.useRef<HTMLAudioElement>(null);
|
||||||
|
const [audioState, setAudioState] = React.useState<{
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
}>({
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
const [playOn, setPlayOn] = React.useState<boolean>(false);
|
||||||
|
const [speakerOn, setSpeakerOn] = React.useState<boolean>(false);
|
||||||
|
const [volume, setVolume] = React.useState<number>(1);
|
||||||
|
const [speed, setSpeed] = React.useState<number>(defaultSpeed);
|
||||||
|
const timer = React.useRef<any>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: () => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const handleShowVolume = useCallback(() => {
|
||||||
|
setSpeakerOn(!speakerOn);
|
||||||
|
}, [speakerOn]);
|
||||||
|
|
||||||
|
const handleSeepdChange = useCallback((value: number | string) => {
|
||||||
|
setSpeed(value as number);
|
||||||
|
audioRef.current!.playbackRate = value as number;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAudioOnPlay = useCallback(() => {
|
||||||
|
console.log('audio play');
|
||||||
|
timer.current = setInterval(() => {
|
||||||
|
setAudioState((prestate) => {
|
||||||
|
return {
|
||||||
|
currentTime: Math.ceil(audioRef.current?.currentTime || 0),
|
||||||
|
duration:
|
||||||
|
prestate.duration || Math.ceil(audioRef.current?.duration || 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (audioRef.current?.paused || audioRef.current?.ended) {
|
||||||
|
clearInterval(timer.current);
|
||||||
|
setPlayOn(false);
|
||||||
|
setAudioState((prestate: any) => {
|
||||||
|
return {
|
||||||
|
currentTime: audioRef.current?.ended ? 0 : prestate.currentTime,
|
||||||
|
duration: prestate.duration
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
setPlayOn(!playOn);
|
||||||
|
if (playOn) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}
|
||||||
|
}, [playOn]);
|
||||||
|
|
||||||
|
const handleFormatVolume = (val?: number) => {
|
||||||
|
if (val === undefined) {
|
||||||
|
return `${round(volume * 100)}%`;
|
||||||
|
}
|
||||||
|
return `${round(val * 100)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback((value: number) => {
|
||||||
|
audioRef.current!.volume = round(value, 2);
|
||||||
|
setVolume(round(value, 2));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initPlayerConfig = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current!.volume = volume;
|
||||||
|
audioRef.current!.playbackRate = speed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
const duration = Math.ceil(audioRef.current?.duration || 0);
|
||||||
|
setAudioState({
|
||||||
|
currentTime: 0,
|
||||||
|
duration:
|
||||||
|
duration && duration !== Infinity ? duration : props.duration || 0
|
||||||
|
});
|
||||||
|
setPlayOn(autoplay);
|
||||||
|
},
|
||||||
|
[autoplay, props.duration]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCurrentChange = useCallback((val: number) => {
|
||||||
|
audioRef.current!.currentTime = val;
|
||||||
|
setAudioState((prestate) => {
|
||||||
|
return {
|
||||||
|
currentTime: val,
|
||||||
|
duration: prestate.duration
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReduceSpeed = () => {
|
||||||
|
setSpeed((pre) => {
|
||||||
|
if (pre - speedConfig.step < speedConfig.min) {
|
||||||
|
return speedConfig.min;
|
||||||
|
}
|
||||||
|
const next = pre - speedConfig.step;
|
||||||
|
audioRef.current!.playbackRate = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSpeed = () => {
|
||||||
|
setSpeed((pre) => {
|
||||||
|
if (pre + speedConfig.step > speedConfig.max) {
|
||||||
|
return speedConfig.max;
|
||||||
|
}
|
||||||
|
const next = pre + speedConfig.step;
|
||||||
|
audioRef.current!.playbackRate = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnLoad = (e: any) => {
|
||||||
|
console.log('onload', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownload = useCallback(() => {
|
||||||
|
const url = props.url || '';
|
||||||
|
const filename = props.name;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}, [props.url, props.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
initPlayerConfig();
|
||||||
|
}
|
||||||
|
}, [audioRef.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="player-wrap" style={{ width: props.width || '100%' }}>
|
||||||
|
<div className="player-ui">
|
||||||
|
<div className="play-content">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<span className="file-name">{props.name}</span>
|
||||||
|
<div className="slider">
|
||||||
|
{/* <span className="time current">
|
||||||
|
{' '}
|
||||||
|
{formatTime(audioState.currentTime)}
|
||||||
|
</span> */}
|
||||||
|
<div className="slider-inner">
|
||||||
|
<Slider
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
styles={{
|
||||||
|
rail: {
|
||||||
|
// height: 6
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
max={audioState.duration}
|
||||||
|
value={audioState.currentTime}
|
||||||
|
onChange={handleCurrentChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* <span className="time">{formatTime(audioState.duration)}</span> */}
|
||||||
|
</div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="audio-control flex-center">
|
||||||
|
<span className="time current">
|
||||||
|
{' '}
|
||||||
|
{formatTime(audioState.currentTime)}
|
||||||
|
</span>
|
||||||
|
<Tooltip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'playground.audio.button.slow'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="backward"
|
||||||
|
disabled={
|
||||||
|
speed === speedConfig.min || speed < speedConfig.min
|
||||||
|
}
|
||||||
|
onClick={handleReduceSpeed}
|
||||||
|
>
|
||||||
|
<FastBackwardOutlined className="font-size-20" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="play-btn">
|
||||||
|
<Button
|
||||||
|
size="middle"
|
||||||
|
type="text"
|
||||||
|
onClick={handlePlay}
|
||||||
|
disabled={!audioState?.duration}
|
||||||
|
icon={
|
||||||
|
!playOn ? (
|
||||||
|
<PlayCircleFilled
|
||||||
|
style={{ fontSize: '22px' }}
|
||||||
|
></PlayCircleFilled>
|
||||||
|
) : (
|
||||||
|
<PauseCircleFilled
|
||||||
|
style={{ fontSize: '22px' }}
|
||||||
|
></PauseCircleFilled>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Button>
|
||||||
|
</span>
|
||||||
|
<Tooltip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'playground.audio.button.fast'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="forward"
|
||||||
|
disabled={
|
||||||
|
speed === speedConfig.max || speed > speedConfig.max
|
||||||
|
}
|
||||||
|
onClick={handleAddSpeed}
|
||||||
|
>
|
||||||
|
<FastForwardOutlined className="font-size-20" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="time">{formatTime(audioState.duration)}</span>
|
||||||
|
</div>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
autoPlay={autoplay}
|
||||||
|
src={props.url}
|
||||||
|
ref={audioRef}
|
||||||
|
preload="metadata"
|
||||||
|
style={{ opacity: 0, position: 'absolute', left: '-9999px' }}
|
||||||
|
onPlay={handleAudioOnPlay}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
></audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default React.memo(AudioPlayer);
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef
|
||||||
|
} from 'react';
|
||||||
|
import { AudioPlayerProps } from './config/type';
|
||||||
|
|
||||||
|
const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||||
|
const { autoplay = false } = props;
|
||||||
|
const audioRef = React.useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
// =================== audio context ======================
|
||||||
|
const audioContext = useRef<any>(null);
|
||||||
|
const analyser = useRef<any>(null);
|
||||||
|
const dataArray = useRef<any>(null);
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
const initAudioContext = useCallback(() => {
|
||||||
|
audioContext.current = new (window.AudioContext ||
|
||||||
|
window.webkitAudioContext)();
|
||||||
|
|
||||||
|
analyser.current = audioContext.current.createAnalyser();
|
||||||
|
analyser.current.fftSize = 512;
|
||||||
|
dataArray.current = new Uint8Array(analyser.current.frequencyBinCount);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateVisualData = useCallback(() => {
|
||||||
|
const source = audioContext.current.createMediaElementSource(
|
||||||
|
audioRef.current
|
||||||
|
);
|
||||||
|
source.connect(analyser.current);
|
||||||
|
analyser.current.connect(audioContext.current.destination);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initEnvents = () => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('complete', () => {});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('play', () => {
|
||||||
|
props.onAnalyse?.(dataArray.current, analyser);
|
||||||
|
props.onPlay?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('pause', () => {
|
||||||
|
props.onAnalyse?.(dataArray.current, analyser);
|
||||||
|
props.onPause?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('timeupdate', () => {
|
||||||
|
props.onTimeUpdate?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('ended', () => {
|
||||||
|
props.onEnded?.();
|
||||||
|
});
|
||||||
|
// add all other events
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('canplay', () => {
|
||||||
|
props.onCanPlay?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('loadeddata', () => {
|
||||||
|
initEnvents();
|
||||||
|
if (!audioContext.current) {
|
||||||
|
initAudioContext();
|
||||||
|
generateVisualData();
|
||||||
|
}
|
||||||
|
props.onLoadedData?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('seeked', () => {
|
||||||
|
props.onSeeked?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('seeking', () => {
|
||||||
|
props.onSeeking?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('volumechange', () => {
|
||||||
|
props.onVolumeChange?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('audioprocess', () => {
|
||||||
|
const current = audioRef.current?.currentTime || 0;
|
||||||
|
props.onAudioProcess?.(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('playing', () => {
|
||||||
|
props.onPlaying?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('loadedmetadata', () => {
|
||||||
|
const duration = audioRef.current?.duration || 0;
|
||||||
|
props.onLoadedMetadata?.(duration);
|
||||||
|
props.onReady?.(duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('ended', () => {
|
||||||
|
props.onEnded?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRef.current.addEventListener('loadeddata', () => {
|
||||||
|
props.onLoadedData?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: () => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
console.log('audioRef.current', audioRef.current, props.url);
|
||||||
|
initEnvents();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (audioContext.current) {
|
||||||
|
audioContext.current.close();
|
||||||
|
}
|
||||||
|
// remove all events
|
||||||
|
|
||||||
|
audioRef.current?.removeEventListener('play', () => {});
|
||||||
|
audioRef.current?.removeEventListener('pause', () => {});
|
||||||
|
audioRef.current?.removeEventListener('timeupdate', () => {});
|
||||||
|
audioRef.current?.removeEventListener('ended', () => {});
|
||||||
|
audioRef.current?.removeEventListener('canplay', () => {});
|
||||||
|
audioRef.current?.removeEventListener('loadeddata', () => {});
|
||||||
|
audioRef.current?.removeEventListener('seeked', () => {});
|
||||||
|
audioRef.current?.removeEventListener('seeking', () => {});
|
||||||
|
audioRef.current?.removeEventListener('volumechange', () => {});
|
||||||
|
audioRef.current?.removeEventListener('audioprocess', () => {});
|
||||||
|
audioRef.current?.removeEventListener('playing', () => {});
|
||||||
|
audioRef.current?.removeEventListener('loadedmetadata', () => {});
|
||||||
|
audioRef.current?.removeEventListener('ended', () => {});
|
||||||
|
audioRef.current?.removeEventListener('loadeddata', () => {});
|
||||||
|
};
|
||||||
|
}, [audioRef.current]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
autoPlay={autoplay}
|
||||||
|
src={props.url}
|
||||||
|
ref={audioRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
opacity: 0
|
||||||
|
}}
|
||||||
|
preload="metadata"
|
||||||
|
></audio>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RawAudioPlayer;
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { formatTime } from '@/utils/index';
|
||||||
|
import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Dropdown, Slider, type MenuProps } from 'antd';
|
||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
import { round } from 'lodash';
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo
|
||||||
|
} from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import AutoTooltip from '../auto-tooltip';
|
||||||
|
import IconFont from '../icon-font';
|
||||||
|
|
||||||
|
type ActionItem = 'download' | 'delete' | 'speed';
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
autoplay?: boolean;
|
||||||
|
url: string;
|
||||||
|
speed?: number;
|
||||||
|
ref?: any;
|
||||||
|
name: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
duration?: number;
|
||||||
|
actions?: ActionItem[];
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SliderWrapper = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
.ant-slider {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ css, token }) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const isDarkMode = token.darkMode as boolean;
|
||||||
|
return {
|
||||||
|
wrapper: css`
|
||||||
|
position: relative;
|
||||||
|
min-width: 360px;
|
||||||
|
height: 54px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: ${isDarkMode
|
||||||
|
? 'var(--ant-color-fill-secondary)'
|
||||||
|
: '#F1F3F4'};
|
||||||
|
border-radius: 28px;
|
||||||
|
.inner {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
.slider {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
.ant-slider {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
.ant-slider-handle {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:focus-within {
|
||||||
|
.ant-slider-handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-handle {
|
||||||
|
opacity: 0;
|
||||||
|
&::before {
|
||||||
|
background-color: var(--ant-color-bg-spotlight);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderStyles = {
|
||||||
|
rail: {
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: 'var(--ant-color-fill-secondary)'
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: 'var(--ant-color-bg-spotlight)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const speedOptions = [
|
||||||
|
{ label: '1x', value: 1 },
|
||||||
|
{ label: '2x', value: 2 },
|
||||||
|
{ label: '3x', value: 3 },
|
||||||
|
{ label: '4x', value: 4 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const speedConfig = {
|
||||||
|
min: 0.5,
|
||||||
|
max: 2,
|
||||||
|
step: 0.25
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const {
|
||||||
|
autoplay = false,
|
||||||
|
speed: defaultSpeed = 1,
|
||||||
|
actions = ['delete'],
|
||||||
|
name,
|
||||||
|
onDelete
|
||||||
|
} = props;
|
||||||
|
const audioRef = React.useRef<HTMLAudioElement>(null);
|
||||||
|
const [audioState, setAudioState] = React.useState<{
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
}>({
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
console.log('audioState', name);
|
||||||
|
const [playOn, setPlayOn] = React.useState<boolean>(false);
|
||||||
|
const [speakerOn, setSpeakerOn] = React.useState<boolean>(false);
|
||||||
|
const [volume, setVolume] = React.useState<number>(1);
|
||||||
|
const [speed, setSpeed] = React.useState<number>(defaultSpeed);
|
||||||
|
const timer = React.useRef<any>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: () => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const handleShowVolume = useCallback(() => {
|
||||||
|
setSpeakerOn(!speakerOn);
|
||||||
|
}, [speakerOn]);
|
||||||
|
|
||||||
|
const handleSeepdChange = useCallback((value: number | string) => {
|
||||||
|
setSpeed(value as number);
|
||||||
|
audioRef.current!.playbackRate = value as number;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAudioOnPlay = useCallback(() => {
|
||||||
|
timer.current = setInterval(() => {
|
||||||
|
setAudioState((prestate) => {
|
||||||
|
return {
|
||||||
|
currentTime: Math.ceil(audioRef.current?.currentTime || 0),
|
||||||
|
duration:
|
||||||
|
prestate.duration || Math.ceil(audioRef.current?.duration || 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (audioRef.current?.paused || audioRef.current?.ended) {
|
||||||
|
clearInterval(timer.current);
|
||||||
|
setPlayOn(false);
|
||||||
|
setAudioState((prestate: any) => {
|
||||||
|
return {
|
||||||
|
currentTime: audioRef.current?.ended ? 0 : prestate.currentTime,
|
||||||
|
duration: prestate.duration
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
setPlayOn(!playOn);
|
||||||
|
if (playOn) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}
|
||||||
|
}, [playOn]);
|
||||||
|
|
||||||
|
const handleFormatVolume = (val?: number) => {
|
||||||
|
if (val === undefined) {
|
||||||
|
return `${round(volume * 100)}%`;
|
||||||
|
}
|
||||||
|
return `${round(val * 100)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback((value: number) => {
|
||||||
|
audioRef.current!.volume = round(value, 2);
|
||||||
|
setVolume(round(value, 2));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initPlayerConfig = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current!.volume = volume;
|
||||||
|
audioRef.current!.playbackRate = speed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
const duration = Math.ceil(audioRef.current?.duration || 0);
|
||||||
|
setAudioState({
|
||||||
|
currentTime: 0,
|
||||||
|
duration:
|
||||||
|
duration && duration !== Infinity ? duration : props.duration || 0
|
||||||
|
});
|
||||||
|
setPlayOn(autoplay);
|
||||||
|
},
|
||||||
|
[autoplay, props.duration]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCurrentChange = useCallback((val: number) => {
|
||||||
|
audioRef.current!.currentTime = val;
|
||||||
|
setAudioState((prestate) => {
|
||||||
|
return {
|
||||||
|
currentTime: val,
|
||||||
|
duration: prestate.duration
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReduceSpeed = () => {
|
||||||
|
setSpeed((pre) => {
|
||||||
|
if (pre - speedConfig.step < speedConfig.min) {
|
||||||
|
return speedConfig.min;
|
||||||
|
}
|
||||||
|
const next = pre - speedConfig.step;
|
||||||
|
audioRef.current!.playbackRate = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSpeed = () => {
|
||||||
|
setSpeed((pre) => {
|
||||||
|
if (pre + speedConfig.step > speedConfig.max) {
|
||||||
|
return speedConfig.max;
|
||||||
|
}
|
||||||
|
const next = pre + speedConfig.step;
|
||||||
|
audioRef.current!.playbackRate = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnLoad = (e: any) => {
|
||||||
|
console.log('onload', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownload = useCallback(() => {
|
||||||
|
const url = props.url || '';
|
||||||
|
const filename = props.name;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename || 'audio.mp3'; // Default filename
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}, [props.url, props.name]);
|
||||||
|
|
||||||
|
const items: MenuProps['items'] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
label: intl.formatMessage({ id: 'common.button.download' }),
|
||||||
|
icon: <DownloadOutlined />,
|
||||||
|
onClick: onDownload
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speed',
|
||||||
|
label: intl.formatMessage({ id: 'playground.params.speed' }),
|
||||||
|
icon: <IconFont type="icon-play-speed"></IconFont>,
|
||||||
|
children: speedOptions.map((item) => ({
|
||||||
|
key: item.value,
|
||||||
|
label: item.label,
|
||||||
|
onClick: () => handleSeepdChange(item.value)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: intl.formatMessage({ id: 'common.button.delete' }),
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.src = '';
|
||||||
|
audioRef.current.load();
|
||||||
|
}
|
||||||
|
setAudioState({ currentTime: 0, duration: 0 });
|
||||||
|
setPlayOn(false);
|
||||||
|
onDelete?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].filter((item) => actions.includes(item.key as ActionItem));
|
||||||
|
}, [actions, intl, onDownload, onDelete, handleSeepdChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
initPlayerConfig();
|
||||||
|
}
|
||||||
|
}, [audioRef.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.wrapper}
|
||||||
|
style={{
|
||||||
|
width: props.width || '100%',
|
||||||
|
height: props.height || '60px',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="inner">
|
||||||
|
<Button
|
||||||
|
size="middle"
|
||||||
|
type="text"
|
||||||
|
onClick={handlePlay}
|
||||||
|
shape="circle"
|
||||||
|
disabled={!audioState?.duration}
|
||||||
|
icon={
|
||||||
|
!playOn ? (
|
||||||
|
<IconFont
|
||||||
|
type="icon-playcircle-fill"
|
||||||
|
style={{ fontSize: '24px' }}
|
||||||
|
></IconFont>
|
||||||
|
) : (
|
||||||
|
<IconFont
|
||||||
|
type="icon-stopcircle-fill"
|
||||||
|
style={{ fontSize: '24px' }}
|
||||||
|
></IconFont>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Button>
|
||||||
|
|
||||||
|
<div className="slider">
|
||||||
|
<div className="flex-center flex-between file-name">
|
||||||
|
<AutoTooltip ghost maxWidth={200}>
|
||||||
|
<span>{name}</span>
|
||||||
|
</AutoTooltip>
|
||||||
|
</div>
|
||||||
|
<SliderWrapper>
|
||||||
|
<span className="time">{formatTime(audioState.currentTime)}</span>
|
||||||
|
<Slider
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
styles={sliderStyles}
|
||||||
|
max={audioState.duration}
|
||||||
|
value={audioState.currentTime}
|
||||||
|
onChange={handleCurrentChange}
|
||||||
|
/>
|
||||||
|
</SliderWrapper>
|
||||||
|
</div>
|
||||||
|
<Dropdown menu={{ items }} trigger={['click']}>
|
||||||
|
<Button
|
||||||
|
icon={<IconFont type="icon-more"></IconFont>}
|
||||||
|
type="text"
|
||||||
|
size="middle"
|
||||||
|
shape="circle"
|
||||||
|
></Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
autoPlay={autoplay}
|
||||||
|
src={props.url}
|
||||||
|
ref={audioRef}
|
||||||
|
preload="metadata"
|
||||||
|
style={{ opacity: 0, position: 'absolute', left: '-9999px' }}
|
||||||
|
onPlay={handleAudioOnPlay}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
></audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default React.memo(AudioPlayer);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.toolbar-wrapper {
|
||||||
|
padding: 0 24px;
|
||||||
|
color: rgba(255, 255, 255, 65%);
|
||||||
|
font-size: 16px;
|
||||||
|
background-color: rgba(0, 0, 0, 10%);
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-wrapper .anticon {
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-wrapper .anticon[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-wrapper .anticon:hover {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import fallbackImg from '@/assets/images/img_fallback.png';
|
||||||
|
import {
|
||||||
|
DownloadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
RotateLeftOutlined,
|
||||||
|
RotateRightOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
ZoomInOutlined,
|
||||||
|
ZoomOutOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Image as AntImage, ImageProps, Space } from 'antd';
|
||||||
|
import { round } from 'lodash';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
const AutoImage: React.FC<
|
||||||
|
ImageProps & {
|
||||||
|
height: number | string;
|
||||||
|
width?: number | string;
|
||||||
|
autoSize?: boolean;
|
||||||
|
preview?: boolean;
|
||||||
|
onLoad?: () => void;
|
||||||
|
}
|
||||||
|
> = (props) => {
|
||||||
|
const { height = 100, width: w, autoSize, preview = true, ...rest } = props;
|
||||||
|
const [width, setWidth] = useState(w || 0);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getImgRatio = useCallback((url: string): Promise<{ ratio: number }> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({ ratio: round(img.width / img.height, 2) });
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve({ ratio: 1 });
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOnLoad = useCallback(async () => {
|
||||||
|
if (autoSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { ratio } = await getImgRatio(props.src || '');
|
||||||
|
|
||||||
|
if (typeof height === 'number') {
|
||||||
|
setWidth(height * ratio);
|
||||||
|
} else {
|
||||||
|
throw new Error('Height must be a number');
|
||||||
|
}
|
||||||
|
}, [getImgRatio, height, props.src]);
|
||||||
|
|
||||||
|
const onDownload = useCallback(() => {
|
||||||
|
const url = props.src || '';
|
||||||
|
const filename = Date.now() + '';
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}, [props.src]);
|
||||||
|
|
||||||
|
const handleImgLoad = useCallback(() => {
|
||||||
|
props.onLoad?.();
|
||||||
|
setIsError(false);
|
||||||
|
}, [props.onLoad]);
|
||||||
|
|
||||||
|
const handleOnError = useCallback((e: any) => {
|
||||||
|
setIsError(true);
|
||||||
|
e.target.src = fallbackImg;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleOnLoad();
|
||||||
|
}, [handleOnLoad]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWidth(w || 0);
|
||||||
|
}, [w]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntImage
|
||||||
|
{...rest}
|
||||||
|
height={isError ? 'auto' : height}
|
||||||
|
width={isError ? '100%' : width}
|
||||||
|
onError={handleOnError}
|
||||||
|
onLoad={handleImgLoad}
|
||||||
|
fallback={fallbackImg}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
preview={
|
||||||
|
preview &&
|
||||||
|
!isError && {
|
||||||
|
mask: <EyeOutlined />,
|
||||||
|
actionsRender: (
|
||||||
|
_,
|
||||||
|
{
|
||||||
|
transform: { scale },
|
||||||
|
actions: {
|
||||||
|
onFlipY,
|
||||||
|
onFlipX,
|
||||||
|
onRotateLeft,
|
||||||
|
onRotateRight,
|
||||||
|
onZoomOut,
|
||||||
|
onZoomIn,
|
||||||
|
onReset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) => (
|
||||||
|
<Space size={12} className="toolbar-wrapper">
|
||||||
|
<DownloadOutlined onClick={onDownload} />
|
||||||
|
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||||
|
<SwapOutlined onClick={onFlipX} />
|
||||||
|
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||||
|
<RotateRightOutlined onClick={onRotateRight} />
|
||||||
|
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||||
|
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||||
|
<UndoOutlined onClick={onReset} />
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoImage;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.img-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-wrapper .auto-image {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-wrapper .progress-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-square {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-square-bg {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-square-fg {
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: square;
|
||||||
|
stroke-dasharray: 400;
|
||||||
|
stroke-dashoffset: 400;
|
||||||
|
transition: stroke-dashoffset 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
position: absolute;
|
||||||
|
color: black;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
.thumb-img {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background-color: var(--ant-geekblue-1);
|
||||||
|
z-index: 10;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-progress-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 30%);
|
||||||
|
|
||||||
|
.ant-progress-text {
|
||||||
|
color: var(--color-white-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
display: flex;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: var(--font-size-middle);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-white-1);
|
||||||
|
display: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.ant-image .ant-image-mask {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity var(--ant-motion-duration-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-image {
|
||||||
|
// height: 100%;
|
||||||
|
width: inherit;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.auto-bg-color {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
filter: blur(100px);
|
||||||
|
backdrop-filter: blur(100px);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-img {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
.img {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-image {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { Progress, ProgressProps, Spin } from 'antd';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { round } from 'lodash';
|
||||||
|
import ResizeObserver from 'rc-resize-observer';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import AutoImage from './index';
|
||||||
|
import './single-image.less';
|
||||||
|
interface SingleImageProps {
|
||||||
|
loading?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
progress?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
dataUrl: string;
|
||||||
|
label?: React.ReactNode;
|
||||||
|
uid: number;
|
||||||
|
preview?: boolean;
|
||||||
|
autoSize?: boolean;
|
||||||
|
onDelete: (uid: number) => void;
|
||||||
|
onClick?: (item: any) => void;
|
||||||
|
autoBgColor?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
loadingSize?: ProgressProps['size'];
|
||||||
|
progressType?: 'line' | 'circle' | 'dashboard';
|
||||||
|
progressColor?: string;
|
||||||
|
progressWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SingleImage: React.FC<SingleImageProps> = (props) => {
|
||||||
|
const {
|
||||||
|
editable,
|
||||||
|
onDelete,
|
||||||
|
onClick,
|
||||||
|
autoSize,
|
||||||
|
uid,
|
||||||
|
loading,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
progress,
|
||||||
|
maxHeight,
|
||||||
|
maxWidth,
|
||||||
|
dataUrl = '',
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
autoBgColor,
|
||||||
|
preview = true,
|
||||||
|
loadingSize = 'default'
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const imgWrapper = React.useRef<HTMLSpanElement>(null);
|
||||||
|
const [imgSize, setImgSize] = React.useState({
|
||||||
|
width: width,
|
||||||
|
height: height
|
||||||
|
});
|
||||||
|
|
||||||
|
const thumImgWrapStyle = React.useMemo(() => {
|
||||||
|
return loading ? { width: '100%', height: '100%' } : {};
|
||||||
|
}, [loading, imgSize]);
|
||||||
|
|
||||||
|
const handleOnClick = useCallback(() => {
|
||||||
|
onClick?.(props);
|
||||||
|
}, [onClick, props]);
|
||||||
|
|
||||||
|
const handleResize = useCallback(
|
||||||
|
(size: { width: number; height: number }) => {
|
||||||
|
if (!autoSize || !size.width || !size.height) return;
|
||||||
|
|
||||||
|
const { width: containerWidth, height: containerHeight } = size;
|
||||||
|
const { width: originalWidth, height: originalHeight } = props;
|
||||||
|
|
||||||
|
if (!originalWidth || !originalHeight) return;
|
||||||
|
|
||||||
|
const widthRatio = containerWidth / originalWidth;
|
||||||
|
const heightRatio = containerHeight / originalHeight;
|
||||||
|
|
||||||
|
const scale = Math.min(widthRatio, heightRatio, 1);
|
||||||
|
|
||||||
|
const newWidth = originalWidth * scale;
|
||||||
|
const newHeight = originalHeight * scale;
|
||||||
|
|
||||||
|
if (newWidth === imgSize.width && newHeight === imgSize.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImgSize({
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[autoSize, props.width, props.height]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOnLoad = React.useCallback(async () => {}, []);
|
||||||
|
|
||||||
|
const handleOnDelete = (uid: number, e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(uid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProgress = () => {
|
||||||
|
<Progress
|
||||||
|
percent={round(progress, 0)}
|
||||||
|
type="dashboard"
|
||||||
|
size={loadingSize}
|
||||||
|
steps={{ count: 50, gap: 2 }}
|
||||||
|
format={() => <span className="font-size-20">{round(progress, 0)}%</span>}
|
||||||
|
railColor="var(--ant-color-fill-secondary)"
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizeObserver onResize={handleResize}>
|
||||||
|
<div
|
||||||
|
style={{ ...style }}
|
||||||
|
key={uid}
|
||||||
|
className={classNames('single-image', {
|
||||||
|
'auto-bg-color': autoBgColor,
|
||||||
|
'auto-size': autoSize,
|
||||||
|
loading: loading
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{autoBgColor && (
|
||||||
|
<div
|
||||||
|
className="mask"
|
||||||
|
style={{
|
||||||
|
background: `url(${dataUrl}) center center / cover no-repeat`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="thumb-img"
|
||||||
|
style={{ ...thumImgWrapStyle }}
|
||||||
|
ref={imgWrapper}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{label && <div className="label">{label}</div>}
|
||||||
|
{loading ? (
|
||||||
|
<span
|
||||||
|
className="progress-wrap"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
border: '1px solid var(--ant-color-split)',
|
||||||
|
borderRadius: 'var(--border-radius-base)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '10px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={handleOnClick}
|
||||||
|
className="img"
|
||||||
|
style={{
|
||||||
|
maxHeight: `min(${maxHeight}, 100%)`,
|
||||||
|
maxWidth: `min(${maxWidth}, 100%)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoImage
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
preview={preview}
|
||||||
|
autoSize={autoSize}
|
||||||
|
src={dataUrl}
|
||||||
|
width={imgSize.width || '100%'}
|
||||||
|
height={imgSize.height || 100}
|
||||||
|
onLoad={handleOnLoad}
|
||||||
|
/>
|
||||||
|
{progress && progress < 100 && (
|
||||||
|
<span className="small-progress-wrap">
|
||||||
|
<Progress
|
||||||
|
percent={round(progress, 0)}
|
||||||
|
type="dashboard"
|
||||||
|
size="small"
|
||||||
|
steps={{ count: 25, gap: 3 }}
|
||||||
|
format={() => (
|
||||||
|
<span className="font-size-12">
|
||||||
|
{round(progress, 0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
strokeColor="var(--color-white-secondary)"
|
||||||
|
railColor="var(--ant-color-fill-secondary)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<span className="del" onClick={(e) => handleOnDelete(uid, e)}>
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ResizeObserver>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SingleImage;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { Tag, Tooltip, type TagProps } from 'antd';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { TooltipOverlayScroller } from '../overlay-scroller';
|
||||||
|
|
||||||
|
// type TagProps = React.ComponentProps<typeof Tag>;
|
||||||
|
|
||||||
|
interface AutoTooltipProps extends Omit<TagProps, 'title'> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
maxWidth?: number | string;
|
||||||
|
minWidth?: number | string;
|
||||||
|
color?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
ghost?: boolean;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
showTitle?: boolean;
|
||||||
|
closable?: boolean;
|
||||||
|
radius?: number | string;
|
||||||
|
filled?: boolean;
|
||||||
|
tooltipProps?: React.ComponentProps<typeof Tooltip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledTag = styled(Tag)`
|
||||||
|
margin: 0;
|
||||||
|
&.tag-filled {
|
||||||
|
border: none;
|
||||||
|
background-color: var(--ant-color-fill-secondary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AutoTooltip: React.FC<AutoTooltipProps> = ({
|
||||||
|
children,
|
||||||
|
maxWidth = '100%',
|
||||||
|
minWidth,
|
||||||
|
ghost = false,
|
||||||
|
title,
|
||||||
|
showTitle = false,
|
||||||
|
tooltipProps,
|
||||||
|
radius = 12,
|
||||||
|
filled = false,
|
||||||
|
...tagProps
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||||
|
const resizeObserver = useRef<ResizeObserver | null>(null);
|
||||||
|
|
||||||
|
const checkOverflow = useCallback(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
const { scrollWidth, clientWidth } = contentRef.current;
|
||||||
|
setIsOverflowing(scrollWidth > clientWidth);
|
||||||
|
}
|
||||||
|
}, [contentRef.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = contentRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
resizeObserver.current?.disconnect();
|
||||||
|
resizeObserver.current = new ResizeObserver(() => {
|
||||||
|
checkOverflow();
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.current?.observe(element);
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkOverflow();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.current?.disconnect();
|
||||||
|
resizeObserver.current = null;
|
||||||
|
};
|
||||||
|
}, [checkOverflow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const debouncedCheckOverflow = throttle(checkOverflow, 200);
|
||||||
|
window.addEventListener('resize', debouncedCheckOverflow);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', debouncedCheckOverflow);
|
||||||
|
debouncedCheckOverflow.cancel();
|
||||||
|
};
|
||||||
|
}, [checkOverflow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkOverflow();
|
||||||
|
}, [children, checkOverflow]);
|
||||||
|
|
||||||
|
const tagStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
maxWidth,
|
||||||
|
minWidth,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap' as const,
|
||||||
|
...tagProps.style
|
||||||
|
}),
|
||||||
|
[maxWidth, tagProps.style]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipOverlayScroller
|
||||||
|
toolTipProps={{
|
||||||
|
...tooltipProps,
|
||||||
|
destroyOnHidden: false
|
||||||
|
}}
|
||||||
|
title={isOverflowing || showTitle ? title || children : false}
|
||||||
|
>
|
||||||
|
{ghost ? (
|
||||||
|
<div ref={contentRef} style={tagStyle} data-overflow={isOverflowing}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StyledTag
|
||||||
|
{...tagProps}
|
||||||
|
variant="outlined"
|
||||||
|
className={`${tagProps.className || ''} ${filled ? 'tag-filled' : ''}`}
|
||||||
|
ref={contentRef}
|
||||||
|
style={{
|
||||||
|
paddingInline: tagProps.closable ? '8px 22px' : 8,
|
||||||
|
borderRadius: radius,
|
||||||
|
...tagStyle
|
||||||
|
}}
|
||||||
|
closeIcon={
|
||||||
|
tagProps.closable ? (
|
||||||
|
<CloseOutlined
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StyledTag>
|
||||||
|
)}
|
||||||
|
</TooltipOverlayScroller>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoTooltip;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { OverlayScroller } from '@/components/overlay-scroller';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TitleTipProps {
|
||||||
|
isOverflowing: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
title: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TitleTip: React.FC<TitleTipProps> = (props) => {
|
||||||
|
const { isOverflowing, showTitle, title, children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayScroller maxHeight={200}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
maxWidth: 'var(--width-tooltip-max)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOverflowing || showTitle ? title || children : ''}
|
||||||
|
</div>
|
||||||
|
</OverlayScroller>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(TitleTip);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import bibtexParse from '@orcid/bibtex-parse-js';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
@inproceedings{Lysenko:2010:GMC:1839778.1839781,\
|
||||||
|
author = {Lysenko, Mikola and Nelaturi, Saigopal and Shapiro, Vadim},\
|
||||||
|
title = {Group morphology with convolution algebras},\
|
||||||
|
booktitle = {Proceedings of the 14th ACM Symposium on Solid and Physical Modeling},\
|
||||||
|
series = {SPM '10},\
|
||||||
|
year = {2010},\
|
||||||
|
isbn = {978-1-60558-984-8},\
|
||||||
|
location = {Haifa, Israel},\
|
||||||
|
pages = {11--22},\
|
||||||
|
numpages = {12},\
|
||||||
|
url = {http://doi.acm.org/10.1145/1839778.1839781},\
|
||||||
|
doi = {10.1145/1839778.1839781},\
|
||||||
|
acmid = {1839781},\
|
||||||
|
publisher = {ACM},\
|
||||||
|
address = {New York, NY, USA},\
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BibTeXViewer: React.FC<{ data: string }> = ({ data }) => {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dataList = bibtexParse.toJSON(data);
|
||||||
|
return (
|
||||||
|
<ol>
|
||||||
|
{dataList.map((item: any, index: number) => (
|
||||||
|
<li key={index} style={{ lineHeight: 2 }}>
|
||||||
|
<Typography.Link href={item.entryTags?.url} target="_blank">
|
||||||
|
{item.entryTags?.title}.{' '}
|
||||||
|
</Typography.Link>
|
||||||
|
<Typography.Text>{item.entryTags?.author}. </Typography.Text>
|
||||||
|
<Typography.Text>[{item.entryTags?.year}] </Typography.Text>
|
||||||
|
{item.entryTags?.journal && (
|
||||||
|
<Typography.Text>.({item.entryTags?.journal})</Typography.Text>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BibTeXViewer;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { DoubleRightOutlined } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
interface MoreButtonProps {
|
||||||
|
show: boolean;
|
||||||
|
loadMore: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MoreWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-block: 16px;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
&.loading {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MoreButton: React.FC<MoreButtonProps> = (props) => {
|
||||||
|
const { show, loading, loadMore } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{show ? (
|
||||||
|
<MoreWrapper className={loading ? 'loading' : ''}>
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
size="middle"
|
||||||
|
type="text"
|
||||||
|
icon={<DoubleRightOutlined rotate={90} />}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: 'common.button.more' })}
|
||||||
|
</Button>
|
||||||
|
</MoreWrapper>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoreButton;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
background-color: var(--ant-color-bg-container);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--ant-color-border);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardWrapper = (props: any) => {
|
||||||
|
const { children, style } = props;
|
||||||
|
return <Wrapper style={{ ...style }}>{children}</Wrapper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardWrapper;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const SimpleCardItemWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ css, token }) => ({
|
||||||
|
wrapper: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: ${token.colorBgContainer};
|
||||||
|
border-radius: ${token.borderRadius}px;
|
||||||
|
padding: ${token.padding}px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${token.padding}px;
|
||||||
|
&.bordered {
|
||||||
|
border: 1px solid ${token.colorBorder};
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: ${token.fontSize}px;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: ${token.fontSize}px;
|
||||||
|
color: ${token.colorTextSecondary};
|
||||||
|
gap: 8px;
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
&.roundRect {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
&.circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}));
|
||||||
|
export const SimpleCardItem: React.FC<{
|
||||||
|
title?: string;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
bordered?: boolean;
|
||||||
|
color?: string;
|
||||||
|
iconType?: string;
|
||||||
|
}> = (props) => {
|
||||||
|
const { styles, cx } = useStyles();
|
||||||
|
|
||||||
|
const { title, content, style, bordered, iconType, color } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx({ bordered: bordered }, styles.wrapper)} style={style}>
|
||||||
|
<div className="title">{title}</div>
|
||||||
|
<div className="content">
|
||||||
|
<span
|
||||||
|
className={cx([iconType], 'icon')}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color || 'transparent'
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
<span>{content}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SimpleCard: React.FC<{
|
||||||
|
dataList: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
iconType: string;
|
||||||
|
}[];
|
||||||
|
height?: string | number;
|
||||||
|
bordered?: boolean;
|
||||||
|
}> = (props) => {
|
||||||
|
const { dataList, bordered } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCardItemWrapper style={{ height: props.height || '100%' }}>
|
||||||
|
{dataList.map((item, index) => (
|
||||||
|
<SimpleCardItem
|
||||||
|
key={index}
|
||||||
|
title={item.label}
|
||||||
|
content={item.value}
|
||||||
|
bordered={bordered}
|
||||||
|
color={item.color}
|
||||||
|
iconType={item.iconType}
|
||||||
|
></SimpleCardItem>
|
||||||
|
))}
|
||||||
|
</SimpleCardItemWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Button } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CheckButtonsProps {
|
||||||
|
options: Global.BaseOption<string | number>[];
|
||||||
|
onChange: (value: string | number) => void;
|
||||||
|
cancelable?: boolean;
|
||||||
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
type?: 'text' | 'primary' | 'default' | 'dashed' | 'link' | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CheckButtons: React.FC<CheckButtonsProps> = (props) => {
|
||||||
|
const [type, setType] = React.useState(props.type || 'text');
|
||||||
|
const [active, setActive] = React.useState<string | number | null>(null);
|
||||||
|
const handleChange = (value: string | number) => {
|
||||||
|
props.onChange(value);
|
||||||
|
if (props.cancelable && active === value) {
|
||||||
|
setActive(null);
|
||||||
|
} else {
|
||||||
|
setActive(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex-center gap-6">
|
||||||
|
{props.options?.map?.((option, index) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={props.size}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleChange(option.value)}
|
||||||
|
variant="filled"
|
||||||
|
color={active === option.value ? 'default' : undefined}
|
||||||
|
type={type}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(CheckButtons);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface CollapseProps {
|
||||||
|
open: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
duration?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Collapse({
|
||||||
|
open,
|
||||||
|
children,
|
||||||
|
minHeight = 0,
|
||||||
|
duration = 200
|
||||||
|
}: CollapseProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState<number | 'auto'>(minHeight);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
const h = el.scrollHeight;
|
||||||
|
setHeight(h);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setHeight('auto');
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
const h = el.scrollHeight;
|
||||||
|
setHeight(h);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setHeight(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [open, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: `height ${duration}ms ease`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import IconFont from '@/components/icon-font';
|
||||||
|
import { Card } from 'antd';
|
||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const CardStyled = styled(Card)`
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: none;
|
||||||
|
&.isOpen {
|
||||||
|
.ant-card-head {
|
||||||
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
|
border-radius: var(--ant-border-radius) var(--ant-border-radius) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ant-card-head {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--ant-color-fill-quaternary);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--ant-border-radius);
|
||||||
|
padding: 0 16px;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--ant-color-fill-secondary);
|
||||||
|
.del-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
.ant-card-head {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--ant-color-fill-quaternary) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ css, token }) => {
|
||||||
|
return {
|
||||||
|
title: css`
|
||||||
|
font-weight: 400;
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
`,
|
||||||
|
expandIcon: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
`,
|
||||||
|
subtitle: css`
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${token.colorTextSecondary};
|
||||||
|
`,
|
||||||
|
content: css`
|
||||||
|
padding-top: 8px;
|
||||||
|
`,
|
||||||
|
left: css`
|
||||||
|
flex: 1;
|
||||||
|
`,
|
||||||
|
right: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
.del-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CollapsibleContainerProps {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
deleteBtn?: React.ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
collapsible?: boolean;
|
||||||
|
showExpandIcon?: boolean;
|
||||||
|
onToggle?: (open: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: 'outlined' | 'borderless' | undefined;
|
||||||
|
iconPlacement?: 'left' | 'right';
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
styles?: {
|
||||||
|
root?: React.CSSProperties;
|
||||||
|
body?: React.CSSProperties;
|
||||||
|
header?: React.CSSProperties;
|
||||||
|
content?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollapsibleContainer({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
right,
|
||||||
|
deleteBtn,
|
||||||
|
defaultOpen = true,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
disabled = false,
|
||||||
|
showExpandIcon = true,
|
||||||
|
variant = 'borderless',
|
||||||
|
className = '',
|
||||||
|
collapsible,
|
||||||
|
iconPlacement = 'left',
|
||||||
|
styles: cardStyles,
|
||||||
|
children
|
||||||
|
}: CollapsibleContainerProps) {
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const isControlled = typeof open === 'boolean';
|
||||||
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||||
|
const isOpen = collapsible
|
||||||
|
? isControlled
|
||||||
|
? (open as boolean)
|
||||||
|
: internalOpen
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (disabled || !collapsible) return;
|
||||||
|
const next = !isOpen;
|
||||||
|
if (!isControlled) setInternalOpen(next);
|
||||||
|
onToggle?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState(isOpen ? 'auto' : '0px');
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (showExpandIcon) {
|
||||||
|
return (
|
||||||
|
<IconFont
|
||||||
|
rotate={isOpen ? 180 : 0}
|
||||||
|
type="icon-down"
|
||||||
|
style={{
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 12
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTitle = () => {
|
||||||
|
if (!collapsible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.title} onClick={toggle}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<div className={styles.expandIcon}>
|
||||||
|
{iconPlacement === 'left' && renderIcon()}
|
||||||
|
{title && <div>{title}</div>}
|
||||||
|
</div>
|
||||||
|
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.right}>
|
||||||
|
{right && <span>{right}</span>}
|
||||||
|
{deleteBtn && <span className="del-btn">{deleteBtn}</span>}
|
||||||
|
{iconPlacement === 'right' && renderIcon()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collapsible) {
|
||||||
|
setHeight('auto');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
const scrollHeight = contentRef.current?.scrollHeight || 0;
|
||||||
|
setHeight(scrollHeight + 'px');
|
||||||
|
const timer = setTimeout(() => setHeight('auto'), 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
const scrollHeight = contentRef.current?.scrollHeight || 0;
|
||||||
|
setHeight(scrollHeight + 'px');
|
||||||
|
requestAnimationFrame(() => setHeight('0px'));
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}, [isOpen, collapsible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardStyled
|
||||||
|
className={classNames(className, { collapsible, disabled, isOpen })}
|
||||||
|
variant={variant}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
...cardStyles?.root
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
...cardStyles?.body
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
...cardStyles?.header
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={renderTitle()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
style={{
|
||||||
|
height: height,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ paddingTop: 8, ...cardStyles?.content }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</CardStyled>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.content-wrapper {
|
||||||
|
.content {
|
||||||
|
padding-block-start: 0;
|
||||||
|
padding-block-end: 32px;
|
||||||
|
padding-inline: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-size-large);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 32px;
|
||||||
|
padding-block-start: 8px;
|
||||||
|
padding-block-end: 16px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
padding-inline-end: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
const ContentWrapper: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: React.ReactNode;
|
||||||
|
titleStyle?: React.CSSProperties;
|
||||||
|
contentStyle?: React.CSSProperties;
|
||||||
|
}> = ({ children, title = false, titleStyle, contentStyle }) => {
|
||||||
|
return (
|
||||||
|
<div className="content-wrapper">
|
||||||
|
{title && (
|
||||||
|
<div className="title" style={{ ...titleStyle }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="content" style={{ ...contentStyle }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentWrapper;
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { CheckCircleFilled, CopyOutlined } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, message, Tooltip } from 'antd';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import AutoTooltip from '../auto-tooltip';
|
||||||
|
|
||||||
|
type CopyButtonProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
text: string;
|
||||||
|
fontSize?: string;
|
||||||
|
type?: 'text' | 'primary' | 'dashed' | 'link' | 'default';
|
||||||
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
shape?: 'circle' | 'round' | 'default';
|
||||||
|
tips?: string;
|
||||||
|
placement?:
|
||||||
|
| 'top'
|
||||||
|
| 'left'
|
||||||
|
| 'right'
|
||||||
|
| 'bottom'
|
||||||
|
| 'topLeft'
|
||||||
|
| 'topRight'
|
||||||
|
| 'bottomLeft'
|
||||||
|
| 'bottomRight';
|
||||||
|
btnStyle?: React.CSSProperties;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
|
children,
|
||||||
|
tips,
|
||||||
|
text,
|
||||||
|
type = 'text',
|
||||||
|
shape = 'default',
|
||||||
|
fontSize = '14px',
|
||||||
|
style,
|
||||||
|
btnStyle,
|
||||||
|
placement,
|
||||||
|
size = 'small'
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<number>();
|
||||||
|
|
||||||
|
const resetCopied = () => {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modern clipboard API (works in secure contexts: HTTPS or localhost)
|
||||||
|
*/
|
||||||
|
const asyncCopy = async (value: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: execCommand with copy event listener
|
||||||
|
* More reliable than textarea selection method
|
||||||
|
*/
|
||||||
|
const execCopy = (value: string): boolean => {
|
||||||
|
let copySuccess = false;
|
||||||
|
|
||||||
|
const onCopy = (event: ClipboardEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
event.clipboardData?.clearData();
|
||||||
|
event.clipboardData?.setData('text/plain', value);
|
||||||
|
copySuccess = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.addEventListener('copy', onCopy, { capture: true });
|
||||||
|
document.execCommand('copy');
|
||||||
|
return copySuccess;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener('copy', onCopy, { capture: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
// Try modern clipboard API first
|
||||||
|
if (await asyncCopy(text)) {
|
||||||
|
setCopied(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to execCommand method
|
||||||
|
if (execCopy(text)) {
|
||||||
|
setCopied(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both methods failed
|
||||||
|
throw new Error('Copy failed');
|
||||||
|
} catch (error) {
|
||||||
|
message.error(intl.formatMessage({ id: 'common.copy.fail' }) as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tipTitle = useMemo(() => {
|
||||||
|
if (copied) {
|
||||||
|
return intl.formatMessage({ id: 'common.button.copied' });
|
||||||
|
}
|
||||||
|
return tips ?? intl.formatMessage({ id: 'common.button.copy' });
|
||||||
|
}, [copied, tips, intl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetCopied();
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-center gap-4" style={{ minWidth: 16 }}>
|
||||||
|
{children && (
|
||||||
|
<AutoTooltip minWidth={20} ghost>
|
||||||
|
{children}
|
||||||
|
</AutoTooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title={tipTitle} placement={placement}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
className="copy-button"
|
||||||
|
type={type}
|
||||||
|
shape={shape}
|
||||||
|
size={size}
|
||||||
|
onClick={handleCopy}
|
||||||
|
style={{ ...btnStyle }}
|
||||||
|
icon={
|
||||||
|
copied ? (
|
||||||
|
<CheckCircleFilled
|
||||||
|
style={{
|
||||||
|
color: 'var(--ant-color-success)',
|
||||||
|
fontSize
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CopyOutlined style={{ fontSize, ...style }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import useBodyScroll from '@/hooks/use-body-scroll';
|
||||||
|
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
type ModalFuncProps
|
||||||
|
} from 'antd';
|
||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const useStyles = createStyles(({ css }) => ({
|
||||||
|
'delete-modal-content': css`
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--font-size-middle);
|
||||||
|
.anticon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: var(--ant-color-warning);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: var(--font-weight-500);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
content: css`
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-left: 30px;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
white-space: pre-line;
|
||||||
|
word-break: break-all;
|
||||||
|
span {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
display: flex;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CheckboxWrapper = styled.div`
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-left: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
.check-text {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ant-color-warning);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface DataOptions {
|
||||||
|
content?: string;
|
||||||
|
selection?: boolean;
|
||||||
|
name?: string;
|
||||||
|
okText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
title?: string;
|
||||||
|
operation: string;
|
||||||
|
checkConfig?: {
|
||||||
|
checkText: string;
|
||||||
|
defautlChecked: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Configuration {
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// default need to pass content and operation
|
||||||
|
const DeleteModal = forwardRef((props, ref) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [configuration, setConfiguration] = useState<Configuration>({
|
||||||
|
checked: false
|
||||||
|
});
|
||||||
|
const [delLoading, setDelLoading] = useState(false);
|
||||||
|
const [config, setConfig] = useState<ModalFuncProps & DataOptions>({} as any);
|
||||||
|
|
||||||
|
const show = (data: ModalFuncProps & DataOptions) => {
|
||||||
|
saveScrollHeight();
|
||||||
|
setConfig(data);
|
||||||
|
setConfiguration({
|
||||||
|
checked: data.checkConfig?.defautlChecked || false
|
||||||
|
});
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
setVisible(false);
|
||||||
|
restoreScrollHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setVisible(false);
|
||||||
|
config.onCancel?.();
|
||||||
|
restoreScrollHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
try {
|
||||||
|
setDelLoading(true);
|
||||||
|
const res = await config.onOk?.();
|
||||||
|
const isArray = Array.isArray(res);
|
||||||
|
if (isArray) {
|
||||||
|
const allSuccess = res.every(
|
||||||
|
(item: any) => item?.status === 'fulfilled'
|
||||||
|
);
|
||||||
|
if (allSuccess) {
|
||||||
|
message.success(intl.formatMessage({ id: 'common.message.success' }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.success(intl.formatMessage({ id: 'common.message.success' }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle error if needed
|
||||||
|
} finally {
|
||||||
|
setVisible(false);
|
||||||
|
setDelLoading(false);
|
||||||
|
restoreScrollHeight();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
configuration
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
style={{
|
||||||
|
top: '20%'
|
||||||
|
}}
|
||||||
|
open={visible}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
destroyOnHidden={false}
|
||||||
|
closeIcon={false}
|
||||||
|
maskClosable={false}
|
||||||
|
keyboard={false}
|
||||||
|
width={460}
|
||||||
|
styles={{
|
||||||
|
footer: {
|
||||||
|
marginTop: '20px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<Space size={20}>
|
||||||
|
<Button onClick={handleCancel} size="middle">
|
||||||
|
{config.cancelText
|
||||||
|
? intl.formatMessage({ id: config.cancelText })
|
||||||
|
: intl.formatMessage({ id: 'common.button.cancel' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleOk}
|
||||||
|
size="middle"
|
||||||
|
danger
|
||||||
|
loading={delLoading}
|
||||||
|
>
|
||||||
|
{config.okText
|
||||||
|
? intl.formatMessage({ id: config.okText })
|
||||||
|
: intl.formatMessage({ id: 'common.button.delete' })}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles['delete-modal-content']}>
|
||||||
|
<span className="title">
|
||||||
|
<ExclamationCircleFilled />
|
||||||
|
<span>
|
||||||
|
{config.title
|
||||||
|
? intl.formatMessage({ id: config.title })
|
||||||
|
: intl.formatMessage({ id: 'common.title.delete.confirm' })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles['content']}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: config.content
|
||||||
|
? intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: config.operation || ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: intl.formatMessage({ id: config.content }),
|
||||||
|
name: config.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{config.checkConfig && (
|
||||||
|
<CheckboxWrapper>
|
||||||
|
<Checkbox
|
||||||
|
checked={configuration.checked}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfiguration({
|
||||||
|
checked: e.target.checked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="check-text">
|
||||||
|
{intl.formatMessage({ id: config.checkConfig?.checkText })}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
|
</CheckboxWrapper>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DeleteModal;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.divider-line {
|
||||||
|
height: 8px;
|
||||||
|
// border-radius: 4px;
|
||||||
|
width: 100%;
|
||||||
|
// background-color: var(--color-fill-1);
|
||||||
|
z-index: 100;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9px;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
// border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import styles from './index.less';
|
||||||
|
const DividerLine: React.FC = () => {
|
||||||
|
return <div className={styles['divider-line']}></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DividerLine;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Dropdown, DropDownProps } from 'antd';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
const DropDownActions: React.FC<DropDownProps> = (props) => {
|
||||||
|
const {
|
||||||
|
menu,
|
||||||
|
trigger = ['hover'],
|
||||||
|
placement = 'bottomRight',
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
return menu?.items?.map((item: any) => ({
|
||||||
|
..._.omit(item, 'locale'),
|
||||||
|
icon: item.icon
|
||||||
|
? React.cloneElement(item.icon, { style: { fontSize: 14 } })
|
||||||
|
: null,
|
||||||
|
label: item.locale ? intl.formatMessage({ id: item.label }) : item.label
|
||||||
|
}));
|
||||||
|
}, [menu?.items, intl]);
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: items,
|
||||||
|
onClick: menu?.onClick
|
||||||
|
}}
|
||||||
|
trigger={trigger}
|
||||||
|
placement={placement}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropDownActions;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.dropdown-button.middle {
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { MoreOutlined } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Dropdown, Space, Tooltip, type MenuProps } from 'antd';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
type Trigger = 'click' | 'hover';
|
||||||
|
interface DropdownButtonsProps {
|
||||||
|
items: MenuProps['items'];
|
||||||
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
trigger?: Trigger[];
|
||||||
|
showText?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: 'filled' | 'outlined';
|
||||||
|
color?: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
onSelect: (val: any, item?: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--ant-color-bg-elevated);
|
||||||
|
padding: 5px;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
box-shadow: var(--ant-box-shadow-secondary);
|
||||||
|
min-width: 160px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DropdownButtons: React.FC<
|
||||||
|
DropdownButtonsProps & { items: MenuProps['items'] }
|
||||||
|
> = ({
|
||||||
|
items,
|
||||||
|
size = 'middle',
|
||||||
|
trigger = ['hover'],
|
||||||
|
showText,
|
||||||
|
disabled,
|
||||||
|
variant,
|
||||||
|
color,
|
||||||
|
extra,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const headItem = _.head(items);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleMenuClick = (item: any) => {
|
||||||
|
const selectItem = _.find(items, { key: item.key });
|
||||||
|
onSelect(item.key, selectItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleButtonClick = (e: any) => {
|
||||||
|
const headItem = _.head(items);
|
||||||
|
onSelect(headItem.key, headItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!items?.length) {
|
||||||
|
return <span></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items?.length === 1 ? (
|
||||||
|
<Tooltip title={intl.formatMessage({ id: headItem?.label })}>
|
||||||
|
<Button
|
||||||
|
className={classNames('dropdown-button', size)}
|
||||||
|
icon={headItem?.icon}
|
||||||
|
size={size}
|
||||||
|
{...headItem?.props}
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
></Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Space.Compact>
|
||||||
|
<>
|
||||||
|
{showText ? (
|
||||||
|
<Button
|
||||||
|
{...headItem?.props}
|
||||||
|
disabled={headItem?.disabled || disabled}
|
||||||
|
className={classNames('dropdown-button', size)}
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
size={size}
|
||||||
|
icon={headItem?.icon}
|
||||||
|
variant={variant}
|
||||||
|
color={color}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: headItem?.label
|
||||||
|
})}
|
||||||
|
{extra}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
title={intl.formatMessage({ id: headItem?.label })}
|
||||||
|
key="leftButton"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
{...headItem?.props}
|
||||||
|
className={classNames('dropdown-button', size)}
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
size={size}
|
||||||
|
icon={headItem?.icon}
|
||||||
|
disabled={headItem?.disabled}
|
||||||
|
></Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
<Dropdown
|
||||||
|
disabled={disabled}
|
||||||
|
trigger={trigger}
|
||||||
|
placement="bottomRight"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
minWidth: 160
|
||||||
|
},
|
||||||
|
itemIcon: {
|
||||||
|
fontSize: 14
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
menu={{
|
||||||
|
onClick: handleMenuClick,
|
||||||
|
items: _.tail(items).map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
...item.props,
|
||||||
|
label:
|
||||||
|
item.locale || item.locale === undefined
|
||||||
|
? intl.formatMessage({ id: item.label })
|
||||||
|
: item.label
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<MoreOutlined />}
|
||||||
|
size={size}
|
||||||
|
key="menu"
|
||||||
|
variant={variant}
|
||||||
|
color="default"
|
||||||
|
className={classNames('dropdown-button', size)}
|
||||||
|
></Button>
|
||||||
|
</Dropdown>
|
||||||
|
</Space.Compact>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownButtons;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import ComponentsMap from '@/components/seal-form/config/components';
|
||||||
|
import { SealFormItemProps } from '@/components/seal-form/types';
|
||||||
|
import { Form } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface FieldItemProps extends SealFormItemProps {
|
||||||
|
widget: keyof typeof ComponentsMap;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldItem: React.FC<FieldItemProps> = (props) => {
|
||||||
|
const { name, widget, required = [], ...rest } = props;
|
||||||
|
|
||||||
|
const Component = ComponentsMap[widget];
|
||||||
|
|
||||||
|
return <Form.Item name={name}></Form.Item>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldItem;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import ComponentsMap from '@/components/seal-form/config/components';
|
||||||
|
import { FormWidgetProps } from '../config/types';
|
||||||
|
|
||||||
|
const FormWidget: React.FC<
|
||||||
|
FormWidgetProps & {
|
||||||
|
onChange?: (data: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
> = ({
|
||||||
|
widget,
|
||||||
|
title: label,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
description,
|
||||||
|
enum: enumValues,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
status,
|
||||||
|
checked,
|
||||||
|
isInFormItems,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const Component = ComponentsMap[widget];
|
||||||
|
|
||||||
|
const optionList = enumValues?.map((item: string | number) => ({
|
||||||
|
label: item,
|
||||||
|
value: item
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Component ? (
|
||||||
|
<Component
|
||||||
|
{...{
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
description,
|
||||||
|
min,
|
||||||
|
max
|
||||||
|
}}
|
||||||
|
status={status}
|
||||||
|
isInFormItems={isInFormItems}
|
||||||
|
disabled={disabled}
|
||||||
|
options={options || optionList}
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormWidget;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Wrapper from '@/components/label-selector/wrapper';
|
||||||
|
import { MinusOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { statusType } from '../config/types';
|
||||||
|
import FormWidget from './form-widget';
|
||||||
|
|
||||||
|
const RowWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WidgetBox = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
interface ListMapProps {
|
||||||
|
minItems?: number;
|
||||||
|
dataList: any[];
|
||||||
|
label?: React.ReactNode;
|
||||||
|
btnText?: string;
|
||||||
|
requiredFields?: string[];
|
||||||
|
validateStatusList?: Record<string, statusType>[];
|
||||||
|
properties: Record<string, any>;
|
||||||
|
disabled?: boolean;
|
||||||
|
onAdd?: (data: any[]) => void;
|
||||||
|
onDelete?: (deletedItem: any, data: any[]) => void;
|
||||||
|
onChange?: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListItemProps {
|
||||||
|
schemaList: any[];
|
||||||
|
data: Record<string, any>;
|
||||||
|
disabled?: boolean;
|
||||||
|
validateStatus?: Record<string, statusType>;
|
||||||
|
onChange?: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItem: React.FC<ListItemProps> = ({
|
||||||
|
schemaList,
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
validateStatus,
|
||||||
|
disabled
|
||||||
|
}) => {
|
||||||
|
const handleValueChange = (name: string, target: any) => {
|
||||||
|
if (target?.target?.type === 'checkbox') {
|
||||||
|
const checked = target.target?.checked;
|
||||||
|
onChange?.({ [name]: checked });
|
||||||
|
} else {
|
||||||
|
const value = target?.target ? target.target.value : target;
|
||||||
|
onChange?.({ [name]: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{schemaList.map((schema: any) => (
|
||||||
|
<FormWidget
|
||||||
|
status={validateStatus?.[schema.name]}
|
||||||
|
widget={schema.type}
|
||||||
|
{...schema}
|
||||||
|
disabled={disabled || schema.readOnly}
|
||||||
|
key={schema.name}
|
||||||
|
value={data?.[schema.name]}
|
||||||
|
checked={data?.[schema.name]}
|
||||||
|
isInFormItems={false}
|
||||||
|
onChange={(target) => handleValueChange(schema.name, target)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListMap: React.FC<ListMapProps> = ({
|
||||||
|
dataList = [],
|
||||||
|
label,
|
||||||
|
btnText,
|
||||||
|
properties = {},
|
||||||
|
requiredFields = [],
|
||||||
|
minItems = 0,
|
||||||
|
validateStatusList = [],
|
||||||
|
disabled,
|
||||||
|
onAdd,
|
||||||
|
onDelete,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const [items, setItems] = React.useState(dataList || []);
|
||||||
|
|
||||||
|
const schemaList = useMemo(() => {
|
||||||
|
const list = Object.entries(properties).map(([key, value]) => ({
|
||||||
|
...value,
|
||||||
|
required: requiredFields.includes(key),
|
||||||
|
name: key
|
||||||
|
}));
|
||||||
|
return list;
|
||||||
|
}, [properties, requiredFields]);
|
||||||
|
|
||||||
|
const handleOnAdd = () => {
|
||||||
|
const keys = Object.keys(properties);
|
||||||
|
const newItems = [
|
||||||
|
...items,
|
||||||
|
{ ...keys.reduce((acc, key) => ({ ...acc, [key]: '' }), {}) }
|
||||||
|
];
|
||||||
|
setItems(newItems);
|
||||||
|
onAdd?.(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const deleteItem = items[index];
|
||||||
|
const newItems = items.filter((_, i) => i !== index);
|
||||||
|
setItems(newItems);
|
||||||
|
onDelete?.(deleteItem, newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (index: number, data: { [key: string]: any }) => {
|
||||||
|
const newItems = [...items];
|
||||||
|
newItems[index] = { ...newItems[index], ...data };
|
||||||
|
setItems(newItems);
|
||||||
|
onChange?.(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataList.length && minItems > 0) {
|
||||||
|
handleOnAdd();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(dataList);
|
||||||
|
}, [dataList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
label={label}
|
||||||
|
btnText={btnText}
|
||||||
|
onAdd={handleOnAdd}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<RowWrapper key={index}>
|
||||||
|
<WidgetBox>
|
||||||
|
<ListItem
|
||||||
|
schemaList={schemaList}
|
||||||
|
data={item}
|
||||||
|
validateStatus={validateStatusList?.[index]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(value) => handleItemChange(index, value)}
|
||||||
|
/>
|
||||||
|
</WidgetBox>
|
||||||
|
{!disabled && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="default"
|
||||||
|
shape="circle"
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
marginLeft: 10,
|
||||||
|
flex: 'none'
|
||||||
|
}}
|
||||||
|
icon={<MinusOutlined />}
|
||||||
|
onClick={() => handleDelete(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RowWrapper>
|
||||||
|
))}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListMap;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// refer to json schema
|
||||||
|
export interface FieldSchema {
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
title?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, FieldSchema>;
|
||||||
|
default?: any;
|
||||||
|
enum?: string[];
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
|
items?: FieldSchema[];
|
||||||
|
widget?: string;
|
||||||
|
min?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type statusType = 'error' | 'warning' | '' | undefined;
|
||||||
|
export interface FormWidgetProps {
|
||||||
|
status?: statusType;
|
||||||
|
isInFormItems?: boolean;
|
||||||
|
widget: 'Input' | 'Select' | 'Checkbox' | 'InputNumber';
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
options?: { label: string; value: string | number }[];
|
||||||
|
description?: string;
|
||||||
|
enum?: (string | number)[];
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
value?: any;
|
||||||
|
checked?: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { FieldSchema } from '../config/types';
|
||||||
|
|
||||||
|
interface ParsedField {
|
||||||
|
name: (string | number)[];
|
||||||
|
schema: FieldSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSchema = (
|
||||||
|
schema: Record<string, FieldSchema>,
|
||||||
|
parentName: (string | number)[] = []
|
||||||
|
): ParsedField[] => {
|
||||||
|
const fields: ParsedField[] = [];
|
||||||
|
|
||||||
|
Object.entries(schema).forEach(([key, fieldSchema]) => {
|
||||||
|
const currentName = [...parentName, key];
|
||||||
|
if (fieldSchema.type === 'object' && fieldSchema.properties) {
|
||||||
|
fields.push(...parseSchema(fieldSchema.properties, currentName));
|
||||||
|
} else if (fieldSchema.type === 'array' && fieldSchema.items) {
|
||||||
|
fields.push({ name: currentName, schema: fieldSchema });
|
||||||
|
} else {
|
||||||
|
fields.push({ name: currentName, schema: fieldSchema });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useParsedFields = (schema: Record<string, FieldSchema>) => {
|
||||||
|
return useMemo(() => parseSchema(schema), [schema]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useParsedFields;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { statusType } from '../config/types';
|
||||||
|
|
||||||
|
export default function useValidateFields(params: {
|
||||||
|
requiredFields?: string[];
|
||||||
|
setValidateStatusList: (statusList: { [key: string]: statusType }[]) => void;
|
||||||
|
}) {
|
||||||
|
const { requiredFields, setValidateStatusList } = params;
|
||||||
|
const validationEnabled = useRef(false);
|
||||||
|
|
||||||
|
const isEmptyValue = (value: any, key: string) => {
|
||||||
|
return !value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRule = (value: any, key: string) => {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listMapValidator = async (_: any, valueList: any) => {
|
||||||
|
if (!validationEnabled.current) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = new Set<string>();
|
||||||
|
const statusList: { [key: string]: statusType }[] = [];
|
||||||
|
|
||||||
|
(valueList || []).forEach((item: any, index: number) => {
|
||||||
|
const status: { [key: string]: statusType } = {};
|
||||||
|
Object.entries(item || {}).forEach(([key, value]) => {
|
||||||
|
if (isEmptyValue(value, key)) {
|
||||||
|
fields.add(key);
|
||||||
|
if (requiredFields?.includes(key)) {
|
||||||
|
status[key] = 'error';
|
||||||
|
} else {
|
||||||
|
status[key] = '';
|
||||||
|
}
|
||||||
|
} else if (validateRule(value, key)) {
|
||||||
|
status[key] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
statusList.push(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidateStatusList(statusList);
|
||||||
|
|
||||||
|
if (fields.size > 0) {
|
||||||
|
return Promise.reject(`${Array.from(fields).join(', ')} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleValidation = (enabled: boolean) => {
|
||||||
|
validationEnabled.current = enabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
listMapValidator,
|
||||||
|
toggleValidation
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Form } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { FieldSchema } from './config/types';
|
||||||
|
|
||||||
|
interface DynamicFormProps {
|
||||||
|
schema: FieldSchema;
|
||||||
|
onSubmit: (values: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit }) => {
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
|
||||||
|
const handleFinish = (values: any) => {
|
||||||
|
onSubmit(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form form={form} onFinish={handleFinish}>
|
||||||
|
{/* Render form fields based on schema */}
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicForm;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Chart from '@/components/echarts/chart';
|
||||||
|
import useChartConfig from '@/components/echarts/config';
|
||||||
|
import EmptyData from '@/components/empty-data';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { memo, useMemo } from 'react';
|
||||||
|
import { ChartProps } from './types';
|
||||||
|
|
||||||
|
const BarChart: React.FC<ChartProps> = (props) => {
|
||||||
|
const {
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
labelFormatter,
|
||||||
|
legendData,
|
||||||
|
title
|
||||||
|
} = props;
|
||||||
|
const {
|
||||||
|
barItemConfig,
|
||||||
|
grid,
|
||||||
|
legend,
|
||||||
|
title: titleConfig,
|
||||||
|
tooltip,
|
||||||
|
xAxis,
|
||||||
|
yAxis
|
||||||
|
} = useChartConfig();
|
||||||
|
|
||||||
|
const dataOptions = useMemo((): any => {
|
||||||
|
const options = {
|
||||||
|
title: {
|
||||||
|
text: ''
|
||||||
|
},
|
||||||
|
grid,
|
||||||
|
tooltip: {
|
||||||
|
...tooltip
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...xAxis,
|
||||||
|
axisLabel: {
|
||||||
|
...xAxis.axisLabel,
|
||||||
|
formatter: labelFormatter
|
||||||
|
},
|
||||||
|
data: []
|
||||||
|
},
|
||||||
|
yAxis,
|
||||||
|
legend: {
|
||||||
|
...legend,
|
||||||
|
data: []
|
||||||
|
},
|
||||||
|
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
const data = _.map(seriesData, (item: any) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...barItemConfig,
|
||||||
|
stack: 'total',
|
||||||
|
itemStyle: {
|
||||||
|
color: item.color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
animation: false,
|
||||||
|
title: {
|
||||||
|
...titleConfig,
|
||||||
|
text: title
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...options.yAxis
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...options.xAxis,
|
||||||
|
data: xAxisData
|
||||||
|
},
|
||||||
|
series: data
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
title,
|
||||||
|
labelFormatter,
|
||||||
|
tooltip,
|
||||||
|
grid,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
legend,
|
||||||
|
barItemConfig
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!seriesData.length ? (
|
||||||
|
<EmptyData height={height} title={title}></EmptyData>
|
||||||
|
) : (
|
||||||
|
<Chart
|
||||||
|
height={height}
|
||||||
|
options={dataOptions}
|
||||||
|
width={width || '100%'}
|
||||||
|
></Chart>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(BarChart);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const TooltipWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background-color: rgba(255, 255, 255, 80%);
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 360px;
|
||||||
|
|
||||||
|
.tooltip-x-name {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-item {
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.tooltip-item-title {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ItemSymbol = styled.span<{ $color: string }>`
|
||||||
|
background-color: ${(props) => props.$color};
|
||||||
|
display: inline-block;
|
||||||
|
marginright: 5px;
|
||||||
|
borderradius: 8px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface ChartTooltipProps {
|
||||||
|
params: any[];
|
||||||
|
callback?: (val: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip: React.FC<ChartTooltipProps> = (props) => {
|
||||||
|
const { params, callback } = props;
|
||||||
|
console.log('params====', params);
|
||||||
|
return (
|
||||||
|
<TooltipWrapper>
|
||||||
|
<span className="tooltip-x-name">{params[0]?.axisValue}</span>
|
||||||
|
<>
|
||||||
|
{params.map((item: any, index: number) => {
|
||||||
|
let value = callback?.(item.data.value) || item.data.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="tooltip-item" key={index}>
|
||||||
|
<span className="tooltip-item-name">
|
||||||
|
<ItemSymbol $color={item.color}></ItemSymbol>
|
||||||
|
<span className="tooltip-title">{item.seriesName}</span>:
|
||||||
|
</span>
|
||||||
|
<span className="tooltip-value">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
</TooltipWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ChartTooltip;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import _, { throttle } from 'lodash';
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef
|
||||||
|
} from 'react';
|
||||||
|
import echarts, { ECOption } from '.';
|
||||||
|
|
||||||
|
const Chart: React.FC<{
|
||||||
|
options: ECOption;
|
||||||
|
chartHeight?: number;
|
||||||
|
height: number | string;
|
||||||
|
width: number | string;
|
||||||
|
ref?: any;
|
||||||
|
}> = forwardRef(({ options, width, height, chartHeight }, ref) => {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const chart = useRef<echarts.EChartsType>();
|
||||||
|
const resizeable = useRef(false);
|
||||||
|
const resizeObserver = useRef<ResizeObserver>();
|
||||||
|
const finished = useRef(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
chart: chart.current
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
if (container.current) {
|
||||||
|
chart.current?.clear();
|
||||||
|
chart.current = echarts.init(container.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOption = (options: ECOption) => {
|
||||||
|
if (!chart.current) return;
|
||||||
|
chart.current?.clear();
|
||||||
|
chart.current?.setOption(options, {
|
||||||
|
notMerge: true,
|
||||||
|
lazyUpdate: true
|
||||||
|
});
|
||||||
|
if (Array.isArray(options.yAxis) && options.yAxis.length > 1) {
|
||||||
|
chart.current?.resize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnFinished = () => {
|
||||||
|
if (!chart.current || finished.current) return;
|
||||||
|
|
||||||
|
const currentChart = chart.current;
|
||||||
|
const optionsYAxis = currentChart.getOption()?.yAxis;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!optionsYAxis ||
|
||||||
|
!Array.isArray(optionsYAxis) ||
|
||||||
|
optionsYAxis.length < 2
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// @ts-ignore
|
||||||
|
const model = currentChart.getModel();
|
||||||
|
|
||||||
|
const yAxisModels = [
|
||||||
|
model.getComponent('yAxis', 0),
|
||||||
|
model.getComponent('yAxis', 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!yAxisModels[0] || !yAxisModels[1]) return;
|
||||||
|
|
||||||
|
const axes = yAxisModels.map((m) => m.axis);
|
||||||
|
|
||||||
|
const intervals = axes.map((axis) => axis.scale.getInterval());
|
||||||
|
const ticksList = axes.map((axis) => axis.scale.getTicks());
|
||||||
|
const counts = ticksList.map((t) => t.length);
|
||||||
|
|
||||||
|
const unifiedCount = Math.max(counts[0], counts[1]);
|
||||||
|
|
||||||
|
const newMax0 = intervals[0] * (unifiedCount - 1);
|
||||||
|
const newMax1 = intervals[1] * (unifiedCount - 1);
|
||||||
|
|
||||||
|
// if newMax0 equal to maxValue0, and newMax1 equal to maxValue1, do not update yAxis
|
||||||
|
if (counts[0] === counts[1]) return;
|
||||||
|
|
||||||
|
const yAxis: any[] = [{}, {}];
|
||||||
|
|
||||||
|
if (counts[0] < unifiedCount) {
|
||||||
|
yAxis[0].max = _.round(newMax0, 2);
|
||||||
|
yAxis[0].interval = intervals[0];
|
||||||
|
yAxis[0].splitNumber = unifiedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[1] < unifiedCount) {
|
||||||
|
yAxis[1].max = _.round(newMax1, 2);
|
||||||
|
yAxis[1].interval = intervals[1];
|
||||||
|
yAxis[1].splitNumber = unifiedCount;
|
||||||
|
}
|
||||||
|
finished.current = true;
|
||||||
|
|
||||||
|
currentChart.setOption({
|
||||||
|
yAxis: yAxis
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (container.current) {
|
||||||
|
init();
|
||||||
|
chart.current?.on('finished', handleOnFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chart.current?.off('finished', handleOnFinished);
|
||||||
|
chart.current?.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resizeable.current = false;
|
||||||
|
finished.current = false;
|
||||||
|
setOption(options);
|
||||||
|
resizeable.current = true;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = throttle(() => {
|
||||||
|
if (resizeable.current) {
|
||||||
|
chart.current?.resize();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
if (container.current) {
|
||||||
|
resizeObserver.current = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.current.observe(container.current);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
resizeObserver.current?.disconnect();
|
||||||
|
resizeObserver.current = undefined;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-wrapper" style={{ width: width, height }}>
|
||||||
|
<div
|
||||||
|
ref={container}
|
||||||
|
style={{ width: width, height: chartHeight || height }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Chart;
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import useUserSettings from '@/hooks/use-user-settings';
|
||||||
|
import { formatLargeNumber } from '@/utils';
|
||||||
|
import { theme } from 'antd';
|
||||||
|
import { isFunction } from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const grid = {
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 20,
|
||||||
|
containLabel: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useChartConfig() {
|
||||||
|
const { userSettings, isDarkTheme } = useUserSettings();
|
||||||
|
const { useToken } = theme;
|
||||||
|
const { token } = useToken();
|
||||||
|
|
||||||
|
const chartColorMap = useMemo(() => {
|
||||||
|
return {
|
||||||
|
titleColor: token.colorText,
|
||||||
|
splitLineColor: token.colorBorder,
|
||||||
|
tickLineColor: token.colorSplit,
|
||||||
|
axislabelColor: token.colorTextTertiary,
|
||||||
|
colorSecondary: token.colorTextSecondary,
|
||||||
|
colorTertiary: token.colorTextTertiary,
|
||||||
|
gaugeBgColor: token.colorFillSecondary,
|
||||||
|
gaugeSplitLineColor: isDarkTheme
|
||||||
|
? 'rgba(255,255,255,.3)'
|
||||||
|
: 'rgba(255, 255, 255, 1)',
|
||||||
|
gaugeSplitLineColor2: isDarkTheme
|
||||||
|
? 'rgba(255,255,255,.5)'
|
||||||
|
: 'rgba(255, 255, 255, 1)',
|
||||||
|
colorBgContainerHover: isDarkTheme ? '#424242' : '#fff'
|
||||||
|
};
|
||||||
|
}, [userSettings.theme, isDarkTheme]);
|
||||||
|
|
||||||
|
const tooltip = {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: chartColorMap.colorBgContainerHover,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
formatter(params: any, callback?: (val: any) => any) {
|
||||||
|
let result = `<span class="tooltip-x-name">${params[0].axisValue}</span>`;
|
||||||
|
|
||||||
|
params.forEach((item: any) => {
|
||||||
|
let value = isFunction(callback)
|
||||||
|
? callback?.(item.data.value)
|
||||||
|
: item.data.value;
|
||||||
|
|
||||||
|
const borderRadius = item.seriesType === 'bar' ? '2px' : '8px';
|
||||||
|
|
||||||
|
result += `<span class="tooltip-item">
|
||||||
|
<span class="tooltip-item-name">
|
||||||
|
<span style="display:inline-block;margin-right:5px;border-radius:${borderRadius};width:8px;height:8px;background-color:${item.color};"></span>
|
||||||
|
<span class="tooltip-title">${item.seriesName}</span>:
|
||||||
|
</span>
|
||||||
|
<span class="tooltip-value">${value}</span>
|
||||||
|
</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<div class="tooltip-wrapper">${result}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const legend = {
|
||||||
|
itemWidth: 8,
|
||||||
|
itemHeight: 8,
|
||||||
|
itemGap: 12,
|
||||||
|
textStyle: {
|
||||||
|
color: chartColorMap.axislabelColor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const xAxis = {
|
||||||
|
type: 'category',
|
||||||
|
axisTick: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: chartColorMap.tickLineColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: chartColorMap.axislabelColor,
|
||||||
|
fontSize: 12
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const yAxis = {
|
||||||
|
nameTextStyle: {
|
||||||
|
padding: [0, 0, 0, -20]
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
type: 'dashed',
|
||||||
|
color: chartColorMap.splitLineColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: chartColorMap.axislabelColor,
|
||||||
|
fontSize: 12,
|
||||||
|
formatter: formatLargeNumber
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
type: 'value'
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = {
|
||||||
|
show: true,
|
||||||
|
left: 'center',
|
||||||
|
textStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: chartColorMap.titleColor
|
||||||
|
},
|
||||||
|
text: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const barItemConfig = {
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 20,
|
||||||
|
barMinWidth: 8,
|
||||||
|
barGap: '30%',
|
||||||
|
barCategoryGap: '50%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineItemConfig = {
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
itemStyle: {},
|
||||||
|
lineStyle: {
|
||||||
|
width: 1.5,
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gaugeItemConfig = {
|
||||||
|
type: 'gauge',
|
||||||
|
radius: '88%',
|
||||||
|
center: ['50%', '65%'],
|
||||||
|
startAngle: 190,
|
||||||
|
endAngle: -10,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
splitNumber: 5,
|
||||||
|
progress: {
|
||||||
|
show: true,
|
||||||
|
roundCap: false,
|
||||||
|
width: 12
|
||||||
|
},
|
||||||
|
pointer: {
|
||||||
|
length: '80%',
|
||||||
|
width: 4,
|
||||||
|
itemStyle: {
|
||||||
|
color: 'auto'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
roundCap: false,
|
||||||
|
lineStyle: {
|
||||||
|
width: 12,
|
||||||
|
color: [
|
||||||
|
[0.5, 'rgba(84, 204, 152, 80%)'],
|
||||||
|
[0.8, 'rgba(250, 173, 20, 80%)'],
|
||||||
|
[1, 'rgba(255, 77, 79, 80%)']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
distance: -11,
|
||||||
|
length: 6,
|
||||||
|
splitNumber: 5,
|
||||||
|
lineStyle: {
|
||||||
|
width: 1.5,
|
||||||
|
color: chartColorMap.gaugeSplitLineColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
distance: -5,
|
||||||
|
length: 5,
|
||||||
|
lineStyle: {
|
||||||
|
width: 1.5,
|
||||||
|
color: chartColorMap.gaugeSplitLineColor2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
distance: 14,
|
||||||
|
color: chartColorMap.axislabelColor,
|
||||||
|
fontSize: 12
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
lineHeight: 40,
|
||||||
|
height: 40,
|
||||||
|
offsetCenter: [5, 30],
|
||||||
|
valueAnimation: false,
|
||||||
|
fontSize: 20,
|
||||||
|
color: chartColorMap.titleColor,
|
||||||
|
formatter(value: any) {
|
||||||
|
return '{value|' + value + '}{unit|%}';
|
||||||
|
},
|
||||||
|
rich: {
|
||||||
|
value: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: chartColorMap.titleColor
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: chartColorMap.titleColor,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: [0, 0, 0, 2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
tooltip,
|
||||||
|
grid,
|
||||||
|
legend,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
title,
|
||||||
|
chartColorMap,
|
||||||
|
barItemConfig,
|
||||||
|
lineItemConfig,
|
||||||
|
gaugeItemConfig,
|
||||||
|
isDark: isDarkTheme
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Chart from '@/components/echarts/chart';
|
||||||
|
import useChartConfig from '@/components/echarts/config';
|
||||||
|
import EmptyData from '@/components/empty-data';
|
||||||
|
import React from 'react';
|
||||||
|
import { ChartProps } from './types';
|
||||||
|
|
||||||
|
const strokeColorFunc = (percent: number) => {
|
||||||
|
if (percent <= 50 || percent === undefined) {
|
||||||
|
return 'rgb(84, 204, 152, 80%)';
|
||||||
|
}
|
||||||
|
if (percent <= 80) {
|
||||||
|
return 'rgba(250, 173, 20, 80%)';
|
||||||
|
}
|
||||||
|
return 'rgba(255, 77, 79, 80%)';
|
||||||
|
};
|
||||||
|
|
||||||
|
const GaugeChart: React.FC<Omit<ChartProps, 'seriesData' | 'xAxisData'>> = (
|
||||||
|
props
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
gaugeItemConfig,
|
||||||
|
title: titleConfig,
|
||||||
|
chartColorMap
|
||||||
|
} = useChartConfig();
|
||||||
|
const { value, height, width, labelFormatter, title, color, gaugeConfig } =
|
||||||
|
props;
|
||||||
|
const titleText = typeof title === 'string' ? title : title?.text;
|
||||||
|
|
||||||
|
if (!value && value !== 0) {
|
||||||
|
return <EmptyData height={height} title={titleText}></EmptyData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDataOptions = () => {
|
||||||
|
const colorValue = color || strokeColorFunc(value);
|
||||||
|
const combineGaugeConfig = {
|
||||||
|
...gaugeItemConfig,
|
||||||
|
...gaugeConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
combineGaugeConfig.detail.rich.value.color = colorValue;
|
||||||
|
combineGaugeConfig.detail.rich.unit.color = colorValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
...titleConfig,
|
||||||
|
text: titleText,
|
||||||
|
textStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: chartColorMap.colorSecondary,
|
||||||
|
fontWeight: 400
|
||||||
|
},
|
||||||
|
top: 10,
|
||||||
|
left: 'center',
|
||||||
|
...(typeof title === 'object' ? title : {})
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
...combineGaugeConfig,
|
||||||
|
axisLine: {
|
||||||
|
...combineGaugeConfig.axisLine,
|
||||||
|
lineStyle: {
|
||||||
|
...combineGaugeConfig.axisLine.lineStyle,
|
||||||
|
color: [
|
||||||
|
[value / 100, colorValue],
|
||||||
|
[1, chartColorMap.gaugeBgColor]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: 'transparent'
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
...combineGaugeConfig.detail,
|
||||||
|
borderColor: colorValue,
|
||||||
|
lineHeight: 20,
|
||||||
|
height: 18,
|
||||||
|
width: 50,
|
||||||
|
formatter: labelFormatter || gaugeItemConfig.detail.formatter
|
||||||
|
},
|
||||||
|
data: [{ value }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataOptions: any = setDataOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chart
|
||||||
|
height={height}
|
||||||
|
options={dataOptions}
|
||||||
|
width={width || '100%'}
|
||||||
|
></Chart>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GaugeChart;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Chart from '@/components/echarts/chart';
|
||||||
|
import useChartConfig from '@/components/echarts/config';
|
||||||
|
import EmptyData from '@/components/empty-data';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ChartProps } from './types';
|
||||||
|
|
||||||
|
const BarChart: React.FC<ChartProps & { maxItems?: number }> = (props) => {
|
||||||
|
const {
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
labelFormatter,
|
||||||
|
legendData,
|
||||||
|
maxItems,
|
||||||
|
title
|
||||||
|
} = props;
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
grid,
|
||||||
|
legend,
|
||||||
|
title: titleConfig,
|
||||||
|
tooltip,
|
||||||
|
xAxis,
|
||||||
|
yAxis
|
||||||
|
} = useChartConfig();
|
||||||
|
|
||||||
|
const dataOptions = useMemo((): any => {
|
||||||
|
const options = {
|
||||||
|
title: {
|
||||||
|
...titleConfig,
|
||||||
|
left: 'start'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
...grid,
|
||||||
|
top: 0,
|
||||||
|
bottom: maxItems
|
||||||
|
? `${(1 / maxItems) * (maxItems - xAxisData.length) * 100}%`
|
||||||
|
: 0
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...tooltip
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...xAxis,
|
||||||
|
axisLabel: {
|
||||||
|
...xAxis.axisLabel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...yAxis,
|
||||||
|
axisLabel: {
|
||||||
|
...yAxis.axisLabel,
|
||||||
|
show: true,
|
||||||
|
overflow: 'truncate',
|
||||||
|
width: 75,
|
||||||
|
ellipsis: '...',
|
||||||
|
margin: 8,
|
||||||
|
formatter(value: string, index: number) {
|
||||||
|
return `{a|${index + 1}}`;
|
||||||
|
},
|
||||||
|
rich: {
|
||||||
|
a: {
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
color: token?.colorTextSecondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...legend,
|
||||||
|
data: []
|
||||||
|
},
|
||||||
|
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
const data = _.map(seriesData, (item: any) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: 20,
|
||||||
|
stack: 'Ad',
|
||||||
|
barGap: '20%',
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter(params: any) {
|
||||||
|
if (params.seriesIndex === 0) {
|
||||||
|
return `{value|${params.name}}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
position: 'left',
|
||||||
|
align: 'left',
|
||||||
|
offset: [5, 18],
|
||||||
|
rich: {
|
||||||
|
value: {
|
||||||
|
textBorderWidth: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
color: token?.colorTextTertiary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: item.color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
animation: false,
|
||||||
|
title: {
|
||||||
|
...options.title,
|
||||||
|
text: title
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...options.yAxis,
|
||||||
|
inverse: true,
|
||||||
|
type: 'category',
|
||||||
|
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
data: xAxisData,
|
||||||
|
axisLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...options.xAxis,
|
||||||
|
type: 'value',
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: data
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
title,
|
||||||
|
labelFormatter,
|
||||||
|
tooltip,
|
||||||
|
grid,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
legend
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEmpty = useMemo(() => {
|
||||||
|
return seriesData?.every?.((item: any) => {
|
||||||
|
return !item?.data?.length;
|
||||||
|
});
|
||||||
|
}, [seriesData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isEmpty ? (
|
||||||
|
<EmptyData
|
||||||
|
height={height}
|
||||||
|
title={_.get(title, 'text', title || '')}
|
||||||
|
></EmptyData>
|
||||||
|
) : (
|
||||||
|
<Chart
|
||||||
|
height={height}
|
||||||
|
chartHeight={typeof height === 'number' ? height - 10 : undefined}
|
||||||
|
options={dataOptions}
|
||||||
|
width={width || '100%'}
|
||||||
|
></Chart>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BarChart;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
BarSeriesOption,
|
||||||
|
GaugeSeriesOption,
|
||||||
|
LineSeriesOption,
|
||||||
|
ScatterSeriesOption
|
||||||
|
} from 'echarts/charts';
|
||||||
|
import { BarChart, GaugeChart, LineChart, ScatterChart } from 'echarts/charts';
|
||||||
|
import type {
|
||||||
|
DatasetComponentOption,
|
||||||
|
GridComponentOption,
|
||||||
|
TitleComponentOption,
|
||||||
|
TooltipComponentOption
|
||||||
|
} from 'echarts/components';
|
||||||
|
import {
|
||||||
|
DataZoomComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
// (filter, sort)
|
||||||
|
TransformComponent
|
||||||
|
} from 'echarts/components';
|
||||||
|
import type { ComposeOption } from 'echarts/core';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
|
type ECOption = ComposeOption<
|
||||||
|
| BarSeriesOption
|
||||||
|
| LineSeriesOption
|
||||||
|
| TitleComponentOption
|
||||||
|
| TooltipComponentOption
|
||||||
|
| GridComponentOption
|
||||||
|
| DatasetComponentOption
|
||||||
|
| GaugeSeriesOption
|
||||||
|
| ScatterSeriesOption
|
||||||
|
>;
|
||||||
|
|
||||||
|
// register components and charts
|
||||||
|
echarts.use([
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
TransformComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
ScatterChart,
|
||||||
|
GaugeChart,
|
||||||
|
LabelLayout,
|
||||||
|
UniversalTransition,
|
||||||
|
CanvasRenderer
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type { ECOption };
|
||||||
|
|
||||||
|
export default echarts;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Chart from '@/components/echarts/chart';
|
||||||
|
import useChartConfig from '@/components/echarts/config';
|
||||||
|
import EmptyData from '@/components/empty-data';
|
||||||
|
import { genColors } from '@/utils';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import echarts from '.';
|
||||||
|
import { ChartProps } from './types';
|
||||||
|
|
||||||
|
const LinearGradient = echarts.graphic.LinearGradient;
|
||||||
|
const LineChart: React.FC<ChartProps> = (props) => {
|
||||||
|
const {
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
yAxisName,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
labelFormatter,
|
||||||
|
tooltipValueFormatter = null,
|
||||||
|
legendData = [],
|
||||||
|
smooth,
|
||||||
|
title,
|
||||||
|
legendOptions,
|
||||||
|
gridOptions,
|
||||||
|
titleOptions,
|
||||||
|
showArea
|
||||||
|
} = props;
|
||||||
|
const {
|
||||||
|
grid,
|
||||||
|
legend,
|
||||||
|
lineItemConfig,
|
||||||
|
title: titleConfig,
|
||||||
|
tooltip,
|
||||||
|
xAxis,
|
||||||
|
yAxis
|
||||||
|
} = useChartConfig();
|
||||||
|
|
||||||
|
const axisLabelFormatter = (value: string, index: number) => {
|
||||||
|
if (labelFormatter) {
|
||||||
|
return labelFormatter(value, index);
|
||||||
|
}
|
||||||
|
if (index === xAxisData.length - 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
title: {
|
||||||
|
text: ''
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
...grid,
|
||||||
|
...gridOptions
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...tooltip,
|
||||||
|
formatter(params: any) {
|
||||||
|
return tooltipValueFormatter
|
||||||
|
? tooltip.formatter(params, tooltipValueFormatter)
|
||||||
|
: tooltip.formatter(params);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...xAxis,
|
||||||
|
axisLabel: {
|
||||||
|
...xAxis.axisLabel,
|
||||||
|
formatter: axisLabelFormatter
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis,
|
||||||
|
legend: {
|
||||||
|
...legend,
|
||||||
|
...legendOptions,
|
||||||
|
data: legendData.map((item: any) => {
|
||||||
|
return {
|
||||||
|
name: item,
|
||||||
|
icon: 'circle'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataOptions = useMemo((): any => {
|
||||||
|
const data = _.map(seriesData, (item: any) => {
|
||||||
|
const colors = genColors({
|
||||||
|
color: item.color,
|
||||||
|
alpha1: 0.25,
|
||||||
|
alpha2: 0.1
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...lineItemConfig,
|
||||||
|
smooth: smooth,
|
||||||
|
itemStyle: {
|
||||||
|
...lineItemConfig.itemStyle,
|
||||||
|
color: item.color
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
...lineItemConfig.lineStyle,
|
||||||
|
color: item.color
|
||||||
|
},
|
||||||
|
areaStyle: showArea
|
||||||
|
? {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: colors[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: colors[1]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
animation: false,
|
||||||
|
title: {
|
||||||
|
...titleConfig,
|
||||||
|
...titleOptions,
|
||||||
|
text: title
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...options.yAxis,
|
||||||
|
name: yAxisName,
|
||||||
|
nameTextStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
align: 'right'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...options.xAxis,
|
||||||
|
data: xAxisData
|
||||||
|
},
|
||||||
|
series: data
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
yAxisName,
|
||||||
|
title,
|
||||||
|
smooth,
|
||||||
|
titleOptions,
|
||||||
|
legendData,
|
||||||
|
options
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!seriesData.length ? (
|
||||||
|
<EmptyData
|
||||||
|
height={height}
|
||||||
|
title={_.get(title, 'text', title || '')}
|
||||||
|
></EmptyData>
|
||||||
|
) : (
|
||||||
|
<Chart
|
||||||
|
height={height}
|
||||||
|
options={dataOptions}
|
||||||
|
width={width || '100%'}
|
||||||
|
></Chart>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineChart;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import Chart from '@/components/echarts/chart';
|
||||||
|
import useChartConfig from '@/components/echarts/config';
|
||||||
|
import EmptyData from '@/components/empty-data';
|
||||||
|
import { genColors } from '@/utils';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import echarts from '.';
|
||||||
|
import { ChartProps } from './types';
|
||||||
|
|
||||||
|
const LinearGradient = echarts.graphic.LinearGradient;
|
||||||
|
|
||||||
|
const MixLineBarChart: React.FC<
|
||||||
|
ChartProps & {
|
||||||
|
chartData: {
|
||||||
|
line: any[];
|
||||||
|
bar: any[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> = (props) => {
|
||||||
|
const {
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
yAxisName,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
labelFormatter,
|
||||||
|
tooltipValueFormatter = null,
|
||||||
|
legendData = [],
|
||||||
|
smooth,
|
||||||
|
title,
|
||||||
|
chartData
|
||||||
|
} = props;
|
||||||
|
const {
|
||||||
|
grid,
|
||||||
|
legend,
|
||||||
|
lineItemConfig,
|
||||||
|
barItemConfig,
|
||||||
|
title: titleConfig,
|
||||||
|
tooltip,
|
||||||
|
xAxis,
|
||||||
|
yAxis
|
||||||
|
} = useChartConfig();
|
||||||
|
|
||||||
|
const { line: lineSeriesData, bar: barSeriesData } = chartData;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
title: {
|
||||||
|
text: ''
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
...grid,
|
||||||
|
right: 0,
|
||||||
|
top: 20,
|
||||||
|
bottom: 10
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...tooltip,
|
||||||
|
formatter(params: any) {
|
||||||
|
return tooltipValueFormatter
|
||||||
|
? tooltip.formatter(params, tooltipValueFormatter)
|
||||||
|
: tooltip.formatter(params);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...xAxis,
|
||||||
|
axisLabel: {
|
||||||
|
...xAxis.axisLabel,
|
||||||
|
formatter: labelFormatter
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis,
|
||||||
|
legend: {
|
||||||
|
...legend,
|
||||||
|
data: legendData,
|
||||||
|
itemGap: 20,
|
||||||
|
bottom: 5,
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataOptions = useMemo((): any => {
|
||||||
|
const linedata = _.map(lineSeriesData, (item: any) => {
|
||||||
|
const colors = genColors({
|
||||||
|
color: item.color,
|
||||||
|
alpha1: 0.5,
|
||||||
|
alpha2: 0.1
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...lineItemConfig,
|
||||||
|
smooth: smooth,
|
||||||
|
itemStyle: {
|
||||||
|
...lineItemConfig.itemStyle,
|
||||||
|
color: item.color
|
||||||
|
},
|
||||||
|
yAxisIndex: 1,
|
||||||
|
lineStyle: {
|
||||||
|
...lineItemConfig.lineStyle,
|
||||||
|
color: item.color
|
||||||
|
}
|
||||||
|
// areaStyle: {
|
||||||
|
// color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
// {
|
||||||
|
// offset: 0,
|
||||||
|
// color: colors[0]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// offset: 1,
|
||||||
|
// color: colors[1]
|
||||||
|
// }
|
||||||
|
// ])
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const barData = _.map(barSeriesData, (item: any) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...barItemConfig,
|
||||||
|
stack: 'total',
|
||||||
|
yAxisIndex: 0,
|
||||||
|
itemStyle: {
|
||||||
|
...item.itemStyle,
|
||||||
|
color: item.color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
animation: false,
|
||||||
|
title: {
|
||||||
|
...titleConfig,
|
||||||
|
text: title
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...options.yAxis
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...options.yAxis,
|
||||||
|
nameTextStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
align: 'right'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
xAxis: {
|
||||||
|
...options.xAxis,
|
||||||
|
data: xAxisData
|
||||||
|
},
|
||||||
|
series: [...barData, ...linedata]
|
||||||
|
};
|
||||||
|
}, [seriesData, xAxisData, yAxisName, title, smooth, legendData, options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!lineSeriesData.length && !barSeriesData.length ? (
|
||||||
|
<EmptyData
|
||||||
|
height={height}
|
||||||
|
title={_.get(title, 'text', title || '')}
|
||||||
|
></EmptyData>
|
||||||
|
) : (
|
||||||
|
<Chart
|
||||||
|
height={height}
|
||||||
|
options={dataOptions}
|
||||||
|
width={width || '100%'}
|
||||||
|
></Chart>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MixLineBarChart;
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import Chart from '@/components/echarts/chart';
|
||||||
|
import useChartConfig from '@/components/echarts/config';
|
||||||
|
import EmptyData from '@/components/empty-data';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { ChartProps } from './types';
|
||||||
|
|
||||||
|
const Scatter: React.FC<
|
||||||
|
ChartProps & {
|
||||||
|
xMax?: number;
|
||||||
|
yMax?: number;
|
||||||
|
}
|
||||||
|
> = (props) => {
|
||||||
|
const { grid, title: titleConfig, isDark, chartColorMap } = useChartConfig();
|
||||||
|
const {
|
||||||
|
seriesData,
|
||||||
|
xAxisData,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
showEmpty,
|
||||||
|
title,
|
||||||
|
xMax = 1,
|
||||||
|
yMax = 1
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const chart = useRef<any>(null);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const colorMap = isDark
|
||||||
|
? {
|
||||||
|
split: chartColorMap.splitLineColor,
|
||||||
|
axis: chartColorMap.axislabelColor,
|
||||||
|
label: chartColorMap.axislabelColor
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
split: '#F2F2F2',
|
||||||
|
axis: '#dcdcdc',
|
||||||
|
label: '#dcdcdc'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
grid: {
|
||||||
|
...grid,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: 2,
|
||||||
|
left: 2,
|
||||||
|
containLabel: true,
|
||||||
|
borderRadius: 4
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
min: -xMax,
|
||||||
|
max: xMax,
|
||||||
|
scale: false,
|
||||||
|
slient: true,
|
||||||
|
splitNumber: 15,
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: colorMap.split
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: colorMap.axis
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
color: colorMap.label
|
||||||
|
},
|
||||||
|
boundaryGap: [0.05, 0.05]
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
min: -yMax,
|
||||||
|
max: yMax,
|
||||||
|
scale: false,
|
||||||
|
slient: true,
|
||||||
|
splitNumber: 10,
|
||||||
|
boundaryGap: [0.05, 0.05],
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: colorMap.split
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: colorMap.axis
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
color: colorMap.label
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
symbol: 'roundRect',
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
shadowColor: 'none',
|
||||||
|
textBorderColor: 'none',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
return params.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
}, [isDark, xMax, yMax]);
|
||||||
|
|
||||||
|
const findOverlappingPoints = useCallback(
|
||||||
|
(data: any[], currentPoint: any) => {
|
||||||
|
const overlappingPoints = [];
|
||||||
|
const symbolRadius = 16;
|
||||||
|
|
||||||
|
const [x1, y1] = chart.current.chart?.convertToPixel(
|
||||||
|
'grid',
|
||||||
|
currentPoint.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const pixelPoints = data.map((point) => {
|
||||||
|
return {
|
||||||
|
...point,
|
||||||
|
value: chart.current.chart?.convertToPixel('grid', point.value)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let j = 0; j < pixelPoints.length; j++) {
|
||||||
|
if (currentPoint.name === pixelPoints[j].name) {
|
||||||
|
overlappingPoints.push({ ...pixelPoints[j] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [x2, y2] = pixelPoints[j].value;
|
||||||
|
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(_.round(x2 - x1, 2), 2) + Math.pow(_.round(y2 - y1, 2), 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance <= symbolRadius) {
|
||||||
|
overlappingPoints.push({ ...pixelPoints[j] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlappingPoints;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNameInTooltip = useCallback((dataList: any[]) => {
|
||||||
|
if (!dataList.length || dataList.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const renderText = (item: any) => {
|
||||||
|
return `<span class="tooltip-item-name">
|
||||||
|
<span style="display:flex;justify-content:center;align-items: center;color:#fff;
|
||||||
|
margin-right:0;border-radius:4px;width:14px;
|
||||||
|
height:14px;background-color:${item?.itemStyle?.color};"
|
||||||
|
>${item.name}</span>
|
||||||
|
</span>`;
|
||||||
|
};
|
||||||
|
return renderText;
|
||||||
|
}, []);
|
||||||
|
const dataOptions = useMemo((): any => {
|
||||||
|
const seriseDataList = seriesData.map((item: any, index: number) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
itemStyle: {
|
||||||
|
color: '#5470c6'
|
||||||
|
},
|
||||||
|
symbolSize: 16
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
borderWidth: 0,
|
||||||
|
backgroundColor: chartColorMap.colorBgContainerHover,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
formatter(params: any, callback?: (val: any) => any) {
|
||||||
|
const dataList = findOverlappingPoints(seriseDataList, params.data);
|
||||||
|
let result = '';
|
||||||
|
const renderText: any = renderNameInTooltip(dataList);
|
||||||
|
dataList.forEach((item: any) => {
|
||||||
|
result += `
|
||||||
|
<span class="tooltip-item" style="justify-content: flex-start;">
|
||||||
|
${renderText ? renderText(item) : ''}
|
||||||
|
<span class="tooltip-value">${item.text}</span>
|
||||||
|
</span>`;
|
||||||
|
});
|
||||||
|
return `<div class="tooltip-wrapper scatter">${result}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...titleConfig,
|
||||||
|
text: title
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
type: 'scatter',
|
||||||
|
labelLayout: {
|
||||||
|
hideOverlap: true
|
||||||
|
},
|
||||||
|
data: seriseDataList
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [seriesData, xAxisData, title, options, findOverlappingPoints]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!seriesData.length && showEmpty ? (
|
||||||
|
<EmptyData
|
||||||
|
height={height}
|
||||||
|
title={_.get(title, 'text', title || '')}
|
||||||
|
></EmptyData>
|
||||||
|
) : (
|
||||||
|
<Chart
|
||||||
|
ref={chart}
|
||||||
|
height={height}
|
||||||
|
options={dataOptions}
|
||||||
|
width={width || '100%'}
|
||||||
|
></Chart>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Scatter;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type {
|
||||||
|
LegendComponentOption,
|
||||||
|
TitleComponentOption
|
||||||
|
} from 'echarts/components';
|
||||||
|
export interface ChartProps {
|
||||||
|
seriesData: any[];
|
||||||
|
showEmpty?: boolean;
|
||||||
|
showArea?: boolean;
|
||||||
|
xAxisData: string[];
|
||||||
|
legendData?: LegendComponentOption['data'];
|
||||||
|
legendOptions?: {
|
||||||
|
[K in keyof LegendComponentOption]?: LegendComponentOption[K];
|
||||||
|
};
|
||||||
|
gridOptions?: {
|
||||||
|
left?: string | number;
|
||||||
|
right?: string | number;
|
||||||
|
top?: string | number;
|
||||||
|
bottom?: string | number;
|
||||||
|
};
|
||||||
|
labelFormatter?: (val?: any, index?: number) => string;
|
||||||
|
tooltipValueFormatter?: (val: any) => string;
|
||||||
|
height: string | number;
|
||||||
|
width?: string | number;
|
||||||
|
title?: string | TitleComponentOption;
|
||||||
|
titleOptions?: {
|
||||||
|
[K in keyof TitleComponentOption]?: TitleComponentOption[K];
|
||||||
|
};
|
||||||
|
value?: number;
|
||||||
|
smooth?: boolean;
|
||||||
|
color?: string;
|
||||||
|
yAxisName?: string;
|
||||||
|
gaugeConfig?: {
|
||||||
|
radius?: string;
|
||||||
|
center?: string[];
|
||||||
|
startAngle?: number;
|
||||||
|
endAngle?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AreaChartItemProps {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
areaStyle: any;
|
||||||
|
data: { time: string; value: number }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.editor-wrap {
|
||||||
|
border-radius: var(--border-radius-mini);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0;
|
||||||
|
|
||||||
|
.code-pre {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: 12px 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--color-editor-header-bg);
|
||||||
|
}
|
||||||
|
// set scrollbar style
|
||||||
|
.scrollbar {
|
||||||
|
.slider {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
const HeaderWrapper = styled.div<{ $height?: number }>`
|
||||||
|
height: ${(props) => (props.$height ? `${props.$height}px` : 'auto')};
|
||||||
|
display: flex;
|
||||||
|
padding-block: 0;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
border-radius: var(--border-radius-mini);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.bordered {
|
||||||
|
border: 1px solid var(--ant-color-border);
|
||||||
|
}
|
||||||
|
&.borderless {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.code-pre {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.scrollbar {
|
||||||
|
.slider {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface EditorwrapProps {
|
||||||
|
headerHeight?: number;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'bordered' | 'borderless';
|
||||||
|
styles?: {
|
||||||
|
wrapper?: React.CSSProperties;
|
||||||
|
header?: React.CSSProperties;
|
||||||
|
content?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const EditorWrap: React.FC<EditorwrapProps> = ({
|
||||||
|
headerHeight = 40,
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
variant = 'borderless',
|
||||||
|
styles = {}
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
style={{ ...styles.wrapper }}
|
||||||
|
className={classNames({
|
||||||
|
bordered: variant === 'bordered',
|
||||||
|
borderless: variant === 'borderless'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{header && <HeaderWrapper $height={headerHeight}>{header}</HeaderWrapper>}
|
||||||
|
<div>{children}</div>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorWrap;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import Editor from '@monaco-editor/react';
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef
|
||||||
|
} from 'react';
|
||||||
|
import EditorWrap from '../editor-wrap';
|
||||||
|
interface ViewerProps {
|
||||||
|
ref?: any;
|
||||||
|
lang: string;
|
||||||
|
defaultLang?: string;
|
||||||
|
config?: any;
|
||||||
|
value: string;
|
||||||
|
height?: string | number;
|
||||||
|
theme?: string;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
|
variant?: 'bordered' | 'borderless';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ViewerEditor: React.FC<ViewerProps> = forwardRef((props, ref) => {
|
||||||
|
const {
|
||||||
|
lang,
|
||||||
|
value,
|
||||||
|
config,
|
||||||
|
defaultLang,
|
||||||
|
height = 380,
|
||||||
|
theme = 'vs-dark',
|
||||||
|
header,
|
||||||
|
variant = 'borderless',
|
||||||
|
placeholder
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const handleBeforeMount = (monaco: any) => {
|
||||||
|
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||||
|
noSemanticValidation: false,
|
||||||
|
noSyntaxValidation: false,
|
||||||
|
diagnosticCodesToIgnore: [80001]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorDidMount = (editor: any, monaco: any) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCode = () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
editorRef.current
|
||||||
|
?.getAction?.('editor.action.formatDocument')
|
||||||
|
?.run()
|
||||||
|
.then(() => {
|
||||||
|
console.log('format success');
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
format: () => {
|
||||||
|
formatCode();
|
||||||
|
},
|
||||||
|
getValue: () => {
|
||||||
|
return editorRef.current?.getValue?.();
|
||||||
|
},
|
||||||
|
setValue: (val: string) => {
|
||||||
|
editorRef.current?.setValue?.(val);
|
||||||
|
},
|
||||||
|
editor: editorRef.current
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formatCode();
|
||||||
|
setTimeout(() => {
|
||||||
|
const lineCount = editorRef.current?.getModel().getLineCount();
|
||||||
|
editorRef.current?.revealLine(lineCount);
|
||||||
|
}, 100);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorWrap header={header} variant={variant}>
|
||||||
|
<Editor
|
||||||
|
height={height}
|
||||||
|
theme={theme}
|
||||||
|
className="monaco-editor"
|
||||||
|
defaultLanguage={defaultLang}
|
||||||
|
language={lang}
|
||||||
|
value={value}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollbar: {
|
||||||
|
verticalScrollbarSize: 6,
|
||||||
|
horizontalScrollbarSize: 6
|
||||||
|
},
|
||||||
|
placeholder: placeholder
|
||||||
|
}}
|
||||||
|
loading={<LoadingOutlined style={{ fontSize: 24 }}></LoadingOutlined>}
|
||||||
|
beforeMount={handleBeforeMount}
|
||||||
|
onMount={handleEditorDidMount}
|
||||||
|
/>
|
||||||
|
</EditorWrap>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ViewerEditor;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Empty } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const EmptyData: React.FC<{
|
||||||
|
height?: string | number;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
}> = ({ height, title }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: height || '100%'
|
||||||
|
}}
|
||||||
|
className="flex-center flex-column "
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3
|
||||||
|
className="justify-center font-size-12"
|
||||||
|
style={{ padding: '4px 0', marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex-center justify-center flex-column"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyData;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Space } from 'antd';
|
||||||
|
|
||||||
|
type FormButtonsProps = {
|
||||||
|
onOk?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
cancelText?: string;
|
||||||
|
okText?: string;
|
||||||
|
showOk?: boolean;
|
||||||
|
showCancel?: boolean;
|
||||||
|
htmlType?: 'submit' | 'button';
|
||||||
|
};
|
||||||
|
const FormButtons: React.FC<FormButtonsProps> = ({
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
cancelText,
|
||||||
|
okText,
|
||||||
|
showCancel = true,
|
||||||
|
showOk = true,
|
||||||
|
htmlType = 'button'
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<Space size={40} style={{ marginTop: '80px' }}>
|
||||||
|
{showOk && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={onOk}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
htmlType={htmlType}
|
||||||
|
>
|
||||||
|
{okText || intl.formatMessage({ id: 'common.button.save' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showCancel && (
|
||||||
|
<Button onClick={onCancel} style={{ width: '98px' }}>
|
||||||
|
{cancelText || intl.formatMessage({ id: 'common.button.cancel' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormButtons;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import CodeViewer from './code-viewer';
|
||||||
|
import './styles/dark.less';
|
||||||
|
|
||||||
|
interface CodeViewerProps {
|
||||||
|
code: string;
|
||||||
|
copyValue?: string;
|
||||||
|
lang: string;
|
||||||
|
autodetect?: boolean;
|
||||||
|
ignoreIllegals?: boolean;
|
||||||
|
copyable?: boolean;
|
||||||
|
height?: string | number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
xScrollable?: boolean;
|
||||||
|
}
|
||||||
|
const DarkViewer: React.FC<CodeViewerProps> = (props) => {
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
copyValue,
|
||||||
|
lang,
|
||||||
|
autodetect,
|
||||||
|
ignoreIllegals,
|
||||||
|
copyable,
|
||||||
|
height = 'auto',
|
||||||
|
xScrollable = false
|
||||||
|
} = props || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeViewer
|
||||||
|
style={props.style}
|
||||||
|
height={height}
|
||||||
|
code={code}
|
||||||
|
copyValue={copyValue}
|
||||||
|
lang={lang}
|
||||||
|
theme="dark"
|
||||||
|
autodetect={autodetect}
|
||||||
|
ignoreIllegals={ignoreIllegals}
|
||||||
|
copyable={copyable}
|
||||||
|
xScrollable={xScrollable}
|
||||||
|
></CodeViewer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DarkViewer;
|
||||||