mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-20 23:40:16 +08:00
Merge branch 'main' into 4-17-lint-config-for-react
This commit is contained in:
@@ -37,6 +37,10 @@
|
||||
"types": "./src/dropdown-menu/index.tsx",
|
||||
"import": "./src/dropdown-menu/index.tsx"
|
||||
},
|
||||
"./meter": {
|
||||
"types": "./src/meter/index.tsx",
|
||||
"import": "./src/meter/index.tsx"
|
||||
},
|
||||
"./number-field": {
|
||||
"types": "./src/number-field/index.tsx",
|
||||
"import": "./src/number-field/index.tsx"
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
/** @public */
|
||||
export type { Placement }
|
||||
|
||||
export const ContextMenu = BaseContextMenu.Root
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Dialog as BaseDialog } from '@base-ui/react/dialog'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export const Dialog = BaseDialog.Root
|
||||
/** @public */
|
||||
export const DialogTrigger = BaseDialog.Trigger
|
||||
export const DialogTitle = BaseDialog.Title
|
||||
export const DialogDescription = BaseDialog.Description
|
||||
|
||||
128
packages/dify-ui/src/meter/__tests__/index.spec.tsx
Normal file
128
packages/dify-ui/src/meter/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
MeterIndicator,
|
||||
MeterLabel,
|
||||
MeterRoot,
|
||||
MeterTrack,
|
||||
MeterValue,
|
||||
} from '../index'
|
||||
|
||||
describe('Meter compound primitives', () => {
|
||||
it('exposes role="meter" with ARIA value metadata', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={40} aria-label="Quota">
|
||||
<MeterTrack>
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const meter = screen.getByLabelText('Quota')
|
||||
await expect.element(meter).toHaveAttribute('role', 'meter')
|
||||
await expect.element(meter).toHaveAttribute('aria-valuemin', '0')
|
||||
await expect.element(meter).toHaveAttribute('aria-valuemax', '100')
|
||||
await expect.element(meter).toHaveAttribute('aria-valuenow', '40')
|
||||
})
|
||||
|
||||
it('respects custom min and max', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={3} min={1} max={5} aria-label="Quota">
|
||||
<MeterTrack>
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const meter = screen.getByLabelText('Quota')
|
||||
await expect.element(meter).toHaveAttribute('aria-valuemin', '1')
|
||||
await expect.element(meter).toHaveAttribute('aria-valuemax', '5')
|
||||
await expect.element(meter).toHaveAttribute('aria-valuenow', '3')
|
||||
})
|
||||
|
||||
it('sets indicator width from value/min/max', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={42} aria-label="Quota">
|
||||
<MeterTrack>
|
||||
<MeterIndicator data-testid="indicator" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const indicator = screen.getByTestId('indicator').element() as HTMLElement
|
||||
expect(indicator.getAttribute('style')).toContain('width: 42%')
|
||||
})
|
||||
|
||||
it('applies tone="error" to the indicator', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={95} aria-label="Quota">
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone="error" data-testid="indicator" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const indicator = screen.getByTestId('indicator').element() as HTMLElement
|
||||
expect(indicator.className).toContain('bg-components-progress-error-progress')
|
||||
})
|
||||
|
||||
it('applies tone="warning" to the indicator', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={85} aria-label="Quota">
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone="warning" data-testid="indicator" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const indicator = screen.getByTestId('indicator').element() as HTMLElement
|
||||
expect(indicator.className).toContain('bg-components-progress-warning-progress')
|
||||
})
|
||||
|
||||
it('defaults to the neutral tone when none is supplied', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={25} aria-label="Quota">
|
||||
<MeterTrack>
|
||||
<MeterIndicator data-testid="indicator" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const indicator = screen.getByTestId('indicator').element() as HTMLElement
|
||||
expect(indicator.className).toContain('bg-components-progress-bar-progress-solid')
|
||||
})
|
||||
|
||||
it('forwards className to MeterTrack alongside the themed base classes', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot value={10} aria-label="Quota">
|
||||
<MeterTrack className="custom-track" data-testid="track">
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
const track = screen.getByTestId('track').element() as HTMLElement
|
||||
expect(track.className).toContain('custom-track')
|
||||
expect(track.className).toContain('bg-components-progress-bar-bg')
|
||||
})
|
||||
|
||||
it('renders MeterLabel and MeterValue inside a compound layout', async () => {
|
||||
const screen = await render(
|
||||
<MeterRoot
|
||||
value={0.42}
|
||||
min={0}
|
||||
max={1}
|
||||
format={{ style: 'percent', maximumFractionDigits: 0 }}
|
||||
aria-label="Score"
|
||||
>
|
||||
<MeterLabel>Score</MeterLabel>
|
||||
<MeterTrack>
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
<MeterValue />
|
||||
</MeterRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByText('Score')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('42%')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
115
packages/dify-ui/src/meter/index.stories.tsx
Normal file
115
packages/dify-ui/src/meter/index.stories.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { MeterIndicator, MeterLabel, MeterRoot, MeterTrack, MeterValue } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Meter',
|
||||
component: MeterRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'A graphical display of a numeric value within a known range. '
|
||||
+ 'Use the compound primitives (`MeterRoot / MeterTrack / MeterIndicator / '
|
||||
+ 'MeterValue / MeterLabel`) for quota, capacity, or score indicators; do '
|
||||
+ 'not use for task-completion progress.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof MeterRoot>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
'value': 42,
|
||||
'aria-label': 'Quota used',
|
||||
},
|
||||
render: args => (
|
||||
<div className="w-[320px]">
|
||||
<MeterRoot {...args}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
'value': 85,
|
||||
'aria-label': 'Quota used',
|
||||
},
|
||||
render: args => (
|
||||
<div className="w-[320px]">
|
||||
<MeterRoot {...args}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone="warning" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
'value': 100,
|
||||
'aria-label': 'Quota used',
|
||||
},
|
||||
render: args => (
|
||||
<div className="w-[320px]">
|
||||
<MeterRoot {...args}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone="error" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ComposedWithLabelAndValue: Story = {
|
||||
args: {
|
||||
'value': 62,
|
||||
'aria-label': 'Storage used',
|
||||
},
|
||||
render: args => (
|
||||
<div className="w-[320px] space-y-2 rounded-xl bg-components-panel-bg p-4">
|
||||
<MeterRoot {...args}>
|
||||
<div className="flex items-center justify-between">
|
||||
<MeterLabel>Storage</MeterLabel>
|
||||
<MeterValue />
|
||||
</div>
|
||||
<MeterTrack className="mt-2">
|
||||
<MeterIndicator tone="warning" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const PercentFormatted: Story = {
|
||||
args: {
|
||||
'value': 0.73,
|
||||
'min': 0,
|
||||
'max': 1,
|
||||
'format': { style: 'percent', maximumFractionDigits: 0 },
|
||||
'aria-label': 'Retrieval score',
|
||||
},
|
||||
render: args => (
|
||||
<div className="w-[320px] space-y-2">
|
||||
<MeterRoot {...args}>
|
||||
<div className="flex items-center justify-between">
|
||||
<MeterLabel>Score</MeterLabel>
|
||||
<MeterValue />
|
||||
</div>
|
||||
<MeterTrack className="mt-2">
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
88
packages/dify-ui/src/meter/index.tsx
Normal file
88
packages/dify-ui/src/meter/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Meter — a graphical display of a numeric value within a known range
|
||||
* (quota usage, capacity, scores). For task-completion semantics use a
|
||||
* Progress primitive instead; `role="meter"` and `role="progressbar"` are
|
||||
* not interchangeable.
|
||||
*
|
||||
* Consumers import from `@langgenius/dify-ui/meter` and must NOT import
|
||||
* `@base-ui/react/meter` directly.
|
||||
*/
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { Meter as BaseMeter } from '@base-ui/react/meter'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export const MeterRoot = BaseMeter.Root
|
||||
export type MeterRootProps = BaseMeter.Root.Props
|
||||
|
||||
const meterTrackClassName
|
||||
= 'relative block h-1 w-full overflow-hidden rounded-md bg-components-progress-bar-bg'
|
||||
|
||||
export type MeterTrackProps = BaseMeter.Track.Props
|
||||
|
||||
export function MeterTrack({ className, ...props }: MeterTrackProps) {
|
||||
return (
|
||||
<BaseMeter.Track
|
||||
className={cn(meterTrackClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const meterIndicatorVariants = cva(
|
||||
'block h-full rounded-md transition-[width] motion-reduce:transition-none',
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: 'bg-components-progress-bar-progress-solid',
|
||||
warning: 'bg-components-progress-warning-progress',
|
||||
error: 'bg-components-progress-error-progress',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
tone: 'neutral',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type MeterTone = NonNullable<VariantProps<typeof meterIndicatorVariants>['tone']>
|
||||
|
||||
export type MeterIndicatorProps = BaseMeter.Indicator.Props & {
|
||||
tone?: MeterTone
|
||||
}
|
||||
|
||||
export function MeterIndicator({ className, tone, ...props }: MeterIndicatorProps) {
|
||||
return (
|
||||
<BaseMeter.Indicator
|
||||
className={cn(meterIndicatorVariants({ tone }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const meterValueClassName = 'system-xs-regular text-text-tertiary tabular-nums'
|
||||
export type MeterValueProps = BaseMeter.Value.Props
|
||||
|
||||
export function MeterValue({ className, ...props }: MeterValueProps) {
|
||||
return (
|
||||
<BaseMeter.Value
|
||||
className={cn(meterValueClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const meterLabelClassName = 'system-xs-medium text-text-tertiary'
|
||||
export type MeterLabelProps = BaseMeter.Label.Props
|
||||
|
||||
export function MeterLabel({ className, ...props }: MeterLabelProps) {
|
||||
return (
|
||||
<BaseMeter.Label
|
||||
className={cn(meterLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,7 @@ export type { Placement }
|
||||
export const Popover = BasePopover.Root
|
||||
export const PopoverTrigger = BasePopover.Trigger
|
||||
export const PopoverClose = BasePopover.Close
|
||||
/** @public */
|
||||
export const PopoverTitle = BasePopover.Title
|
||||
/** @public */
|
||||
export const PopoverDescription = BasePopover.Description
|
||||
|
||||
type PopoverContentProps = {
|
||||
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
/** @public */
|
||||
export type { Placement }
|
||||
|
||||
export const Select = BaseSelect.Root
|
||||
export const SelectValue = BaseSelect.Value
|
||||
/** @public */
|
||||
export const SelectGroup = BaseSelect.Group
|
||||
|
||||
const selectTriggerVariants = cva(
|
||||
@@ -81,7 +79,6 @@ export function SelectLabel({
|
||||
)
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Slider as BaseSlider } from '@base-ui/react/slider'
|
||||
import { cn } from '../cn'
|
||||
|
||||
/** @public */
|
||||
export const SliderRoot = BaseSlider.Root
|
||||
|
||||
type SliderRootProps = BaseSlider.Root.Props<number>
|
||||
@@ -15,7 +14,6 @@ const sliderControlClassName = cn(
|
||||
|
||||
type SliderControlProps = BaseSlider.Control.Props
|
||||
|
||||
/** @public */
|
||||
export function SliderControl({ className, ...props }: SliderControlProps) {
|
||||
return (
|
||||
<BaseSlider.Control
|
||||
@@ -32,7 +30,6 @@ const sliderTrackClassName = cn(
|
||||
|
||||
type SliderTrackProps = BaseSlider.Track.Props
|
||||
|
||||
/** @public */
|
||||
export function SliderTrack({ className, ...props }: SliderTrackProps) {
|
||||
return (
|
||||
<BaseSlider.Track
|
||||
@@ -49,7 +46,6 @@ const sliderIndicatorClassName = cn(
|
||||
|
||||
type SliderIndicatorProps = BaseSlider.Indicator.Props
|
||||
|
||||
/** @public */
|
||||
export function SliderIndicator({ className, ...props }: SliderIndicatorProps) {
|
||||
return (
|
||||
<BaseSlider.Indicator
|
||||
@@ -71,7 +67,6 @@ const sliderThumbClassName = cn(
|
||||
|
||||
type SliderThumbProps = BaseSlider.Thumb.Props
|
||||
|
||||
/** @public */
|
||||
export function SliderThumb({ className, ...props }: SliderThumbProps) {
|
||||
return (
|
||||
<BaseSlider.Thumb
|
||||
|
||||
@@ -3,10 +3,22 @@ import { toast, ToastHost } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line vars-on-top
|
||||
var BASE_UI_ANIMATIONS_DISABLED: boolean | undefined
|
||||
}
|
||||
|
||||
describe('base/ui/toast', () => {
|
||||
beforeAll(() => {
|
||||
// Base UI waits for `requestAnimationFrame` + `getAnimations().finished`
|
||||
// before unmounting a toast. Fake timers can't reliably drive that path,
|
||||
// so short-circuit it to keep auto-dismiss assertions deterministic in CI.
|
||||
globalThis.BASE_UI_ANIMATIONS_DISABLED = true
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.useFakeTimers()
|
||||
toast.dismiss()
|
||||
})
|
||||
|
||||
@@ -16,6 +28,10 @@ describe('base/ui/toast', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.BASE_UI_ANIMATIONS_DISABLED = undefined
|
||||
})
|
||||
|
||||
it('should render a success toast when called through the typed shortcut', async () => {
|
||||
const screen = await render(<ToastHost />)
|
||||
|
||||
|
||||
@@ -490,8 +490,8 @@ describe('Capacity Full Components Integration', () => {
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a progress bar rendered
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
// Should have a meter rendered
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
@@ -531,10 +531,9 @@ describe('Capacity Full Components Integration', () => {
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
const { container } = render(<AppsFull loc="test" />)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
expect(container.querySelector('.bg-components-progress-error-progress')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -819,8 +818,8 @@ describe('Usage Display Edge Cases', () => {
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Should have an indeterminate progress bar
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
// Below-threshold storage renders the redacted placeholder instead of a Meter
|
||||
expect(document.body.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
@@ -846,15 +845,10 @@ describe('Usage Display Edge Cases', () => {
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
const { container } = render(<PlanComp loc="test" />)
|
||||
|
||||
// 20% usage - normal color
|
||||
const progressBars = screen.getAllByTestId('billing-progress-bar')
|
||||
// At least one should have the normal progress color
|
||||
const hasNormalColor = progressBars.some(bar =>
|
||||
bar.classList.contains('bg-components-progress-bar-progress-solid'),
|
||||
)
|
||||
expect(hasNormalColor).toBe(true)
|
||||
// 20% usage — at least one Meter indicator should carry the neutral tone
|
||||
expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -197,8 +197,8 @@ describe('AppsFull', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should apply distinct progress bar styling at different usage levels', () => {
|
||||
const renderWithUsage = (used: number, total: number) => {
|
||||
it('applies neutral / warning / error tone at distinct usage levels', () => {
|
||||
const findToneClass = (used: number, total: number) => {
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -208,19 +208,18 @@ describe('AppsFull', () => {
|
||||
reset: { apiRateLimit: null, triggerEvents: null },
|
||||
},
|
||||
}))
|
||||
const { unmount } = render(<AppsFull loc="billing_dialog" />)
|
||||
const className = screen.getByTestId('billing-progress-bar').className
|
||||
const { container, unmount } = render(<AppsFull loc="billing_dialog" />)
|
||||
const indicator = container.querySelector(
|
||||
'[class*="bg-components-progress-"]:not([class*="progress-bar-bg"])',
|
||||
)
|
||||
const className = indicator?.className ?? ''
|
||||
unmount()
|
||||
return className
|
||||
}
|
||||
|
||||
const normalClass = renderWithUsage(2, 10)
|
||||
const warningClass = renderWithUsage(6, 10)
|
||||
const errorClass = renderWithUsage(8, 10)
|
||||
|
||||
expect(normalClass).not.toBe(warningClass)
|
||||
expect(warningClass).not.toBe(errorClass)
|
||||
expect(normalClass).not.toBe(errorClass)
|
||||
expect(findToneClass(2, 10)).toContain('bg-components-progress-bar-progress-solid')
|
||||
expect(findToneClass(6, 10)).toContain('bg-components-progress-warning-progress')
|
||||
expect(findToneClass(8, 10)).toContain('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
import type { MeterTone } from '@langgenius/dify-ui/meter'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ProgressBar from '@/app/components/billing/progress-bar'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -12,9 +13,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import UpgradeBtn from '../upgrade-btn'
|
||||
import s from './style.module.css'
|
||||
|
||||
const LOW = 50
|
||||
const MIDDLE = 80
|
||||
|
||||
const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
loc,
|
||||
className,
|
||||
@@ -25,16 +23,8 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
const isTeam = plan.type === Plan.team
|
||||
const usage = plan.usage.buildApps
|
||||
const total = plan.total.buildApps
|
||||
const percent = usage / total * 100
|
||||
const color = (() => {
|
||||
if (percent < LOW)
|
||||
return 'bg-components-progress-bar-progress-solid'
|
||||
|
||||
if (percent < MIDDLE)
|
||||
return 'bg-components-progress-warning-progress'
|
||||
|
||||
return 'bg-components-progress-error-progress'
|
||||
})()
|
||||
const percent = total > 0 ? (usage / total) * 100 : 0
|
||||
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs',
|
||||
@@ -78,10 +68,11 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
percent={percent}
|
||||
color={color}
|
||||
/>
|
||||
<MeterRoot value={Math.min(percent, 100)} max={100}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={tone} />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ProgressBar from '../index'
|
||||
|
||||
describe('ProgressBar', () => {
|
||||
describe('Normal Mode (determinate)', () => {
|
||||
it('renders with provided percent and color', () => {
|
||||
render(<ProgressBar percent={42} color="bg-test-color" />)
|
||||
|
||||
const bar = screen.getByTestId('billing-progress-bar')
|
||||
expect(bar.getAttribute('style')).toContain('width: 42%')
|
||||
})
|
||||
|
||||
it('caps width at 100% when percent exceeds max', () => {
|
||||
render(<ProgressBar percent={150} color="bg-test-color" />)
|
||||
|
||||
const bar = screen.getByTestId('billing-progress-bar')
|
||||
expect(bar.getAttribute('style')).toContain('width: 100%')
|
||||
})
|
||||
|
||||
it('renders with default color when no color prop is provided', () => {
|
||||
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
|
||||
|
||||
const bar = screen.getByTestId('billing-progress-bar')
|
||||
expect(bar.getAttribute('style')).toContain('width: 20%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Indeterminate Mode', () => {
|
||||
it('should render indeterminate progress bar when indeterminate is true', () => {
|
||||
render(<ProgressBar percent={0} color="bg-test-color" indeterminate />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render normal progress bar when indeterminate is true', () => {
|
||||
render(<ProgressBar percent={50} color="bg-test-color" indeterminate />)
|
||||
|
||||
expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with different width based on indeterminateFull prop', () => {
|
||||
const { rerender } = render(
|
||||
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />,
|
||||
)
|
||||
|
||||
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
|
||||
const partialClassName = bar.className
|
||||
|
||||
rerender(
|
||||
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />,
|
||||
)
|
||||
|
||||
const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className
|
||||
expect(partialClassName).not.toBe(fullClassName)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type ProgressBarProps = {
|
||||
percent: number
|
||||
color: string
|
||||
indeterminate?: boolean
|
||||
indeterminateFull?: boolean // For Sandbox users: full width stripe
|
||||
}
|
||||
|
||||
const ProgressBar = ({
|
||||
percent = 0,
|
||||
color = 'bg-components-progress-bar-progress-solid',
|
||||
indeterminate = false,
|
||||
indeterminateFull = false,
|
||||
}: ProgressBarProps) => {
|
||||
if (indeterminate) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md bg-components-progress-bar-bg">
|
||||
<div
|
||||
data-testid="billing-progress-bar-indeterminate"
|
||||
className={cn('h-1 rounded-md bg-progress-bar-indeterminate-stripe', indeterminateFull ? 'w-full' : 'w-[30px]')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md bg-components-progress-bar-bg">
|
||||
<div
|
||||
data-testid="billing-progress-bar"
|
||||
className={cn('h-1 rounded-md', color)}
|
||||
style={{
|
||||
width: `${Math.min(percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressBar
|
||||
@@ -71,8 +71,8 @@ describe('UsageInfo', () => {
|
||||
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies distinct styling when usage is close to or exceeds the limit', () => {
|
||||
const { rerender } = render(
|
||||
it('applies the neutral / warning / error tone as usage crosses thresholds', () => {
|
||||
const { rerender, container } = render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
@@ -81,7 +81,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const normalBarClass = screen.getByTestId('billing-progress-bar').className
|
||||
expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<UsageInfo
|
||||
@@ -92,8 +92,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const warningBarClass = screen.getByTestId('billing-progress-bar').className
|
||||
expect(warningBarClass).not.toBe(normalBarClass)
|
||||
expect(container.querySelector('.bg-components-progress-warning-progress')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<UsageInfo
|
||||
@@ -104,9 +103,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const errorBarClass = screen.getByTestId('billing-progress-bar').className
|
||||
expect(errorBarClass).not.toBe(normalBarClass)
|
||||
expect(errorBarClass).not.toBe(warningBarClass)
|
||||
expect(container.querySelector('.bg-components-progress-error-progress')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the icon when hideIcon is true', () => {
|
||||
@@ -126,8 +123,8 @@ describe('UsageInfo', () => {
|
||||
|
||||
describe('Storage Mode', () => {
|
||||
describe('Below Threshold', () => {
|
||||
it('should render indeterminate progress bar when usage is below threshold', () => {
|
||||
render(
|
||||
it('should render the redacted placeholder when usage is below threshold', () => {
|
||||
const { container } = render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
@@ -139,8 +136,8 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "< threshold" format when usage is below threshold (non-sandbox)', () => {
|
||||
@@ -183,8 +180,8 @@ describe('UsageInfo', () => {
|
||||
expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => {
|
||||
const { rerender } = render(
|
||||
it('should render different placeholder widths for sandbox vs non-sandbox', () => {
|
||||
const { rerender, container } = render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
@@ -197,7 +194,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
|
||||
const sandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className
|
||||
|
||||
rerender(
|
||||
<UsageInfo
|
||||
@@ -212,14 +209,14 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
|
||||
const nonSandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className
|
||||
expect(sandboxBarClass).not.toBe(nonSandboxBarClass)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sandbox Full Capacity', () => {
|
||||
it('should render determinate progress bar when sandbox usage >= threshold', () => {
|
||||
render(
|
||||
it('should render the Meter when sandbox usage >= threshold', () => {
|
||||
const { container } = render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
@@ -232,8 +229,8 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => {
|
||||
@@ -258,8 +255,8 @@ describe('UsageInfo', () => {
|
||||
})
|
||||
|
||||
describe('Pro/Team Users Above Threshold', () => {
|
||||
it('should render normal progress bar when usage >= threshold', () => {
|
||||
render(
|
||||
it('should render the Meter when usage >= threshold', () => {
|
||||
const { container } = render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
@@ -273,8 +270,8 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should display actual usage when usage >= threshold', () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { defaultPlan } from '../../config'
|
||||
import { Plan } from '../../type'
|
||||
import VectorSpaceInfo from '../vector-space-info'
|
||||
|
||||
const queryPlaceholder = () => document.body.querySelector('[aria-hidden="true"]')
|
||||
|
||||
// Mock provider context with configurable plan
|
||||
let mockPlanType = Plan.sandbox
|
||||
let mockVectorSpaceUsage = 30
|
||||
@@ -55,16 +57,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceUsage = 30
|
||||
})
|
||||
|
||||
it('should render indeterminate progress bar when usage is below threshold', () => {
|
||||
it('should render the redacted placeholder when usage is below threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render indeterminate bar for sandbox users', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeInTheDocument()
|
||||
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "< 50" format for sandbox below threshold', () => {
|
||||
@@ -80,11 +77,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceUsage = 50
|
||||
})
|
||||
|
||||
it('should render determinate progress bar when at full capacity', () => {
|
||||
it('should render the Meter when at full capacity', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeNull()
|
||||
})
|
||||
|
||||
it('should display "50 / 50 MB" format when at full capacity', () => {
|
||||
@@ -101,10 +98,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceUsage = 30
|
||||
})
|
||||
|
||||
it('should render indeterminate progress bar when usage is below threshold', () => {
|
||||
it('should render the redacted placeholder when usage is below threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeInTheDocument()
|
||||
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "< 50 / total" format when below threshold', () => {
|
||||
@@ -121,11 +119,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceUsage = 100
|
||||
})
|
||||
|
||||
it('should render normal progress bar when usage >= threshold', () => {
|
||||
it('should render the Meter when usage >= threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeNull()
|
||||
})
|
||||
|
||||
it('should display actual usage when above threshold', () => {
|
||||
@@ -142,10 +140,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceUsage = 30
|
||||
})
|
||||
|
||||
it('should render indeterminate progress bar when usage is below threshold', () => {
|
||||
it('should render the redacted placeholder when usage is below threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeInTheDocument()
|
||||
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "< 50 / total" format when below threshold', () => {
|
||||
@@ -163,11 +162,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceUsage = 100
|
||||
})
|
||||
|
||||
it('should render normal progress bar when usage >= threshold', () => {
|
||||
it('should render the Meter when usage >= threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeNull()
|
||||
})
|
||||
|
||||
it('should display actual usage when above threshold', () => {
|
||||
@@ -179,23 +178,26 @@ describe('VectorSpaceInfo', () => {
|
||||
})
|
||||
|
||||
describe('Pro/Team Plan Usage States', () => {
|
||||
const renderAndGetBarClass = (usage: number) => {
|
||||
const findToneClass = (usage: number) => {
|
||||
mockPlanType = Plan.professional
|
||||
mockVectorSpaceUsage = usage
|
||||
const { unmount } = render(<VectorSpaceInfo />)
|
||||
const className = screen.getByTestId('billing-progress-bar').className
|
||||
const { container, unmount } = render(<VectorSpaceInfo />)
|
||||
const indicator = container.querySelector(
|
||||
'[class*="bg-components-progress-"]:not([class*="progress-bar-bg"])',
|
||||
)
|
||||
const className = indicator?.className ?? ''
|
||||
unmount()
|
||||
return className
|
||||
}
|
||||
|
||||
it('should show distinct progress bar styling at different usage levels', () => {
|
||||
const normalClass = renderAndGetBarClass(100)
|
||||
const warningClass = renderAndGetBarClass(4100)
|
||||
const errorClass = renderAndGetBarClass(5200)
|
||||
it('should apply neutral / warning / error tone at distinct usage levels', () => {
|
||||
const normalClass = findToneClass(100)
|
||||
const warningClass = findToneClass(4100)
|
||||
const errorClass = findToneClass(5200)
|
||||
|
||||
expect(normalClass).not.toBe(warningClass)
|
||||
expect(warningClass).not.toBe(errorClass)
|
||||
expect(normalClass).not.toBe(errorClass)
|
||||
expect(normalClass).toContain('bg-components-progress-bar-progress-solid')
|
||||
expect(warningClass).toContain('bg-components-progress-warning-progress')
|
||||
expect(errorClass).toContain('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,16 +216,11 @@ describe('VectorSpaceInfo', () => {
|
||||
expect(screen.getByText('102400MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render indeterminate progress bar when usage is below threshold', () => {
|
||||
it('should render the redacted placeholder when usage is below threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render indeterminate bar for enterprise below threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeInTheDocument()
|
||||
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "< 50 / total" format when below threshold', () => {
|
||||
@@ -241,11 +238,11 @@ describe('VectorSpaceInfo', () => {
|
||||
mockVectorSpaceTotal = 102400 // 100 GB
|
||||
})
|
||||
|
||||
it('should render normal progress bar when usage >= threshold', () => {
|
||||
it('should render the Meter when usage >= threshold', () => {
|
||||
render(<VectorSpaceInfo />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(queryPlaceholder()).toBeNull()
|
||||
})
|
||||
|
||||
it('should display actual usage when above threshold', () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
import type { ComponentType, FC } from 'react'
|
||||
import type { MeterTone } from '@langgenius/dify-ui/meter'
|
||||
import type { ComponentType, FC, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
import ProgressBar from '../progress-bar'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@@ -26,8 +27,6 @@ type Props = {
|
||||
isSandboxPlan?: boolean
|
||||
}
|
||||
|
||||
const WARNING_THRESHOLD = 80
|
||||
|
||||
const UsageInfo: FC<Props> = ({
|
||||
className,
|
||||
Icon,
|
||||
@@ -47,20 +46,21 @@ const UsageInfo: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Special display logic for usage below threshold (only in storage mode)
|
||||
const isBelowThreshold = storageMode && usage < storageThreshold
|
||||
// Sandbox at full capacity (usage >= threshold and it's sandbox plan)
|
||||
const isSandboxFull = storageMode && isSandboxPlan && usage >= storageThreshold
|
||||
|
||||
const percent = usage / total * 100
|
||||
const getProgressColor = () => {
|
||||
if (percent >= 100)
|
||||
return 'bg-components-progress-error-progress'
|
||||
if (percent >= WARNING_THRESHOLD)
|
||||
return 'bg-components-progress-warning-progress'
|
||||
return 'bg-components-progress-bar-progress-solid'
|
||||
}
|
||||
const color = getProgressColor()
|
||||
// Single source of truth: sandbox full is visually clamped to 100%; all other
|
||||
// determinate cases show the real percent capped at 100. Tone derives from
|
||||
// this, so we never need a separate tone override.
|
||||
const rawPercent = total > 0 ? (usage / total) * 100 : 0
|
||||
const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100)
|
||||
const tone: MeterTone
|
||||
= effectivePercent >= 100
|
||||
? 'error'
|
||||
: effectivePercent >= 80
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
|
||||
const isUnlimited = total === NUM_INFINITE
|
||||
let totalDisplay: string | number = isUnlimited ? t('plansCommon.unlimited', { ns: 'billing' }) : total
|
||||
if (!isUnlimited && unit && unitPosition === 'inline')
|
||||
@@ -68,35 +68,26 @@ const UsageInfo: FC<Props> = ({
|
||||
const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix'
|
||||
const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('usagePage.resetsIn', { ns: 'billing', count: resetInDays }) : undefined)
|
||||
|
||||
const renderRightInfo = () => {
|
||||
if (resetText) {
|
||||
return (
|
||||
const rightInfo: ReactNode = resetText
|
||||
? (
|
||||
<div className="ml-auto flex-1 text-right system-xs-regular text-text-tertiary">
|
||||
{resetText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (showUnit) {
|
||||
return (
|
||||
<div className="ml-auto system-xs-medium text-text-tertiary">
|
||||
{unit}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
: showUnit
|
||||
? (
|
||||
<div className="ml-auto system-xs-medium text-text-tertiary">
|
||||
{unit}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
|
||||
// Render usage display
|
||||
const renderUsageDisplay = () => {
|
||||
// Storage mode: special display logic
|
||||
const usageDisplay: ReactNode = (() => {
|
||||
if (storageMode) {
|
||||
// Sandbox user at full capacity
|
||||
if (isSandboxFull) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
{storageThreshold}
|
||||
</span>
|
||||
<span>{storageThreshold}</span>
|
||||
<span className="system-md-regular text-text-quaternary">/</span>
|
||||
<span>
|
||||
{storageThreshold}
|
||||
@@ -106,7 +97,6 @@ const UsageInfo: FC<Props> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Usage below threshold - show "< 50 MB" or "< 50 / 5GB"
|
||||
if (isBelowThreshold) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -125,7 +115,6 @@ const UsageInfo: FC<Props> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Pro/Team users with usage >= threshold - show actual usage
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{usage}</span>
|
||||
@@ -135,7 +124,6 @@ const UsageInfo: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Default display (storageMode = false)
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{usage}</span>
|
||||
@@ -143,9 +131,32 @@ const UsageInfo: FC<Props> = ({
|
||||
<span>{totalDisplay}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})()
|
||||
|
||||
const renderWithTooltip = (children: React.ReactNode) => {
|
||||
const bar: ReactNode = isBelowThreshold
|
||||
? (
|
||||
// Decorative "< N MB" placeholder — not a meter, not a progressbar.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="overflow-hidden rounded-md bg-components-progress-bar-bg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 rounded-md bg-progress-bar-indeterminate-stripe',
|
||||
isSandboxPlan ? 'w-full' : 'w-[30px]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<MeterRoot value={effectivePercent} max={100}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={tone} />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
)
|
||||
|
||||
const wrapWithStorageTooltip = (children: ReactNode) => {
|
||||
if (storageMode && storageTooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -159,23 +170,6 @@ const UsageInfo: FC<Props> = ({
|
||||
return children
|
||||
}
|
||||
|
||||
// Render progress bar with optional tooltip wrapper
|
||||
const renderProgressBar = () => {
|
||||
const progressBar = (
|
||||
<ProgressBar
|
||||
percent={isBelowThreshold ? 0 : percent}
|
||||
color={isSandboxFull ? 'bg-components-progress-error-progress' : color}
|
||||
indeterminate={isBelowThreshold}
|
||||
indeterminateFull={isBelowThreshold && isSandboxPlan}
|
||||
/>
|
||||
)
|
||||
return renderWithTooltip(progressBar)
|
||||
}
|
||||
|
||||
const renderUsageWithTooltip = () => {
|
||||
return renderWithTooltip(renderUsageDisplay())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
|
||||
{!hideIcon && Icon && (
|
||||
@@ -194,10 +188,10 @@ const UsageInfo: FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 system-md-semibold text-text-primary">
|
||||
{renderUsageWithTooltip()}
|
||||
{renderRightInfo()}
|
||||
{wrapWithStorageTooltip(usageDisplay)}
|
||||
{rightInfo}
|
||||
</div>
|
||||
{renderProgressBar()}
|
||||
{wrapWithStorageTooltip(bar)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MeterIndicator, MeterLabel, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
@@ -21,7 +22,9 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
|
||||
: 'modelProvider.card.creditsExhaustedDescription'
|
||||
|
||||
const usedCredits = totalCredits - credits
|
||||
const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100
|
||||
const hasTotal = totalCredits > 0
|
||||
const meterValue = hasTotal ? Math.min(usedCredits, totalCredits) : 1
|
||||
const meterMax = hasTotal ? totalCredits : 1
|
||||
|
||||
return (
|
||||
<div className="mx-2 mt-0.5 mb-1 rounded-lg bg-background-section-burn p-3">
|
||||
@@ -45,11 +48,11 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<MeterRoot value={meterValue} max={meterMax} className="mt-3 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
<MeterLabel className="system-xs-medium text-text-tertiary">
|
||||
{t('modelProvider.card.usageLabel', { ns: 'common' })}
|
||||
</span>
|
||||
</MeterLabel>
|
||||
<div className="flex items-center gap-0.5 system-xs-regular text-text-tertiary">
|
||||
<CreditsCoin className="h-3 w-3" />
|
||||
<span>
|
||||
@@ -59,13 +62,10 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-md bg-components-progress-error-bg">
|
||||
<div
|
||||
className="h-full rounded-l-[6px] bg-components-progress-error-progress"
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MeterTrack className="bg-components-progress-error-bg">
|
||||
<MeterIndicator tone="error" />
|
||||
</MeterTrack>
|
||||
</MeterRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user