Commit 2bec3706 by CaIon

feat(web): refine API key and user quota displays

Show remaining and used API key quota with a progress bar, and use consistent mobile cards, group multiplier badges, and activity timestamps. Present available user balance with used quota underneath and translate the new labels.

Resolve full API keys only for explicit copy or chat actions. Reviewed OWASP Authentication and Session Management guidance and ASVS 5.0.0 V14.2.6 and V8.3.1; backend authorization is unchanged, and regression tests cover refused and denied key resolution. This frontend change does not assert application-wide ASVS compliance.

Validation: 55 related component tests passed; the latest mobile group and quota changes passed 35 focused tests. TypeScript, scoped lint, formatting, production build, and git diff checks passed. Responsive previews verified narrow screens and finite, unlimited, exhausted, and inactive quota states.
parent a5e41a89
......@@ -148,7 +148,7 @@ function SplitHeaderTableView<TData>({
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
'w-full caption-bottom text-sm tabular-nums',
props.tableClassName
)}
style={tableSizing.style}
......
......@@ -33,6 +33,7 @@ type TruncatedCellProps = {
side?: 'top' | 'bottom' | 'left' | 'right'
tooltipClassName?: string
tooltipContent?: React.ReactNode
tabIndex?: number
}
export function TruncatedCell({
......@@ -43,12 +44,14 @@ export function TruncatedCell({
side = 'top',
tooltipClassName,
tooltipContent,
tabIndex,
}: TruncatedCellProps) {
const content = tooltipContent ?? getTextContent(children)
if (!content) {
return (
<div
tabIndex={tabIndex}
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
......@@ -65,6 +68,7 @@ export function TruncatedCell({
<TooltipTrigger
render={
<div
tabIndex={tabIndex}
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
......
......@@ -16,11 +16,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
import { Badge } from './ui/badge'
export function GroupMultiplierBadge(props: {
children?: ReactNode
className?: string
label?: string
ratio?: number | null
}) {
let colorClassName =
'border-muted-foreground/30 bg-muted text-muted-foreground'
if (props.ratio != null && props.ratio > 1) {
colorClassName = 'border-warning/30 bg-warning/10 text-warning'
} else if (props.ratio != null && props.ratio < 1) {
colorClassName = 'border-info/30 bg-info/10 text-info'
}
return (
<Badge
variant='outline'
className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-xs leading-none font-medium shadow-none',
!props.label && 'font-mono tabular-nums',
colorClassName,
props.className
)}
>
{props.children}
<span>{props.label ?? `${props.ratio}x`}</span>
</Badge>
)
}
type GroupBadgeProps = Omit<
StatusBadgeProps,
......@@ -29,16 +61,8 @@ type GroupBadgeProps = Omit<
group?: string | null
label?: string
ratio?: number | null
}
function getGroupRatioClassName(ratio: number): string {
if (ratio > 1) {
return 'bg-warning/10 text-warning'
}
if (ratio < 1) {
return 'bg-info/10 text-info'
}
return 'bg-muted text-muted-foreground'
ratioLabel?: string
containerClassName?: string
}
function getGroupLabel(params: {
......@@ -60,6 +84,8 @@ export function GroupBadge(props: GroupBadgeProps) {
group,
label: labelOverride,
ratio,
ratioLabel,
containerClassName,
copyable = false,
showDot,
className,
......@@ -89,21 +115,19 @@ export function GroupBadge(props: GroupBadgeProps) {
/>
)
if (ratio == null) {
if (ratio == null && !ratioLabel) {
return badge
}
return (
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
<span
className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs',
containerClassName
)}
>
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
<span
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
getGroupRatioClassName(ratio)
)}
>
<span>{ratio}x</span>
</span>
<GroupMultiplierBadge ratio={ratio} label={ratioLabel} />
</span>
)
}
......@@ -30,10 +30,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
>
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
className
)}
className={cn('w-full caption-bottom text-sm tabular-nums', className)}
{...props}
/>
</div>
......
......@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test } from 'vitest'
const { createInstance } = await import('i18next')
......@@ -61,93 +62,99 @@ function CellHarness(props: {
}
describe('API key group table cell', () => {
test('renders an unclipped ring and a localized Auto ratio when API data uses a nonlocalized string', () => {
test('keeps the group and compact localized multiplier together with one subtle flowing edge', () => {
const { container } = render(
<CellHarness
group='auto'
ratio='自动'
crossGroupRetry
shouldReduceMotion={false}
/>
<CellHarness group='auto' ratio='自动' crossGroupRetry />
)
const badgeCell = container.querySelector<HTMLElement>(
'[data-api-key-group-cell="auto"]'
)
expect(badgeCell).toHaveClass('overflow-visible')
expect(badgeCell).not.toHaveClass('overflow-hidden')
const frames = container.querySelectorAll('[data-auto-group-frame]')
const movingRings = container.querySelectorAll(
'[data-auto-group-flow-border]'
)
expect(frames.length).toBe(1)
expect(movingRings.length).toBe(1)
for (const frame of frames) {
expect(frame).toHaveClass(
'relative',
'overflow-visible',
'rounded-4xl',
'p-px'
)
}
const ratio = container.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(ratio).toHaveTextContent('Auto Ratio')
expect(ratio).not.toHaveTextContent('x')
const group = screen.getByText('Cross-group')
const multiplier = screen
.getByText('Auto')
.closest<HTMLElement>('[data-slot="badge"]')
expect(group).toBeInTheDocument()
expect(multiplier).toHaveClass('h-5', 'min-w-12', 'rounded-md')
expect(multiplier).not.toHaveTextContent('Ratio')
expect(container).not.toHaveTextContent('自动')
expect(container).toHaveTextContent('Cross-group')
const crossGroupBadge = [
...container.querySelectorAll<HTMLElement>('[data-slot="status-badge"]'),
].find((badge) => badge.textContent === 'Cross-group')
expect(crossGroupBadge).not.toBeUndefined()
expect(crossGroupBadge?.closest('[data-auto-group-frame]')).toBeNull()
expect(container.querySelector('[data-auto-group-frame]')).toBeNull()
const flow = container.querySelector('[data-auto-group-flow-border]')
expect(flow).toHaveClass('auto-group-flow-border-subtle')
expect(flow).toHaveAttribute('aria-hidden', 'true')
expect(group.closest('[data-api-key-group-cell]')).toContainElement(
multiplier
)
})
test('keeps the static Auto ratio frame but omits its moving layer for reduced motion', () => {
test('keeps the automatic tag visible but static when reduced motion is requested', () => {
const { container } = render(
<CellHarness group='auto' ratio='Auto' shouldReduceMotion />
)
expect(screen.getByText('Auto')).toBeInTheDocument()
expect(container.querySelector('[data-auto-group-flow-border]')).toBeNull()
})
expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(1)
expect(
container.querySelectorAll('[data-auto-group-flow-border]').length
).toBe(0)
test('does not invent a multiplier while automatic ratio data is unavailable', () => {
render(<CellHarness group='auto' />)
expect(screen.getByText('Cross-group')).toBeInTheDocument()
expect(screen.queryByText('Auto')).not.toBeInTheDocument()
})
test('shows only the cross-group badge when ratio data is unavailable', () => {
const { container } = render(
<CellHarness group='auto' shouldReduceMotion={false} />
)
test.each([
[0.8, 'bg-info/10', 'text-info', 'border-info/30'],
[1, 'bg-muted', 'text-muted-foreground', 'border-muted-foreground/30'],
[3, 'bg-warning/10', 'text-warning', 'border-warning/30'],
])(
'preserves the original %s multiplier color in the compact layout',
(ratio, background, color, border) => {
const { container } = render(
<CellHarness group='default' ratio={ratio} />
)
const multiplier = screen.getByText(`${ratio}x`).parentElement
expect(multiplier).toHaveClass(
background,
color,
border,
'rounded-full',
'font-mono',
'h-5',
'min-w-12'
)
expect(
container.querySelector('[data-auto-group-flow-border]')
).toBeNull()
}
)
expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(0)
expect(
container.querySelectorAll('[data-auto-group-flow-border]').length
).toBe(0)
expect(container.querySelector('[data-auto-group-effect="ratio"]')).toBe(
null
test('labels the user group multiplier as inherited without inventing a numeric value', async () => {
render(<CellHarness group='' />)
expect(screen.getByText('User Group')).toBeInTheDocument()
expect(screen.getByText('Inherited')).toBeInTheDocument()
expect(screen.getByText('Inherited').parentElement).toHaveClass(
'border-muted-foreground/30',
'rounded-full'
)
expect(container).toHaveTextContent('Cross-group')
expect(container).not.toHaveTextContent('Auto')
expect(container).not.toHaveTextContent('Ratio')
expect(screen.queryByText('1x')).not.toBeInTheDocument()
await userEvent.tab()
expect(await screen.findByText('Follow user group')).toBeVisible()
})
test('narrows normal group ratios to numbers and never applies Auto rings', () => {
const { container, rerender } = render(
<CellHarness group='vip' ratio='自动' shouldReduceMotion={false} />
)
expect(container).toHaveTextContent('vip')
expect(container).not.toHaveTextContent('自动')
expect(container.querySelector('[data-auto-group-frame]')).toBe(null)
expect(container.querySelector('[data-auto-group-flow-border]')).toBe(null)
rerender(<CellHarness group='vip' ratio={3} shouldReduceMotion={false} />)
test('keeps a long group name and exact multiplier available through keyboard focus', async () => {
const groupName = 'production-with-a-very-long-custom-group-name'
render(<CellHarness group={groupName} ratio={12.345678} />)
expect(
screen.getByText(groupName).closest('[data-slot="tooltip-trigger"]')
).toHaveClass('max-w-50')
expect(screen.getByText('12.345678x')).toBeInTheDocument()
await userEvent.tab()
expect(
await screen.findByText(groupName, {
selector: '[data-slot="tooltip-content"]',
})
).toBeVisible()
})
expect(container).toHaveTextContent('3x')
expect(container.querySelector('[data-auto-group-frame]')).toBe(null)
test('never turns a string-valued normal group ratio into an automatic multiplier', () => {
render(<CellHarness group='vip' ratio='自动' />)
expect(screen.getByText('vip')).toBeInTheDocument()
expect(screen.queryByText('Auto')).not.toBeInTheDocument()
expect(screen.queryByText('自动')).not.toBeInTheDocument()
})
})
......@@ -97,7 +97,7 @@ function getCommandItem(label: string): HTMLElement {
}
describe('API key group combobox Auto effect', () => {
test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', () => {
test('uses the compact table capsules in the selected group and dropdown options', () => {
setReducedMotion(false)
render(<Harness initialValue='auto' />)
......@@ -116,20 +116,23 @@ describe('API key group combobox Auto effect', () => {
'auto-group-flow-border'
)
const triggerRatio = trigger.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(triggerRatio).toHaveTextContent('Auto Ratio')
const triggerRatio = within(trigger)
.getByText('Auto')
.closest('[data-slot="badge"]')
expect(triggerRatio).toHaveTextContent('Auto')
expect(triggerRatio).not.toHaveTextContent('Ratio')
expect(triggerRatio).not.toHaveTextContent('x')
expect(trigger).not.toHaveTextContent('自动')
expect(triggerRatio).toHaveClass(
'relative',
'overflow-visible',
'rounded-4xl'
'rounded-md',
'h-5',
'min-w-12'
)
expect(
triggerRatio?.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument()
).toHaveClass('auto-group-flow-border-subtle')
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
......@@ -142,20 +145,31 @@ describe('API key group combobox Auto effect', () => {
expect(
autoOption.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument()
const optionRatio = autoOption.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
expect(optionRatio).toHaveTextContent('Auto Ratio')
const optionRatio = within(autoOption)
.getByText('Auto')
.closest('[data-slot="badge"]')
expect(optionRatio).toHaveTextContent('Auto')
expect(optionRatio).not.toHaveTextContent('Ratio')
expect(
optionRatio?.querySelector('[data-auto-group-flow-border]')
).toBeInTheDocument()
).toHaveClass('auto-group-flow-border-subtle')
const defaultOption = getCommandItem('User group')
expect(defaultOption).not.toHaveAttribute('data-auto-group-effect')
expect(defaultOption.querySelector('[data-auto-group-flow-border]')).toBe(
null
)
expect(defaultOption).toHaveTextContent('1x Ratio')
const defaultRatio = within(defaultOption)
.getByText('1x')
.closest('[data-slot="badge"]')
expect(defaultRatio).toHaveClass(
'h-5',
'min-w-12',
'rounded-full',
'font-mono',
'border-muted-foreground/30'
)
expect(defaultRatio).not.toHaveTextContent('Ratio')
expect(
defaultOption.querySelector('[data-auto-group-effect="ratio"]')
).toBe(null)
......@@ -198,17 +212,13 @@ describe('API key group combobox Auto effect', () => {
const trigger = getTrigger()
expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger')
expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null)
expect(
trigger.querySelector('[data-auto-group-effect="ratio"]')
).toBeInTheDocument()
expect(within(trigger).getByText('Auto')).toBeInTheDocument()
fireEvent.click(trigger)
const autoOption = getCommandItem('Global automatic routing')
expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option')
expect(autoOption.querySelector('[data-auto-group-flow-border]')).toBe(null)
expect(
autoOption.querySelector('[data-auto-group-effect="ratio"]')
).toBeInTheDocument()
expect(within(autoOption).getByText('Auto')).toBeInTheDocument()
setReducedMotion(false)
})
})
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import {
act,
cleanup,
render,
screen,
within,
waitFor,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { Toaster, toast } from 'sonner'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
import { apiKeySchema, type ApiKey } from '../../types'
import { ApiKeyQuotaCell } from '../api-key-quota-cell'
import { useApiKeysColumns } from '../api-keys-columns'
import { ApiKeysProvider } from '../api-keys-provider'
import { ApiKeysTable } from '../api-keys-table'
const now = 1_700_000_000_000
const key = apiKeySchema.parse({
id: 7,
name: 'production',
key: 'demo********1234',
status: 1,
remain_quota: 40_000_000,
used_quota: 60_000_000,
unlimited_quota: false,
expired_time: -1,
created_time: 0,
accessed_time: 0,
group: 'default',
model_limits_enabled: false,
})
const i18n = createInstance()
await i18n.init({
lng: 'en',
resources: { en: { translation: {} } },
initAsync: false,
})
const clients: QueryClient[] = []
function QuotaTable(props: { apiKey: ApiKey }) {
const columns = useApiKeysColumns(now).filter(
(column) => column.id === 'quota'
)
const table = useReactTable({
columns,
data: [props.apiKey],
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map((group) => (
<tr key={group.id}>
{group.headers.map((header) => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
function renderQuota(apiKey: ApiKey = key) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
})
clients.push(client)
return render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<QuotaTable apiKey={apiKey} />
</QueryClientProvider>
</I18nextProvider>
)
}
beforeEach(() => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
localStorage.clear()
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
vi.spyOn(api, 'get').mockResolvedValue({ data: { success: true, data: {} } })
})
afterEach(() => {
cleanup()
toast.dismiss()
localStorage.clear()
clients.splice(0).forEach((client) => client.clear())
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
})
it('shows remaining and used amounts above a progress bar, with the currency only in the header', () => {
renderQuota()
expect(
screen.getByRole('columnheader', { name: 'Quota ($)' })
).toBeInTheDocument()
const trigger = screen.getByRole('button', {
name: /Remaining 80; Remaining percentage 40%; Used amount 120/,
})
expect(trigger).toHaveTextContent('Remaining80Used amount120')
expect(trigger).not.toHaveTextContent('$')
expect(trigger.querySelector('svg')).toBeNull()
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '40')
})
it.each([
['unused', 500000, 0, 100, 'text-emerald-500'],
['low remaining', 150000, 350000, 30, 'text-amber-500'],
['critical remaining', 50000, 450000, 10, 'text-rose-500'],
['exhausted', 0, 500000, 0, null],
['overdrawn', -50000, 500000, 0, null],
['zero total', 0, 0, 0, null],
['negative total', -500000, 100000, 0, null],
])(
'renders the %s progress without invalid values or hiding negative balances',
(_label, remaining, used, percentage, color) => {
renderQuota({ ...key, remain_quota: remaining, used_quota: used })
const button = screen.getByRole('button')
const progress = screen.getByRole('progressbar')
expect(progress).toHaveAttribute('aria-valuenow', String(percentage))
if (color) expect(progress).toHaveClass(color)
if (remaining < 0) {
expect(
within(button).getByText(remaining === -500000 ? '-1' : '-0.1')
).toHaveClass('text-destructive')
}
}
)
it('shows unlimited with cumulative usage and explains it on demand', async () => {
renderQuota({ ...key, unlimited_quota: true })
const button = screen.getByRole('button', { name: /Unlimited/ })
expect(button).toHaveTextContent('Unlimited')
expect(button).toHaveTextContent('RemainingUnlimitedUsed amount120')
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
await userEvent.click(button)
const detail = await screen.findByRole('dialog')
expect(within(detail).getByText('120')).toBeInTheDocument()
expect(detail).toHaveTextContent(
'This API key has no quota limit. Requests still require available wallet or subscription quota.'
)
})
it('keeps small custom-currency amounts exact and shows full values in the detail', async () => {
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CUSTOM',
customCurrencySymbol: '🐱',
},
})
renderQuota({ ...key, remain_quota: 1900, used_quota: 1100 })
expect(
screen.getByRole('columnheader', { name: 'Quota (🐱)' })
).toBeInTheDocument()
const button = screen.getByRole('button')
expect(button).toHaveTextContent('0.0038')
expect(button).not.toHaveTextContent('🐱')
await userEvent.click(button)
const detail = await screen.findByRole('dialog')
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
expect(within(detail).getByText('0.006')).toBeInTheDocument()
})
it.each([
['disabled', { status: 2 }],
['expired status', { status: 3 }],
['exhausted status', { status: 4 }],
['expired timestamp', { expired_time: now / 1000 - 1 }],
])('renders the %s progress bar in a neutral color', (_label, overrides) => {
renderQuota({ ...key, ...overrides })
expect(screen.getByRole('progressbar')).toHaveClass(
'text-muted-foreground/60'
)
})
it('recalculates the progress when remaining quota is edited', () => {
const { rerender } = render(
<I18nextProvider i18n={i18n}>
<ApiKeyQuotaCell apiKey={key} now={now} />
</I18nextProvider>
)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '40')
rerender(
<I18nextProvider i18n={i18n}>
<ApiKeyQuotaCell
apiKey={{ ...key, remain_quota: 90_000_000 }}
now={now}
/>
</I18nextProvider>
)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '60')
expect(screen.getByText('180')).toBeInTheDocument()
})
it('opens details with the keyboard and restores focus when Escape closes them', async () => {
renderQuota()
const user = userEvent.setup()
const button = screen.getByRole('button')
act(() => button.focus())
await user.keyboard('{Enter}')
const detail = await screen.findByRole('dialog')
expect(within(detail).getByText('80')).toBeInTheDocument()
expect(within(detail).getByText('120')).toBeInTheDocument()
expect(within(detail).getByText('200')).toBeInTheDocument()
expect(within(detail).getByText('Remaining percentage')).toBeInTheDocument()
expect(within(detail).getByText('40%')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(button).toHaveFocus()
})
it('keeps a long amount within its column while showing the full amount in details', async () => {
renderQuota({ ...key, remain_quota: 123456789000000, used_quota: 0 })
const button = screen.getByRole('button')
expect(button).toHaveClass('w-full', 'min-w-0')
expect(within(button).getByText('246,913,578')).toHaveClass('truncate')
await userEvent.click(button)
expect(
within(await screen.findByRole('dialog')).getAllByText('246,913,578')
).toHaveLength(2)
})
function KeysPage() {
return (
<ApiKeysProvider>
<ApiKeysTable />
<Toaster />
</ApiKeysProvider>
)
}
async function renderKeysPage(status = 1, overrides: Partial<ApiKey> = {}) {
let currentKey = { ...key, status, ...overrides }
vi.mocked(api.get).mockImplementation(async (url) => {
if (url.startsWith('/api/token/')) {
return {
data: { success: true, data: { items: [currentKey], total: 1 } },
}
}
return { data: { success: true, data: { default: { ratio: 1 } } } }
})
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: { success: true, data: { key: 'fake-key-for-test-only' } },
})
const put = vi.spyOn(api, 'put').mockImplementation(async (_url, data) => {
const update = data as { id: number; status: number }
currentKey = { ...currentKey, status: update.status }
return { data: { success: true, data: currentKey } }
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
client.setQueryData(['status'], {})
clients.push(client)
const root = createRootRoute()
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
const keysRoute = createRoute({
getParentRoute: () => auth,
path: 'keys/',
component: KeysPage,
})
const router = createRouter({
routeTree: root.addChildren([auth.addChildren([keysRoute])]),
history: createMemoryHistory({ initialEntries: ['/keys/'] }),
})
await router.load()
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
</I18nextProvider>
)
await screen.findByText(currentKey.name)
return { post, put }
}
it('combines creation and last use while keeping expiry, models and IP restrictions separate', async () => {
await renderKeysPage()
for (const name of ['Name', 'API Key', 'Group', 'Models', 'IP Restriction']) {
expect(screen.getByRole('columnheader', { name })).toBeInTheDocument()
}
expect(screen.getByRole('columnheader', { name: 'Time' })).toBeInTheDocument()
expect(
screen.getByRole('columnheader', { name: 'Expires' })
).toBeInTheDocument()
const timeCell = screen.getByRole('cell', { name: /Created.*Last Used/ })
expect(within(timeCell).getByText('Last Used')).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: /Remaining 80; Remaining percentage 40%; Used amount 120/,
})
).toBeInTheDocument()
})
it('restores dates hidden by the old default and preserves unrelated column preferences', async () => {
localStorage.setItem(
'api-keys:column-visibility',
JSON.stringify({
created_time: false,
accessed_time: false,
expired_time: false,
model_limits: false,
})
)
await renderKeysPage()
expect(screen.getByRole('columnheader', { name: 'Time' })).toBeInTheDocument()
expect(
screen.getByRole('columnheader', { name: 'Expires' })
).toBeInTheDocument()
expect(
screen.queryByRole('columnheader', { name: 'Models' })
).not.toBeInTheDocument()
})
it.each([
[1, 'Disable', 2, 'Disabled'],
[2, 'Enable', 1, 'Enabled'],
])(
'keeps status %s toggling at its original row button without fetching a full key',
async (status, action, nextStatus, nextLabel) => {
const { post, put } = await renderKeysPage(status)
const user = userEvent.setup()
const button = screen.getByRole('button', { name: action })
act(() => button.focus())
await user.keyboard('{Enter}')
await waitFor(() =>
expect(put).toHaveBeenCalledWith('/api/token/?status_only=true', {
id: 7,
status: nextStatus,
})
)
await screen.findByText(nextLabel)
expect(post).not.toHaveBeenCalled()
}
)
it('keeps expired status when the server refuses reactivation', async () => {
const { put, post } = await renderKeysPage(3)
put.mockResolvedValue({ data: { success: false, message: 'Token expired' } })
await userEvent.click(screen.getByRole('button', { name: 'Enable' }))
await screen.findByText('Token expired')
expect(screen.getByText('Expired')).toBeInTheDocument()
expect(screen.queryByText('Enabled')).not.toBeInTheDocument()
expect(post).not.toHaveBeenCalled()
})
it.each([true, false])(
'fetches a full key only on explicit copy and honors permission success=%s',
async (success) => {
const user = userEvent.setup()
const { post } = await renderKeysPage()
post.mockResolvedValue(
success
? { data: { success: true, data: { key: 'fake-key-for-test-only' } } }
: { data: { success: false, message: 'Verification required' } }
)
const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
await user.click(screen.getByRole('button', { name: 'Open menu' }))
expect(post).not.toHaveBeenCalled()
await user.click(screen.getByRole('menuitem', { name: 'Copy Key' }))
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/token/7/key'))
if (success) {
await waitFor(() =>
expect(copy).toHaveBeenCalledWith('sk-fake-key-for-test-only')
)
} else {
await screen.findByText('Verification required')
expect(copy).not.toHaveBeenCalled()
}
}
)
it('keeps full mobile information without group or quota section headings', async () => {
const matchMedia = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...matchMedia(query),
matches: query.includes('max-width'),
}))
i18n.addResourceBundle('zh', 'translation', zh.translation)
await i18n.changeLanguage('zh')
try {
await renderKeysPage()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
expect(screen.queryByText('额度 ($)')).not.toBeInTheDocument()
expect(screen.getByText('($)')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /剩余 80;/ })).toBeInTheDocument()
expect(screen.getByText('80')).toBeInTheDocument()
expect(screen.getByText('120')).toBeInTheDocument()
expect(screen.getByText(zh.translation['Created'])).toBeInTheDocument()
expect(screen.getByText(zh.translation['Last Used'])).toBeInTheDocument()
expect(screen.getByText(zh.translation['Expires'])).toBeInTheDocument()
expect(
screen.queryByText(zh.translation['Group'], { exact: true })
).not.toBeInTheDocument()
expect(screen.getByText('default')).toBeInTheDocument()
expect(screen.getByText('1x')).toBeInTheDocument()
expect(screen.getByText(zh.translation['Models'])).toBeInTheDocument()
expect(
screen.getByText(zh.translation['IP Restriction'])
).toBeInTheDocument()
} finally {
await i18n.changeLanguage('en')
}
})
it('keeps mobile quota readable and opens complete model and IP restrictions by tapping', async () => {
const matchMedia = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...matchMedia(query),
matches: query.includes('max-width'),
}))
await renderKeysPage(1, {
name: 'production-with-a-long-key-name',
used_quota: 2245080000,
unlimited_quota: true,
model_limits_enabled: true,
model_limits: 'model-alpha,model-beta-with-a-long-name',
allow_ips: '192.0.2.1\n2001:db8::1',
})
const quota = screen.getByRole('button', {
name: /Unlimited; Used amount 4,490.16/,
})
expect(quota.querySelector('[data-slot="api-key-quota-values"]')).toHaveClass(
'grid-cols-[auto_minmax(0,1fr)]'
)
expect(within(quota).getByText('Unlimited')).toHaveClass(
'text-right',
'text-sm',
'font-normal'
)
expect(within(quota).getByText('4,490.16')).toHaveClass(
'font-mono',
'text-sm',
'font-normal',
'text-right'
)
await userEvent.click(screen.getByRole('button', { name: /Models: 2 model/ }))
let details = await screen.findByRole('dialog')
expect(within(details).getByText('model-alpha')).toBeVisible()
expect(within(details).getByText('model-beta-with-a-long-name')).toBeVisible()
await userEvent.keyboard('{Escape}')
await userEvent.click(
screen.getByRole('button', { name: /IP Restriction: 2 IP/ })
)
details = await screen.findByRole('dialog')
expect(within(details).getByText('192.0.2.1')).toBeVisible()
expect(within(details).getByText('2001:db8::1')).toBeVisible()
})
......@@ -256,21 +256,21 @@ describe('Auto group order editor', () => {
name: 'VIP',
title: 'Priority access',
description: 'Priority access',
ratio: '3x Ratio',
ratio: '3x',
},
{
index: '2',
name: 'Default',
title: 'Standard access',
description: 'Standard access',
ratio: '1x Ratio',
ratio: '1x',
},
{
index: '3',
name: 'Team',
title: 'Shared access',
description: 'Shared access',
ratio: '2x Ratio',
ratio: '2x',
},
])
......
......@@ -26,12 +26,10 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
// AutoGroupBadge,
GroupRatioBadge,
type GroupRatio,
} from './auto-group-visuals'
import { GroupRatioBadge, type GroupRatio } from './auto-group-visuals'
type ApiKeyGroupCellProps = {
crossGroupRetry: boolean
......@@ -42,16 +40,26 @@ type ApiKeyGroupCellProps = {
export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 640px)')
if (props.group !== 'auto') {
const ratio = typeof props.ratio === 'number' ? props.ratio : undefined
const group = props.group?.trim() || ''
if (group !== 'auto') {
const ratio =
group && typeof props.ratio === 'number' ? props.ratio : undefined
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={props.group || '-'}
className={isMobile ? 'w-full' : 'max-w-50'}
tabIndex={0}
tooltipContent={group || t('Follow user group')}
tooltipClassName='break-all'
>
<GroupBadge group={props.group} ratio={ratio} />
<GroupBadge
group={group}
ratio={ratio}
ratioLabel={group ? undefined : t('Inherited')}
className='px-0'
containerClassName={cn('gap-3', isMobile && 'w-full justify-between')}
/>
</TruncatedCell>
)
}
......@@ -62,7 +70,11 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
render={
<BadgeCell
data-api-key-group-cell='auto'
className='gap-1.5 overflow-visible text-xs'
tabIndex={0}
className={cn(
'ml-0 gap-3 overflow-visible text-xs',
isMobile ? 'w-full justify-between' : 'max-w-50'
)}
/>
}
>
......@@ -70,8 +82,8 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
label={t('Cross-group')}
variant='info'
copyable={false}
className='px-0'
/>
{/*<AutoGroupBadge shouldReduceMotion={props.shouldReduceMotion} />*/}
<GroupRatioBadge
ratio={props.ratio}
isAuto
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress'
import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { API_KEY_STATUS } from '../constants'
import type { ApiKey } from '../types'
type ApiKeyQuotaCellProps = {
apiKey: ApiKey
now: number
variant?: 'table' | 'card'
}
export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
const { t, i18n } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const used = props.apiKey.used_quota
const remaining = props.apiKey.remain_quota
const total = used + remaining
const hasProgress = !props.apiKey.unlimited_quota && total > 0
const percentage = hasProgress
? Math.min(100, Math.max(0, (remaining / total) * 100))
: 0
const formattedUsed = formatQuotaWithCurrency(used, { showSymbol: false })
const formattedRemaining = formatQuotaWithCurrency(remaining, {
showSymbol: false,
})
const formattedTotal = formatQuotaWithCurrency(total, { showSymbol: false })
const formattedPercentage = new Intl.NumberFormat(
toIntlLocale(i18n.resolvedLanguage || i18n.language),
{ maximumFractionDigits: 1 }
).format(percentage)
const isInactive =
props.apiKey.status !== API_KEY_STATUS.ENABLED ||
remaining <= 0 ||
(props.apiKey.expired_time !== -1 &&
props.apiKey.expired_time * 1000 <= props.now)
let progressColor = 'text-emerald-500'
if (isInactive) progressColor = 'text-muted-foreground/60'
else if (percentage <= 10) progressColor = 'text-rose-500'
else if (percentage <= 30) progressColor = 'text-amber-500'
const usageDescription = `${t('Used amount')} ${formattedUsed}`
const remainingDescription = hasProgress
? `${t('Remaining')} ${formattedRemaining}; ${t('Remaining percentage')} ${formattedPercentage}%`
: `${t('Remaining')} ${formattedRemaining}`
const triggerLabel = props.apiKey.unlimited_quota
? `${t('Unlimited')}; ${usageDescription}`
: `${remainingDescription}; ${usageDescription}`
return (
<Popover>
<div
className={cn(
'w-full min-w-0',
props.variant === 'card' ? 'space-y-2.5' : 'space-y-1.5'
)}
>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={
props.variant === 'card'
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
: triggerLabel
}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.variant === 'card' ? 'py-0' : 'py-0.5'
)}
/>
}
>
<span
data-slot='api-key-quota-values'
className='grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-2 gap-y-1 text-xs'
>
<span className='text-muted-foreground'>
{t('Remaining')}
{props.variant === 'card' && (
<span className='ml-1'>({quotaUnit})</span>
)}
</span>
<span
className={cn(
'min-w-0 truncate text-right',
props.variant === 'card'
? 'text-sm leading-5 font-normal'
: 'font-medium',
!props.apiKey.unlimited_quota && 'font-mono tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
>
{props.apiKey.unlimited_quota
? t('Unlimited')
: formattedRemaining}
</span>
<span className='text-muted-foreground'>{t('Used amount')}</span>
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right font-mono tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
>
{formattedUsed}
</span>
</span>
</PopoverTrigger>
{!props.apiKey.unlimited_quota && (
<Progress
value={percentage}
aria-label={t('Remaining percentage')}
className={cn(
'w-full [&_[data-slot=progress-indicator]]:bg-current',
progressColor
)}
/>
)}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>
{t('Quota')} ({quotaUnit})
</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>{t('Remaining')}</dt>
<dd className='text-right font-mono break-all'>
{formattedRemaining}
</dd>
</>
)}
<dt className='text-muted-foreground'>{t('Used amount')}</dt>
<dd className='text-right font-mono break-all'>{formattedUsed}</dd>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>
{t('Current total quota')}
</dt>
<dd className='text-right font-mono break-all'>
{formattedTotal}
</dd>
</>
)}
{hasProgress && (
<>
<dt className='text-muted-foreground'>
{t('Remaining percentage')}
</dt>
<dd className='text-right font-mono'>{formattedPercentage}%</dd>
</>
)}
</dl>
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.apiKey.unlimited_quota
? t(
'This API key has no quota limit. Requests still require available wallet or subscription quota.'
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)}
</p>
</PopoverContent>
</Popover>
)
}
......@@ -16,14 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import dayjs from '@/lib/dayjs'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { ApiKey } from '../types'
interface ApiKeyTimestampCellProps {
timestamp: number
now: number
......@@ -42,6 +48,9 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
const absoluteTime = formatTimestampToDate(props.timestamp)
return (
......@@ -51,14 +60,15 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn(
'block truncate font-mono text-xs tabular-nums',
props.className
)}
className={cn('block truncate text-xs', props.className)}
/>
}
>
{relativeTime}
{relativePrefix}
{relativeNumber && (
<span className='font-mono tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span>
......@@ -66,3 +76,50 @@ export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
</Tooltip>
)
}
export function ApiKeyActivityCell(props: {
apiKey: ApiKey
now: number
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const accessedTime = props.apiKey.accessed_time
const isStale =
accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return (
<div
className={cn(
'grid min-w-0 gap-y-1 text-xs',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<ApiKeyTimestampCell
timestamp={props.apiKey.created_time}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{t('Last Used')}</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
isStale ? 'text-warning' : 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
......@@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import {
......@@ -34,7 +35,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { formatQuota } from '@/lib/format'
import type { ApiKey } from '../types'
import { useApiKeys } from './api-keys-provider'
......@@ -142,47 +142,65 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
)
}
type UnlimitedQuotaBadgeProps = {
used: number
type ApiKeyRestrictionProps = {
apiKey: ApiKey
detailsTrigger?: 'hover' | 'click'
}
export function UnlimitedQuotaBadge(props: UnlimitedQuotaBadgeProps) {
export function ModelLimitsCell(props: ApiKeyRestrictionProps) {
const { t } = useTranslation()
const formattedUsed = formatQuota(props.used)
const models = props.apiKey.model_limits_enabled
? (props.apiKey.model_limits || '').split(',').filter(Boolean)
: []
return (
<Popover>
<PopoverTrigger
render={
<button
type='button'
className='focus-visible:ring-ring/50 -ml-1.5 cursor-help rounded-4xl focus-visible:ring-[3px] focus-visible:outline-none'
aria-label={`${t('Unlimited')}; ${t('Used:')} ${formattedUsed}`}
/>
}
>
<StatusBadge
label={t('Unlimited')}
variant='neutral'
copyable={false}
/>
</PopoverTrigger>
<PopoverContent className='w-auto p-2' side='top'>
<span className='text-xs'>
{t('Used:')} {formattedUsed}
</span>
</PopoverContent>
</Popover>
<ApiKeyRestrictionCell
items={models}
label={t('{{count}} models', { count: models.length })}
title={t('Models')}
emptyLabel={t('Unlimited')}
detailsTrigger={props.detailsTrigger}
/>
)
}
export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
export function IpRestrictionsCell(props: ApiKeyRestrictionProps) {
const { t } = useTranslation()
const ips = (props.apiKey.allow_ips || '')
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
return (
<ApiKeyRestrictionCell
items={ips}
label={t('{{count}} IP(s)', { count: ips.length })}
title={t('IP Restriction')}
emptyLabel={t('No restriction')}
detailsTrigger={props.detailsTrigger}
/>
)
}
function ApiKeyRestrictionCell(props: {
items: string[]
label: string
title: string
emptyLabel: string
detailsTrigger?: 'hover' | 'click'
}) {
if (!props.items.length) {
if (props.detailsTrigger === 'click') {
return (
<span className='inline-flex items-center gap-1.5 text-xs'>
<span className='text-muted-foreground'>{props.title}</span>
<span>{props.emptyLabel}</span>
</span>
)
}
return (
<StatusBadge
label={t('Unlimited')}
label={props.emptyLabel}
variant='neutral'
copyable={false}
className='-ml-1.5'
......@@ -190,67 +208,46 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
)
}
const models = apiKey.model_limits.split(',').filter(Boolean)
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} model(s)', { count: models.length })}
variant='neutral'
copyable={false}
/>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
{models.map((m) => (
<div key={m} className='font-mono'>
{m}
</div>
))}
const details = (
<div className='max-h-[200px] space-y-1 overflow-y-auto text-xs'>
{props.items.map((item) => (
<div key={item} className='font-mono break-all'>
{item}
</div>
</TooltipContent>
</Tooltip>
))}
</div>
)
}
export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation()
const allowIps = apiKey.allow_ips?.trim()
if (!allowIps) {
if (props.detailsTrigger === 'click') {
return (
<StatusBadge
label={t('No restriction')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
aria-label={`${props.title}: ${props.label}`}
className='h-7 max-w-full justify-start px-0 text-xs font-normal underline decoration-dotted underline-offset-4'
/>
}
>
{props.label}
</PopoverTrigger>
<PopoverContent align='start' className='max-w-[calc(100vw-2rem)]'>
<PopoverTitle>{props.title}</PopoverTitle>
{details}
</PopoverContent>
</Popover>
)
}
const ips = allowIps
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
copyable={false}
/>
<StatusBadge label={props.label} variant='neutral' copyable={false} />
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
{ips.map((ip) => (
<div key={ip} className='font-mono'>
{ip}
</div>
))}
</div>
{details}
</TooltipContent>
</Tooltip>
)
......
......@@ -22,37 +22,27 @@ import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getCurrencyDisplay } from '@/lib/currency'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types'
import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import { ApiKeyQuotaCell } from './api-key-quota-cell'
import {
ApiKeyActivityCell,
ApiKeyTimestampCell,
} from './api-key-timestamp-cell'
import {
ApiKeyCell,
IpRestrictionsCell,
ModelLimitsCell,
UnlimitedQuotaBadge,
} from './api-keys-cells'
import { DataTableRowActions } from './data-table-row-actions'
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
function useGroupRatios(): Record<string, number | string> {
const { data } = useQuery({
queryKey: ['user-groups'],
......@@ -75,11 +65,13 @@ function useGroupRatios(): Record<string, number | string> {
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const groupRatios = useGroupRatios()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
return [
{
id: 'select',
......@@ -88,7 +80,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
......@@ -96,7 +88,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
......@@ -143,52 +135,10 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
{
id: 'quota',
accessorKey: 'remain_quota',
header: t('Quota'),
cell: ({ row }) => {
const apiKey = row.original
if (apiKey.unlimited_quota) {
return <UnlimitedQuotaBadge used={apiKey.used_quota} />
}
const used = apiKey.used_quota
const remaining = apiKey.remain_quota
const total = used + remaining
const percentage = total > 0 ? (remaining / total) * 100 : 0
return (
<Tooltip>
<TooltipTrigger render={<div className='w-[150px] space-y-1' />}>
<div className='flex justify-between text-xs'>
<span className='font-medium tabular-nums'>
{formatQuota(remaining)}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatQuota(total)}
</span>
</div>
<Progress
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('Used:')} {formatQuota(used)}
</div>
<div>
{t('Remaining:')} {formatQuota(remaining)} (
{percentage.toFixed(1)}%)
</div>
<div>
{t('Total:')} {formatQuota(total)}
</div>
</div>
</TooltipContent>
</Tooltip>
)
},
size: 170,
header: `${t('Quota')} (${quotaUnit})`,
cell: ({ row }) => <ApiKeyQuotaCell apiKey={row.original} now={now} />,
size: 220,
minSize: 220,
},
{
accessorKey: 'group',
......@@ -227,39 +177,11 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
meta: { mobileHidden: true },
},
{
id: 'activity_time',
accessorKey: 'created_time',
header: t('Created'),
cell: ({ row }) => (
<ApiKeyTimestampCell
timestamp={row.getValue('created_time')}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className='text-muted-foreground'
/>
),
size: 180,
meta: { mobileHidden: true },
},
{
accessorKey: 'accessed_time',
header: t('Last Used'),
cell: ({ row }) => {
const accessedTime = row.getValue('accessed_time') as number
const isStale =
accessedTime > 0 && accessedTime * 1000 < staleAccessThreshold
return (
<ApiKeyTimestampCell
timestamp={accessedTime}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className={isStale ? 'text-warning' : 'text-muted-foreground'}
/>
)
},
size: 180,
header: t('Time'),
cell: ({ row }) => <ApiKeyActivityCell apiKey={row.original} now={now} />,
size: 220,
meta: { mobileHidden: true },
},
{
......@@ -277,16 +199,17 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
/>
)
}
const isExpired = expiredTime * 1000 < now
return (
<ApiKeyTimestampCell
timestamp={expiredTime}
now={now}
locale={locale}
justNowLabel={justNowLabel}
className={cn(
isExpired ? 'text-destructive' : 'text-muted-foreground'
)}
className={
expiredTime * 1000 <= now
? 'text-destructive'
: 'text-muted-foreground'
}
/>
)
},
......
......@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import type { Table as TanstackTable } from '@tanstack/react-table'
import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
......@@ -42,7 +42,6 @@ import {
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getApiKeys, searchApiKeys } from '../api'
......@@ -53,7 +52,13 @@ import {
ERROR_MESSAGES,
} from '../constants'
import type { ApiKey } from '../types'
import { ApiKeyCell, UnlimitedQuotaBadge } from './api-keys-cells'
import { ApiKeyQuotaCell } from './api-key-quota-cell'
import { ApiKeyActivityCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
ModelLimitsCell,
IpRestrictionsCell,
} from './api-keys-cells'
import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
......@@ -72,11 +77,11 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
function ApiKeysMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
<div className='min-w-0 space-y-3'>
{API_KEYS_MOBILE_SKELETON_IDS.map((id) => (
<div
key={id}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
className='border-border/60 bg-card space-y-2 rounded-xl border p-3.5'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
......@@ -96,9 +101,11 @@ function ApiKeysMobileSkeleton() {
function ApiKeysMobileList({
table,
isLoading,
now,
}: {
table: TanstackTable<ApiKey>
isLoading: boolean
now: number
}) {
const { t } = useTranslation()
const rows = table.getRowModel().rows
......@@ -126,34 +133,37 @@ function ApiKeysMobileList({
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
<div className='min-w-0 space-y-3'>
{rows.map((row) => {
const apiKey = row.original
const statusConfig = API_KEY_STATUSES[apiKey.status]
const total = apiKey.used_quota + apiKey.remain_quota
const groupCell = row
.getAllCells()
.find((cell) => cell.column.id === 'group')
const expiryCell = row
.getAllCells()
.find((cell) => cell.column.id === 'expired_time')
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
'border-border/60 bg-card min-w-0 space-y-2 rounded-xl border p-3.5 text-xs leading-4',
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
<div className='text-sm leading-5 font-semibold break-words'>
{apiKey.name}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
className='shrink-0 px-0 text-xs font-normal'
/>
)}
</div>
......@@ -165,19 +175,38 @@ function ApiKeysMobileList({
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
{apiKey.unlimited_quota ? (
<UnlimitedQuotaBadge used={apiKey.used_quota} />
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(total)}
</span>
</span>
)}
<div className='min-w-0 space-y-3 py-1'>
<div className='min-w-0'>
{groupCell &&
flexRender(
groupCell.column.columnDef.cell,
groupCell.getContext()
)}
</div>
<ApiKeyQuotaCell apiKey={apiKey} now={now} variant='card' />
</div>
<div className='flex flex-wrap items-center gap-x-5 gap-y-1'>
<ModelLimitsCell apiKey={apiKey} detailsTrigger='click' />
<IpRestrictionsCell apiKey={apiKey} detailsTrigger='click' />
</div>
<div className='grid grid-cols-3 items-start gap-3 border-t pt-2'>
<div className='col-span-2 min-w-0'>
<ApiKeyActivityCell
apiKey={apiKey}
now={now}
layout='columns'
/>
</div>
<div className='min-w-0 space-y-1 [&_[data-slot=status-badge]]:text-xs [&_[data-slot=status-badge]]:font-normal'>
<div className='text-muted-foreground'>{t('Expires')}</div>
{expiryCell &&
flexRender(
expiryCell.column.columnDef.cell,
expiryCell.getContext()
)}
</div>
</div>
</div>
)
......@@ -293,6 +322,23 @@ export function ApiKeysTable() {
ensurePageInRange,
})
const columnVisibility = table.getState().columnVisibility
useEffect(() => {
// Restore the dates hidden by the previous default when adopting the combined time column.
if (
columnVisibility.activity_time === undefined &&
columnVisibility.created_time === false &&
columnVisibility.accessed_time === false &&
columnVisibility.expired_time === false
) {
table.setColumnVisibility((previous) => ({
...previous,
activity_time: true,
expired_time: true,
}))
}
}, [columnVisibility, table])
return (
<DataTablePage
table={table}
......@@ -305,6 +351,9 @@ export function ApiKeysTable() {
)}
skeletonKeyPrefix='api-keys-skeleton'
applyHeaderSize
getColumnClassName={(columnId) =>
columnId === 'quota' ? 'pr-8' : undefined
}
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
searchDebounceMs: 500,
......@@ -326,7 +375,9 @@ export function ApiKeysTable() {
},
],
}}
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
mobile={
<ApiKeysMobileList table={table} isLoading={isLoading} now={now} />
}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
}
......
......@@ -19,8 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { Badge } from '@/components/ui/badge'
import { GroupBadge, GroupMultiplierBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
export type GroupRatio = number | string | null | undefined
......@@ -30,6 +29,7 @@ export const AUTO_GROUP_FRAME_CLASS_NAME =
type AutoGroupFlowBorderProps = {
shouldReduceMotion: boolean
appearance?: 'default' | 'subtle'
}
export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
......@@ -39,7 +39,10 @@ export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
<span
aria-hidden='true'
data-auto-group-flow-border='true'
className='auto-group-flow-border pointer-events-none absolute -inset-px'
className={cn(
'auto-group-flow-border pointer-events-none absolute -inset-px',
props.appearance === 'subtle' && 'auto-group-flow-border-subtle'
)}
/>
)
}
......@@ -68,22 +71,6 @@ export function AutoGroupFrame(props: AutoGroupFrameProps) {
)
}
function getRatioBadgeClassName(ratio: GroupRatio, isAuto: boolean): string {
if (isAuto || typeof ratio !== 'number') {
return 'border-primary/30 bg-primary/10 text-primary'
}
if (ratio > 5) {
return 'border-destructive/30 bg-destructive/10 text-destructive'
}
if (ratio > 3) {
return 'border-warning/30 bg-warning/10 text-warning'
}
if (ratio > 1) {
return 'border-info/30 bg-info/10 text-info'
}
return 'border-success/30 bg-success/10 text-success'
}
type GroupRatioBadgeProps = {
isAuto?: boolean
ratio: GroupRatio
......@@ -97,38 +84,26 @@ export function GroupRatioBadge(props: GroupRatioBadgeProps) {
return null
}
const label =
typeof props.ratio === 'number'
? `${props.ratio}x ${t('Ratio')}`
: `${t('Auto')} ${t('Ratio')}`
const badge = (
<Badge
variant='outline'
return (
<GroupMultiplierBadge
ratio={typeof props.ratio === 'number' ? props.ratio : undefined}
label={typeof props.ratio === 'number' ? undefined : t('Auto')}
className={cn(
'max-w-full truncate text-[10px] sm:text-xs',
getRatioBadgeClassName(props.ratio, props.isAuto === true)
props.isAuto &&
'overflow-visible rounded-md border-primary/30 bg-primary/10 text-primary'
)}
>
{label}
</Badge>
)
if (!props.isAuto) {
return <span className='max-w-24 shrink-0 sm:max-w-none'>{badge}</span>
}
return (
<AutoGroupFrame
effect='ratio'
shouldReduceMotion={props.shouldReduceMotion ?? false}
className='max-w-24 sm:max-w-none'
>
{badge}
</AutoGroupFrame>
{props.isAuto && (
<AutoGroupFlowBorder
appearance='subtle'
shouldReduceMotion={props.shouldReduceMotion ?? false}
/>
)}
</GroupMultiplierBadge>
)
}
export function AutoGroupBadge(props: AutoGroupFlowBorderProps) {
export function AutoGroupBadge(props: { shouldReduceMotion: boolean }) {
return (
<AutoGroupFrame
effect='badge'
......
......@@ -86,34 +86,16 @@ export function DataTableRowActions<TData>({
triggerRefresh,
setResolvedKey,
resolveRealKey,
resolvedKeys,
loadingKeys,
} = useApiKeys()
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
const { chatPresets, serverAddress } = useChatPresets()
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const resolvedRealKey = resolvedKeys[apiKey.id]
const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
const hasChatPresets = chatPresets.length > 0
const toggleLabel = isEnabled ? t('Disable') : t('Enable')
const handleMenuOpenChange = useCallback(
(open: boolean) => {
if (open && !resolvedRealKey && !isRealKeyLoading) {
void resolveRealKey(apiKey.id)
}
},
[apiKey.id, isRealKeyLoading, resolvedRealKey, resolveRealKey]
)
const getCachedRealKey = useCallback(() => {
if (resolvedRealKey) return resolvedRealKey
void resolveRealKey(apiKey.id)
toast.info(t('API key is loading, please try again in a moment'))
return null
}, [apiKey.id, resolvedRealKey, resolveRealKey, t])
const handleOpenChatPreset = useCallback(
async (preset: ChatPreset) => {
const realKey = await resolveRealKey(apiKey.id)
......@@ -156,9 +138,9 @@ export function DataTableRowActions<TData>({
)
const handleToggleStatus = async (
e?: React.MouseEvent<HTMLButtonElement>
event?: React.MouseEvent<HTMLButtonElement>
) => {
e?.stopPropagation()
event?.stopPropagation()
const newStatus = isEnabled
? API_KEY_STATUS.DISABLED
: API_KEY_STATUS.ENABLED
......@@ -236,11 +218,11 @@ export function DataTableRowActions<TData>({
ariaLabel={t('Open menu')}
contentClassName='w-[200px]'
modal={false}
onOpenChange={handleMenuOpenChange}
>
<DropdownMenuItem
disabled={isRealKeyLoading}
onClick={async () => {
const realKey = getCachedRealKey()
const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
const ok = await copyToClipboard(realKey)
if (ok) toast.success(t('Copied'))
......@@ -252,8 +234,9 @@ export function DataTableRowActions<TData>({
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isRealKeyLoading}
onClick={async () => {
const realKey = getCachedRealKey()
const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
const connStr = encodeChannelConnectionInfo(
realKey,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import {
act,
cleanup,
render,
screen,
within,
waitFor,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
import type { User } from '../../types'
import { useUsersColumns } from '../users-columns'
import { UsersProvider } from '../users-provider'
import { UsersTable } from '../users-table'
const clients: QueryClient[] = []
const i18n = createInstance()
await i18n.init({
lng: 'en',
resources: { en: { translation: {} } },
initAsync: false,
})
function QuotaTable(props: { remaining: number; used: number }) {
const columns = useUsersColumns().filter((column) =>
['quota', 'used_quota'].includes(
column.id ?? ('accessorKey' in column ? String(column.accessorKey) : '')
)
)
const table = useReactTable({
columns,
data: [
{
id: 1,
username: 'test',
display_name: '',
role: 1,
status: 1,
quota: props.remaining,
used_quota: props.used,
request_count: 0,
group: 'default',
} as User,
],
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map((group) => (
<tr key={group.id}>
{group.headers.map((header) => (
<th key={header.id} data-sortable={header.column.getCanSort()}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
beforeEach(() => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
})
afterEach(() => {
cleanup()
clients.splice(0).forEach((client) => client.clear())
useAuthStore.getState().auth.reset()
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
})
it('shows labeled balance and cumulative usage in one sortable quota column', () => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={1900} used={1100} />
</I18nextProvider>
)
expect(
screen.getByRole('columnheader', { name: 'Available Balance ($)' })
).toHaveAttribute('data-sortable', 'true')
expect(screen.getAllByRole('columnheader')).toHaveLength(1)
const cells = screen.getAllByRole('cell')
expect(within(cells[0]).getByText('0.0038')).toBeInTheDocument()
expect(cells).toHaveLength(1)
expect(
within(cells[0]).queryByText('Available Balance')
).not.toBeInTheDocument()
expect(screen.getByText('0.0038').parentElement).toHaveClass('text-left')
expect(within(cells[0]).getByText('0.0022')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText('0.006')).not.toBeInTheDocument()
})
it.each([0, 500000])(
'shows usage for a zero balance only when used quota is nonzero (used=%s)',
(used) => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={0} used={used} />
</I18nextProvider>
)
if (used === 0) {
expect(screen.getByRole('cell')).toHaveTextContent(/^No Quota$/)
expect(screen.queryByText('Used amount')).not.toBeInTheDocument()
return
}
expect(screen.queryByText('No Quota')).not.toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('0')).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('1')).toBeInTheDocument()
}
)
it('preserves a negative balance and uses warning styling', () => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={-500000} used={1000000} />
</I18nextProvider>
)
expect(screen.getByText('-1')).toHaveClass('text-destructive')
expect(screen.getByText('2')).toBeInTheDocument()
})
it('shows the custom symbol only in the column header', () => {
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CUSTOM',
customCurrencySymbol: '🐱',
customCurrencyExchangeRate: 1,
},
})
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={1900} used={1100} />
</I18nextProvider>
)
expect(
screen.getByRole('columnheader', { name: 'Available Balance (🐱)' })
).toBeInTheDocument()
expect(screen.getByRole('cell')).not.toHaveTextContent('🐱')
expect(
within(screen.getByRole('cell')).getByText('0.0038')
).toBeInTheDocument()
expect(
within(screen.getByRole('cell')).getByText('0.0022')
).toBeInTheDocument()
})
function UsersPage() {
return (
<UsersProvider>
<UsersTable />
</UsersProvider>
)
}
async function renderUsersList(emptyInvitation = false) {
useAuthStore.getState().auth.setUser({ id: 1, username: 'admin', role: 100 })
const get = vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
items: [
{
id: 2,
username: 'long-user-name-for-table-layout',
display_name: 'A display name',
role: 1,
status: 1,
quota: 1900,
used_quota: 1100,
request_count: 0,
group: 'default',
aff_count: emptyInvitation ? 0 : 2,
aff_history_quota: emptyInvitation ? 0 : 500000,
inviter_id: emptyInvitation ? 0 : 42,
},
],
total: 1,
},
},
})
const root = createRootRoute()
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
const users = createRoute({
getParentRoute: () => auth,
path: 'users/',
component: UsersPage,
})
const router = createRouter({
routeTree: root.addChildren([auth.addChildren([users])]),
history: createMemoryHistory({ initialEntries: ['/users/'] }),
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
await router.load()
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
</I18nextProvider>
)
await screen.findByText('long-user-name-for-table-layout')
return get
}
it('sends balance sorting to the server and keeps invitation details on two lines', async () => {
const get = await renderUsersList()
expect(
screen.getByRole('columnheader', { name: 'User Group' })
).toBeInTheDocument()
await userEvent.click(
screen.getByRole('button', { name: 'Available Balance ($)' })
)
await userEvent.click(screen.getByRole('menuitem', { name: 'Desc' }))
await waitFor(() =>
expect(get).toHaveBeenCalledWith('/api/user/', {
params: expect.objectContaining({ sort_by: 'quota', sort_order: 'desc' }),
})
)
expect(
screen.queryByRole('button', { name: 'Total Used' })
).not.toBeInTheDocument()
expect(screen.getByText('Inviter ID: 42')).toBeInTheDocument()
expect(screen.getByText(/Invited 2 users · Earnings:/)).toBeInTheDocument()
})
it('shows both labeled amounts on mobile cards in Chinese', async () => {
const originalMatchMedia = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...originalMatchMedia(query),
matches: query.includes('max-width'),
}))
i18n.addResourceBundle('zh', 'translation', zh.translation)
await i18n.changeLanguage('zh')
try {
await renderUsersList()
expect(screen.getByText('可用余额 ($)')).toBeInTheDocument()
expect(screen.getByText('已用')).toBeInTheDocument()
expect(screen.getByText('0.0038')).toBeInTheDocument()
expect(screen.getByText('0.0022')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
} finally {
await i18n.changeLanguage('en')
}
})
it('hides date columns by default and replaces empty invitation information with a dash', async () => {
await renderUsersList(true)
expect(
screen.queryByRole('columnheader', { name: /Created At/ })
).not.toBeInTheDocument()
expect(
screen.queryByRole('columnheader', { name: /Last Login/ })
).not.toBeInTheDocument()
const row = screen.getByRole('row', {
name: /long-user-name-for-table-layout/,
})
expect(within(row).getByText('—')).toBeInTheDocument()
expect(screen.queryByText('No Inviter')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'View' }))
await userEvent.click(
screen.getByRole('menuitemcheckbox', { name: 'Created At' })
)
expect(
screen.getByRole('columnheader', { name: /Created At/ })
).toBeInTheDocument()
})
it('updates the header unit and converted amounts together when currency settings change', () => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={500000} used={1000000} />
</I18nextProvider>
)
act(() =>
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CNY',
usdExchangeRate: 7,
},
})
)
expect(
screen.getByRole('columnheader', { name: 'Available Balance (¥)' })
).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('7')).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('14')).toBeInTheDocument()
expect(screen.getByRole('cell')).not.toHaveTextContent('¥')
})
it('labels raw quota mode as tokens without introducing a currency symbol', () => {
useSystemConfigStore.getState().setConfig({
currency: { ...DEFAULT_CURRENCY_CONFIG, quotaDisplayType: 'TOKENS' },
})
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={100} used={200} />
</I18nextProvider>
)
expect(
screen.getByRole('columnheader', { name: 'Available Balance (Tokens)' })
).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('100')).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('200')).toBeInTheDocument()
})
......@@ -19,34 +19,20 @@ For commercial licensing, please contact support@quantumnous.com
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatQuota } from '@/lib/format'
import { formatQuotaWithCurrency } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
type UserQuotaCellProps = {
used: number
remaining: number
}
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
used: number
}
export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation()
const total = props.used + props.remaining
const percentage = total > 0 ? (props.remaining / total) * 100 : 0
const formattedRemaining = formatQuota(props.remaining)
const formattedTotal = formatQuota(total)
useSystemConfigStore((state) => state.config.currency)
if (total === 0) {
if (props.remaining === 0 && props.used === 0) {
return (
<StatusBadge
label={t('No Quota')}
......@@ -58,41 +44,22 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
}
return (
<Tooltip>
<TooltipTrigger
render={
<div className='w-full min-w-0 cursor-help space-y-1.5 overflow-hidden' />
}
<div className='min-w-0 space-y-1 text-left tabular-nums'>
<div
className={cn(
'font-mono text-sm font-semibold whitespace-nowrap',
props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
>
<div className='grid min-w-0 grid-cols-2 gap-x-4 text-xs'>
<span className='min-w-0 truncate font-medium tabular-nums'>
{formattedRemaining}
</span>
<span className='text-muted-foreground min-w-0 truncate text-right tabular-nums'>
{formattedTotal}
</span>
</div>
<Progress
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('Used:')} {formatQuota(props.used)}
</div>
<div>
{t('Remaining:')} {formattedRemaining}
</div>
<div>
{t('Total:')} {formattedTotal}
</div>
<div>
{t('Percentage:')} {percentage.toFixed(1)}%
</div>
</div>
</TooltipContent>
</Tooltip>
{formatQuotaWithCurrency(props.remaining, { showSymbol: false })}
</div>
<div className='text-muted-foreground flex items-baseline gap-1 text-xs whitespace-nowrap'>
<span>{t('Used amount')}</span>
<span className='font-mono'>
{formatQuotaWithCurrency(props.used, { showSymbol: false })}
</span>
</div>
</div>
)
}
......@@ -30,7 +30,9 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
USER_STATUS,
......@@ -44,6 +46,9 @@ import { UserQuotaCell } from './user-quota-cell'
export function useUsersColumns(): ColumnDef<User>[] {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
return [
{
id: 'select',
......@@ -52,7 +57,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
......@@ -60,7 +65,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
......@@ -141,7 +146,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
<TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false}
/>
</TooltipTrigger>
......@@ -163,18 +168,18 @@ export function useUsersColumns(): ColumnDef<User>[] {
{
id: 'quota',
accessorKey: 'quota',
header: t('Quota'),
header: `${t('Available Balance')} (${quotaUnit})`,
cell: ({ row }) => {
const user = row.original
return <UserQuotaCell used={user.used_quota} remaining={user.quota} />
return <UserQuotaCell remaining={user.quota} used={user.used_quota} />
},
size: 300,
minSize: 260,
size: 180,
minSize: 160,
meta: { mobileOrder: 40 },
},
{
accessorKey: 'group',
header: t('Group'),
header: t('User Group'),
cell: ({ row }) => {
const group = row.getValue('group') as string
return (
......@@ -202,14 +207,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return null
}
return (
<div className='flex items-center gap-x-2'>
{roleConfig.icon && (
<roleConfig.icon size={16} className='text-muted-foreground' />
)}
<span className='text-sm'>{t(roleConfig.labelKey)}</span>
</div>
)
return <span className='text-sm'>{t(roleConfig.labelKey)}</span>
},
filterFn: (row, id, value) => {
return value.includes(String(row.getValue(id)))
......@@ -227,63 +225,25 @@ export function useUsersColumns(): ColumnDef<User>[] {
const affHistoryQuota = user.aff_history_quota || 0
const inviterId = user.inviter_id || 0
if (affCount === 0 && affHistoryQuota === 0 && inviterId === 0) {
return <span className='text-muted-foreground text-sm'></span>
}
return (
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-1 overflow-hidden'>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Invited')}: ${affCount}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Number of users invited')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Revenue')}: ${formatQuota(affHistoryQuota)}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Total invitation revenue')}</p>
</TooltipContent>
</Tooltip>
{inviterId > 0 && (
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Inviter')}: ${inviterId}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>
{t('Invited by user ID')} {inviterId}
</p>
</TooltipContent>
</Tooltip>
<div className='min-w-0 space-y-1 text-xs'>
{(affCount > 0 || affHistoryQuota !== 0) && (
<LongText>
{t('Invited {{count}} users', { count: affCount })} ·{' '}
{t('Earnings')}:{' '}
<span className='tabular-nums'>
{formatQuota(affHistoryQuota)}
</span>
</LongText>
)}
{inviterId === 0 && (
<StatusBadge
label={t('No Inviter')}
variant='neutral'
copyable={false}
/>
{inviterId > 0 && (
<LongText className='text-muted-foreground'>
{t('Inviter')} ID: {inviterId}
</LongText>
)}
</div>
)
......
......@@ -174,6 +174,7 @@ export function UsersTable() {
data: users,
columns,
enableRowSelection: true,
initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters,
globalFilter,
pagination,
......@@ -232,13 +233,10 @@ export function UsersTable() {
},
],
}}
getRowClassName={(row, { isMobile }) =>
isDisabledUserRow(row.original)
? isMobile
? DISABLED_ROW_MOBILE
: DISABLED_ROW_DESKTOP
: undefined
}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledUserRow(row.original)) return undefined
return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
}}
bulkActions={<DataTableBulkActions table={table} />}
/>
)
......
......@@ -474,6 +474,7 @@
"API key is required": "API key is required",
"API Key mode (does not support batch creation)": "API Key mode (does not support batch creation)",
"API Key mode: use APIKey|Region": "API Key mode: use APIKey|Region",
"API Key Quota": "API Key Quota",
"API Key updated successfully": "API Key updated successfully",
"API Keys": "API Keys",
"API Private Key": "API Private Key",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "Auto-fill when one field exists and another is missing",
"Auto-refreshing every {{seconds}}s": "Auto-refreshing every {{seconds}}s",
"Auto-retry status codes": "Auto-retry status codes",
"Automatic selection": "Automatic selection",
"Automatically disable channel on repeated failures": "Automatically disable channel on repeated failures",
"Automatically disable channels exceeding this response time": "Automatically disable channels exceeding this response time",
"Automatically disable channels when tests fail": "Automatically disable channels when tests fail",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "Automatically sync model list when upstream changes are detected",
"Availability (last 24h)": "Availability (last 24h)",
"Available": "Available",
"Available Balance": "Available Balance",
"Available channels: {{count}}": "Available channels: {{count}}",
"Available credits are ordered by soonest expiration.": "Available credits are ordered by soonest expiration.",
"Available disk space": "Available disk space",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "Current pricing conditions",
"Current quota": "Current quota",
"Current token": "Current token",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "Current total = used + remaining. Changing the remaining quota updates the total and percentage.",
"Current total quota": "Current total quota",
"Current v{{from}} → install v{{to}}": "Current v{{from}} → install v{{to}}",
"Current value": "Current value",
"Current Value": "Current Value",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "Duration Unit",
"Duration Value": "Duration Value",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "Dynamic declarations cannot be previewed. Retry or review the source below.",
"Dynamic multiplier": "Dynamic multiplier",
"Dynamic operation": "Dynamic operation",
"Dynamic Pricing": "Dynamic Pricing",
"e.g. ¥ or HK$": "e.g. ¥ or HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Each tier supports up to 2 conditions. The last tier without conditions is the fallback.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.",
"Earnings": "Earnings",
"Edit": "Edit",
"Edit {{title}}": "Edit {{title}}",
"Edit all channels with tag:": "Edit all channels with tag:",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead extension not detected. Please ensure it is installed and active.",
"Flush interval (minutes)": "Flush interval (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Follow the guided steps to prepare your workspace before the first login.",
"Follow user group": "Follow user group",
"Font": "Font",
"Footer": "Footer",
"Footer text displayed at the bottom of pages": "Footer text displayed at the bottom of pages",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "Information unavailable",
"Inherit global Auto order": "Inherit global Auto order",
"Inherit vendor icon": "Inherit vendor icon",
"Inherited": "Inherited",
"Inherited from {{vendor}}": "Inherited from {{vendor}}",
"Initial quota given to new users": "Initial quota given to new users",
"Initial quota given to new users ({{formattedQuota}})": "Initial quota given to new users ({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "Invitation Quota",
"Invite Info": "Invite Info",
"Invited": "Invited",
"Invited {{count}} users": "Invited {{count}} users",
"Invited by user ID": "Invited by user ID",
"Invited Users": "Invited Users",
"Invitee Reward": "Invitee Reward",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "No integrity hash",
"No integrity verification": "No integrity verification",
"No Inviter": "No Inviter",
"No IP restriction": "No IP restriction",
"No keys found": "No keys found",
"No latency data available": "No latency data available",
"No linked models": "No linked models",
......@@ -3426,6 +3437,7 @@
"No products match your search": "No products match your search",
"No providers available": "No providers available",
"No Quota": "No Quota",
"No quota limit": "No quota limit",
"No ratio differences found": "No ratio differences found",
"No recent usage": "No recent usage",
"No records": "No records",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "Relying Party Display Name",
"Relying Party ID": "Relying Party ID",
"Remaining": "Remaining",
"Remaining percentage": "Remaining percentage",
"Remaining quota": "Remaining quota",
"Remaining Quota ({{currency}})": "Remaining Quota ({{currency}})",
"Remaining quota units": "Remaining quota units",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.",
"Routing & Overrides": "Routing & Overrides",
"Routing Group": "Routing Group",
"Routing Reliability": "Routing Reliability",
"Routing Strategy": "Routing Strategy",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "This action cannot be undone. This will permanently delete your account and remove all your data from our servers.",
"This action will permanently remove 2FA protection from your account.": "This action will permanently remove 2FA protection from your account.",
"This announcement will be removed from the list.": "This announcement will be removed from the list.",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "This API key has no quota limit. Requests still require available wallet or subscription quota.",
"This API shortcut will be removed from the list.": "This API shortcut will be removed from the list.",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "This base URL points at a private or local network host. Make sure it is an upstream you control.",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "This base URL uses plain HTTP, so the channel key is sent unencrypted.",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "Topup Amount",
"Total": "Total",
"Total {{count}} records": "Total {{count}} records",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.",
"Total Allocated": "Total Allocated",
"Total check-ins": "Total check-ins",
"Total consumed": "Total consumed",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Total quota included in the plan, usable per billing period. 0 means unlimited.",
"Total requests allowed per period. 0 = unlimited.": "Total requests allowed per period. 0 = unlimited.",
"Total requests made": "Total requests made",
"Total Revenue": "Total Revenue",
"Total time": "Total time",
"Total tokens": "Total tokens",
"Total Tokens": "Total Tokens",
"Total Usage": "Total Usage",
"Total Used": "Total Used",
"Total:": "Total:",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "Track per-request consumption to power usage analytics. Keeping this on increases database writes.",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "Usage Logs",
"Usage mode": "Usage mode",
"Usage parameters": "Usage parameters",
"Usage percentage": "Usage percentage",
"Usage prices": "Usage prices",
"Usage-based": "Usage-based",
"Usage-based billing": "Usage-based billing",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "Use your Passkey",
"used": "used",
"Used": "Used",
"Used {{used}} of {{total}} ({{percent}}%)": "Used {{used}} of {{total}} ({{percent}}%)",
"Used / Remaining": "Used / Remaining",
"Used amount": "Used",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.",
"Used by route auth templates": "Used by route auth templates",
"Used for load balancing. Higher weight = more requests": "Used for load balancing. Higher weight = more requests",
......
......@@ -474,6 +474,7 @@
"API key is required": "La clé API est requise",
"API Key mode (does not support batch creation)": "Mode clé API (ne prend pas en charge la création par lots)",
"API Key mode: use APIKey|Region": "Mode clé API : utiliser APIKey|Region",
"API Key Quota": "Quota de la clé API",
"API Key updated successfully": "Clé API mise à jour avec succès",
"API Keys": "Clés API",
"API Private Key": "Clé privée de l'API",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "Remplissage automatique si un champ existe et l'autre est manquant",
"Auto-refreshing every {{seconds}}s": "Actualisation automatique toutes les {{seconds}} s",
"Auto-retry status codes": "Codes de statut de nouvelle tentative auto",
"Automatic selection": "Sélection auto",
"Automatically disable channel on repeated failures": "Désactiver automatiquement le canal en cas d'échecs répétés",
"Automatically disable channels exceeding this response time": "Désactiver automatiquement les canaux dépassant ce temps de réponse",
"Automatically disable channels when tests fail": "Désactiver automatiquement les canaux lorsque les tests échouent",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "Synchroniser automatiquement la liste des modèles lorsque des changements en amont sont détectés",
"Availability (last 24h)": "Disponibilité (dernières 24 h)",
"Available": "Disponible",
"Available Balance": "Solde disponible",
"Available channels: {{count}}": "Canaux : {{count}}",
"Available credits are ordered by soonest expiration.": "Les crédits disponibles sont triés par expiration la plus proche.",
"Available disk space": "Espace disque disponible",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "Conditions tarifaires actuelles",
"Current quota": "Quota actuel",
"Current token": "Jeton actuel",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "Total actuel = utilisé + restant. Modifier le quota restant actualise le total et le pourcentage.",
"Current total quota": "Quota total actuel",
"Current v{{from}} → install v{{to}}": "Version actuelle v{{from}} → à installer v{{to}}",
"Current value": "Valeur actuelle",
"Current Value": "Valeur actuelle",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "Unité de durée",
"Duration Value": "Valeur de durée",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "Les déclarations dynamiques ne peuvent pas être prévisualisées. Réessayez ou consultez le code ci-dessous.",
"Dynamic multiplier": "Multiplicateur variable",
"Dynamic operation": "Opération dynamique",
"Dynamic Pricing": "Tarification dynamique",
"e.g. ¥ or HK$": "par ex. ¥ ou HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Chaque palier accepte jusqu’à 2 conditions ; le dernier palier sert de repli sans condition. Utilisez la longueur complète de l’entrée pour éviter un mauvais aiguillage lorsque les lectures de cache réduisent les tokens d’entrée facturables.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Chaque palier prend en charge jusqu’à 2 conditions. Le dernier palier sans condition sert de repli.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Gagnez des récompenses quand des utilisateurs rejoignent via votre lien de parrainage. Transférez-les vers votre solde à tout moment.",
"Earnings": "Gains",
"Edit": "Modifier",
"Edit {{title}}": "Modifier {{title}}",
"Edit all channels with tag:": "Modifier tous les canaux avec l'étiquette :",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Extension FluentRead non détectée. Veuillez vous assurer qu'elle est installée et activée.",
"Flush interval (minutes)": "Intervalle d’écriture (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Suivez les étapes guidées pour préparer votre espace de travail avant la première connexion.",
"Follow user group": "Groupe utilisateur",
"Font": "Police",
"Footer": "Pied de page",
"Footer text displayed at the bottom of pages": "Texte de pied de page affiché en bas des pages",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "Information indisponible",
"Inherit global Auto order": "Hériter de l’ordre Auto global",
"Inherit vendor icon": "Hériter de l’icône du fournisseur",
"Inherited": "Hérité",
"Inherited from {{vendor}}": "Héritée de {{vendor}}",
"Initial quota given to new users": "Quota initial donné aux nouveaux utilisateurs",
"Initial quota given to new users ({{formattedQuota}})": "Quota initial donné aux nouveaux utilisateurs ({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "Quota d'invitation",
"Invite Info": "Informations sur l'invitation",
"Invited": "Invité",
"Invited {{count}} users": "{{count}} invitations",
"Invited by user ID": "Invité par l'ID utilisateur",
"Invited Users": "Utilisateurs invités",
"Invitee Reward": "Récompense de l'invité",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "Aucune empreinte d’intégrité",
"No integrity verification": "Aucun contrôle d’intégrité",
"No Inviter": "Pas d'inviteur",
"No IP restriction": "Non restreint",
"No keys found": "Aucune clé trouvée",
"No latency data available": "Aucune donnée de latence disponible",
"No linked models": "Aucun modèle associé",
......@@ -3426,6 +3437,7 @@
"No products match your search": "Aucun produit ne correspond à votre recherche",
"No providers available": "Aucun fournisseur disponible",
"No Quota": "Aucun quota",
"No quota limit": "Sans plafond",
"No ratio differences found": "Aucune différence de ratio trouvée",
"No recent usage": "Aucune utilisation récente",
"No records": "Aucun enregistrement",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "Nom d'affichage de la partie de confiance",
"Relying Party ID": "ID de la partie de confiance",
"Remaining": "Restant",
"Remaining percentage": "Pourcentage restant",
"Remaining quota": "Quota restant",
"Remaining Quota ({{currency}})": "Quota restant ({{currency}})",
"Remaining quota units": "Unités de quota restantes",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Les routes avec le même chemin d’entrée sont réparties par modèle client exact. Les requêtes non associées utilisent le repli final.",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Les routes avec le même chemin d’entrée correspondent aux noms exacts des modèles client. Séparez plusieurs modèles par des virgules et laissez vide uniquement le repli final.",
"Routing & Overrides": "Routage et surcharges",
"Routing Group": "Groupe de routage",
"Routing Reliability": "Fiabilité du routage",
"Routing Strategy": "Stratégie de routage",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "Les lignes sont les groupes d’utilisateurs, les colonnes les groupes de facturation. Les cellules vides utilisent le taux de base affiché en gris.",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Cette action est irréversible. Cela supprimera définitivement votre compte et toutes vos données de nos serveurs.",
"This action will permanently remove 2FA protection from your account.": "Cette action supprimera définitivement la protection 2FA de votre compte.",
"This announcement will be removed from the list.": "Cette annonce sera retirée de la liste.",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "Cette clé API est sans plafond. Les requêtes nécessitent toujours un solde ou un quota d’abonnement disponible.",
"This API shortcut will be removed from the list.": "Ce raccourci API sera retiré de la liste.",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "Cette URL de base pointe vers un hôte privé ou local. Vérifiez qu'il s'agit d'un amont que vous contrôlez.",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "Cette URL de base utilise HTTP en clair : la clé du canal sera envoyée sans chiffrement.",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "Montant de la recharge",
"Total": "Total",
"Total {{count}} records": "{{count}} enregistrements au total",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "Total = utilisé + restant. Ce n’est ni une allocation initiale ni un budget périodique. Modifier le quota restant change le total et le pourcentage.",
"Total Allocated": "Total alloué",
"Total check-ins": "Total des connexions",
"Total consumed": "Consommé total",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Quota total inclus dans le forfait, utilisable par période de facturation. 0 signifie illimité.",
"Total requests allowed per period. 0 = unlimited.": "Total des requêtes autorisées par période. 0 = illimité.",
"Total requests made": "Requêtes totales effectuées",
"Total Revenue": "Revenus cumulés",
"Total time": "Durée totale",
"Total tokens": "Jetons totaux",
"Total Tokens": "Jetons totaux",
"Total Usage": "Utilisation totale",
"Total Used": "Total consommé",
"Total:": "Total :",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "Suivre la consommation par requête pour l'analyse de l'utilisation. Garder ceci activé augmente les écritures en base de données.",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "Journaux d'utilisation",
"Usage mode": "Mode d'utilisation",
"Usage parameters": "Paramètres d'utilisation",
"Usage percentage": "Pourcentage utilisé",
"Usage prices": "Tarifs d'utilisation",
"Usage-based": "Basé sur l'utilisation",
"Usage-based billing": "Facturation à l’usage",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "Utiliser votre clé d'accès (Passkey)",
"used": "utilisé",
"Used": "Utilisé",
"Used {{used}} of {{total}} ({{percent}}%)": "Utilisé : {{used}} sur {{total}} ({{percent}} %)",
"Used / Remaining": "Utilisé / Restant",
"Used amount": "Utilisé",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Utilisé comme SuccessURL sur le nouveau produit. Une confirmation vous sera demandée si ce champ est laissé vide.",
"Used by route auth templates": "Utilise par les modeles auth de route",
"Used for load balancing. Higher weight = more requests": "Utilisé pour l'équilibrage de charge. Poids plus élevé = plus de requêtes",
......
......@@ -474,6 +474,7 @@
"API key is required": "APIキーが必要です",
"API Key mode (does not support batch creation)": "APIキー モード(一括作成には対応していません)",
"API Key mode: use APIKey|Region": "APIキーモード: use APIKey | Region",
"API Key Quota": "APIキー割当",
"API Key updated successfully": "APIキーが正常に更新されました",
"API Keys": "APIキー",
"API Private Key": "API 秘密鍵",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "一方のフィールドがあり他方が欠けている場合に自動補完",
"Auto-refreshing every {{seconds}}s": "{{seconds}} 秒ごとに自動更新",
"Auto-retry status codes": "自動リトライするステータスコード",
"Automatic selection": "自動選択",
"Automatically disable channel on repeated failures": "繰り返しの失敗でチャネルを自動的に無効にする",
"Automatically disable channels exceeding this response time": "この応答時間を超えるチャネルを自動的に無効にする",
"Automatically disable channels when tests fail": "テストが失敗したときにチャネルを自動的に無効にする",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "アップストリームの変更が検出されたときにモデルリストを自動的に同期",
"Availability (last 24h)": "可用性(過去 24 時間)",
"Available": "空き",
"Available Balance": "利用可能残高",
"Available channels: {{count}}": "利用可能 {{count}}",
"Available credits are ordered by soonest expiration.": "利用可能なクレジットは有効期限の近い順に表示されます。",
"Available disk space": "利用可能なディスク容量",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "現在の料金条件",
"Current quota": "現在のクォータ",
"Current token": "現在のトークン",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "現在の合計 = 使用済み + 残り。残りの利用枠を変更すると、合計と使用率も更新されます。",
"Current total quota": "現在の合計枠",
"Current v{{from}} → install v{{to}}": "現在 v{{from}} → インストール v{{to}}",
"Current value": "現在の値",
"Current Value": "現在の値",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "期間単位",
"Duration Value": "期間値",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "動的に生成される宣言はプレビューできません。再試行するか、下のソースを確認してください。",
"Dynamic multiplier": "動的倍率",
"Dynamic operation": "動的操作",
"Dynamic Pricing": "ダイナミック価格設定",
"e.g. ¥ or HK$": "例: ¥ または HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "各階層は最大2つの条件をサポートします。最後の階層は条件なしのフォールバックです。キャッシュヒットで課金対象の入力トークンが減っても誤った階層にならないよう、条件には完全な入力長を使用してください。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "各段階は最大 2 つの条件に対応します。条件のない最後の段階がフォールバックです。",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "ユーザーがあなたの紹介リンクから登録すると報酬を獲得できます。貯まった報酬はいつでも残高へ振り替えられます。",
"Earnings": "収益",
"Edit": "編集",
"Edit {{title}}": "{{title}}を編集",
"Edit all channels with tag:": "タグを持つすべてのチャネルを編集:",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead 拡張機能が検出されませんでした。インストールされていて有効になっていることを確認してください。",
"Flush interval (minutes)": "書き込み間隔(分)",
"Follow the guided steps to prepare your workspace before the first login.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。",
"Follow user group": "ユーザーに従う",
"Font": "フォント",
"Footer": "フッター",
"Footer text displayed at the bottom of pages": "ページ下部に表示されるフッターテキスト",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "情報を取得できません",
"Inherit global Auto order": "グローバル Auto 順序を継承",
"Inherit vendor icon": "プロバイダーのアイコンを継承",
"Inherited": "継承",
"Inherited from {{vendor}}": "{{vendor}} から継承",
"Initial quota given to new users": "新規ユーザーに付与される初期クォータ",
"Initial quota given to new users ({{formattedQuota}})": "新規ユーザーに付与される初期クォータ({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "招待クォータ",
"Invite Info": "招待情報",
"Invited": "招待済み",
"Invited {{count}} users": "招待 {{count}} 人",
"Invited by user ID": "ユーザーIDによる招待",
"Invited Users": "招待されたユーザー",
"Invitee Reward": "招待された側の報酬",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "整合性ハッシュなし",
"No integrity verification": "整合性検証なし",
"No Inviter": "招待者なし",
"No IP restriction": "制限なし",
"No keys found": "キーが見つかりません",
"No latency data available": "レイテンシデータがありません",
"No linked models": "関連モデルはありません",
......@@ -3426,6 +3437,7 @@
"No products match your search": "検索に一致する製品がありません",
"No providers available": "利用可能なプロバイダーがありません",
"No Quota": "クォータなし",
"No quota limit": "上限なし",
"No ratio differences found": "比率の差異は見つかりませんでした",
"No recent usage": "最近の使用なし",
"No records": "記録がありません",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "依拠当事者表示名",
"Relying Party ID": "依拠当事者ID",
"Remaining": "残り",
"Remaining percentage": "残りの割合",
"Remaining quota": "残りクォータ",
"Remaining Quota ({{currency}})": "残りのクォータ ({{currency}})",
"Remaining quota units": "残りクォータ単位",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同じ入口パスのルートは、クライアント model の完全一致で分岐します。一致しないリクエストは最後のフォールバックを使います。",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "同じ入口パスのルートは、クライアントの正確なモデル名で一致します。複数のモデルはカンマで区切り、最後のフォールバックだけを空にします。",
"Routing & Overrides": "ルーティングと上書き",
"Routing Group": "ルーティンググループ",
"Routing Reliability": "ルーティング信頼性",
"Routing Strategy": "ルーティング戦略",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "行はユーザーグループ、列は課金グループです。空のセルはグレー表示の基本倍率にフォールバックします。",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "この操作は元に戻せません。これにより、あなたのアカウントは完全に削除され、すべてのデータがサーバーから削除されます。",
"This action will permanently remove 2FA protection from your account.": "この操作により、アカウントから2FA保護が完全に削除されます。",
"This announcement will be removed from the list.": "このお知らせはリストから削除されます。",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "このAPIキーに上限はありませんが、リクエストにはウォレット残高またはサブスクリプションの利用枠が必要です。",
"This API shortcut will be removed from the list.": "この API ショートカットはリストから削除されます。",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "この Base URL はプライベートまたはローカルネットワークのホストを指しています。自身が管理する上流であることを確認してください。",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "この Base URL は平文の HTTP を使用しているため、チャネルキーが暗号化されずに送信されます。",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "チャージ額",
"Total": "合計",
"Total {{count}} records": "全 {{count}} 件",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "合計 = 使用済み + 残り。初期の割当量や期間ごとの予算ではありません。残りを変更すると合計と使用率も変わります。",
"Total Allocated": "総割り当て",
"Total check-ins": "総チェックイン数",
"Total consumed": "合計消費量",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。",
"Total requests allowed per period. 0 = unlimited.": "期間ごとに許可されるリクエストの総数。0 = 無制限。",
"Total requests made": "合計リクエスト数",
"Total Revenue": "累計収益",
"Total time": "総時間",
"Total tokens": "合計トークン",
"Total Tokens": "合計トークン",
"Total Usage": "総使用量",
"Total Used": "累計使用額",
"Total:": "合計:",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "リクエストごとの消費を追跡し、使用状況分析に利用します。これをオンにすると、データベースへの書き込みが増加します。",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "利用履歴",
"Usage mode": "利用モード",
"Usage parameters": "使用量パラメータ",
"Usage percentage": "使用率",
"Usage prices": "使用量料金",
"Usage-based": "使用量ベース",
"Usage-based billing": "使用量ベースの課金",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "パスキーを使用",
"used": "使用済み",
"Used": "使用済み",
"Used {{used}} of {{total}} ({{percent}}%)": "使用済み {{used}} / {{total}}({{percent}}%)",
"Used / Remaining": "使用済み / 残り",
"Used amount": "使用済み",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "新しい商品の SuccessURL として使用されます。空のままにすると確認を求められます。",
"Used by route auth templates": "ルート認証テンプレートで使用",
"Used for load balancing. Higher weight = more requests": "ロードバランシングに使用されます。重みが高いほどリクエスト数が増えます",
......
......@@ -474,6 +474,7 @@
"API key is required": "Требуется ключ API",
"API Key mode (does not support batch creation)": "Режим API-ключа (не поддерживает пакетное создание)",
"API Key mode: use APIKey|Region": "Режим API Key: use APIKey|Region",
"API Key Quota": "Квота API-ключа",
"API Key updated successfully": "API ключ успешно обновлен",
"API Keys": "Ключи API",
"API Private Key": "Секретный ключ API",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "Автозаполнение, когда одно поле есть, а другое отсутствует",
"Auto-refreshing every {{seconds}}s": "Автообновление каждые {{seconds}} с",
"Auto-retry status codes": "Коды авто-повтора",
"Automatic selection": "Автовыбор",
"Automatically disable channel on repeated failures": "Автоматически отключать канал при повторных неудачах",
"Automatically disable channels exceeding this response time": "Автоматически отключать каналы, превышающие это время ответа",
"Automatically disable channels when tests fail": "Автоматически отключать каналы при сбое тестов",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "Автоматически синхронизировать список моделей при обнаружении изменений у провайдера",
"Availability (last 24h)": "Доступность (последние 24 ч)",
"Available": "Доступно",
"Available Balance": "Доступный баланс",
"Available channels: {{count}}": "Доступно: {{count}}",
"Available credits are ordered by soonest expiration.": "Доступные сбросы отсортированы по ближайшему истечению.",
"Available disk space": "Доступное дисковое пространство",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "Текущие условия тарификации",
"Current quota": "Текущая квота",
"Current token": "Текущий токен",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "Текущий итог = использовано + остаток. Изменение остатка квоты обновляет итог и процент использования.",
"Current total quota": "Текущая общая квота",
"Current v{{from}} → install v{{to}}": "Текущая v{{from}} → установка v{{to}}",
"Current value": "Текущее значение",
"Current Value": "Текущее значение",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "Единица срока",
"Duration Value": "Значение срока",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "Динамические объявления нельзя просмотреть заранее. Повторите попытку или изучите исходный код ниже.",
"Dynamic multiplier": "Динамический множитель",
"Dynamic operation": "Динамическая операция",
"Dynamic Pricing": "Динамическое ценообразование",
"e.g. ¥ or HK$": "напр. ¥ или HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Каждый уровень поддерживает до 2 условий; последний уровень является резервным и не содержит условий. Используйте полную длину входа для условий уровня, чтобы кэш-попадания не снижали оплачиваемые входные токены и не приводили к неверному маршруту.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Каждый уровень поддерживает до 2 условий. Последний уровень без условий используется как резервный.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Получайте вознаграждения, когда пользователи регистрируются по вашей реферальной ссылке. Переводите накопленные вознаграждения на баланс в любое время.",
"Earnings": "Доход",
"Edit": "Редактировать",
"Edit {{title}}": "Редактировать {{title}}",
"Edit all channels with tag:": "Редактировать все каналы с тегом:",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Расширение FluentRead не обнаружено. Убедитесь, что оно установлено и активно.",
"Flush interval (minutes)": "Интервал записи (минуты)",
"Follow the guided steps to prepare your workspace before the first login.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.",
"Follow user group": "Группа пользователя",
"Font": "Шрифт",
"Footer": "Подвал",
"Footer text displayed at the bottom of pages": "Текст нижнего колонтитула, отображаемый внизу страниц",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "Информация недоступна",
"Inherit global Auto order": "Наследовать глобальный порядок Auto",
"Inherit vendor icon": "Использовать иконку поставщика",
"Inherited": "Наследуется",
"Inherited from {{vendor}}": "От поставщика {{vendor}}",
"Initial quota given to new users": "Начальная квота, предоставляемая новым пользователям",
"Initial quota given to new users ({{formattedQuota}})": "Начальная квота, предоставляемая новым пользователям ({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "Квота приглашений",
"Invite Info": "Информация о приглашении",
"Invited": "Приглашен",
"Invited {{count}} users": "Приглашено: {{count}}",
"Invited by user ID": "Приглашен пользователем с ID",
"Invited Users": "Приглашенные пользователи",
"Invitee Reward": "Награда приглашенному",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "Нет хеша целостности",
"No integrity verification": "Без проверки целостности",
"No Inviter": "Нет пригласившего",
"No IP restriction": "Без ограничений",
"No keys found": "Ключи не найдены",
"No latency data available": "Данные о задержке недоступны",
"No linked models": "Нет связанных моделей",
......@@ -3426,6 +3437,7 @@
"No products match your search": "Нет продуктов, соответствующих вашему поиску",
"No providers available": "Нет доступных провайдеров",
"No Quota": "Нет квоты",
"No quota limit": "Без лимита",
"No ratio differences found": "Различия в коэффициентах не найдены",
"No recent usage": "Нет недавнего использования",
"No records": "Нет записей",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "Отображаемое имя проверяющей стороны",
"Relying Party ID": "Идентификатор проверяющей стороны",
"Remaining": "Остаток",
"Remaining percentage": "Оставшаяся доля",
"Remaining quota": "Остаток квоты",
"Remaining Quota ({{currency}})": "Остаток квоты ({{currency}})",
"Remaining quota units": "Остаток единиц квоты",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Маршруты с одним входным путем разделяются по точной модели клиента. Несовпавшие запросы идут в последний резерв.",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Маршруты с одним входным путем сопоставляются с точными именами моделей клиента. Несколько моделей разделяйте запятыми, пустым оставляйте только последний резерв.",
"Routing & Overrides": "Маршрутизация и переопределения",
"Routing Group": "Группа маршрутизации",
"Routing Reliability": "Надежность маршрутизации",
"Routing Strategy": "Стратегия маршрутизации",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "Строки — группы пользователей, столбцы — тарифные группы. Пустые ячейки используют базовый коэффициент, показанный серым.",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Это действие невозможно отменить. Это безвозвратно удалит вашу учетную запись и все ваши данные с наших серверов.",
"This action will permanently remove 2FA protection from your account.": "Это действие безвозвратно удалит защиту 2FA из вашей учетной записи.",
"This announcement will be removed from the list.": "Это объявление будет удалено из списка.",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "У этого API-ключа нет лимита. Для запросов по-прежнему нужен доступный баланс кошелька или квота подписки.",
"This API shortcut will be removed from the list.": "Этот ярлык API будет удален из списка.",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "Этот базовый URL указывает на частный или локальный хост. Убедитесь, что это подконтрольный вам вышестоящий сервис.",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "Этот базовый URL использует незашифрованный HTTP, поэтому ключ канала передаётся в открытом виде.",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "Сумма пополнения",
"Total": "Всего",
"Total {{count}} records": "Всего записей: {{count}}",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "Итого = использовано + остаток. Это не начальная квота и не бюджет на период. Изменение остатка меняет итог и процент использования.",
"Total Allocated": "Всего выделено",
"Total check-ins": "Общая проверка",
"Total consumed": "Всего использовано",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.",
"Total requests allowed per period. 0 = unlimited.": "Общее количество запросов, разрешенных за период. 0 = без ограничений.",
"Total requests made": "Всего сделанных запросов",
"Total Revenue": "Общий доход",
"Total time": "Общее время",
"Total tokens": "Всего токенов",
"Total Tokens": "Всего токенов",
"Total Usage": "Общее использование",
"Total Used": "Всего использовано",
"Total:": "Всего:",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "Отслеживать потребление для каждого запроса для аналитики использования. Сохранение этой опции увеличивает количество записей в базу данных.",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "Журнал использования",
"Usage mode": "Режим использования",
"Usage parameters": "Параметры использования",
"Usage percentage": "Процент использования",
"Usage prices": "Цены за использование",
"Usage-based": "На основе использования",
"Usage-based billing": "Оплата по объёму",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "Используйте свой ключ доступа",
"used": "использовано",
"Used": "Использовано",
"Used {{used}} of {{total}} ({{percent}}%)": "Использовано {{used}} из {{total}} ({{percent}}%)",
"Used / Remaining": "Использовано / Осталось",
"Used amount": "Использовано",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Используется как SuccessURL для нового продукта. Если оставить пустым, потребуется подтверждение.",
"Used by route auth templates": "Используется шаблонами auth маршрута",
"Used for load balancing. Higher weight = more requests": "Используется для балансировки нагрузки. Больший вес = больше запросов",
......
......@@ -474,6 +474,7 @@
"API key is required": "Khóa API là bắt buộc",
"API Key mode (does not support batch creation)": "Chế độ Khóa API (không hỗ trợ tạo hàng loạt)",
"API Key mode: use APIKey|Region": "Chế độ khóa API: sử dụng APIKey|Region",
"API Key Quota": "Hạn mức khóa API",
"API Key updated successfully": "API Key đã được cập nhật thành công",
"API Keys": "Khóa API",
"API Private Key": "Khóa riêng API",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "Tự động điền khi một trường có giá trị và trường khác thiếu",
"Auto-refreshing every {{seconds}}s": "Tự động làm mới mỗi {{seconds}} giây",
"Auto-retry status codes": "Mã trạng thái tự thử lại",
"Automatic selection": "Chọn tự động",
"Automatically disable channel on repeated failures": "Tự động vô hiệu hóa kênh khi xảy ra lỗi lặp lại",
"Automatically disable channels exceeding this response time": "Tự động vô hiệu hóa các kênh vượt quá thời gian phản hồi này",
"Automatically disable channels when tests fail": "Tự động vô hiệu hóa các kênh khi kiểm thử thất bại",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "Tự động đồng bộ danh sách mô hình khi phát hiện thay đổi từ nguồn",
"Availability (last 24h)": "Khả dụng (24 giờ qua)",
"Available": "Khả dụng",
"Available Balance": "Số dư khả dụng",
"Available channels: {{count}}": "Kênh khả dụng: {{count}}",
"Available credits are ordered by soonest expiration.": "Các lượt khả dụng được sắp xếp theo thời điểm hết hạn gần nhất.",
"Available disk space": "Dung lượng đĩa khả dụng",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "Điều kiện tính giá hiện tại",
"Current quota": "Hạn mức hiện tại",
"Current token": "Token hiện tại",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "Tổng hiện tại = đã dùng + còn lại. Thay đổi hạn mức còn lại sẽ cập nhật tổng và tỷ lệ đã dùng.",
"Current total quota": "Tổng hạn mức hiện tại",
"Current v{{from}} → install v{{to}}": "Hiện tại v{{from}} → cài đặt v{{to}}",
"Current value": "Giá trị hiện tại",
"Current Value": "Present value",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "Đơn vị thời lượng",
"Duration Value": "Giá trị thời lượng",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "Không thể xem trước khai báo được tạo động. Hãy thử lại hoặc xem mã nguồn bên dưới.",
"Dynamic multiplier": "Hệ số động",
"Dynamic operation": "Thao tác động",
"Dynamic Pricing": "Giá linh hoạt",
"e.g. ¥ or HK$": "ví dụ ¥ hoặc HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện; tầng cuối cùng là tầng dự phòng không có điều kiện. Hãy dùng độ dài đầu vào đầy đủ cho điều kiện tầng để tránh chọn sai tầng khi cache hit làm giảm token đầu vào tính phí.",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện. Tầng cuối cùng không có điều kiện là tầng dự phòng.",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Nhận phần thưởng khi người dùng đăng ký qua liên kết giới thiệu của bạn. Chuyển phần thưởng tích lũy vào số dư bất cứ lúc nào.",
"Earnings": "Thu nhập",
"Edit": "Chỉnh sửa",
"Edit {{title}}": "Chỉnh sửa {{title}}",
"Edit all channels with tag:": "Chỉnh sửa tất cả các kênh với thẻ:",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Không phát hiện tiện ích mở rộng FluentRead. Vui lòng đảm bảo nó đã được cài đặt và kích hoạt.",
"Flush interval (minutes)": "Khoảng ghi xuống DB (phút)",
"Follow the guided steps to prepare your workspace before the first login.": "Thực hiện theo các bước hướng dẫn để chuẩn bị không gian làm việc của bạn trước lần đăng nhập đầu tiên.",
"Follow user group": "Theo nhóm người dùng",
"Font": "Phông chữ",
"Footer": "Chân trang",
"Footer text displayed at the bottom of pages": "Văn bản chân trang hiển thị ở cuối các trang",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "Chưa đọc được thông tin",
"Inherit global Auto order": "Kế thừa thứ tự Auto toàn cục",
"Inherit vendor icon": "Kế thừa biểu tượng nhà cung cấp",
"Inherited": "Kế thừa",
"Inherited from {{vendor}}": "Kế thừa từ {{vendor}}",
"Initial quota given to new users": "Hạn mức ban đầu cấp cho người dùng mới",
"Initial quota given to new users ({{formattedQuota}})": "Hạn mức ban đầu cấp cho người dùng mới ({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "Hạn mức lời mời",
"Invite Info": "Thông tin mời",
"Invited": "Đã mời",
"Invited {{count}} users": "Đã mời {{count}} người",
"Invited by user ID": "Được mời bởi ID người dùng",
"Invited Users": "Người dùng được mời",
"Invitee Reward": "Referral reward",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "Không có mã băm toàn vẹn",
"No integrity verification": "Không kiểm tra toàn vẹn",
"No Inviter": "Không có người mời",
"No IP restriction": "Không giới hạn",
"No keys found": "Không tìm thấy khóa",
"No latency data available": "Không có dữ liệu độ trễ",
"No linked models": "Chưa có mô hình liên kết",
......@@ -3426,6 +3437,7 @@
"No products match your search": "Không có sản phẩm nào khớp với tìm kiếm của bạn",
"No providers available": "Không có nhà cung cấp khả dụng",
"No Quota": "Không hạn ngạch",
"No quota limit": "Không giới hạn",
"No ratio differences found": "Không tìm thấy sự khác biệt tỷ lệ",
"No recent usage": "Chưa có sử dụng gần đây",
"No records": "Chưa có bản ghi",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "Tên Hiển Thị của Bên Tin Cậy",
"Relying Party ID": "Định danh Bên phụ thuộc",
"Remaining": "Còn lại",
"Remaining percentage": "Tỷ lệ còn lại",
"Remaining quota": "Hạn ngạch còn lại",
"Remaining Quota ({{currency}})": "Hạn mức còn lại ({{currency}})",
"Remaining quota units": "Đơn vị hạn ngạch còn lại",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Các tuyến có cùng đường dẫn vào được tách theo model client chính xác. Yêu cầu chưa khớp sẽ dùng nhánh dự phòng cuối cùng.",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Các tuyến có cùng đường dẫn vào khớp theo tên model chính xác từ yêu cầu client. Ngăn cách nhiều model bằng dấu phẩy và chỉ để trống nhánh dự phòng cuối cùng.",
"Routing & Overrides": "Định tuyến & ghi đè",
"Routing Group": "Nhóm định tuyến",
"Routing Reliability": "Độ tin cậy định tuyến",
"Routing Strategy": "Chiến lược định tuyến",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "Hàng là nhóm người dùng, cột là nhóm tính phí. Ô trống sẽ dùng hệ số cơ bản hiển thị màu xám.",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Hành động này không thể hoàn tác. Việc này sẽ xóa vĩnh viễn tài khoản của bạn và loại bỏ tất cả dữ liệu của bạn khỏi máy chủ của chúng tôi.",
"This action will permanently remove 2FA protection from your account.": "Hành động này sẽ vĩnh viễn gỡ bỏ tính năng bảo vệ",
"This announcement will be removed from the list.": "Thông báo này sẽ bị xóa khỏi danh sách.",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "Khóa API này không có hạn mức. Yêu cầu vẫn cần số dư ví hoặc hạn mức gói đăng ký khả dụng.",
"This API shortcut will be removed from the list.": "Lối tắt API này sẽ bị xóa khỏi danh sách.",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "Base URL này trỏ tới máy chủ mạng nội bộ hoặc cục bộ. Hãy chắc chắn đây là upstream do bạn kiểm soát.",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "Base URL này dùng HTTP không mã hóa, khóa kênh sẽ được gửi ở dạng chưa mã hóa.",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "Số tiền nạp",
"Total": "Tổng cộng",
"Total {{count}} records": "Tổng cộng {{count}} bản ghi",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "Tổng = đã dùng + còn lại, không phải hạn mức ban đầu hay ngân sách định kỳ. Thay đổi phần còn lại sẽ thay đổi tổng và tỷ lệ đã dùng.",
"Total Allocated": "Tổng phân bổ",
"Total check-ins": "Tổng số lần điểm danh",
"Total consumed": "Tổng tiêu thụ",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Tổng hạn ngạch bao gồm trong gói, dùng được mỗi kỳ thanh toán. 0 nghĩa là không giới hạn.",
"Total requests allowed per period. 0 = unlimited.": "Tổng số yêu cầu được phép mỗi kỳ. 0 = không giới hạn.",
"Total requests made": "Tổng lượt yêu cầu",
"Total Revenue": "Tổng thu nhập",
"Total time": "Tổng thời gian",
"Total tokens": "Tổng số token",
"Total Tokens": "Tổng số token",
"Total Usage": "Tổng Mức Sử dụng",
"Total Used": "Tổng đã dùng",
"Total:": "Tổng cộng:",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "Theo dõi mức tiêu thụ theo từng yêu cầu để phục vụ phân tích mức độ sử dụng. Việc bật tính năng này làm tăng số lượt ghi vào cơ sở dữ liệu.",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "Nhật ký sử dụng",
"Usage mode": "Chế độ sử dụng",
"Usage parameters": "Tham số sử dụng",
"Usage percentage": "Tỷ lệ đã dùng",
"Usage prices": "Giá sử dụng",
"Usage-based": "Dựa trên sử dụng",
"Usage-based billing": "Tính phí theo mức sử dụng",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "Sử dụng Passkey của bạn",
"used": "đã sử dụng, cũ",
"Used": "Đã sử dụng",
"Used {{used}} of {{total}} ({{percent}}%)": "Đã dùng {{used}} trên {{total}} ({{percent}}%)",
"Used / Remaining": "Đã dùng / Còn lại",
"Used amount": "Đã dùng",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Được dùng làm SuccessURL trên sản phẩm mới. Bạn sẽ được yêu cầu xác nhận nếu để trống.",
"Used by route auth templates": "Dùng trong mẫu xác thực route",
"Used for load balancing. Higher weight = more requests": "Được sử dụng để cân bằng tải. Trọng số càng cao = càng nhiều yêu cầu",
......
......@@ -474,6 +474,7 @@
"API key is required": "需要 API 金鑰",
"API Key mode (does not support batch creation)": "API Key 模式(不支援大量建立)",
"API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
"API Key Quota": "權杖額度",
"API Key updated successfully": "API 金鑰更新成功",
"API Keys": "API 金鑰",
"API Private Key": "API 私鑰",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "在一個欄位有值、另一個缺失時自動補齊",
"Auto-refreshing every {{seconds}}s": "每 {{seconds}} 秒自動重新整理",
"Auto-retry status codes": "自動重試狀態碼",
"Automatic selection": "自動選擇",
"Automatically disable channel on repeated failures": "重複失敗時自動停用渠道",
"Automatically disable channels exceeding this response time": "自動停用超出此回應時間的渠道",
"Automatically disable channels when tests fail": "當測試失敗時自動停用渠道",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "偵測到上游模型變更時自動同步模型清單",
"Availability (last 24h)": "可用率(最近 24 小時)",
"Available": "可用",
"Available Balance": "可用餘額",
"Available channels: {{count}}": "可用渠道 {{count}}",
"Available credits are ordered by soonest expiration.": "可用次數按最早到期排序。",
"Available disk space": "可用磁碟空間",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "目前計價條件",
"Current quota": "目前額度",
"Current token": "目前權杖",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "目前總額度 = 已用 + 剩餘。修改剩餘額度後,總額度和已用比例會隨之更新。",
"Current total quota": "目前總額度",
"Current v{{from}} → install v{{to}}": "目前版本 v{{from}} → 安裝版本 v{{to}}",
"Current value": "目前值",
"Current Value": "目前值",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "有效期單位",
"Duration Value": "有效期數值",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "動態產生的宣告無法預覽。可重試,或查看下方原始碼。",
"Dynamic multiplier": "動態倍率",
"Dynamic operation": "動態操作",
"Dynamic Pricing": "動態收費",
"e.g. ¥ or HK$": "例如,¥ 或 HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每個檔位最多支援 2 個條件;最後一個檔位是不帶條件的兜底檔。建議使用完整輸入長度作為檔位條件,避免緩存命中減少收費輸入 token 後誤判檔位。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每個階梯最多支援 2 個條件。最後一個無條件階梯作為兜底。",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.",
"Earnings": "收益",
"Edit": "編輯",
"Edit {{title}}": "編輯{{title}}",
"Edit all channels with tag:": "編輯所有帶有標籤的渠道:",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "未偵測到 FluentRead 擴展。請確保已安裝並啟用。",
"Flush interval (minutes)": "刷庫間隔(分鐘)",
"Follow the guided steps to prepare your workspace before the first login.": "請按照引導步驟在首次登入前準備您的工作區。",
"Follow user group": "跟隨使用者",
"Font": "字體",
"Footer": "頁腳",
"Footer text displayed at the bottom of pages": "顯示在頁面底部的頁腳文字",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "暫時無法讀取",
"Inherit global Auto order": "繼承全域 Auto 順序",
"Inherit vendor icon": "繼承供應商圖示",
"Inherited": "繼承",
"Inherited from {{vendor}}": "繼承自 {{vendor}}",
"Initial quota given to new users": "授予新用戶的初始配額",
"Initial quota given to new users ({{formattedQuota}})": "授予新用戶的初始配額({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "邀請額度",
"Invite Info": "邀請資訊",
"Invited": "已邀請",
"Invited {{count}} users": "邀請 {{count}} 人",
"Invited by user ID": "由用戶 ID 邀請",
"Invited Users": "受邀用戶",
"Invitee Reward": "受邀者獎勵",
......@@ -2813,7 +2823,7 @@
"Last Tested": "上次測試",
"Last updated:": "上次更新時間:",
"Last used": "上次使用",
"Last Used": "最後使用時間",
"Last Used": "最後使用",
"Last used IP": "上次使用 IP",
"Last used:": "上次使用時間:",
"Latency": "延遲",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "無完整性雜湊",
"No integrity verification": "無完整性驗證",
"No Inviter": "無邀請人",
"No IP restriction": "未限制",
"No keys found": "未找到金鑰",
"No latency data available": "暫無延遲數據",
"No linked models": "尚無關聯模型",
......@@ -3426,6 +3437,7 @@
"No products match your search": "沒有產品匹配您的搜尋",
"No providers available": "暫無可用供應商",
"No Quota": "無餘額",
"No quota limit": "不限額",
"No ratio differences found": "未發現比率差異",
"No recent usage": "暫無使用記錄",
"No records": "尚無記錄",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "依賴方顯示名稱",
"Relying Party ID": "依賴方 ID",
"Remaining": "剩餘",
"Remaining percentage": "剩餘比例",
"Remaining quota": "剩餘配額",
"Remaining Quota ({{currency}})": "剩餘額度 ({{currency}})",
"Remaining quota units": "剩餘配額單位",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同一入口路徑按客戶端 model 精確分流;未命中的請求走最後的兜底。",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "同一入口路徑按客戶端請求中的精確模型名匹配。多個模型用英文逗號分隔,只有最後的兜底可留空。",
"Routing & Overrides": "路由與覆蓋",
"Routing Group": "路由分組",
"Routing Reliability": "路由可靠性",
"Routing Strategy": "路由策略",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "行為用戶分組,列為收費分組。空白單元格回退到灰色顯示的基礎倍率。",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作無法撤銷。這將永久刪除您的用戶並從我們的伺服器中移除您的所有數據。",
"This action will permanently remove 2FA protection from your account.": "此操作將永久移除您用戶的 2FA 保護。",
"This announcement will be removed from the list.": "此公告將從列表中移除。",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "此權杖不限額,請求仍受錢包餘額或訂閱額度限制。",
"This API shortcut will be removed from the list.": "此 API 快捷方式將從列表中移除。",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "該 Base URL 指向內網或本機位址,請確認這是你可控的上游服務。",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "該 Base URL 使用明文 HTTP,渠道金鑰將以未加密方式傳送。",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "儲值金額",
"Total": "總計",
"Total {{count}} records": "共 {{count}} 筆記錄",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "總額度 = 已用 + 剩餘,並非初始額度或週期預算。修改剩餘額度會改變總額度和已用比例。",
"Total Allocated": "總分配",
"Total check-ins": "累計簽到",
"Total consumed": "總消耗",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的總額度,每個收費周期可用;0 表示不限量",
"Total requests allowed per period. 0 = unlimited.": "每周期允許的總請求數。0 = 無限制。",
"Total requests made": "總請求數",
"Total Revenue": "累計收益",
"Total time": "總耗時",
"Total tokens": "總 Token",
"Total Tokens": "總 Token 數",
"Total Usage": "總用量",
"Total Used": "累計已用",
"Total:": "總計:",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "追蹤每個請求的消耗,以支援使用情況分析。保持開啟會增加資料庫寫入。",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "使用日誌",
"Usage mode": "使用模式",
"Usage parameters": "用量參數",
"Usage percentage": "已用比例",
"Usage prices": "用量價格",
"Usage-based": "基於使用量",
"Usage-based billing": "按用量計費",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "使用您的通行金鑰",
"used": "已使用",
"Used": "已使用",
"Used {{used}} of {{total}} ({{percent}}%)": "已用 {{used}} / {{total}}({{percent}}%)",
"Used / Remaining": "已使用 / 剩餘",
"Used amount": "已用",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "用作新產品的 SuccessURL。若留空,系統會要求你確認。",
"Used by route auth templates": "用於路由認證模板",
"Used for load balancing. Higher weight = more requests": "用於負載平衡。權重越高 = 請求越多",
......
......@@ -474,6 +474,7 @@
"API key is required": "需要 API 密钥",
"API Key mode (does not support batch creation)": "API Key 模式(不支持批量创建)",
"API Key mode: use APIKey|Region": "API Key 模式:使用 APIKey|Region",
"API Key Quota": "令牌额度",
"API Key updated successfully": "API 密钥更新成功",
"API Keys": "API 密钥",
"API Private Key": "API 私钥",
......@@ -629,6 +630,7 @@
"Auto-fill when one field exists and another is missing": "在一个字段有值、另一个缺失时自动补齐",
"Auto-refreshing every {{seconds}}s": "每 {{seconds}} 秒自动刷新",
"Auto-retry status codes": "自动重试状态码",
"Automatic selection": "自动选择",
"Automatically disable channel on repeated failures": "重复失败时自动禁用渠道",
"Automatically disable channels exceeding this response time": "自动禁用超出此响应时间的渠道",
"Automatically disable channels when tests fail": "当测试失败时自动禁用渠道",
......@@ -638,6 +640,7 @@
"Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表",
"Availability (last 24h)": "可用率(最近 24 小时)",
"Available": "可用",
"Available Balance": "可用余额",
"Available channels: {{count}}": "可用渠道 {{count}}",
"Available credits are ordered by soonest expiration.": "可用次数按最早到期排序。",
"Available disk space": "可用磁盘空间",
......@@ -1399,6 +1402,8 @@
"Current pricing conditions": "当前计价条件",
"Current quota": "当前额度",
"Current token": "当前令牌",
"Current total = used + remaining. Changing the remaining quota updates the total and percentage.": "当前总额度 = 已用 + 剩余。修改剩余额度后,总额度和已用比例会随之更新。",
"Current total quota": "当前总额度",
"Current v{{from}} → install v{{to}}": "当前版本 v{{from}} → 安装版本 v{{to}}",
"Current value": "当前值",
"Current Value": "当前值",
......@@ -1711,6 +1716,7 @@
"Duration Unit": "有效期单位",
"Duration Value": "有效期数值",
"Dynamic declarations cannot be previewed. Retry or review the source below.": "动态生成的声明无法预览。可重试,或查看下方源码。",
"Dynamic multiplier": "动态倍率",
"Dynamic operation": "动态操作",
"Dynamic Pricing": "动态计费",
"e.g. ¥ or HK$": "例如,¥ 或 HK$",
......@@ -1769,6 +1775,7 @@
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每个档位最多支持 2 个条件;最后一个档位是不带条件的兜底档。建议使用完整输入长度作为档位条件,避免缓存命中减少计费输入 token 后误判档位。",
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每个阶梯最多支持 2 个条件。最后一个无条件阶梯作为兜底。",
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "用户通过您的推荐链接注册后,您即可获得奖励。可随时将累计奖励转入余额。",
"Earnings": "收益",
"Edit": "编辑",
"Edit {{title}}": "编辑{{title}}",
"Edit all channels with tag:": "编辑所有带有标签的渠道:",
......@@ -2338,6 +2345,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "未检测到 FluentRead 扩展。请确保已安装并激活。",
"Flush interval (minutes)": "刷库间隔(分钟)",
"Follow the guided steps to prepare your workspace before the first login.": "请按照引导步骤在首次登录前准备您的工作区。",
"Follow user group": "跟随用户",
"Font": "字体",
"Footer": "页脚",
"Footer text displayed at the bottom of pages": "显示在页面底部的页脚文本",
......@@ -2663,6 +2671,7 @@
"Information unavailable": "暂时无法读取",
"Inherit global Auto order": "继承全局 Auto 顺序",
"Inherit vendor icon": "继承供应商图标",
"Inherited": "继承",
"Inherited from {{vendor}}": "继承自 {{vendor}}",
"Initial quota given to new users": "授予新用户的初始配额",
"Initial quota given to new users ({{formattedQuota}})": "授予新用户的初始配额({{formattedQuota}})",
......@@ -2732,6 +2741,7 @@
"Invitation Quota": "邀请额度",
"Invite Info": "邀请信息",
"Invited": "已邀请",
"Invited {{count}} users": "邀请 {{count}} 人",
"Invited by user ID": "由用户 ID 邀请",
"Invited Users": "受邀用户",
"Invitee Reward": "受邀者奖励",
......@@ -2813,7 +2823,7 @@
"Last Tested": "上次测试",
"Last updated:": "上次更新时间:",
"Last used": "最后使用",
"Last Used": "最后使用时间",
"Last Used": "最后使用",
"Last used IP": "最后使用 IP",
"Last used:": "上次使用时间:",
"Latency": "延迟",
......@@ -3363,6 +3373,7 @@
"No integrity hash": "无完整性哈希",
"No integrity verification": "无完整性校验",
"No Inviter": "无邀请人",
"No IP restriction": "未限制",
"No keys found": "未找到密钥",
"No latency data available": "暂无延迟数据",
"No linked models": "暂无关联模型",
......@@ -3426,6 +3437,7 @@
"No products match your search": "没有产品匹配您的搜索",
"No providers available": "暂无可用提供商",
"No Quota": "无余额",
"No quota limit": "不限额",
"No ratio differences found": "未发现比率差异",
"No recent usage": "暂无使用记录",
"No records": "暂无记录",
......@@ -4307,6 +4319,7 @@
"Relying Party Display Name": "依赖方显示名称",
"Relying Party ID": "依赖方 ID",
"Remaining": "剩余",
"Remaining percentage": "剩余比例",
"Remaining quota": "剩余配额",
"Remaining Quota ({{currency}})": "剩余额度 ({{currency}})",
"Remaining quota units": "剩余配额单位",
......@@ -4533,6 +4546,7 @@
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同一入口路径按客户端 model 精确分流;未命中的请求走最后的兜底。",
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "同一入口路径按客户端请求中的精确模型名匹配。多个模型用英文逗号分隔,只有最后的兜底可留空。",
"Routing & Overrides": "路由与覆盖",
"Routing Group": "路由分组",
"Routing Reliability": "路由可靠性",
"Routing Strategy": "路由策略",
"Rows are user groups, columns are billing groups. Empty cells fall back to the base ratio shown in gray.": "行为用户分组,列为计费分组。空白单元格回退到灰色显示的基础倍率。",
......@@ -5287,6 +5301,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作无法撤消。这将永久删除您的账户并从我们的服务器中移除您的所有数据。",
"This action will permanently remove 2FA protection from your account.": "此操作将永久移除您账户的 2FA 保护。",
"This announcement will be removed from the list.": "此公告将从列表中移除。",
"This API key has no quota limit. Requests still require available wallet or subscription quota.": "此令牌不限额,请求仍受钱包余额或订阅额度约束。",
"This API shortcut will be removed from the list.": "此 API 快捷方式将从列表中移除。",
"This base URL points at a private or local network host. Make sure it is an upstream you control.": "该 Base URL 指向内网或本机地址,请确认这是你可控的上游服务。",
"This base URL uses plain HTTP, so the channel key is sent unencrypted.": "该 Base URL 使用明文 HTTP,渠道密钥将以未加密方式发送。",
......@@ -5494,6 +5509,7 @@
"Topup Amount": "充值金额",
"Total": "总计",
"Total {{count}} records": "共 {{count}} 条记录",
"Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.": "总额度 = 已用 + 剩余,并非初始额度或周期预算。修改剩余额度会改变总额度和已用比例。",
"Total Allocated": "总分配",
"Total check-ins": "累计签到",
"Total consumed": "总消耗",
......@@ -5510,10 +5526,12 @@
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量",
"Total requests allowed per period. 0 = unlimited.": "每周期允许的总请求数。0 = 无限制。",
"Total requests made": "总请求数",
"Total Revenue": "累计收益",
"Total time": "总耗时",
"Total tokens": "总 Token",
"Total Tokens": "总 Token 数",
"Total Usage": "总用量",
"Total Used": "累计已用",
"Total:": "总计:",
"TPM": "TPM",
"Track per-request consumption to power usage analytics. Keeping this on increases database writes.": "跟踪每个请求的消耗,以支持使用情况分析。保持开启会增加数据库写入。",
......@@ -5734,6 +5752,7 @@
"Usage Logs": "使用日志",
"Usage mode": "使用模式",
"Usage parameters": "用量参数",
"Usage percentage": "已用比例",
"Usage prices": "用量价格",
"Usage-based": "基于使用量",
"Usage-based billing": "按用量计费",
......@@ -5772,7 +5791,9 @@
"Use your Passkey": "使用您的通行密钥",
"used": "已使用",
"Used": "已使用",
"Used {{used}} of {{total}} ({{percent}}%)": "已用 {{used}} / {{total}}({{percent}}%)",
"Used / Remaining": "已使用 / 剩余",
"Used amount": "已用",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "用作新产品的 SuccessURL。若留空,系统会要求你确认。",
"Used by route auth templates": "用于路由认证模板",
"Used for load balancing. Higher weight = more requests": "用于负载均衡。权重越高 = 请求越多",
......
......@@ -713,6 +713,18 @@ For commercial licensing, please contact support@quantumnous.com
animation: auto-group-flow-border-travel 3.2s linear infinite;
}
.auto-group-flow-border-subtle {
padding: 1px;
background: conic-gradient(
from var(--auto-group-flow-angle),
transparent 0deg 300deg,
color-mix(in oklch, var(--primary) 30%, transparent) 318deg,
var(--primary) 348deg,
transparent 360deg
);
animation-duration: 3s;
}
@media (prefers-reduced-motion: reduce) {
.auto-group-flow-border {
display: none;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment