Compare commits

..
19 Commits
Author SHA1 Message Date
jialin c5203c01d0 chore: remove experimental from metax 2026-04-15 16:45:13 +08:00
jialin e7a9d2af00 chore: sharegpt description 2026-03-24 17:31:31 +08:00
jialin 59740d1601 fix: a non-running instance is selected in creating benchmark 2026-03-24 17:10:18 +08:00
jialin d874e502e2 fix: add ShareGPT profile 2026-03-24 16:20:03 +08:00
jialin 722b385bcb feat: add --openai-support 2026-03-23 15:59:11 +08:00
jialin aa7247baaf fix: copy failed in non-localhost and non-https 2026-03-23 12:31:45 +08:00
jialin 73f3cfceb1 fix(style): error message overlap input box 2026-03-19 14:02:16 +08:00
jialin f2fe080f7b fix: bedrock required fields 2026-03-18 18:31:29 +08:00
jialin eee73be77e fix: typos: position 2026-03-18 15:19:06 +08:00
jialin 6e4dd30104 fix: show required fields for provider 2026-03-18 15:01:04 +08:00
jialin 19b88f3375 fix: open the advanced when configured 2026-03-17 17:29:38 +08:00
jialin 94b3206111 fix: reset filter in export modal 2026-03-16 14:44:18 +08:00
jialin ea4ea56e59 fix: show backend warning hint after submitting in editing mode 2026-03-16 14:06:41 +08:00
jialin 042f8fed47 chore: update model cols 2026-03-16 11:11:46 +08:00
jialin f7c3b28cc8 style: same key tooltip 2026-03-16 10:31:57 +08:00
jialin 1462768aa9 fix: title for editing apikey 2026-03-12 19:34:14 +08:00
jialin 07fa3c8824 feat: add profile filter in benchmark 2026-03-12 18:51:10 +08:00
jialin 61f83f31d7 chore: add nvidia notes 2026-03-11 20:24:20 +08:00
jialin 6f2c1daa41 feat: display provider models 2026-03-11 20:00:16 +08:00
926 changed files with 38963 additions and 54247 deletions
+1
View File
@@ -0,0 +1 @@
src/components/icon-font/iconfont/iconfont.js
+16
View File
@@ -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/']
};
-3
View File
@@ -13,6 +13,3 @@
.swc .swc
.DS_Store .DS_Store
.idea .idea
.claude
/dist.zip
.cache
+5 -2
View File
@@ -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"]
} }
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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']
}; };
-265
View File
@@ -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 });
};
```
-46
View File
@@ -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
View File
@@ -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: {},
-9
View File
@@ -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[] = [];
-3
View File
@@ -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;
+95 -209
View File
@@ -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,49 +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). name: 'credentials',
// A folder so it matches the other top-level groups; more usage views can path: '/cluster-management/credentials',
// graduate in here later. key: 'credentials',
name: 'billingAndUsage', icon: 'icon-credential-outline',
path: '/usage', selectedIcon: 'icon-credential-filled',
key: 'usageGroup', defaultIcon: 'icon-credential-outline',
icon: 'icon-usage-outlined', component: './cluster-management/credentials'
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
routes: [
{
path: '/usage',
redirect: '/usage/overview'
},
{
name: 'usage',
path: '/usage/overview',
key: 'usage',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
component: './usage/index'
} }
] ]
}, },
@@ -345,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',
@@ -371,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'
} }
] ]
}, },
@@ -397,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'
@@ -419,5 +307,3 @@ const baseRoutes = [
component: './404' component: './404'
} }
]; ];
export default applyRouteExtensions(baseRoutes);
+8 -22
View File
@@ -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.
let latestCommit = '';
let versionTag = '';
try {
latestCommit = execSync('git rev-parse HEAD').toString().trim();
versionTag = execSync(`git tag --contains ${latestCommit}`)
.toString() .toString()
.trim(); .trim();
} catch { const versionTag = child_process
// Not a git checkout / git unavailable; rely on env overrides. .execSync(`git tag --contains ${latestCommit}`)
} .toString()
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that .trim();
// checks this source tree out as a sub-package can stamp its own return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
// 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)
};
}; };
-79
View File
@@ -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 }]
}
}
]);
+15 -21
View File
@@ -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.22",
"@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"
+9811 -13806
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -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);
-14
View File
@@ -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;
+5 -42
View File
@@ -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
}); };
}; };
+5 -150
View File
@@ -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
}; };
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

-1
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

-16
View File
@@ -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;
}
-1
View File
@@ -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;
} }
-12
View File
@@ -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;
}
}
}
}
-6
View File
@@ -63,12 +63,6 @@ export const fromClusterCreationAtom = atom(false);
export const clusterSessionAtom = atom<{ export const clusterSessionAtom = atom<{
firstAddWorker: boolean; firstAddWorker: boolean;
firstAddCluster: boolean; firstAddCluster: boolean;
// 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);
-3
View File
@@ -1,3 +0,0 @@
import { atom } from 'jotai';
export const activeModelsAtom = atom<any[]>([]);
-20
View File
@@ -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] || {};
};
-25
View File
@@ -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
});
-117
View File
@@ -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;
@@ -36,111 +27,3 @@ export const initialPasswordAtom = atomWithStorage<string>(
'initialPassword', '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());
};
+2 -7
View File
@@ -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) {
+182
View File
@@ -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;
+49
View File
@@ -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);
+19
View File
@@ -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;
}
+192
View File
@@ -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;
}
+103
View File
@@ -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;
}
}
}
+311
View File
@@ -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);
+21
View File
@@ -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;
}
+131
View File
@@ -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;
}
+144
View File
@@ -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;
}
}
}
+208
View File
@@ -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;
+151
View File
@@ -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;
+28
View File
@@ -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);
+47
View File
@@ -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;
+46
View File
@@ -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;
+16
View File
@@ -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;
+109
View File
@@ -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>
);
};
+43
View File
@@ -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>
);
}
+219
View File
@@ -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>
);
}
+17
View File
@@ -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;
}
}
+24
View File
@@ -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;
+156
View File
@@ -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;
+222
View File
@@ -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;
+20
View File
@@ -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;
}
}
+6
View File
@@ -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;
}
+150
View File
@@ -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
};
}
+26
View File
@@ -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;
+107
View File
@@ -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);
+75
View File
@@ -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;
+150
View File
@@ -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;
+236
View File
@@ -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
};
}
+97
View File
@@ -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;
+185
View File
@@ -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;
+60
View File
@@ -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;
+172
View File
@@ -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;
+176
View File
@@ -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;
+234
View File
@@ -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;
+45
View File
@@ -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 }[];
}
+24
View File
@@ -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;
}
}
}
+66
View File
@@ -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;
+109
View File
@@ -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;
+34
View File
@@ -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;
+44
View File
@@ -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;
@@ -0,0 +1,44 @@
import CodeViewer from './code-viewer';
import './styles/light.less';
interface CodeViewerProps {
code: string;
copyValue?: string;
lang: string;
autodetect?: boolean;
ignoreIllegals?: boolean;
copyable?: boolean;
height?: string | number;
style?: React.CSSProperties;
xScrollable?: boolean;
}
const LightViewer: React.FC<CodeViewerProps> = (props) => {
const {
code,
copyValue,
lang,
autodetect,
ignoreIllegals,
copyable,
style,
height = 'auto',
xScrollable = false
} = props || {};
return (
<CodeViewer
style={style}
height={height}
code={code}
copyValue={copyValue}
lang={lang}
theme="light"
autodetect={autodetect}
ignoreIllegals={ignoreIllegals}
copyable={copyable}
xScrollable={xScrollable}
></CodeViewer>
);
};
export default LightViewer;
@@ -0,0 +1,172 @@
import classNames from 'classnames';
import hljs from 'highlight.js';
import { useMemo } from 'react';
import styled from 'styled-components';
import CopyButton from '../copy-button';
import { escapeHtml } from './utils';
interface CodeViewerProps {
code: string;
copyValue?: string;
lang: string;
autodetect?: boolean;
ignoreIllegals?: boolean;
copyable?: boolean;
height?: string | number;
theme?: 'light' | 'dark';
style?: React.CSSProperties;
xScrollable?: boolean;
}
interface CodeHeaderProps {
copyValue: string;
copyable: boolean;
lang: string;
theme: 'light' | 'dark';
}
const CodeHeaderWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
height: 32px;
padding: 0 12px;
font-size: 12px;
color: var(--ant-color-text-tertiary);
background-color: #fafafa;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&.dark {
background-color: var(--color-editor-header-bg);
color: rgba(255, 255, 255, 0.65);
}
`;
const Wrapper = styled.div`
border-radius: var(--border-radius-mini);
&:hover {
.custome-scrollbar {
&::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar-thumb);
border-radius: 4px;
}
}
}
`;
const CodeHeader: React.FC<CodeHeaderProps> = ({
copyValue,
lang,
theme,
copyable
}) => {
if (!copyable) {
return null;
}
return (
<CodeHeaderWrapper
className={classNames({
dark: theme === 'dark',
light: theme === 'light'
})}
>
<span>{lang}</span>
<CopyButton
text={copyValue}
size="small"
style={{ color: '#abb2bf' }}
></CopyButton>
</CodeHeaderWrapper>
);
};
const CodeViewer: React.FC<CodeViewerProps> = (props) => {
const {
code = '',
copyValue,
lang,
autodetect = true,
ignoreIllegals = true,
copyable = true,
height = 'auto',
style,
xScrollable = false
} = props || {};
const highlightedCode = useMemo(() => {
const autodetectLang = autodetect && !lang;
const cannotDetectLanguage = !autodetectLang && !hljs.getLanguage(lang);
let className = '';
if (!cannotDetectLanguage) {
className = `hljs ${lang}`;
}
// No idea what language to use, return raw code
if (cannotDetectLanguage) {
console.warn(`The language "${lang}" you specified could not be found.`);
return {
value: escapeHtml(code),
className: className
};
}
if (autodetectLang) {
const result = hljs.highlightAuto(code);
return {
value: result.value,
className: className
};
}
const result = hljs.highlight(code, {
language: lang,
ignoreIllegals: ignoreIllegals
});
return {
value: result.value,
className: className
};
}, [code, lang, autodetect, ignoreIllegals]);
return (
<Wrapper>
<CodeHeader
copyValue={copyValue || code}
lang={lang}
copyable={copyable}
theme={props.theme || 'light'}
></CodeHeader>
<pre
className={classNames(
'code-pre custome-scrollbar custom-scrollbar-horizontal ',
{
dark: props.theme === 'dark',
light: props.theme === 'light',
'x-scrollable': xScrollable
}
)}
style={{
marginBottom: 0,
height: height,
...style
}}
>
<code
style={{
minHeight: height,
...(xScrollable ? { width: 'max-content' } : {})
}}
className={classNames(highlightedCode.className, {
dark: props.theme === 'dark',
light: props.theme === 'light'
})}
dangerouslySetInnerHTML={{
__html: highlightedCode.value
}}
></code>
</pre>
</Wrapper>
);
};
export default CodeViewer;
+63
View File
@@ -0,0 +1,63 @@
import useUserSettings from '@/hooks/use-user-settings';
import React from 'react';
import CodeViewerDark from './code-viewer-dark';
import CodeViewerLight from './code-viewer-light';
import './styles/index.less';
const HighlightCode: React.FC<{
code: string;
lang?: string;
copyable?: boolean;
theme?: 'light' | 'dark';
fixedTheme?: 'light' | 'dark';
xScrollable?: boolean;
height?: string | number;
style?: React.CSSProperties;
copyValue?: string;
}> = (props) => {
const {
style,
code,
copyValue,
lang = 'bash',
copyable = true,
theme,
height = 'auto',
xScrollable = false
} = props;
const { userSettings } = useUserSettings();
const currentTheme = React.useMemo(() => {
const res = theme || userSettings.theme === 'realDark' ? 'dark' : 'light';
return res;
}, [theme, userSettings.theme]);
return (
<div className="high-light-wrapper hj-wrapper">
{currentTheme === 'dark' ? (
<CodeViewerDark
lang={lang}
code={code}
copyValue={copyValue}
copyable={copyable}
height={height}
xScrollable={xScrollable}
style={style}
/>
) : (
<CodeViewerLight
style={style}
lang={lang}
code={code}
copyValue={copyValue}
copyable={copyable}
height={height}
xScrollable={xScrollable}
/>
)}
</div>
);
};
export default HighlightCode;
@@ -0,0 +1,106 @@
// @ts-ingore
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
code.hljs {
padding: 3px 5px;
}
/*
Atom One Dark by Daniel Gamage
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
base: #282c34
mono-1: #abb2bf
mono-2: #818896
mono-3: #5c6370
hue-1: #56b6c2
hue-2: #61aeee
hue-3: #c678dd
hue-4: #98c379
hue-5: #e06c75
hue-5-2: #be5046
hue-6: #d19a66
hue-6-2: #e6c07b
*/
.code-pre.dark {
.hljs {
color: #abb2bf;
background: var(--color-editor-dark);
}
.hljs-comment,
.hljs-quote {
color: #5c6370;
font-style: italic;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #c678dd;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #e06c75;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string {
color: #98c379;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-type,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #d19a66;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-title {
color: #61aeee;
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #e6c07b;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}
}
@@ -0,0 +1,84 @@
.high-light-wrapper {
text-align: left;
font-size: var(--font-size-code);
.hljs {
font-weight: var(--font-weight-normal);
padding-inline: 0;
padding-block: 1.2em;
&::-webkit-scrollbar {
height: var(--scrollbar-size);
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 4px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&.light {
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar-thumb);
border-radius: 4px;
}
}
}
&.dark {
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-handle-light-bg);
border-radius: 4px;
}
}
}
}
.code-pre {
padding-inline: 12px 12px;
border-radius: 0 0 var(--border-radius-mini) var(--border-radius-mini);
position: relative;
white-space: pre-wrap;
code {
line-height: 1.6;
}
&.copyable {
padding-inline: 12px 32px;
}
.copy-button {
position: absolute;
top: 6px;
right: 6px;
}
&.dark {
background-color: var(--color-editor-dark);
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-handle-light-bg);
border-radius: 4px;
}
}
}
&.light {
background-color: rgb(250, 250, 250);
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar-thumb);
border-radius: 4px;
}
}
}
}
}

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