Compare commits

..
175 Commits
Author SHA1 Message Date
jialin 5f405e4823 feat: cluster topology entry 2026-05-14 10:43:29 +08:00
jialinandjialin 96b50538ed feat: add antv/g6 2026-05-13 17:58:31 +08:00
gitlawrandjialin 2dae886fd1 fix(gpu-service): use gpustack-{slug} namespace for org resources
The legacy ``getCurrentOrganizationId`` helper returned the raw numeric
org id from localStorage, which the GPU-service / storage call sites
then used as the K8s namespace path segment — producing requests like
``/v2/clusters/1/proxy/.../namespaces/5/instances``. The backend
creates a namespace named ``gpustack-{slug}`` (matching
``get_namespace_name``), so the request hit a namespace that doesn't
exist.

Replace the helper with ``getCurrentOrgNamespace`` that:

- prefers the Org the caller is acting under (numeric
  ``currentOrganizationId`` from localStorage, slug looked up in the
  persisted ``organizationList`` or ``allOrganizations`` cache);
- falls back to the selected cluster's owner Org slug for the admin
  "All" view, where there is no explicit Org context but the resource
  still has to land in some Org's namespace — call sites pass
  ``currentCluster?.owner_principal_id`` through;
- falls back to ``gpustack-default`` only if neither path resolves a
  slug (first load before any cache hydrates, etc.).

``ClusterListItem`` is widened with the optional ``owner_principal_id``
field so TypeScript accepts the fallback argument; the backend has
been returning it via ``ClusterPublic`` all along.
2026-05-13 12:29:09 +08:00
jialinandjialin beb74b3c47 fix: update lock.yaml 2026-05-13 10:52:44 +08:00
jialinandjialin d93ee98473 fix: eslinit context.getDeclaredVariables 2026-05-13 10:47:45 +08:00
jialinandjialin f081f0c1cf chore: dropdown styles token, icons url 2026-05-13 10:47:45 +08:00
jialinandjialin 4e49c399d0 fix: dashboard colors, users rank 2026-05-12 19:01:11 +08:00
jialinandjialin 2527555945 fix: HRM in dev 2026-05-12 14:31:52 +08:00
gitlawrandjialin 5ef671f047 feat: dashboard route gated on canSeeOrgAdmin
Relax the dashboard route from canSeeAdmin (strict platform admin)
to canSeeOrgAdmin so the access seam can widen the audience past
the platform admin — by default platform admin, plus whatever an
access extension chooses to admit. The dashboard endpoint takes
care of scoping the response per caller.

Also move Dashboard under the canSeeOrgAdmin bullet in the
access.ts predicate notes.
2026-05-12 14:20:18 +08:00
gitlawrandjialin 10781da086 fix: plugin locale merge re-runs on HMR; API Key form allows Personal
- locale-merger: drop the module-level dedup Set. addLocale is
  idempotent, and the guard made HMR-added plugin locale keys
  silently skipped — strings like organizations.members.selectUsers
  stayed unresolved until a full dev-server restart.
- api-keys form: pass allowPersonal: true to CreateOrgScopeField so
  admin in Platform-wide mode can target their own Personal Org when
  creating a key (unlike infra resources where Personal doesn't fit).
2026-05-12 14:20:18 +08:00
jialinandjialin c279a7f186 fix: gpu service access 2026-05-11 21:05:08 +08:00
gitlawrandjialin 7dd1c9ac5e refactor(backends): rename organization_id to owner_principal_id
Aligns the InferenceBackend ownership column with the principal-based
ownership model used elsewhere (clusters, model_routes). NULL identifies
platform-curated rows shared across orgs; a populated value identifies
an org-scoped override or custom backend.
2026-05-11 20:28:29 +08:00
gitlawrandjialin 125a5be91f feat: restore cluster detail surface, gate cluster-list name link via plugin
The "chore: hide cluster detail" change had commented out both the
cluster-detail route and the cluster name link in the list. The
detail page component still ships in the OSS tree, so reach is
restored but the link is plugin-gated rather than unconditional:
plain OSS renders the name as a span; with a registered
`clusterDetail.linkableName` flag the name turns into a
`Typography.Link` that fires the parent's existing `onCellClick`
(which navigates to the detail route).

Detail tabs adjustments for the in-cluster context:
- Drop the Deployments tab (deployments are managed from the
  top-level Models page; the detail view is scoped to a single
  cluster's hardware).
- `WorkerList` and `GPUList` accept an optional `clusterId`. When
  set, the list pins `cluster_id` in its query and hides the
  cluster-filter dropdown so the user can't scope away from the
  cluster they're inside.

`clusterDetail.extraTabs(clusterId, intl)` exposes a plugin slot
that appends additional tab items — used by the enterprise plugin
to inject per-cluster Access / Quotas surfaces. No-op without a
plugin.
2026-05-11 20:28:29 +08:00
jialinandjialin e25d48cc89 fix: update react-route version 2026-05-11 20:09:37 +08:00
jialinandjialin 5f96f367a6 chore: lock pnpm version 2026-05-11 19:49:21 +08:00
jialinandjialin 01fe2e66fb fix: style issues 2026-05-11 19:49:21 +08:00
jialinandjialin 1b74285de9 fix: storage instance keep the same cluster 2026-05-11 19:49:21 +08:00
jialinandjialin b13ba0a229 fix: alignment api 2026-05-11 19:49:21 +08:00
jialinandjialin 9e0dd5b003 fix: display templates by manufacturer 2026-05-11 19:49:21 +08:00
jialinandjialin 4128de61f9 fix: api test 2026-05-11 19:49:21 +08:00
jialinandjialin fc900c8ce6 fix: ssh key, template test 2026-05-11 19:49:21 +08:00
jialinandjialin 63ac4e0e75 fix: instance api alignment 2026-05-11 19:49:21 +08:00
jialinandjialin 7ee1635833 fix: template api alignment 2026-05-11 19:49:21 +08:00
jialinandjialin 7d0cfeff77 fix: storage api alignment 2026-05-11 19:49:21 +08:00
jialinandjialin 7fa44644fd style: scroller 2026-05-11 19:49:21 +08:00
jialinandjialin e9152e8725 refactor: add instance modal 2026-05-11 19:49:21 +08:00
jialinandjialin 7ea7f6666d feat: templates, storage 2026-05-11 19:49:21 +08:00
jialinandjialin 3ae49dc826 feat: add intances list 2026-05-11 19:49:21 +08:00
gitlawrandjialin 75d7eff63b chore: bump @gpustack/core-ui to ^1.0.10
1.0.10 ships the fix for the rolldown-emitted `require("react")` shim
that broke 1.0.9 runtime (the dist bundle no longer calls the
runtime require helper for react / react-dom). Pin the OSS host's
package.json spec accordingly and regenerate the lockfile.

Side fix in `pages/login/index.tsx`: 1.0.10's `CoreHooks` type
makes `useCurrentUser` and `useTableFetch` required. The login
page's CoreUIProvider never needed them (no current-user reads
and no tables), so cast the hooks block to `any` rather than
mounting stubs that would never run.
2026-05-11 19:30:21 +08:00
gitlawrandjialin 8618718b71 chore: sync pnpm-lock.yaml with package.json
Two pre-existing drifts between package.json and pnpm-lock.yaml were
breaking `pnpm install --frozen-lockfile` in CI:

- `@gpustack/core-ui` was bumped to `^1.0.9` in package.json without
  regenerating the lockfile (lockfile still pinned the prior 1.0.6
  resolution). Pin to `^1.0.7` instead — 1.0.9 ships a packaging
  glitch that surfaces a `Calling \`require\` for "react"` exception
  at module init under MFSU, so the matching enterprise build was
  already reverted; 1.0.7 is the last known-good across both repos.
- `react-router-dom` had `^6.30.3` in package.json but `6.30.3`
  (no caret) in the lockfile spec. Match the lockfile to keep the
  intentional pin from the original add.

`pnpm install --frozen-lockfile` now passes locally.
2026-05-11 19:30:21 +08:00
gitlawrandjialin 8d17000272 feat: users row actions accept plugin entries
The per-row user actions menu was hard-wired to a fixed list and any
plugin-contributed control had to sit as a separate trigger beside
the dropdown. Move the seam: plugins now contribute dropdown items
directly via `users.rowActions`, and we dispatch the click to the
plugin entry by key while the host's `handleSelect` keeps owning
the built-in keys.

Each entry is `{ key, labelId, icon?, danger?, show?(user), onClick(user) }`.
For UI state the onClick might need (drawers, modals), plugins can
mount a single `components.UsersPageGlobal` slot that the page now
renders once outside the table — typical pattern is a jotai atom
the rowAction writes to and the global slot reads from.

Drop the side-mounted `PluginExtraFields name="UserRowActions"` and
its surrounding `<Space>`; the action column is back to a single
DropdownButtons with all entries in one menu.
2026-05-11 19:30:21 +08:00
gitlawrandjialin df53774628 feat: access-control seam exposes prepended policies + create default
The OSS access-control form currently lets a plugin replace the
`allowed_users` policy entry. Multi-tenancy also wants:
- an extra radio option for "Org-scoped" sitting in front of the
  built-ins, and
- a context-sensitive create-time default (routes inside a non-
  platform Org should land on the Org-scoped policy rather than
  `authed`).

Extend the `accessControl` slot with two more hooks:
- `prependedPolicies?: { policyValue, labelId, tipsId?, Field? }[]`
  — entries prepended to the radio group; each may carry an
  optional content `Field` rendered when selected.
- `resolveCreateDefault?: () => string | undefined` — overrides the
  initial `access_policy` for the create flow; the OSS fallback is
  still `authed`.

The host's tooltip list mirrors the same order. Existing behaviour
without a plugin is unchanged.
2026-05-11 19:30:21 +08:00
gitlawrandjialin 68235e188a fix: refresh Access Settings policy from /access response
Reopening the Access Settings dialog after a save could leave the
radio on the wrong policy: the form initialized `access_policy`
from the route list's snapshot, but the list isn't refreshed when
the dialog closes, so the snapshot's value silently shadowed the
just-saved policy.

Backend now returns the route's authoritative `access_policy` on
`GET /v2/model-routes/{id}/access`. Use that value when the GET
resolves, falling back to the parent snapshot only as the initial
seed so the radio isn't briefly unselected.

While we're here, widen the `access_policy` type from a closed
union to `string` — plugins can contribute additional policy
values via the `accessControl.allowedUsersOverride` slot, so the
wire type shouldn't restrict to the OSS-side enum.
2026-05-11 19:30:21 +08:00
gitlawrandjialin f37fad11b1 feat: access-control modal seam to swap the allowed_users surface
The OSS Access Settings form hardcodes the `allowed_users` policy
(per-user explicit grants via the legacy transfer widget). Multi-
tenancy needs a different surface that grants by Org / Group / User
principals via the `/v2/model-routes/{id}/principals` endpoints, and
the enterprise plugin will ship that — but the OSS form should stay
visually unchanged when no plugin is loaded.

Add a plugin extension point on the form: if a registered plugin
exposes `accessControl.allowedUsersOverride: { policyValue, labelId,
tipsId?, Field }`, the form replaces the `allowed_users` radio entry
with the plugin's labelled option, swaps the access-scope tooltip
copy, and renders `<Field form routeId action />` for the override
policy. Without a plugin the form's radio / tooltip / content path
are unchanged.

`handleOnFinish` continues to send `users` only for `allowed_users`;
any other policy (including the plugin's value) goes through with
an empty users list, leaving the plugin's Field to manage its own
principal CRUD inline.
2026-05-11 19:30:21 +08:00
gitlawrandjialin 93fa277bdd fix: Access Control Modal user picker uses /user-directory
The form populated its candidate list via the admin-only
`GET /v2/users`, so opening Route Access Settings as a non-admin
caller (e.g. an org owner managing their own route in the
enterprise plugin) returned 403. Add a `queryUserDirectory` client
wrapper around the slim `/v2/user-directory` endpoint (gated to
platform admin OR org owner) and switch `getUserList` over. Response
shape is the same so the rest of the form is unchanged.
2026-05-11 19:30:21 +08:00
jialinandjialin ea7fa63079 chore(deps): add react-route 2026-05-11 14:47:45 +08:00
jialinandjialin d15db4b4dd chore: update ui 1.0.9 2026-05-11 14:32:07 +08:00
jialinandjialin ed06d57a31 feat: add slots 2026-05-11 14:27:49 +08:00
gitlawr 168c55990f fix: pick landing route by access predicate to avoid redirect loop
Access extensions can narrow ``canSeeAdmin`` to ``false`` even when
``currentUser.is_admin`` is ``true``. The default-landing logic
keyed off ``is_admin`` and pushed admin to ``/dashboard``; when
``canSeeAdmin`` is narrowed there, ``Exception`` 403-redirects to
``/``, which re-pushes ``/dashboard``, looping into a blank screen.
Switch the landing pick (and the masthead-click handler) to consult
the resolved ``access.canSeeAdmin`` predicate instead.

Behavior is unchanged for the default access module, where
``canSeeAdmin`` mirrors ``is_admin``.
2026-05-09 17:22:27 +08:00
gitlawrandjialin 2889ba7c78 feat: plugin extension slots for access, request interceptors, Users actions, and form fields
Four purely-additive seams that let build-time tooling extend host
behaviour without forking files:

* `src/access.extensions.ts` — identity `applyAccessExtensions`
  (mirrors `config/routes.extensions.ts`). `src/access.ts` runs the
  resolved predicate object through it. Adds two new predicates,
  `canSeeOrgAdmin` and `canManageCurrentOrg`, alongside the existing
  `canSeeAdmin`. Resources, Models children, Cluster Management, and
  Resources/Backends are retagged from `canSeeAdmin` to
  `canSeeOrgAdmin`. Users and Dashboard stay strict.
* `src/request.extensions.ts` — identity-empty
  `extraRequestInterceptors`. `src/request-config.tsx` spreads it
  into the existing `requestInterceptors` list so extensions can
  inject context-aware headers without forking the request config.
* Users page action column — renders
  `getGPUStackPlugin()?.components?.UserRowActions` next to the
  existing DropdownButtons inside a Space when a plugin component
  is registered. If absent, the cell renders exactly as before.
* `src/components/plugin-extra-fields.tsx` — generic component-slot
  helper. Renders `pluginManager.components.<name>` if registered,
  forwarding a `context` payload. Used by create/edit forms to let
  plugins inject extra `Form.Item` fields. Mounted in the relevant
  create forms — API Keys, Cloud Credentials, Clusters, Model
  Routes, Model Providers, and Inference Backends — under the slot
  name `CreateOrgScopeField`. Resources whose org is implicit from a
  parent (Models / Workers / Benchmarks / Worker Pools / Model Files
  inherit from the chosen Cluster) deliberately don't mount the slot.
2026-05-08 18:29:52 +08:00
gitlawrandjialin 896b8ef326 feat: expose useTableFetch via CoreUIProvider
Inject the host's `useTableFetch` hook into the CoreUIProvider's
`hooks` prop alongside the already-wired `useCurrentUser`, so plugins
that import `useTableFetch` from `@gpustack/core-ui` resolve it through
the host instead of throwing
"`hooks.useTableFetch is not a function`" at the first list-page render.

Companion to the earlier `feat: add use current user` change — core-ui
exposes both hooks as host-injected, but only the user hook had been
plumbed.
2026-05-08 14:46:35 +08:00
jialinandjialin f08d5c904c refactor: dynamic routes 2026-05-08 09:33:16 +08:00
jialinandjialin 4be5fb4d3d feat: add use current user 2026-05-07 18:26:48 +08:00
jialinandjialin a2d5d55518 chore: backend default command 2026-05-07 18:26:48 +08:00
jialinandjialin e1b28b01df chore: backend placeholder 2026-05-07 18:26:48 +08:00
gitlawrandjialin b6bbe880a0 feat(plugin): expose OrgSwitcher slot in extraRender
Add a single host-side mount point in extraRender.tsx:

    const OrgSwitcher = pluginManager?.components?.OrgSwitcher;
    ...
    {OrgSwitcher && <OrgSwitcher />}

A registered plugin can supply an Org switcher via
components.OrgSwitcher. When no plugin provides it, the host
renders nothing here, so the build is visually unchanged.
2026-05-07 18:16:42 +08:00
jialinandjialin 65ad57fc4c fix: iluvatar env check command 2026-05-06 19:32:01 +08:00
jialinandjialin 469e63b898 fix: keep the source deploying from files 2026-05-06 15:09:15 +08:00
jialinandjialin d471233f83 fix: sync row replicas when start 2026-05-06 14:43:03 +08:00
jialinandjialin 01576212a3 fix: bool type in python code 2026-05-06 14:43:03 +08:00
jialinandjialin 898c7f1be2 refactor: dashboard usage 2026-05-06 12:27:22 +08:00
jialinandjialin 705b33e307 style: parameters tooltip 2026-05-06 12:27:22 +08:00
jialinandjialin 2592f44bc2 fix: append routeID to model proxy URL and drop X-GPUStack-Model header 2026-05-06 12:27:22 +08:00
jialinandjialin 9914db6b96 fix: hide distributed_inference for custom backend 2026-04-29 20:51:48 +08:00
jialinandjialin bb93ad37a1 feat: benchmark inject params 2026-04-29 20:51:48 +08:00
jialinandjialin 93bc632088 fix: stt error handler 2026-04-29 20:51:48 +08:00
jialinandjialin ab89ddffc8 fix: tts stream error 2026-04-29 20:51:48 +08:00
jialinandjialin 7794e57bb1 fix: update lock file 2026-04-29 18:21:24 +08:00
jialinandjialin 7c15bb6ef6 chore(deps): update ui 1.0.5 2026-04-29 18:09:55 +08:00
jialinandjialin ac99156c90 feat: ip access control 2026-04-29 18:09:55 +08:00
jialinandjialin c68f546be9 fix: apikey filters 2026-04-29 18:09:55 +08:00
jialinandjialin 217285223c fix: list-input, labels-selector form 2026-04-29 18:09:55 +08:00
jialinandjialin 3144328874 fix: chart flicker 2026-04-29 09:29:04 +08:00
jialinandjialin 26880b4612 refactor: usage chart 2026-04-29 09:29:04 +08:00
jialinandjialin e7aac14ef1 fix: keep previous run checkbox visible and fall back to main worker 2026-04-28 14:41:36 +08:00
jialinandjialin 52211cd4ea fix: previous run description 2026-04-28 14:41:36 +08:00
jialinandjialin 331c59b7c1 fix: logs container 2026-04-28 14:41:36 +08:00
jialinandjialin b605b17561 fix: main worker flag 2026-04-28 14:41:36 +08:00
jialinandjialin 33163ac96c style: start history selection 2026-04-28 14:41:36 +08:00
jialinandjialin 757af11ab3 chore: history options 2026-04-28 14:41:36 +08:00
jialinandjialin 81a87ef362 chore: logs history selection 2026-04-28 14:41:36 +08:00
jialinandjialin 0cb260f854 feat: logs history 2026-04-28 14:41:36 +08:00
jialinandjialin 0ffc2ee924 chore: update ui version 2026-04-27 21:10:34 +08:00
jialinandjialin e4992e8313 fix: custom logos 2026-04-27 21:10:34 +08:00
jialinandjialin 519020077a chore: update lock file 2026-04-24 14:28:30 +08:00
jialinandjialin 6fccee3711 fix: impoer external components 2026-04-24 14:28:30 +08:00
jialinandjialin 597a86562a fix: rebase main 2026-04-24 14:28:30 +08:00
jialinandjialin 16449613e0 chore: core-ui deps 2026-04-24 14:28:30 +08:00
jialinandjialin 6c161c6ae7 build: sync config 2026-04-24 14:28:30 +08:00
jialinandjialin 48b357b1c6 chore: update hooks import 2026-04-24 14:28:30 +08:00
jialinandjialin a795544074 fix: CInput not found 2026-04-24 14:28:30 +08:00
jialinandjialin 8711c27b78 chore: replace components with core-ui, upgrade eslint 2026-04-24 14:28:30 +08:00
jialinandjialin d48aa99dcc fix: deleted tag 2026-04-22 13:16:48 +08:00
jialinandjialin 5f3b9b88ff fix: reset page after filters changed 2026-04-22 13:16:48 +08:00
jialinandjialin f566ea3b63 fix: table sorter 2026-04-22 13:16:48 +08:00
jialinandjialin 8ba77beeed fix: sync filters 2026-04-22 13:16:48 +08:00
jialinandjialin 128f59ed6f chore: locales 2026-04-22 13:16:48 +08:00
jialinandjialin c92f56ec8f style: card style 2026-04-22 13:16:48 +08:00
jialinandjialin daa3d74500 fix: usage datepicker 2026-04-22 13:16:48 +08:00
jialinandjialin ad7fc3a993 fix: usage ux 2026-04-22 13:16:48 +08:00
jialinandjialin 084822814d fix: models, apikeys Cascader 2026-04-22 13:16:48 +08:00
jialinandjialin 3a58bd1eef fix: show tag only one selected 2026-04-22 13:16:48 +08:00
jialinandjialin 94d3fa356c fix: usage filter 2026-04-22 13:16:48 +08:00
jialinandjialin 7d75714662 feat: usage 2026-04-22 13:16:48 +08:00
jialinandjialin b057d52499 fix: switch model do not work in chat 2026-04-17 20:35:16 +08:00
jialinandjialin 2d726ded30 feat: admin apikeys 2026-04-17 20:35:16 +08:00
jialinandjialin f45d9bda61 style: font size 2026-04-16 15:16:47 +08:00
jialinandjialin 684c1ab600 fix: apikey model access 2026-04-16 15:03:59 +08:00
jialinandjialin 3ac2720ba8 chore: remove experimental from metax 2026-04-16 15:03:59 +08:00
jialinandjialin 7b743f35b3 fix: apikey custom tag 2026-04-15 11:30:40 +08:00
jialinandjialin e1b2a4aee2 fix: python test code 2026-04-15 10:15:36 +08:00
jialinandjialin 308c350582 style: server version 2026-04-15 10:15:36 +08:00
jialinandjialin 41c9b86fd9 fix: k8s volume config 2026-04-14 20:42:01 +08:00
jialinandjialin 46b9855c87 fix: custom apikey 2026-04-14 20:42:01 +08:00
jialinandjialin c667de90ed style: worker versio info 2026-04-14 20:42:01 +08:00
jialinandjialin cf13e3bed0 fix: volume mounts default empty 2026-04-10 15:33:59 +08:00
jialinandjialin 0956538b79 fix: volume name update while typing 2026-04-10 15:17:58 +08:00
jialinandjialin f2a4ab2654 feat: k8s volume mounts 2026-04-10 15:17:58 +08:00
jialinandjialin aeabdab781 fix(style): all scope tag 2026-04-10 09:52:36 +08:00
jialinandjialin f04b5cdc27 feat: custom api key 2026-04-09 21:27:50 +08:00
jialinandjialin b947e3a8b9 fix: show gpu driver version 2026-04-09 21:27:50 +08:00
jialinandjialin ae3148a86f feat: validate tts form items 2026-04-09 12:14:36 +08:00
jialinandjialin 78bf76e965 fix: python code for eidting image 2026-04-09 12:14:36 +08:00
jialinandjialin 7beb5756f7 fix: chat content height 2026-04-09 12:14:36 +08:00
jialinandjialin a2b0236ea3 refactor: table filters ux 2026-04-08 18:54:06 +08:00
jialinandjialin 144914110a feat: more filters component 2026-04-08 18:54:06 +08:00
jialinandjialin c73609d155 fix: adjust border raduis 2026-04-08 18:54:06 +08:00
jialinandjialin 53cd8ba00c fix: page error while route changing 2026-04-08 18:54:06 +08:00
jialinandjialin 1c09cc6c8c refactor: ui/ux 2026-04-08 18:54:06 +08:00
jialinandjialin 06160546a6 feat: show the worker version 2026-03-31 15:05:38 +08:00
jialinandjialin 0b292829fa fix(style): select content align 2026-03-31 15:05:38 +08:00
jialinandjialin 392c51fced chore: instance state filter 2026-03-26 18:20:55 +08:00
jialinandjialin 144d5a6d5f style: instance cell style 2026-03-26 18:20:55 +08:00
jialinandjialin 43dfc7e1f0 fix: normalize parameter string by removing line-continuation backslashes 2026-03-26 18:20:55 +08:00
jialinandjialin adde7fba37 feat: add instance view 2026-03-26 18:20:55 +08:00
jialinandjialin 33fe180997 refactor: model instance item 2026-03-26 18:20:55 +08:00
jialinandjialin a95b7e9561 feat: add instance view 2026-03-26 18:20:55 +08:00
jialinandjialin 08330e0b82 fix: row-textarea upload img 2026-03-26 17:59:33 +08:00
jialinandjialin c74118e913 refactor: embedding messages format 2026-03-26 17:59:33 +08:00
jialinandjialin bb3cef89c1 fix(style): dashboard usage filters 2026-03-26 17:59:33 +08:00
jialinandjialin 589936199c chore: sharegpt description 2026-03-26 17:59:33 +08:00
jialinandjialin 710e2e1996 fix: a non-running instance is selected in creating benchmark 2026-03-26 17:59:33 +08:00
bakiburakogunandjialin aa794c7646 fix(i18n): add missing ShareGPT profile and fix untranslated model tips in tr-TR 2026-03-25 09:48:20 +08:00
jialinandjialin 48779aed29 fix: add ShareGPT profile 2026-03-24 16:29:41 +08:00
jialinandjialin 3907e1ac76 fix(style): hint-input minwidth 2026-03-24 16:29:41 +08:00
jialinandjialin 4d6af4fbf8 fix: copybutton do not work 2026-03-24 10:37:15 +08:00
bakiburakogunandjialin 9c3b0efb30 revert: restore fileName: any for consistency with other locale files 2026-03-23 15:55:07 +08:00
Baki Burak Öğünjialingemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
8bcb7ffe4d Update src/locales/tr-TR.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-23 15:55:07 +08:00
bakiburakogunandjialin 77f0740ec9 feat(i18n): add Turkish (tr-TR) locale translation
Add complete Turkish localization for the GPUStack UI including all 18 translation files covering common UI, menus, dashboard, models, playground, resources, API keys, users, backends, benchmarks, clusters, providers, routes, shortcuts, usage, and vendor names.
2026-03-23 15:55:07 +08:00
jialinandjialin 8b5e407758 fix: alert info height 2026-03-20 17:23:55 +08:00
jialinandjialin 6c88a5554f fix: bedrock required fields 2026-03-20 17:23:55 +08:00
jialinandjialin d779ee06b1 fix: typos: position 2026-03-20 17:23:55 +08:00
jialinandjialin f873d4e245 fix: show required fields for provider 2026-03-20 17:23:55 +08:00
jialinandjialin 0a0e03dc68 fix: open the advanced when configured 2026-03-20 17:23:55 +08:00
jialinandjialin f0e19c8934 fix: spin size 2026-03-19 12:28:18 +08:00
jialinandjialin a6af51dc32 fix: delay querying backends 2026-03-19 12:28:18 +08:00
jialinandjialin fd1a079271 refactor: deployments dir tree 2026-03-19 12:28:18 +08:00
jialinandjialin 9840a48301 fix: depercated props 2026-03-19 12:28:18 +08:00
jialinandjialin 2dc7c9adf1 feat: show the apikey mask value 2026-03-17 16:35:55 +08:00
jialinandjialin a86bfe7fd3 chore: upgrade antd 6.3.3 2026-03-17 16:35:55 +08:00
jialinandjialin d99475bb80 style: table overflow scroller style 2026-03-17 16:35:55 +08:00
jialinandjialin 194b7aff2e fix: autofilled in add user form 2026-03-17 16:35:55 +08:00
jialinandjialin f6d5733183 fix: video gen 2026-03-17 16:35:55 +08:00
jialinandjialin 0ca055b8e8 fix: parser multiple backend pramas on paste 2026-03-17 16:35:55 +08:00
jialinandjialin 9aae2c7f50 fix: use html audio to load stream 2026-03-17 16:35:55 +08:00
jialinandjialin c29ad1270b fix: stt streaming response handler 2026-03-17 16:35:55 +08:00
jialinandjialin 49a60c25af feat: add streaming for stt 2026-03-17 16:35:55 +08:00
jialinandjialin 39bcc158bb feat: add stream mode for tts 2026-03-17 16:35:55 +08:00
jialinandjialin 4f02bcd40c feat: paste multiple lines in parameters input 2026-03-17 16:35:55 +08:00
jialinandjialin 556c4a434d chore: benchmark profile filter 2026-03-17 16:35:55 +08:00
jialinandjialin d389b035b7 refactor: playground params form 2026-03-17 16:35:55 +08:00
jialinandjialin 9447088f89 refactor: adjust playground pages dir 2026-03-17 16:35:55 +08:00
jialinandjialin 64955c26a7 refactor: playground params container 2026-03-17 16:35:55 +08:00
jialinandjialin 68519223fa fix: pagination cache for table list 2026-03-17 16:35:55 +08:00
jialinandjialin e72cacc4b2 chore: autocomplete user info 2026-03-17 16:35:55 +08:00
jialinandjialin ed4be2328f chore: store pagination for list 2026-03-17 16:35:55 +08:00
jialinandjialin e241c6cb6f refactor: embeddding input list 2026-03-17 16:35:55 +08:00
jialinandjialin 6e98fb4274 fix: reset filter in export modal 2026-03-16 14:48:46 +08:00
jialinandjialin 83f6f41787 fix: show backend warning hint after submitting in editing mode 2026-03-16 14:48:46 +08:00
jialinandjialin 18c10e5839 chore: update model cols 2026-03-16 14:48:46 +08:00
jialinandjialin 11de47c813 style: same key tooltip 2026-03-16 14:48:46 +08:00
jialinandjialin 85ea619b18 fix: title for editing apikey 2026-03-16 14:48:46 +08:00
jialinandjialin 3f57cf21f7 feat: add profile filter in benchmark 2026-03-16 14:48:46 +08:00
jialinandjialin b75c773879 chore: add nvidia notes 2026-03-16 14:48:46 +08:00
jialinandjialin 95070ae382 feat: display provider models 2026-03-16 14:48:46 +08:00
811 changed files with 38947 additions and 39612 deletions
-1
View File
@@ -1 +0,0 @@
src/components/icon-font/iconfont/iconfont.js
-16
View File
@@ -1,16 +0,0 @@
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 -1
View File
@@ -12,4 +12,6 @@
/.mfsu
.swc
.DS_Store
.idea
.idea
.claude
/dist.zip
+2 -5
View File
@@ -1,14 +1,11 @@
{
"*.{md,json}": ["prettier --cache --write"],
"*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"],
"*.{css,less}": [
"max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.{css,less}": ["prettier --cache --write"],
"!public/vs/**": [],
"*.ts?(x)": [
"max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write"
],
"src/locales/**/*.ts": ["npx tsx src/locales/check.ts"]
"src/locales/**/*.ts": ["node --import tsx src/locales/check.ts"]
}
+46
View File
@@ -0,0 +1,46 @@
## 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.
+6 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from '@umijs/max';
import keepAlive from './keep-alive';
import { extraMfsuExclude } from './mfsu.extensions';
import { compressionPluginConfig, monacoPluginConfig } from './plugins';
import proxy from './proxy';
import routes from './routes';
@@ -19,6 +20,9 @@ export default defineConfig({
history: {
type: 'hash'
},
define: {
'process.env.ENABLE_ENTERPRISE': process.env.ENABLE_ENTERPRISE
},
analyze: {
analyzerMode: 'server',
analyzerPort: 8888,
@@ -29,7 +33,7 @@ export default defineConfig({
defaultSizes: 'parsed' // stat // gzip
},
mfsu: {
exclude: ['lodash', 'ml-pca']
exclude: ['lodash', 'ml-pca', ...extraMfsuExclude]
},
base: process.env.npm_config_base || '/',
...(isProduction
@@ -73,6 +77,7 @@ export default defineConfig({
antd: {
style: 'less'
},
title: 'GPUStack',
hash: true,
access: {},
model: {},
+9
View File
@@ -0,0 +1,9 @@
// 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
@@ -0,0 +1,3 @@
// 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;
+126 -45
View File
@@ -1,6 +1,7 @@
import { keepAliveRoutes } from './keep-alive';
import { applyRouteExtensions } from './routes.extensions';
export default [
const baseRoutes = [
{
name: 'dashboard',
path: '/dashboard',
@@ -8,7 +9,10 @@ export default [
icon: 'icon-dashboard',
selectedIcon: 'icon-dashboard-filled',
defaultIcon: 'icon-dashboard',
access: 'canSeeAdmin',
// `canSeeOrgAdmin` widens to anyone the access seam grants
// admin-ish visibility — by default platform admin, plus
// whatever the routes extension chooses to allow.
access: 'canSeeOrgAdmin',
component: './dashboard',
routes: []
},
@@ -32,7 +36,7 @@ export default [
icon: 'icon-chat',
selectedIcon: 'icon-chat-filled',
defaultIcon: 'icon-chat',
component: './playground/index'
component: './playground/chat/index'
},
{
name: 'embedding',
@@ -42,7 +46,7 @@ export default [
icon: 'icon-embedding',
selectedIcon: 'icon-embedding-filled',
defaultIcon: 'icon-embedding',
component: './playground/embedding'
component: './playground/embedding/index'
},
{
name: 'rerank',
@@ -52,7 +56,7 @@ export default [
icon: 'icon-reranker',
selectedIcon: 'icon-reranker-filled',
defaultIcon: 'icon-reranker',
component: './playground/rerank'
component: './playground/rerank/index'
},
{
name: 'text2images',
@@ -62,7 +66,7 @@ export default [
icon: 'icon-image1',
selectedIcon: 'icon-image-filled',
defaultIcon: 'icon-image1',
component: './playground/images'
component: './playground/images/index'
},
{
name: 'speech',
@@ -72,8 +76,19 @@ export default [
icon: 'icon-audio1',
selectedIcon: 'icon-audio-filled',
defaultIcon: 'icon-audio1',
component: './playground/speech'
component: './playground/speech/index'
}
// {
// 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'
// }
]
},
{
@@ -92,40 +107,9 @@ export default [
icon: 'icon-layers',
selectedIcon: 'icon-layers-filled',
defaultIcon: 'icon-layers',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
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',
path: '/models/user-models',
@@ -136,6 +120,45 @@ export default [
access: 'canSeeUser',
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: 'usage',
path: '/models/usage',
key: 'usage',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
component: './usage/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',
path: '/models/benchmark',
@@ -143,7 +166,7 @@ export default [
icon: 'icon-speed',
selectedIcon: 'icon-speed-filled',
defaultIcon: 'icon-speed',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
component: './benchmark/index'
},
{
@@ -153,17 +176,64 @@ export default [
icon: 'icon-speed',
selectedIcon: 'icon-speed-filled',
defaultIcon: 'icon-speed',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
hideInMenu: true,
component: './benchmark/details'
}
]
},
{
name: 'gpuService',
path: '/gpu-service',
key: 'gpuService',
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-storage-outlined',
selectedIcon: 'icon-storage-filled',
defaultIcon: 'icon-storage-outlined',
component: './gpu-service/storage'
},
{
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'
}
]
},
{
name: 'resources',
path: '/resources',
key: 'resources',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
routes: [
{
path: '/resources',
@@ -194,7 +264,7 @@ export default [
icon: 'icon-backend',
selectedIcon: 'icon-backend-filled',
defaultIcon: 'icon-backend',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
component: './backends/index'
},
{
@@ -212,7 +282,7 @@ export default [
name: 'clusterManagement',
path: '/cluster-management',
key: 'clusterManagement',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
routes: [
{
path: '/cluster-management',
@@ -256,7 +326,6 @@ export default [
name: 'accessControl',
path: '/access-control',
key: 'accessControl',
access: 'canSeeAdmin',
routes: [
{
path: '/access-control',
@@ -269,7 +338,17 @@ export default [
icon: 'icon-users',
selectedIcon: 'icon-users-filled',
defaultIcon: 'icon-users',
access: 'canSeeAdmin',
component: './users'
},
{
name: 'apikeys',
path: '/access-control/api-keys',
key: 'apikeys',
selectedIcon: 'icon-key-filled',
icon: 'icon-key',
defaultIcon: 'icon-key',
component: './api-keys'
}
]
},
@@ -307,3 +386,5 @@ export default [
component: './404'
}
];
export default applyRouteExtensions(baseRoutes);
+78
View File
@@ -0,0 +1,78 @@
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/'
]),
{
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 }]
}
}
]);
+21 -15
View File
@@ -1,9 +1,9 @@
{
"private": true,
"author": "jialin",
"author": "gpustack",
"scripts": {
"build": "max build",
"check:locales": "npx tsx ./src/locales/check.ts",
"check:locales": "node --import tsx ./src/locales/check.ts",
"dev": "max dev",
"format": "prettier --cache --write .",
"postinstall": "max setup",
@@ -12,13 +12,12 @@
"setup": "max setup",
"start": "npm run dev"
},
"resolutions": {
"immer": "^9.0.6"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.10",
"@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6",
@@ -31,29 +30,27 @@
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.5",
"ansi-to-html": "^0.7.2",
"antd": "^6.1.2",
"antd": "^6.3.3",
"antd-style": "^3.6.2",
"axios": "^1.8.2",
"classnames": "^2.5.1",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
"culori": "^4.0.2",
"dayjs": "^1.11.11",
"dompurify": "^3.2.6",
"driver.js": "^1.3.1",
"echarts": "^5.5.1",
"epubjs": "^0.3.93",
"file-saver": "^2.0.5",
"has-ansi": "^5.0.1",
"highlight.js": "^11.10.0",
"jdenticon": "^3.3.0",
"jotai": "^2.8.4",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"katex": "^0.16.21",
"lamejs": "github:zhuker/lamejs",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mammoth": "^1.8.0",
"marked": "^14.1.0",
"minimatch": "^3.1.2",
"ml-dataset-iris": "^1.2.1",
@@ -63,7 +60,6 @@
"numeral": "^2.0.6",
"overlayscrollbars": "^2.10.0",
"overlayscrollbars-react": "^0.5.6",
"pdfjs-dist": "^4.7.76",
"query-string": "^9.0.0",
"rc-resize-observer": "^1.4.3",
"rc-virtual-list": "^3.14.8",
@@ -73,6 +69,7 @@
"react-hotkeys-hook": "^4.5.0",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^9.0.3",
"react-router-dom": "^6.30.3",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
@@ -80,26 +77,33 @@
"semver": "^7.7.3",
"simplebar-react": "^3.2.6",
"styled-components": "^6.1.15",
"tinycolor2": "^1.6.0",
"umi-presets-pro": "^2.0.3",
"wavesurfer.js": "^7.8.8",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
"wavesurfer.js": "^7.8.8"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^25.0.3",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/semver": "^7.7.1",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1",
"@umijs/plugins": "^4.4.11",
"babel-plugin-named-asset-import": "^0.3.8",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"compression-webpack-plugin": "^11.1.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"eslint": "^8.56.0",
"eslint-plugin-unused-imports": "^3.2.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"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",
"file-loader": "^6.2.0",
"globals": "^17.4.0",
"husky": "^9.0.11",
"less-loader": "^12.2.0",
"lint-staged": "^15.2.2",
@@ -111,10 +115,12 @@
"prettier-plugin-two-style-order": "^1.0.1",
"tsx": "^4.19.3",
"typescript": "^5.4.5",
"typescript-eslint": "^8.58.0",
"url-loader": "^4.1.1",
"webpack-bundle-analyzer": "^4.10.2",
"worker-loader": "^3.0.8"
},
"packageManager": "pnpm@9.3.0",
"pnpm": {
"overrides": {
"elliptic": "^6.6.1"
+13977 -10023
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,14 @@
// 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;
+20 -4
View File
@@ -1,5 +1,7 @@
import { applyAccessExtensions } from './access.extensions';
export default (initialState: { currentUser?: Global.UserInfo }) => {
const canSeeAdmin = !!(
const isPlatformAdmin = !!(
initialState &&
initialState.currentUser &&
initialState.currentUser.is_admin
@@ -10,10 +12,24 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
!initialState.currentUser.is_admin
);
return {
canSeeAdmin,
// Predicate roles, top-down by strictness:
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
// 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.
// * `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,
canManageCurrentOrg: false,
canSeeUser,
canDelete: true,
canLogin: true
};
});
};
+28 -3
View File
@@ -1,6 +1,10 @@
import { userSettingsHelperAtom } from '@/atoms/settings';
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
import { setAtomStorage } from '@/atoms/utils';
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
import { COLOR_PRIMARY } from '@/config/theme/constants';
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
import { GPUStackPluginManager } from '@/plugins/manager';
import { requestConfig } from '@/request-config';
import {
queryCurrentUserState,
@@ -14,7 +18,8 @@ import {
readState,
writeState
} from '@/utils/localstore/index';
import { RequestConfig, history } from '@umijs/max';
import '@gpustack/core-ui/style.css';
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
import { message } from 'antd';
// only for the first login and access from http://localhost
@@ -33,9 +38,27 @@ const checkDefaultPage = async (userInfo: any) => {
export async function getInitialState(): Promise<{
fetchUserInfo: () => Promise<Global.UserInfo>;
currentUser?: Global.UserInfo;
pluginData?: Record<string, any>;
}> {
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 () => {
try {
const data = await updateCheck();
@@ -103,11 +126,13 @@ export async function getInitialState(): Promise<{
checkDefaultPage(userInfo);
return {
fetchUserInfo,
currentUser: userInfo
currentUser: userInfo,
pluginData
};
}
return {
fetchUserInfo
fetchUserInfo,
pluginData
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+16
View File
@@ -293,6 +293,10 @@
gap: 16px;
}
.items-center {
align-items: center;
}
.color-white-tertiary {
color: var(--color-white-tertiary);
}
@@ -368,3 +372,15 @@ textarea:hover {
.line-6 {
line-height: 24px;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
+12
View File
@@ -8,3 +8,15 @@
}
}
}
.scroll-table {
.ant-table {
.ant-table-container {
.ant-table-body,
.ant-table-content {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar-thumb) transparent;
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const activeModelsAtom = atom<any[]>([]);
+6
View File
@@ -0,0 +1,6 @@
import { ClusterListItem } from '@/pages/cluster-management/config/types';
import { atom } from 'jotai';
export const currentClusterAtom = atom<
(Partial<ClusterListItem> & { label?: string; value?: number }) | null
>(null);
+20
View File
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,25 @@
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
});
+72
View File
@@ -27,3 +27,75 @@ export const initialPasswordAtom = atomWithStorage<string>(
'initialPassword',
''
);
// Namespace the server creates for an Org's resources on each Kubernetes
// cluster. The format must match the backend's ``get_namespace_name``
// helper — ``gpustack-{slug}`` — because the GPU-instance / storage CRDs
// (worker.gpustack.ai/v1) are namespaced and the server-side admission
// keys off this exact name.
//
// 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 slug might still cover the
// cluster-owner fallback.
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
const lookupOrgNamespace = (id: number | null): string | null => {
if (id == null) return null;
// Normalise both sides to strings — the stored id type varies between
// localStorage payloads (some writers stringify, others persist as a
// JSON number); strict equality would silently miss those cases.
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 Array<{ id: number; slug?: string }>;
if (!Array.isArray(list)) continue;
const match = list.find((item) => String(item?.id) === target);
if (match?.slug) return `gpustack-${match.slug}`;
} catch {
// ignore malformed cache; continue checking other keys
}
}
return null;
};
-182
View File
@@ -1,182 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,19 +0,0 @@
.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
@@ -1,192 +0,0 @@
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);
@@ -1,22 +0,0 @@
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;
@@ -1,39 +0,0 @@
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
@@ -1,103 +0,0 @@
.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
@@ -1,311 +0,0 @@
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);
@@ -1,165 +0,0 @@
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;
@@ -1,406 +0,0 @@
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
@@ -1,21 +0,0 @@
.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
@@ -1,131 +0,0 @@
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;
@@ -1,44 +0,0 @@
.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
@@ -1,144 +0,0 @@
.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
@@ -1,208 +0,0 @@
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
@@ -1,151 +0,0 @@
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
@@ -1,28 +0,0 @@
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
@@ -1,47 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,16 +0,0 @@
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
@@ -1,109 +0,0 @@
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
@@ -1,43 +0,0 @@
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);
@@ -1,57 +0,0 @@
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
@@ -1,219 +0,0 @@
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
@@ -1,17 +0,0 @@
.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
@@ -1,24 +0,0 @@
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
@@ -1,156 +0,0 @@
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
@@ -1,222 +0,0 @@
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
@@ -1,20 +0,0 @@
.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
@@ -1,6 +0,0 @@
import styles from './index.less';
const DividerLine: React.FC = () => {
return <div className={styles['divider-line']}></div>;
};
export default DividerLine;
@@ -1,40 +0,0 @@
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;
@@ -1,4 +0,0 @@
.dropdown-button.middle {
height: 28px;
width: 28px;
}
-150
View File
@@ -1,150 +0,0 @@
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;
@@ -1,19 +0,0 @@
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;
@@ -1,59 +0,0 @@
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;
@@ -1,175 +0,0 @@
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;
@@ -1,39 +0,0 @@
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;
}
@@ -1,33 +0,0 @@
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;
@@ -1,61 +0,0 @@
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
@@ -1,26 +0,0 @@
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
@@ -1,107 +0,0 @@
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
@@ -1,75 +0,0 @@
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
@@ -1,150 +0,0 @@
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
@@ -1,236 +0,0 @@
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
@@ -1,97 +0,0 @@
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
@@ -1,185 +0,0 @@
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
@@ -1,60 +0,0 @@
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
@@ -1,172 +0,0 @@
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
@@ -1,176 +0,0 @@
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
@@ -1,234 +0,0 @@
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
@@ -1,45 +0,0 @@
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
@@ -1,24 +0,0 @@
.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
@@ -1,66 +0,0 @@
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
@@ -1,109 +0,0 @@
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
@@ -1,34 +0,0 @@
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
@@ -1,44 +0,0 @@
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;
@@ -1,43 +0,0 @@
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;
@@ -1,44 +0,0 @@
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;
@@ -1,172 +0,0 @@
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
@@ -1,63 +0,0 @@
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;
@@ -1,106 +0,0 @@
// @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;
}
}
@@ -1,84 +0,0 @@
.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;
}
}
}
}
}
@@ -1,106 +0,0 @@
// @ts-ingore
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
code.hljs {
padding: 3px 5px;
}
/*
Atom One Light by Daniel Gamage
Original One Light Syntax theme from https://github.com/atom/one-light-syntax
base: #fafafa
mono-1: #383a42
mono-2: #686b77
mono-3: #a0a1a7
hue-1: #0184bb
hue-2: #4078f2
hue-3: #a626a4
hue-4: #50a14f
hue-5: #e45649
hue-5-2: #c91243
hue-6: #986801
hue-6-2: #c18401
*/
.code-pre.light {
.hljs {
color: #383a42;
background: var(--color-editor-light);
}
.hljs-comment,
.hljs-quote {
color: #a0a1a7;
font-style: italic;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #a626a4;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #e45649;
}
.hljs-literal {
color: #0184bb;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string {
color: #50a14f;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-type,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #986801;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-title {
color: #4078f2;
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #c18401;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}
}
-8
View File
@@ -1,8 +0,0 @@
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
@@ -1,915 +0,0 @@
@font-face {
font-family: iconfont; /* Project id 4613488 */
src: url('iconfont.woff2?t=1770985961668') format('woff2'),
url('iconfont.woff?t=1770985961668') format('woff'),
url('iconfont.ttf?t=1770985961668') format('truetype');
}
.iconfont {
font-family: iconfont !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-grafana::before {
content: "\e61e";
}
.icon-chart::before {
content: "\e6da";
}
.icon-monitor-02::before {
content: "\e6d9";
}
.icon-monitor::before {
content: "\e6d8";
}
.icon-metrics::before {
content: "\e6d6";
}
.icon-export::before {
content: "\e6d7";
}
.icon-license::before {
content: "\e6d5";
}
.icon-community::before {
content: "\e6d3";
}
.icon-person::before {
content: "\e6d4";
}
.icon-public::before {
content: "\e6d2";
}
.icon-charger::before {
content: "\e6d0";
}
.icon-disabled::before {
content: "\e6d1";
}
.icon-source::before {
content: "\e6cf";
}
.icon-Serviceprovider::before {
content: "\e630";
}
.icon-database::before {
content: "\e6ce";
}
.icon-filters::before {
content: "\e6cc";
}
.icon-speed-filled::before {
content: "\e6cb";
}
.icon-shield::before {
content: "\e6c9";
}
.icon-shield-filled::before {
content: "\e6ca";
}
.icon-openai::before {
content: "\e61d";
}
.icon-anthropic::before {
content: "\e74d";
}
.icon-doubao::before {
content: "\e618";
}
.icon-qwen::before {
content: "\e8b2";
}
.icon-deepseek::before {
content: "\e61c";
}
.icon-extension-outline::before {
content: "\e6c7";
}
.icon-extension-filled::before {
content: "\e6c8";
}
.icon-thead::before {
content: "\e617";
}
.icon-video-filled02::before {
content: "\e6c5";
}
.icon-video-outline::before {
content: "\e6c6";
}
.icon-video::before {
content: "\e6c3";
}
.icon-video-filled::before {
content: "\e6c4";
}
.icon-refresh::before {
content: "\e6c2";
}
.icon-settings-02::before {
content: "\e6bf";
}
.icon-arrow_forward::before {
content: "\e6c0";
}
.icon-logout::before {
content: "\e6c1";
}
.icon-amd-logo::before {
content: "\e6be";
}
.icon-cloud::before {
content: "\e6bc";
}
.icon-server02::before {
content: "\e6bd";
}
.icon-drag_handle::before {
content: "\e6b9";
}
.icon-basic::before {
content: "\e6bb";
}
.icon-settings::before {
content: "\e6b8";
}
.icon-speed::before {
content: "\e6ba";
}
.icon-permission::before {
content: "\e6b6";
}
.icon-captive_portal::before {
content: "\e6b7";
}
.icon-lock_open_right::before {
content: "\e6b3";
}
.icon-lock_person::before {
content: "\e6b4";
}
.icon-lock_open::before {
content: "\e6b5";
}
.icon-question::before {
content: "\e6b2";
}
.icon-aws::before {
content: "\e616";
}
.icon-aws1::before {
content: "\e62f";
}
.icon-manage_user::before {
content: "\e6b1";
}
.icon-private::before {
content: "\e6b0";
}
.icon-stop3::before {
content: "\e6af";
}
.icon-version::before {
content: "\e6ae";
}
.icon-edit-content::before {
content: "\e6ab";
}
.icon-code_block::before {
content: "\e6ac";
}
.icon-parameters::before {
content: "\e6ad";
}
.icon-backend-filled::before {
content: "\e6a9";
}
.icon-backend::before {
content: "\e6aa";
}
.icon-down2::before {
content: "\e6a7";
}
.icon-nvidia2::before {
content: "\e60d";
}
.icon-centos::before {
content: "\e6cd";
}
.icon-redhat::before {
content: "\ec7b";
}
.icon-ubuntu::before {
content: "\edd3";
}
.icon-debian::before {
content: "\eb74";
}
.icon-alma-linux::before {
content: "\e6a8";
}
.icon-fedora::before {
content: "\e61a";
}
.icon-rocky-linux::before {
content: "\e620";
}
.icon-nvidia1::before {
content: "\e980";
}
.icon-nvidia::before {
content: "\e60c";
}
.icon-amd::before {
content: "\e6a6";
}
.icon-huawei::before {
content: "\e615";
}
.icon-metax::before {
content: "\e6a4";
}
.icon-ascend::before {
content: "\e6a3";
}
.icon-huaweicloud::before {
content: "\e614";
}
.icon-alicloud::before {
content: "\e784";
}
.icon-tencentcloud::before {
content: "\e609";
}
.icon-cluster2-outline::before {
content: "\e6a1";
}
.icon-cluster2-filled::before {
content: "\e6a2";
}
.icon-cluster-filled::before {
content: "\e69f";
}
.icon-cluster-outline::before {
content: "\e6a0";
}
.icon-admin-user::before {
content: "\e69d";
}
.icon-user::before {
content: "\e69e";
}
.icon-detail-info::before {
content: "\e69b";
}
.icon-docker::before {
content: "\e69c";
}
.icon-digitalocean::before {
content: "\eb79";
}
.icon-rocket-launch1::before {
content: "\e699";
}
.icon-rocket-launch-fill::before {
content: "\e69a";
}
.icon-credential-filled::before {
content: "\e697";
}
.icon-credential-outline::before {
content: "\e698";
}
.icon-k8s-filled::before {
content: "\e63e";
}
.icon-k8s-outline::before {
content: "\e64b";
}
.icon-huggingface1::before {
content: "\e605";
}
.icon-modelscope_light::before {
content: "\e696";
}
.icon-catalog1::before {
content: "\e691";
}
.icon-chat-filled::before {
content: "\e692";
}
.icon-chat::before {
content: "\e693";
}
.icon-files-filled::before {
content: "\e694";
}
.icon-files::before {
content: "\e695";
}
.icon-models-filled::before {
content: "\e682";
}
.icon-image-filled::before {
content: "\e683";
}
.icon-audio1::before {
content: "\e684";
}
.icon-image1::before {
content: "\e685";
}
.icon-audio-filled::before {
content: "\e686";
}
.icon-reranker-filled::before {
content: "\e687";
}
.icon-embedding-filled::before {
content: "\e68a";
}
.icon-models::before {
content: "\e68b";
}
.icon-reranker::before {
content: "\e68c";
}
.icon-embedding::before {
content: "\e68d";
}
.icon-gpu-filled::before {
content: "\e68e";
}
.icon-catalog-filled::before {
content: "\e68f";
}
.icon-gpu1::before {
content: "\e690";
}
.icon-language::before {
content: "\e679";
}
.icon-help::before {
content: "\e67a";
}
.icon-key-filled::before {
content: "\e67b";
}
.icon-key::before {
content: "\e67d";
}
.icon-resources::before {
content: "\e67e";
}
.icon-users::before {
content: "\e67f";
}
.icon-resources-filled::before {
content: "\e680";
}
.icon-users-filled::before {
content: "\e681";
}
.icon-model::before {
content: "\e676";
}
.icon-model-filled::before {
content: "\e678";
}
.icon-layers-filled::before {
content: "\e671";
}
.icon-layers::before {
content: "\e673";
}
.icon-experiment::before {
content: "\e674";
}
.icon-experiment-filled::before {
content: "\e675";
}
.icon-dashboard::before {
content: "\e66d";
}
.icon-dashboard-filled::before {
content: "\e66e";
}
.icon-expand-left::before {
content: "\ea48";
}
.icon-expand-right::before {
content: "\ea49";
}
.icon-users-fill::before {
content: "\e677";
}
.icon-layers-fill::before {
content: "\e89d";
}
.icon-key-fill::before {
content: "\e80e";
}
.icon-server-fill::before {
content: "\e7a3";
}
.icon-model-fill::before {
content: "\e7b7";
}
.icon-left_panel_close::before {
content: "\e668";
}
.icon-left_panel_open::before {
content: "\e669";
}
.icon-playcircle-fill::before {
content: "\e665";
}
.icon-stopcircle-fill::before {
content: "\e666";
}
.icon-play-speed::before {
content: "\e856";
}
.icon-more::before {
content: "\e62e";
}
.icon-play::before {
content: "\e9f9";
}
.icon-pause::before {
content: "\e713";
}
.icon-dark_theme::before {
content: "\e646";
}
.icon-theme-auto-1::before {
content: "\e660";
}
.icon-theme-auto::before {
content: "\e663";
}
.icon-auto-theme-1::before {
content: "\e664";
}
.icon-auto-theme::before {
content: "\e662";
}
.icon-cols_3::before {
content: "\e65c";
}
.icon-cols_6::before {
content: "\e65d";
}
.icon-cols_2::before {
content: "\e65e";
}
.icon-cols_4::before {
content: "\e65f";
}
.icon-a-save1::before {
content: "\e65b";
}
.icon-uncollapse_all::before {
content: "\e657";
}
.icon-collapse::before {
content: "\e656";
}
.icon-rocket-launch::before {
content: "\e689";
}
.icon-user-filled::before {
content: "\e625";
}
.icon-assistant::before {
content: "\e62d";
}
.icon-assistant-filled::before {
content: "\e847";
}
.icon-save3::before {
content: "\e655";
}
.icon-fankuifaqs::before {
content: "\e7bf";
}
.icon-issues::before {
content: "\e816";
}
.icon-neicun::before {
content: "\e688";
}
.icon-collapse_all::before {
content: "\e66f";
}
.icon-down::before {
content: "\e654";
}
.icon-fenxiang::before {
content: "\e604";
}
.icon-mosaic-2::before {
content: "\e64f";
}
.icon-stars::before {
content: "\e8a8";
}
.icon-mosaic::before {
content: "\e636";
}
.icon-outline-play::before {
content: "\e653";
}
.icon-SelectionInverse::before {
content: "\eace";
}
.icon-justice1::before {
content: "\e652";
}
.icon-New_img::before {
content: "\e733";
}
.icon-new_release_outlined::before {
content: "\e66c";
}
.icon-new-releases::before {
content: "\e60f";
}
.icon-catalog::before {
content: "\e62b";
}
.icon-save2::before {
content: "\e635";
}
.icon-left-template::before {
content: "\e62a";
}
.icon-logs::before {
content: "\e6ec";
}
.icon-gpu::before {
content: "\e71e";
}
.icon-filled-gpu::before {
content: "\e6de";
}
.icon-outline-gpu::before {
content: "\e641";
}
.icon-ts-tubiao_webserver::before {
content: "\e716";
}
.icon-server::before {
content: "\e66a";
}
.icon-host::before {
content: "\e7c6";
}
.icon-playcircle::before {
content: "\e80f";
}
.icon-recreate::before {
content: "\e6a5";
}
.icon-save1::before {
content: "\e647";
}
.icon-save::before {
content: "\e67c";
}
.icon-upload_image::before {
content: "\e613";
}
.icon-sound-wave::before {
content: "\e619";
}
.icon-rank1::before {
content: "\e7cb";
}
.icon-cube::before {
content: "\e769";
}
.icon-speaker-slash::before {
content: "\ebb6";
}
.icon-random::before {
content: "\e603";
}
.icon-suijisenlin::before {
content: "\e60e";
}
.icon-stop2::before {
content: "\e8db";
}
.icon-stop::before {
content: "\e60b";
}
.icon-image::before {
content: "\e62c";
}
.icon-SpeakerSlash::before {
content: "\e661";
}
.icon-SpeakerHigh::before {
content: "\e670";
}
.icon-user_voice::before {
content: "\e667";
}
.icon-audio::before {
content: "\e985";
}
.icon-hard-disk::before {
content: "\eb1d";
}
.icon-new::before {
content: "\e612";
}
.icon-tu2::before {
content: "\e607";
}
.icon-robot::before {
content: "\e634";
}
.icon-robot1::before {
content: "\e602";
}
.icon-aizhineng::before {
content: "\e672";
}
.icon-copy::before {
content: "\e720";
}
.icon-AIzhineng::before {
content: "\e608";
}
.icon-clear::before {
content: "\e60a";
}
.icon-keyboard::before {
content: "\e61b";
}
.icon-networkerror::before {
content: "\e624";
}
.icon-external-link::before {
content: "\e66b";
}
.icon-huggingface::before {
content: "\e7d1";
}
.icon-ollama::before {
content: "\e601";
}
.icon-a-layout6-line::before {
content: "\e9ef";
}
.icon-a-Layout5::before {
content: "\e610";
}
.icon-English::before {
content: "\e8b3";
}
.icon-chinese::before {
content: "\e611";
}
.icon-yingguo::before {
content: "\e606";
}
.icon-code::before {
content: "\e84f";
}
.icon-stop1::before {
content: "\e783";
}
.icon-command::before {
content: "\e600";
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
-71
View File
@@ -1,71 +0,0 @@
import IconFont from '@/components/icon-font';
import {
ApiOutlined,
CopyOutlined,
DeleteOutlined,
DockerOutlined,
DownloadOutlined,
EditOutlined,
ExperimentOutlined,
FileTextOutlined,
KubernetesOutlined,
ProfileOutlined,
RetweetOutlined,
StarOutlined,
ThunderboltOutlined
} from '@ant-design/icons';
import React from 'react';
const icons = {
EditOutlined: React.createElement(EditOutlined),
ExperimentOutlined: React.createElement(ExperimentOutlined),
DeleteOutlined: React.createElement(DeleteOutlined),
ThunderboltOutlined: React.createElement(ThunderboltOutlined),
RetweetOutlined: React.createElement(RetweetOutlined),
DownloadOutlined: React.createElement(DownloadOutlined),
FileTextOutlined: React.createElement(FileTextOutlined),
ApiOutlined: React.createElement(ApiOutlined),
KubernetesOutlined: React.createElement(KubernetesOutlined),
ProfileOutlined: React.createElement(ProfileOutlined),
DockerOutlined: React.createElement(DockerOutlined),
Stop: React.createElement(IconFont, { type: 'icon-stop1' }),
Play: React.createElement(IconFont, { type: 'icon-outline-play' }),
Catalog: React.createElement(IconFont, { type: 'icon-catalog' }),
HF: React.createElement(IconFont, { type: 'icon-huggingface' }),
Ollama: React.createElement(IconFont, { type: 'icon-ollama' }),
ModelScope: React.createElement(IconFont, { type: 'icon-tu2' }),
LocalPath: React.createElement(IconFont, { type: 'icon-hard-disk' }),
Launch: React.createElement(IconFont, { type: 'icon-rocket-launch' }),
Deployment: React.createElement(IconFont, { type: 'icon-rocket-launch1' }),
Docker: React.createElement(IconFont, { type: 'icon-docker' }),
DigitalOcean: React.createElement(IconFont, { type: 'icon-digitalocean' }),
DetailInfo: React.createElement(IconFont, { type: 'icon-detail-info' }),
HuaweiCloud: React.createElement(IconFont, { type: 'icon-huaweicloud' }),
AliCloud: React.createElement(IconFont, { type: 'icon-alicloud' }),
TencentCloud: React.createElement(IconFont, { type: 'icon-tencentcloud' }),
Nvidia: React.createElement(IconFont, { type: 'icon-nvidia' }),
Ascend: React.createElement(IconFont, { type: 'icon-ascend' }),
Catalog1: React.createElement(IconFont, { type: 'icon-catalog1' }),
AMD: React.createElement(IconFont, { type: 'icon-amd' }),
KubernetesFilled: React.createElement(IconFont, { type: 'icon-k8s-filled' }),
EditContent: React.createElement(IconFont, { type: 'icon-edit-content' }),
Yaml: React.createElement(IconFont, { type: 'icon-code_block' }),
Version: React.createElement(IconFont, { type: 'icon-version' }),
Parameter: React.createElement(IconFont, { type: 'icon-parameters' }),
Private: React.createElement(IconFont, { type: 'icon-private' }),
AWS: React.createElement(IconFont, { type: 'icon-aws' }),
LockOpenRight: React.createElement(IconFont, {
type: 'icon-lock_open_right'
}),
LockPerson: React.createElement(IconFont, { type: 'icon-lock_person' }),
LockOpen: React.createElement(IconFont, { type: 'icon-lock_open' }),
Permission: React.createElement(IconFont, { type: 'icon-permission' }),
CaptivePortal: React.createElement(IconFont, { type: 'icon-captive_portal' }),
StarOutlined: React.createElement(StarOutlined),
Charger: React.createElement(IconFont, { type: 'icon-charger' }),
Disabled: React.createElement(IconFont, { type: 'icon-disabled' }),
CopyOutlined: React.createElement(CopyOutlined),
Metrics: React.createElement(IconFont, { type: 'icon-metrics' })
};
export default icons;
-8
View File
@@ -1,8 +0,0 @@
import { createFromIconfontCN } from '@ant-design/icons';
import './iconfont/iconfont.js';
const IconFont = createFromIconfontCN({
scriptUrl: ''
});
export default IconFont;
@@ -1,149 +0,0 @@
/**
* Creates a canvas element and its rendering context.
* @param {number} width - Canvas width.
* @param {number} height - Canvas height.
* @returns {{ canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D }} - The created canvas and context.
*/
function createCanvas(
width: number,
height: number
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return { canvas, ctx: canvas.getContext('2d')! };
}
/**
* Checks if a pixel is white.
* @param {Uint8ClampedArray} pixels - The image pixel data.
* @param {number} x - The pixel's X coordinate.
* @param {number} y - The pixel's Y coordinate.
* @param {number} width - The image width.
* @returns {boolean} - Whether the pixel is white.
*/
function isWhite(
pixels: Uint8ClampedArray,
x: number,
y: number,
width: number
): boolean {
let index = (y * width + x) * 4;
return (
pixels[index] === 255 &&
pixels[index + 1] === 255 &&
pixels[index + 2] === 255
);
}
/**
* Performs a flood fill to find all connected white pixels.
* @param {Uint8ClampedArray} pixels - The image pixel data.
* @param {number} width - The image width.
* @param {number} height - The image height.
* @param {number} x - The starting X coordinate.
* @param {number} y - The starting Y coordinate.
* @param {boolean[][]} visited - A 2D array to track visited pixels.
* @param {Array<{ x: number, y: number }>} block - The list of coordinates forming a white block.
*/
function floodFill(
pixels: Uint8ClampedArray,
width: number,
height: number,
x: number,
y: number,
visited: boolean[][],
block: Array<{ x: number; y: number }>
): void {
let stack: Array<[number, number]> = [[x, y]];
let directions: Array<[number, number]> = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1], // 4-directional search (can be extended to 8-directional)
[1, 1],
[-1, -1],
[1, -1],
[-1, 1] // Diagonal directions
];
while (stack.length) {
let [cx, cy] = stack.pop()!;
if (
cx < 0 ||
cy < 0 ||
cx >= width ||
cy >= height ||
visited[cy][cx] ||
!isWhite(pixels, cx, cy, width)
) {
continue;
}
visited[cy][cx] = true;
block.push({ x: cx, y: cy });
directions.forEach(([dx, dy]) => stack.push([cx + dx, cy + dy]));
}
}
/**
* Extracts all white blocks from an image.
* @param {ImageData} imageData - The image pixel data.
* @returns {Array<Array<{ x: number, y: number }>>} - List of white blocks, each containing pixel coordinates.
*/
function getWhiteBlocks(
imageData: ImageData
): Array<Array<{ x: number; y: number }>> {
const { data, width, height } = imageData;
let visited: boolean[][] = Array.from({ length: height }, () =>
new Array(width).fill(false)
);
let whiteBlocks: Array<Array<{ x: number; y: number }>> = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (isWhite(data, x, y, width) && !visited[y][x]) {
let block: Array<{ x: number; y: number }> = [];
floodFill(data, width, height, x, y, visited, block);
whiteBlocks.push(block);
}
}
}
return whiteBlocks;
}
/**
* Loads an image from a file.
* @param {File} file - The image file.
* @returns {Promise<HTMLImageElement>} - The loaded image element.
*/
function loadImage(file: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = file;
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image loading failed'));
});
}
/**
* Processes the image file and extracts white blocks.
* @param {File} file - The uploaded image file.
* @returns {Promise<Array<Array<{ x: number, y: number }>>>} - List of white blocks with pixel coordinates.
*/
async function processImage(
file: string
): Promise<Array<Array<{ x: number; y: number }>>> {
const img = await loadImage(file);
const { canvas, ctx } = createCanvas(img.width, img.height);
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const whiteBlocks = getWhiteBlocks(imageData);
URL.revokeObjectURL(img.src);
return whiteBlocks;
}
export { processImage };

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