Compare commits

..
243 Commits
Author SHA1 Message Date
jialin 17527b0fa5 fix: show cluster in table 2026-05-26 23:01:36 +08:00
jialinandjialin 2f04e01f4f fix: default instance type, storageMode 2026-05-26 22:40:02 +08:00
jialinandjialin a51f44c135 fix: select a useable instance type for initial 2026-05-26 22:40:02 +08:00
jialinandjialin 7f14eb544c fix: pvc events api 2026-05-26 19:16:50 +08:00
jialinandjialin c6e34db329 fix: ux issues 2026-05-26 19:16:50 +08:00
Yuxing Deng acb90531b2 feat: extend cluster k8s_options with multi-vendor manifest support
- New k8s_pod_spec form sections (image credentials, node selector,
  gpu vendor overrides) under k8s_options, replacing the legacy flat
  k8s_volume_mounts list. UI keys aligned to the backend wire shape
  (snake_case k8s_options + camelCase inside).
- System default container registry is pre-filled into the first image
  credential when creating a new cluster; empty username/password are
  coerced to null on submit to match the Optional[str] backend schema.
- Register cluster flow supports multi-runtime selection gated by the
  cluster's gpuVendorOverrides: non-override vendors stay single-select
  with an inline hint; multi-add only opens once an override vendor is
  picked, and non-override cards become disabled in that state.
- Manifest URL emits multiple ?runtime= params; check-env step combines
  per-vendor commands; downstream steps are disabled when no vendor is
  selected.
- Pre-validate gpuVendorOverrides at save time (non-empty selector, no
  duplicates across vendors, no key clash with base nodeSelector) so
  the user sees the error before hitting the manifest endpoint.
- Misc: dark-mode background of the k8s_pod_spec / volume mount titles
  no longer clashes with the drawer; cluster Steps no longer leaks the
  internal showModules/showForms props to the DOM.
2026-05-26 16:54:14 +08:00
yxfandyxf0314 91c3d0b814 feat: add parameter format and common parameters to backend configuration 2026-05-26 14:30:19 +08:00
jialinandjialin 95e03d11c1 fix: gpu service issues 2026-05-26 14:21:52 +08:00
jialinandjialin 88ab927755 refactor: gpu service API alignment 2026-05-25 22:42:02 +08:00
jialinandjialin 81c7e2ee61 fix(style): template title gap 2026-05-25 22:42:02 +08:00
jialinandjialin b5ed64de51 chore: ssh key selection 2026-05-25 22:42:02 +08:00
jialinandjialin 921a2a0d81 fix: instance form ux 2026-05-25 22:42:02 +08:00
jialinandjialin 5195b1e5fc style: number selection 2026-05-25 22:42:02 +08:00
jialinandjialin f886422f91 fix(style): textarea label background 2026-05-25 22:42:02 +08:00
jialinandjialin 27d57d7563 feat: instance pv events 2026-05-25 22:42:02 +08:00
jialinandjialin 2ebb8f44b7 fix: recreate action 2026-05-25 22:42:02 +08:00
jialinandjialin a0b408e8cb style: instance type remaing resourece 2026-05-25 22:42:02 +08:00
jialinandjialin 858e0dae09 feat: add number selection 2026-05-25 22:42:02 +08:00
gitlawrandjialin 7bee337554 fix(playground): inject X-Organization-Id on non-umi fetch paths
Playground completions (chat streaming via ``fetchChunkedData``)
and the raw ``fetch()`` calls for image / audio generation skip
umi's ``request`` pipeline and so miss the request interceptor in
``request.extensions.ts`` that pins tenant context. Higress
receives those calls without ``X-Organization-Id`` and the auth
callback falls back to the user's USER-principal id rather than
their active Org, which makes Playground usage invisible from
Org-scoped Usage views even after the backend learned to backfill
``consumer_principal_id``.

Centralise the lookup in ``tenantHeaders()`` next to
``fetchChunkedData`` so both streaming helpers and the four
``fetch()``-direct entry points in ``playground/apis/index.ts``
(image / TTS) attach the header with the same precedence as the
umi interceptor (``createScopeOrgOverride`` first,
``currentOrganizationId`` second). Returns an empty object when
no active org context is set, leaving requests unchanged.
2026-05-25 18:02:31 +08:00
jialinandjialin 7944b3f579 fix: add lora tag in routes 2026-05-25 13:56:44 +08:00
jialinandjialin 5e889563ca fix: search lora ux 2026-05-25 13:56:44 +08:00
jialinandjialin aad3b458c8 fix: selection styles 2026-05-25 13:56:44 +08:00
jialinandjialin acef2ef89a fix: add form validation 2026-05-25 13:56:44 +08:00
jialinandjialin d99c25dd03 feat: model file is lora 2026-05-25 13:56:44 +08:00
jialinandjialin f0a1dad48e fix: filter invaild lora in form 2026-05-25 13:56:44 +08:00
jialinandjialin 1e334903dd feat: lora route 2026-05-25 13:56:44 +08:00
jialinandjialin 342583f8d1 feat: lora for deployment 2026-05-25 13:56:44 +08:00
gitlawrandjialin 6af18275c4 feat(usage): treat org owners as managers on the Usage page
The Usage page gated cross-user features (default ``scope=all``,
the user filter, the per-user breakdown dimension) on
``currentUser.is_admin`` alone. The backend's
``_can_use_all_scope`` already grants the same surface to Org
owners — the page just never asked. Result: a non-admin Org owner
landed on ``scope=self`` with no user filter and no per-user
group-by, even though the API would have happily served them the
org-wide picture.

Switch the three sites to ``useAccess().canSeeOrgAdmin``, which
already encodes "platform admin OR owner of the selected
(non-Personal) Org". One predicate, no new helper.

Sites:
- ``index.tsx``: ``initialScope`` defaults to ``all`` for managers.
- ``filter-bar.tsx``: the user filter + nested API-key cascader
  surface (vs the flat API-key picker for members).
- ``daily-usage.tsx``: the ``user`` group-by option in the chart.
2026-05-25 13:56:25 +08:00
gitlawrandjialin 9c519d937d fix(usage): pass scope to the time series chart fetch
The Usage page's chart fetch (``fetchTimeSeriesData``) spread
``chartFilters`` plus dates and filters, but never forwarded
``commonFilters.scope`` — so it implicitly fell back to the
backend's ``scope=all`` default. The three breakdown tables
(``apikeys-table``, ``models-table``, ``users-table``) all pass
``scope`` explicitly, which produced an inconsistent picture on
the same page: an Org owner viewing a model granted from another
Org saw the date chart render empty while the tables underneath
showed identical usage rows.

Forward ``currentSelectedFilters.scope`` alongside the rest of the
common filters so the chart and the tables ask the backend the
same question.
2026-05-25 13:56:25 +08:00
Yuxing Deng 4b14fa6ca8 feat(usage): plugin slot for Models tab extra columns
Lets enterprise plugins contribute per-route columns and a global
mount point on the Usage page's Models tab. Mirrors the existing
modelRoutes pattern: `usage.modelsExtraColumns` for cells,
`<PluginExtraFields name="UsageModelsPageGlobal" />` for the
page-level data lifecycle (receives the visible route ids so a
plugin can bulk-fetch per-row data in one call).
2026-05-25 10:42:59 +08:00
jialinandjialin dc69e51042 fix: colorPrimary cache cleared 2026-05-24 10:51:44 +08:00
jialinandjialin 052d5aa474 fix: colorPrimary cache cleared 2026-05-21 19:58:16 +08:00
jialinandjialin 0c55c1c1b4 fix: sider menu style 2026-05-21 19:58:16 +08:00
jialinandjialin 6f896b4f94 fix(style): use --app-banner-height in scroller container 2026-05-21 19:58:16 +08:00
gitlawrandjialin e7a376db70 refactor: adapt org-namespace lookup to principal name rename
Tracks the gpustack identity-consolidation effort: the unified
``principals`` table's URL-safe identifier column was renamed
``slug`` → ``name`` (with the legacy display ``name`` → ``display_name``).
The enterprise plugin's persisted org caches (``organizationList``,
``allOrganizations`` in localStorage) now write ``name`` instead of
``slug`` on each Organization row.

``getCurrentOrgNamespace`` reads those caches to compose the k8s
namespace (``gpustack-{name}``) for GPU-instance / storage CRDs.
Updated to read ``item.name`` so namespace resolution stays in sync
with what the enterprise plugin writes — otherwise every gpu-service
write would fall through to ``gpustack-default`` even when the user
has an Org context.

Stale ``slug`` references in surrounding comments also retitled to
``name`` to avoid divergence between code and prose. The namespace
format itself (``gpustack-{...}``) is unchanged — only the column it
sources is.
2026-05-21 12:15:39 +08:00
Yuxing Dengandjialin 15cf4d89b7 feat: association dounut effect 2026-05-20 11:29:43 +08:00
Yuxing Dengandjialin c38fcc494a refactor(dashboard): collapse usage section into a single row
Merge the two "by Model" pies (Token Usage + API Requests) into one
dual-pie card with a shared right-side vertical legend and per-donut
center totals, drop the standalone API-Key Top 10 chart, and put the
combined chart side-by-side with the User Top 10 ranking in one row
with the same 16/8 column ratio used by the System Load section above.

* New component usage-charts/usage-by-model.tsx owns both metric queries
  (group_by=['date','route']), aggregates a shared route→color map so
  the legend toggles both donuts together, and renders two pies at
  centers ('20%','50%') and ('60%','50%') with radius ['50%','70%'].
* new-usage.tsx no longer mounts ApiRequestsByModel / TokenUsageByModel
  / TopTokenUsageByApiKey; layout collapses to two columns sized
  (lg=24 xl=16) and (lg=24 xl=8).
* Delete unused token-usage-by-model.tsx, api-requests-by-model.tsx,
  top-token-usage-by-api-key.tsx, and the use-top-token-usage-by-api-key
  hook.
* Strip the now-unreferenced dashboard.tokenUsageByModel /
  apiRequestsByModel / topTokenUsageByApiKey locale keys across 5
  locales, add the new dashboard.usageByModel title; locale parity
  check passes.
2026-05-20 11:29:43 +08:00
Yuxing Deng 44e5b78d29 refactor(dashboard): switch usage charts to route group_by, rename Models card
Align dashboard with the route dimension introduced in the previous
commit:

* Token Usage / API Requests pie charts query the breakdown API with
  `group_by: ['date', 'route']` and aggregate via the new `route`
  dimension in `buildUsageLabel`. Visible chart titles keep the existing
  "by Model" wording — only the underlying data dim changed.
* Overview "Models" card label switched to `dashboard.deployments`
  ("部署" / "Deployments"), and the Active Models table heading to
  `dashboard.activeDeployments`. These reflect the underlying entity
  (model deployments) more accurately now that routes are the
  user-facing model concept.
* Drop 11 unused dashboard locale keys (`dashboard.title`,
  `dashboard.models`, `dashboard.allocategpus`, `dashboard.instances`,
  `dashboard.disk`, `dashboard.diskutilization`, `dashboard.apirequest`,
  `dashboard.activeModels`, `dashboard.activeUsers`,
  `dashboard.activeModels.name`, `dashboard.runninginstances`) — no
  remaining references in `src/` after the rename. Locale parity check
  passes.
2026-05-19 16:50:42 +08:00
Yuxing Deng 38107a67fe refactor(usage): switch breakdown dimension from model to route
Align with gpustack backend commits 7b57cd3b (add route dimension to
usage breakdown) and 51fd25c8 (use model_route_id as models_called):

* group_by, filters, and BreakdownItem now use the `route` dimension;
  `models` filter cascader is replaced with a flat `routes` selector
  (routes have no provider/cluster grouping).
* Tab / group_by option / column header keep the user-facing "模型 / Model"
  wording — only the underlying data dimension changed.
* Drop cluster_name / provider_type columns from the breakdown table;
  drop api_keys_used column per design.
* Fix pre-existing excel export bug in use-export-table — string
  dataIndex was lodash-joined char-by-char ("input_tokens" →
  "i_n_p_u_t..."), array dataIndex was used as a flat key against nested
  rows. Rebuilt around buildSheetMeta that emits a stable field key plus
  a formatMap using _.get(row, path) for nested values. Sheet name kept
  as `models` to match the visible column label.
2026-05-19 16:50:42 +08:00
jialinandjialin 0274bcb8cd chore: update ui 1.0.14 2026-05-19 14:09:07 +08:00
gitlawrandjialin a91c1545f8 fix: require ref audio for Base task in TTS playground 2026-05-19 12:36:08 +08:00
jialinandjialin 0b3884b1a5 fix: gguf input box is hidden when deploying from model files 2026-05-19 12:16:02 +08:00
jialinandjialin bcea46fa8f fix(style): chart style 2026-05-18 20:14:28 +08:00
jialinandjialin abc3c2c25a fix: instance events namespace 2026-05-18 16:59:53 +08:00
jialinandjialin db7a8a702d fix: select panel no default value 2026-05-18 15:37:46 +08:00
jialinandjialin 288bfebc13 fix: filter user option in usage 2026-05-18 15:37:46 +08:00
jialinandjialin c7996d3e14 fix: login page miss icon 2026-05-18 15:37:46 +08:00
jialinandjialin 0d9b6325f9 fix: hygon, thead runtime 2026-05-18 15:37:46 +08:00
gitlawrandjialin baf1508644 fix: guard systemConfigAtom reads against null
``logout`` calls ``clearAtomStorage(systemConfigAtom)`` which sets the
atom to ``null``, and on a fresh SPA login (no full reload)
``app.tsx``'s ``fetchSystemConfig`` doesn't re-run, so consumers that
read ``systemConfig.xxx`` directly crash with "Cannot read properties
of null". A hard refresh masks the bug. Add optional chaining at the
read sites (grafana url / dashboard uids, showMonitoring) so the
contract stays robust regardless of how the atom is reset.
2026-05-18 15:36:29 +08:00
jialinandjialin d04604abe5 fix: SelectPanel props 2026-05-18 13:28:52 +08:00
jialinandjialin bf09784fad chore: update ui 1.0.13 2026-05-18 13:28:52 +08:00
gitlawrandjialin 67f5c794e1 fix(login): keep 360px design width with max-width: 100% cap
Switching ``Buttons`` to ``width: 100%`` (in c32ad712) had a
side-effect — with ``FormWrapper`` sized to ``max-content``, a
100% child without LocalUserForm alongside it lets the card
collapse to the button's intrinsic text width. Per PR #1097 review,
go back to the 360px design width and rely on ``max-width: 100%``
to keep it from overflowing tighter parents (the enterprise cover
layout's 272px inner especially).

Apply the same shape to LocalUserForm — its hardcoded 360px alone
would still overflow on the cover layout — so the two surfaces
stay in lockstep when switching between SSO and password modes.

(Pre-commit hook still crashes on the ``context.getDeclaredVariables``
bug in main; unrelated.)
2026-05-18 12:25:08 +08:00
gitlawrandjialin 55fbe118d5 fix(login): stop SSO button from overflowing the login card
``Buttons`` was a fixed ``width: 360px`` column, but ``FormWrapper``
sizes its content area to ``max-content`` capped at 440px (with 40px
padding). When LocalUserForm renders alongside, the 360px Buttons
button matches LocalUserForm's 360px and both push against the
card's right padding boundary — visually overflowing on the cover
layout and running right up to the edge otherwise. Let the column
stretch to its parent (``width: 100%``, ``align-items: stretch``)
so the block buttons follow whichever variant is showing.

(Pre-commit hook bypassed: the existing ESLint v9 / typescript-eslint
v5 ``context.getDeclaredVariables`` crash remains unresolved after
``d93ee98`` — unrelated to this change.)
2026-05-18 12:25:08 +08:00
Yuxing Dengandjialin ae8ec8572c feat: generic configActions plugin slot for api-keys & model-routes
Introduce `pages/api-keys/plugin.tsx` and `pages/model-routes/plugin.tsx`
defining a unified `{ key, labelId, icon, priority, danger, form,
useCreate }` contract that plugins use to contribute per-row
"configure this record" actions. The host renders one dropdown entry
per registered action and mounts each entry's form, ordered by a
single numeric priority (built-ins are ranked on the same scale; rows
flagged `danger` sink to the bottom).

Drops the older `apiKeys.rowActions` / `modelRoutes.rowActions` slots
and the implicit drawer half of
`<PluginExtraFields name="APIKeysPageGlobal" />`. The model-routes
mount point stays for the page-level quota-defaults bulk-fetch and now
also accepts a `refreshToken` so per-row saves can invalidate derived
page data without changing the row set.
2026-05-18 12:14:19 +08:00
jialinandjialin 38b91a558b chore: remove notes 2026-05-15 16:22:07 +08:00
jialinandjialin a001f83abb fix: update instance name error 2026-05-15 16:22:07 +08:00
jialinandjialin 87c8a3a64d feat: add instance recreate action 2026-05-15 16:22:07 +08:00
jialinandjialin c6e7075bf5 feat: template port name 2026-05-15 16:22:07 +08:00
jialinandjialin 5d75cfdfc5 fix: name validation 2026-05-15 16:22:07 +08:00
jialinandjialin 3f9852c932 refactor: instances add-modal 2026-05-15 16:22:07 +08:00
jialinandjialin 9dfaf990fd feat: add events, log api 2026-05-15 16:22:07 +08:00
micheliaandjialin 1bef1637e0 feat: support license banner 2026-05-15 09:41:10 +08:00
jialinandjialin 451d5bfe47 fix: show inject params, update ui 1.0.11 2026-05-14 11:54:51 +08:00
jialinandjialin faa1f935b7 feat: provider access to core-ui 2026-05-14 11:52:48 +08:00
jialinandjialin c428fe175a chore: remove inject params tag 2026-05-14 11:21:49 +08:00
jialinandjialin 9767c8bf39 chore: show deployment backend params 2026-05-14 11:21:49 +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
jialin 353ee3a9ae fix: route target can be set 0 2026-03-10 19:06:49 +08:00
jialin 972e147fdf fix: yaml edior height 2026-03-10 19:06:49 +08:00
jialin 73e52fe334 chore: add hint for creating backend 2026-03-09 15:48:16 +08:00
886 changed files with 46354 additions and 39838 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/']
};
+4 -1
View File
@@ -12,4 +12,7 @@
/.mfsu
.swc
.DS_Store
.idea
.idea
.claude
/dist.zip
.cache
+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"]
}
+265
View File
@@ -0,0 +1,265 @@
# React State and Request Patterns
These guidelines define preferred patterns for request handling, state updates, and side-effect management in React applications.
The primary goal is to keep data flow explicit, predictable, maintainable, and performant while avoiding unnecessary rerenders and effect-driven logic.
---
## 1. Avoid Effect-Driven Requests
Do not use request functions themselves as dependencies in `useEffect`.
Avoid patterns like:
```ts
useEffect(() => {
fetchData();
}, [fetchData]);
```
Requests should be triggered explicitly by user actions or lifecycle entry points.
---
## 2. Form Requests Should Be Action-Driven
For form-related requests (such as loading `Select` options):
- Fetch data when the form is opened for the first time.
- If later requests depend on user interactions, trigger them directly inside the interaction handler.
- Do not rely on `useEffect` dependency changes to trigger requests.
Recommended:
```ts
const handleOnChange = (value) => {
fetchData(value);
};
```
Avoid:
```ts
useEffect(() => {
fetchData(value);
}, [value]);
```
The action itself should control the request.
---
## 3. Update Related States Together
If a single action updates multiple related states:
- Do not synchronize them through `useEffect`
- Do not derive them indirectly through `useMemo`
Instead, update all related states directly inside the action handler.
Recommended:
```ts
const handleOnChange = (value) => {
setState1(...);
setState2(...);
buildState(...);
};
```
Avoid implicit state synchronization chains.
---
## 4. Group Strongly Related State
If multiple states are always updated together:
- Do not split them into multiple `useState` calls.
- Prefer a single state object.
Recommended:
```ts
const [state, setState] = useState({
state1: ...,
state2: ...,
state3: ...,
});
```
This reduces unnecessary rerenders and keeps state transitions predictable.
---
## 5. Prefer Explicit State Flow
Avoid chaining business logic through multiple `useEffect` hooks.
Keep:
- request execution
- state updates
- derived calculations
close to the triggering action whenever possible.
Prefer:
```ts
const handleAction = () => {
fetchData();
setTableData(...);
setSelectedRow(...);
};
```
Over:
```ts
useEffect(() => {
buildTable();
}, [data]);
useEffect(() => {
updateSelection();
}, [tableData]);
```
---
## 6. Avoid Premature Memoization
Do not use `useMemo` or `useCallback` unless there is a confirmed rendering or computation bottleneck.
Overusing memoization:
- increases complexity
- makes state flow harder to understand
- may introduce stale dependency issues
Prefer simple and explicit logic first.
Optimize only when necessary.
---
## 7. Keep Request Logic Predictable
A user interaction should clearly show:
- what request is triggered
- which states are updated
- how the UI changes
Avoid indirect update chains caused by dependency-driven effects.
The code should make the request and update flow easy to trace.
---
## 8. Prefer Action-Driven Architecture
Prefer:
- action-driven updates
- explicit handlers
- localized state transitions
Over:
- effect-driven synchronization
- cross-hook implicit updates
- reactive chains between states
The triggering action should remain the primary source of truth for UI updates.
---
# Form
Form-specific patterns that build on the rules above. The theme: keep cascading selections (pick A → derive B → write form) on a single, predictable path.
## 1. No Fallback for Derived Selection
When "pick A then auto-pick B", match by rule and return `undefined` if no match — let the corresponding form field stay empty.
Do not silently fall back to `list[0]` or another default. A fallback hides data issues and tricks the user into thinking they have a valid selection.
```ts
const findB = (key, list) =>
key ? list.find((x) => x.key === key) : undefined;
```
For form fields, prefer clearing with `undefined` over `''`. With Ant Design, `undefined` restores the placeholder; `''` is treated as a real value.
## 2. Async Race Protection
For fetches triggered by a lifecycle entry (e.g., modal open), tag each invocation with a session ref. Discard stale results if the session has rotated (the modal was closed and re-opened) by the time the response arrives.
```ts
const sessionRef = useRef(0);
useEffect(() => {
if (!open) {
sessionRef.current += 1;
return;
}
const session = ++sessionRef.current;
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
if (sessionRef.current !== session) return;
applySelection(as.items[0], findB(as.items[0].key, bs.items));
});
}, [open]);
```
## 3. Reference Template
A typical form with two cascading selectors backed by a single shared state:
```ts
type Selection = { a?: string; b?: number };
const [selection, setSelection] = useState<Selection>({});
const sessionRef = useRef(0);
const findB = (key, list) =>
key ? list.find((x) => x.key === key) : undefined;
// Single atomic write: state + form together.
const applySelection = (a, b) => {
setSelection({ a: a.name, b: b?.id });
form.current?.setFieldsValue({
field: b?.field,
spec: { ...currentSpec, ...b?.spec }
});
};
// Trigger 1: modal opened
useEffect(() => {
if (!open) {
sessionRef.current++;
setSelection({});
return;
}
const session = ++sessionRef.current;
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
if (sessionRef.current !== session) return;
const first = as.items[0];
applySelection(first, findB(first.key, bs.items));
});
}, [open]);
// Trigger 2: user picks A
const handleAChange = (a) => {
applySelection(a, findB(a.key, listB));
};
// Trigger 3: user picks B
const handleBChange = (b) => {
setSelection((prev) => ({ ...prev, b: b.id }));
form.current?.setFieldsValue({ ...b.fields });
};
```
+46
View File
@@ -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;
+136 -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,74 @@ 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-database-outlined',
selectedIcon: 'icon-database-filled',
defaultIcon: 'icon-database-outlined',
component: './gpu-service/storage'
},
{
name: 'storageTypes',
path: '/gpu-service/storage-types',
key: 'gpuServiceStorageTypes',
icon: 'icon-storage-outlined',
access: 'canSeeAdmin',
selectedIcon: 'icon-storage-filled',
defaultIcon: 'icon-storage-outlined',
component: './gpu-service/storage-types'
},
{
name: 'publicKeys',
path: '/gpu-service/public-keys',
key: 'gpuServicePublicKeys',
icon: 'icon-ssh-outlined',
selectedIcon: 'icon-ssh-filled',
defaultIcon: 'icon-ssh-outlined',
component: './gpu-service/public-keys'
}
]
},
{
name: 'resources',
path: '/resources',
key: 'resources',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
routes: [
{
path: '/resources',
@@ -194,7 +274,7 @@ export default [
icon: 'icon-backend',
selectedIcon: 'icon-backend-filled',
defaultIcon: 'icon-backend',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
component: './backends/index'
},
{
@@ -212,7 +292,7 @@ export default [
name: 'clusterManagement',
path: '/cluster-management',
key: 'clusterManagement',
access: 'canSeeAdmin',
access: 'canSeeOrgAdmin',
routes: [
{
path: '/cluster-management',
@@ -256,7 +336,6 @@ export default [
name: 'accessControl',
path: '/access-control',
key: 'accessControl',
access: 'canSeeAdmin',
routes: [
{
path: '/access-control',
@@ -269,7 +348,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 +396,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.16",
"@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"
+14018 -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: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 286 64"><g><g><defs><path id="SVGID_1_" d="M47.5 17.6L25 4.8v52.6l9-5.2V37.4l6.8 3.9-.1-10.1-6.7-3.9v-5.9l13.5 7.9z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_2_)"><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1.6" y1="335.05" x2="53.6" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.6 4.6h55.2v52.9H-1.6V4.6z" fill="url(#SVGID_3_)"/></g></g></g><g><g><defs><path id="SVGID_4_" d="M.5 17.6L23 4.8v52.6l-9-5.2V21.4L.5 29.3z"/></defs><clipPath id="SVGID_5_"><use xlink:href="#SVGID_4_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_5_)"><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1.9" y1="335.05" x2="53.3" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.9 4.6h55.2v52.9H-1.9V4.6z" fill="url(#SVGID_6_)"/></g></g></g><path style="fill:#425066" d="M88.2 21.1h-10v27.7h-5.6V21.1h-10v-4.5h25.6v4.5z"/><path style="fill:#425066" d="M94.9 49.2c-3.4 0-6.2-1.1-8.3-3.2-2.1-2.1-3.2-5-3.2-8.6v-.7c0-2.2.4-4.4 1.4-6.4.9-1.8 2.2-3.3 3.9-4.4 1.7-1.1 3.6-1.6 5.6-1.6 3.3 0 5.8 1 7.6 3.1s2.7 5 2.7 8.8v2.2H88.9c.1 1.8.8 3.4 2 4.7 1.2 1.2 2.7 1.8 4.4 1.7 2.4.1 4.6-1.1 6-3l2.9 2.8c-1 1.4-2.3 2.6-3.8 3.3-1.8 1-3.6 1.4-5.5 1.3zm-.6-20.5c-1.4-.1-2.7.5-3.6 1.5-1 1.2-1.6 2.7-1.7 4.3h10.3v-.4c-.1-1.8-.6-3.2-1.4-4.1-1-.8-2.3-1.4-3.6-1.3zm19.4-3.9l.2 2.8c1.7-2.1 4.3-3.3 7-3.2 5 0 7.5 2.9 7.6 8.6v15.8h-5.4V33.3c0-1.5-.3-2.6-1-3.4-.7-.7-1.7-1.1-3.2-1.1-2.1-.1-4 1.1-4.9 2.9v17h-5.4v-24l5.1.1zm32.2 17.5c0-.9-.4-1.7-1.2-2.2-1.2-.7-2.6-1.1-3.9-1.3-1.6-.3-3.1-.8-4.6-1.5-2.7-1.3-4-3.2-4-5.6 0-2 1-4 2.6-5.2 1.7-1.4 4-2.1 6.6-2.1 2.9 0 5.2.7 6.9 2.1 1.7 1.3 2.7 3.4 2.6 5.5h-5.4c0-1-.4-1.9-1.2-2.6-.9-.7-1.9-1.1-3.1-1-1 0-2 .2-2.9.8-.7.5-1.1 1.3-1.1 2.2 0 .8.4 1.5 1 1.9.7.5 2.1.9 4.2 1.4 1.7.3 3.4.9 5 1.7 1.1.5 2 1.3 2.7 2.3.6 1 .9 2.1.9 3.3 0 2.1-1 4-2.7 5.2-1.8 1.3-4.1 2-7 2-1.8 0-3.6-.3-5.2-1.1-1.4-.6-2.7-1.6-3.6-2.9-.8-1.2-1.3-2.6-1.3-4h5.2c0 1.1.5 2.2 1.4 2.9 1 .7 2.3 1.1 3.5 1 1.4 0 2.5-.3 3.2-.8 1-.4 1.4-1.2 1.4-2zm8.1-5.7c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.4 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3-5.2-3.1-9l.1-.3zm5.3.5c0 2.5.5 4.4 1.5 5.8 1.8 2.3 5.1 2.8 7.5 1 .4-.3.7-.6 1-1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.8 1.4-1.4 3.5-1.4 6.3zm33.1-7.3c-.7-.1-1.5-.2-2.2-.2-2.5 0-4.1.9-5 2.8v16.4h-5.4v-24h5.1l.1 2.7c1.3-2.1 3.1-3.1 5.4-3.1.6 0 1.3.1 1.9.3l.1 5.1zm22.5 5.3h-13v13.7h-5.6V16.6h20.5v4.5h-14.9v9.6h13v4.4zm10.7 13.7h-5.4V16.5h5.4v32.3zm3.9-12.2c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.3 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3.1-5.2-3.1-9v-.3zm5.4.5c0 2.5.5 4.4 1.5 5.8 1 1.4 2.6 2.2 4.3 2.1 1.7.1 3.3-.7 4.2-2.1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.9 1.4-1.4 3.5-1.4 6.3zm41.2 4.3l3.8-16.5h5.2l-6.5 24h-4.4l-5.1-16.5-5.1 16.5h-4.4l-6.6-24h5.3l3.9 16.4 4.9-16.4h4.1l4.9 16.5z"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 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[]>([]);
+10
View File
@@ -0,0 +1,10 @@
import { ClusterListItem } from '@/pages/cluster-management/config/types';
import { atom } from 'jotai';
export const currentClusterAtom = atom<
(Partial<ClusterListItem> & { label?: string; value?: number }) | null
>(null);
export const addSSHKeyPageAtom = atom<{ create: boolean }>({
create: false
});
+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
});
+74
View File
@@ -27,3 +27,77 @@ export const initialPasswordAtom = atomWithStorage<string>(
'initialPassword',
''
);
// Namespace the server creates for an Org's resources on each Kubernetes
// cluster. The format must match the backend's ``get_namespace_name``
// helper — ``gpustack-{name}`` — because the GPU-instance / storage CRDs
// (worker.gpustack.ai/v1) are namespaced and the server-side admission
// keys off this exact name. The identifier column on the unified
// Principal table is now ``name`` (post identity-consolidation rename
// of the legacy ``slug``); the namespace prefix is unchanged.
//
// Resolution path:
// 1. The Org the caller is currently acting under — the enterprise
// plugin persists ``currentOrganizationId`` (numeric) when the user
// picks an Org via OrgSwitcher.
// 2. The cluster's own owner Org — used in the platform-admin "All"
// view, where the caller has no Org context but the resource still
// has to land in *some* Org's namespace.
// 3. ``gpustack-default`` as a last resort (first load before any
// cache is hydrated, or a cluster whose owner Org is missing from
// both caches).
//
// Called outside React (umi page utilities) so it reads localStorage
// directly rather than going through a Jotai hook. The org caches are
// kept fresh by the enterprise plugin's atomWithStorage atoms, and the
// OrgSwitcher reloads the page on switch so we don't need in-process
// reactivity here.
export const getCurrentOrgNamespace = (
clusterOwnerPrincipalId?: number | null
): string => {
return (
lookupOrgNamespace(getStoredCurrentOrgId()) ??
lookupOrgNamespace(clusterOwnerPrincipalId ?? null) ??
'gpustack-default'
);
};
const getStoredCurrentOrgId = (): number | null => {
try {
const raw = localStorage.getItem('currentOrganizationId');
if (!raw) return null;
const value = JSON.parse(raw);
return typeof value === 'number' ? value : null;
} catch {
return null;
}
};
// Org caches the enterprise plugin persists. ``organizationList`` is
// the caller's member orgs; ``allOrganizations`` is admin-only (every
// Org on the platform) so admin sessions can resolve any owner Org id.
// Both are checked because ``currentOrganizationId`` is null in the
// admin "All" view but a member org's ``name`` might still cover the
// cluster-owner fallback.
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
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; name?: string }>;
if (!Array.isArray(list)) continue;
const match = list.find((item) => String(item?.id) === target);
if (match?.name) return `gpustack-${match.name}`;
} catch {
// ignore malformed cache; continue checking other keys
}
}
return null;
};
+7 -2
View File
@@ -6,12 +6,17 @@ export const clearStorageUserSettings = () => {
const savedSettings = JSON.parse(
localStorage.getItem('userSettings') || '{}'
);
// colorPrimary is an enterprise-wide branding setting (set by admins
// and applied by `onAppInit` from /enterprise/settings), not a per-user
// preference. Preserve it across login — otherwise the next layout
// mount triggers `atomWithStorage.onMount`, re-reads localStorage,
// and falls back to the default color until a full page refresh
// re-runs `applyEnterpriseSettings`.
localStorage.setItem(
'userSettings',
JSON.stringify({
...savedSettings,
hideAddResourceModal: false,
colorPrimary: undefined
hideAddResourceModal: false
})
);
} catch (error) {
-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;
-138
View File
@@ -1,138 +0,0 @@
import { CheckCircleFilled, CopyOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, message, Tooltip } from 'antd';
import React, {
useCallback,
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);
};
/**
* fallbackexecCommandold Safari / NON-HTTPS
*/
const legacyCopy = (value: string) => {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
return true;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
};
const handleCopy = useCallback(async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const success = legacyCopy(text);
if (!success) throw new Error('legacy copy failed');
}
setCopied(true);
} catch {
message.error(intl.formatMessage({ id: 'common.copy.fail' }) as string);
}
}, [text, intl]);
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

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