mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 09:27:03 +08:00
refactor(web): unify table cells and quota details
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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 [email protected]
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TimestampCellProps {
|
||||
timestamp: number
|
||||
now?: number
|
||||
format?: 'relative' | 'absolute'
|
||||
locale?: string
|
||||
justNowLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TimestampCell(props: TimestampCellProps) {
|
||||
if (!props.timestamp || props.timestamp === -1) {
|
||||
return <span className='text-muted-foreground'>-</span>
|
||||
}
|
||||
|
||||
const timestampMs = props.timestamp * 1000
|
||||
const absoluteTime = formatTimestampToDate(props.timestamp)
|
||||
if (props.format === 'absolute') {
|
||||
return (
|
||||
<time
|
||||
dateTime={new Date(timestampMs).toISOString()}
|
||||
className={cn('block whitespace-nowrap tabular-nums', props.className)}
|
||||
>
|
||||
{absoluteTime}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
const now = props.now ?? Date.now()
|
||||
const isJustNow = timestampMs <= now && now - timestampMs < 60_000
|
||||
const relativeTime = isJustNow
|
||||
? props.justNowLabel
|
||||
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
|
||||
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
|
||||
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<time
|
||||
dateTime={new Date(timestampMs).toISOString()}
|
||||
tabIndex={0}
|
||||
className={cn('block truncate', props.className)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{relativePrefix}
|
||||
{relativeNumber && (
|
||||
<span className='tabular-nums'>{relativeNumber}</span>
|
||||
)}
|
||||
{relativeSuffix}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className='tabular-nums'>{absoluteTime}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActivityTimeCell(props: {
|
||||
createdAt: number
|
||||
lastAt: number
|
||||
lastLabel: string
|
||||
lastClassName?: string
|
||||
now?: number
|
||||
format?: 'relative' | 'absolute'
|
||||
layout?: 'rows' | 'columns'
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-table-text='secondary'
|
||||
className={cn(
|
||||
'grid min-w-0 gap-y-1 text-xs font-normal',
|
||||
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>
|
||||
<TimestampCell
|
||||
timestamp={props.createdAt}
|
||||
now={props.now}
|
||||
format={props.format}
|
||||
locale={locale}
|
||||
justNowLabel={t('Just now')}
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
props.layout === 'columns' && 'whitespace-normal'
|
||||
)}
|
||||
/>
|
||||
<span className='text-muted-foreground'>{props.lastLabel}</span>
|
||||
<TimestampCell
|
||||
timestamp={props.lastAt}
|
||||
now={props.now}
|
||||
format={props.format}
|
||||
locale={locale}
|
||||
justNowLabel={t('Just now')}
|
||||
className={cn(
|
||||
props.lastClassName ?? 'text-muted-foreground',
|
||||
props.layout === 'columns' && 'whitespace-normal'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -145,12 +145,9 @@ function SplitHeaderTableView<TData>({
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn(
|
||||
'w-full caption-bottom text-sm tabular-nums',
|
||||
props.tableClassName
|
||||
)}
|
||||
<Table
|
||||
withContainer={false}
|
||||
className={props.tableClassName}
|
||||
style={tableSizing.style}
|
||||
>
|
||||
{tableSizing.colgroup}
|
||||
@@ -162,7 +159,7 @@ function SplitHeaderTableView<TData>({
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</table>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -42,8 +42,8 @@ export function GroupMultiplierBadge(props: {
|
||||
<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',
|
||||
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-sm leading-none font-medium shadow-none',
|
||||
!props.label && 'tabular-nums',
|
||||
colorClassName,
|
||||
props.className
|
||||
)}
|
||||
@@ -122,7 +122,7 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs',
|
||||
'inline-flex max-w-full min-w-0 items-center gap-2 text-sm',
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -23,6 +25,21 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function MaskedValueTrigger(props: ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
{...props}
|
||||
className={cn(
|
||||
'text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface MaskedValueDisplayProps {
|
||||
/** 弹层内标题,如 "Full API Key" / "Full Code" */
|
||||
@@ -44,15 +61,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PopoverTrigger render={<MaskedValueTrigger />}>
|
||||
<span className='truncate'>{props.maskedValue}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
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 [email protected]
|
||||
*/
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type QuotaDetailsPopoverProps = {
|
||||
title: string
|
||||
triggerLabel: string
|
||||
details: ReadonlyArray<{ label: string; value: string }>
|
||||
description?: string
|
||||
children: ReactNode
|
||||
afterTrigger?: ReactNode
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
export function QuotaDetailsPopover(props: QuotaDetailsPopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<div className={cn('w-full min-w-0', props.className)}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
aria-label={props.triggerLabel}
|
||||
className={cn(
|
||||
'h-auto w-full min-w-0 justify-start px-0 py-0.5 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
|
||||
props.triggerClassName
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</PopoverTrigger>
|
||||
{props.afterTrigger}
|
||||
</div>
|
||||
<PopoverContent
|
||||
align='start'
|
||||
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
|
||||
>
|
||||
<PopoverTitle>{props.title}</PopoverTitle>
|
||||
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
|
||||
{props.details.map((detail) => (
|
||||
<Fragment key={detail.label}>
|
||||
<dt className='text-muted-foreground'>{detail.label}</dt>
|
||||
<dd className='text-right break-all'>{detail.value}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
{props.description && (
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -22,17 +22,31 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
function Table({
|
||||
className,
|
||||
withContainer = true,
|
||||
...props
|
||||
}: React.ComponentProps<'table'> & { withContainer?: boolean }) {
|
||||
const table = (
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn(
|
||||
'w-full caption-bottom text-sm tabular-nums [font-family:var(--font-body)] [&_td]:text-sm [&_td]:font-medium [&_th]:text-sm [&_:is(th,td)_*]:[font-family:inherit] [&_:is(th,td)_*]:[font-size:inherit] [&_:is(th,td)_*]:[font-weight:inherit]',
|
||||
'[&_[data-table-text=secondary]]:text-xs [&_[data-table-text=secondary]]:font-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!withContainer) return table
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot='table-container'
|
||||
className='relative w-full overflow-x-auto overflow-y-hidden'
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn('w-full caption-bottom text-sm tabular-nums', className)}
|
||||
{...props}
|
||||
/>
|
||||
{table}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('API key group table cell', () => {
|
||||
color,
|
||||
border,
|
||||
'rounded-full',
|
||||
'font-mono',
|
||||
'tabular-nums',
|
||||
'h-5',
|
||||
'min-w-12'
|
||||
)
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('API key group combobox Auto effect', () => {
|
||||
'h-5',
|
||||
'min-w-12',
|
||||
'rounded-full',
|
||||
'font-mono',
|
||||
'tabular-nums',
|
||||
'border-muted-foreground/30'
|
||||
)
|
||||
expect(defaultRatio).not.toHaveTextContent('Ratio')
|
||||
|
||||
@@ -508,7 +508,7 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by
|
||||
'font-normal'
|
||||
)
|
||||
expect(within(quota).getByText('4,490.16')).toHaveClass(
|
||||
'font-mono',
|
||||
'tabular-nums',
|
||||
'text-sm',
|
||||
'font-normal',
|
||||
'text-right'
|
||||
|
||||
@@ -18,13 +18,7 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { QuotaDetailsPopover } from '@/components/quota-details-popover'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
|
||||
@@ -78,78 +72,45 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
|
||||
? `${t('Unlimited')}; ${usageDescription}`
|
||||
: `${remainingDescription}; ${usageDescription}`
|
||||
|
||||
const details = []
|
||||
if (!props.apiKey.unlimited_quota) {
|
||||
details.push({ label: t('Remaining'), value: formattedRemaining })
|
||||
}
|
||||
details.push({ label: t('Used amount'), value: formattedUsed })
|
||||
if (!props.apiKey.unlimited_quota) {
|
||||
details.push({ label: t('Current total quota'), value: formattedTotal })
|
||||
}
|
||||
if (hasProgress) {
|
||||
details.push({
|
||||
label: t('Remaining percentage'),
|
||||
value: `${formattedPercentage}%`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full min-w-0',
|
||||
props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 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={cn(
|
||||
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-xs',
|
||||
props.variant === 'card'
|
||||
? 'grid-cols-[auto_minmax(0,1fr)]'
|
||||
: 'grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{props.variant === 'card' && (
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Remaining')}
|
||||
<span className='ml-1'>({quotaUnit})</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
props.variant === 'card'
|
||||
? 'text-right text-sm leading-5 font-normal'
|
||||
: 'text-left 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>
|
||||
{props.variant === 'card' && (
|
||||
<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 && (
|
||||
<QuotaDetailsPopover
|
||||
title={`${t('Quota')} (${quotaUnit})`}
|
||||
triggerLabel={
|
||||
props.variant === 'card'
|
||||
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
|
||||
: triggerLabel
|
||||
}
|
||||
details={details}
|
||||
description={
|
||||
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.'
|
||||
)
|
||||
}
|
||||
className={
|
||||
props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5'
|
||||
}
|
||||
triggerClassName={props.variant === 'card' ? 'py-0' : undefined}
|
||||
afterTrigger={
|
||||
!props.apiKey.unlimited_quota && (
|
||||
<Progress
|
||||
value={percentage}
|
||||
aria-label={t('Remaining percentage')}
|
||||
@@ -158,55 +119,53 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
|
||||
progressColor
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span
|
||||
data-slot='api-key-quota-values'
|
||||
className={cn(
|
||||
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-sm',
|
||||
props.variant === 'card'
|
||||
? 'grid-cols-[auto_minmax(0,1fr)]'
|
||||
: 'grid-cols-2'
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
{props.variant === 'card' && (
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Remaining')}
|
||||
<span className='ml-1'>({quotaUnit})</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
props.variant === 'card'
|
||||
? 'text-right text-sm leading-5 font-normal'
|
||||
: 'text-left font-medium',
|
||||
!props.apiKey.unlimited_quota && 'tabular-nums',
|
||||
!props.apiKey.unlimited_quota &&
|
||||
remaining < 0 &&
|
||||
'text-destructive',
|
||||
remaining === 0 &&
|
||||
!props.apiKey.unlimited_quota &&
|
||||
'text-muted-foreground'
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
>
|
||||
{props.apiKey.unlimited_quota ? t('Unlimited') : formattedRemaining}
|
||||
</span>
|
||||
{props.variant === 'card' && (
|
||||
<span className='text-muted-foreground'>{t('Used amount')}</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground min-w-0 truncate text-right tabular-nums',
|
||||
props.variant === 'card' && 'text-sm leading-5 font-normal'
|
||||
)}
|
||||
{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>
|
||||
>
|
||||
{formattedUsed}
|
||||
</span>
|
||||
</span>
|
||||
</QuotaDetailsPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,108 +18,32 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { ActivityTimeCell } from '@/components/activity-time-cell'
|
||||
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
|
||||
locale?: string
|
||||
justNowLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
|
||||
if (!props.timestamp || props.timestamp === -1) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const timestampMs = props.timestamp * 1000
|
||||
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<time
|
||||
dateTime={new Date(timestampMs).toISOString()}
|
||||
tabIndex={0}
|
||||
className={cn('block truncate text-xs', props.className)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{relativePrefix}
|
||||
{relativeNumber && (
|
||||
<span className='font-mono tabular-nums'>{relativeNumber}</span>
|
||||
)}
|
||||
{relativeSuffix}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className='font-mono tabular-nums'>{absoluteTime}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
export { TimestampCell as ApiKeyTimestampCell } from '@/components/activity-time-cell'
|
||||
|
||||
export function ApiKeyActivityCell(props: {
|
||||
apiKey: ApiKey
|
||||
now: number
|
||||
layout?: 'rows' | 'columns'
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const { t } = useTranslation()
|
||||
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>
|
||||
<ActivityTimeCell
|
||||
createdAt={props.apiKey.created_time}
|
||||
lastAt={accessedTime}
|
||||
lastLabel={t('Last Used')}
|
||||
lastClassName={isStale ? 'text-warning' : 'text-muted-foreground'}
|
||||
now={props.now}
|
||||
layout={props.layout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BadgeCell } from '@/components/data-table'
|
||||
import { MaskedValueTrigger } from '@/components/masked-value-display'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PopoverTrigger render={<MaskedValueTrigger />}>
|
||||
<span className='truncate'>{maskedKey}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
||||
@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format'
|
||||
|
||||
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
|
||||
import { isRedemptionExpired, isTimestampExpired } from '../lib'
|
||||
import { type Redemption } from '../types'
|
||||
import type { Redemption } from '../types'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'key',
|
||||
header: t('Code'),
|
||||
header: t('Redemption Code'),
|
||||
cell: function CodeCell({ row }) {
|
||||
const redemption = row.original
|
||||
const key = redemption.key
|
||||
|
||||
@@ -42,8 +42,10 @@ import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import zh from '@/i18n/locales/zh.json'
|
||||
import { api } from '@/lib/api'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
DEFAULT_CURRENCY_CONFIG,
|
||||
@@ -132,7 +134,7 @@ afterEach(() => {
|
||||
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
|
||||
})
|
||||
|
||||
it('shows labeled balance and cumulative usage in one sortable quota column', () => {
|
||||
it('shows balance above secondary usage text and opens quota details on click', async () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QuotaTable remaining={1900} used={1100} />
|
||||
@@ -148,32 +150,58 @@ it('shows labeled balance and cumulative usage in one sortable quota column', ()
|
||||
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.getByText('0.0038').parentElement).toHaveClass('grid-cols-1')
|
||||
expect(screen.getByText('Used amount').parentElement).toHaveAttribute(
|
||||
'data-table-text',
|
||||
'secondary'
|
||||
)
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('0.006')).not.toBeInTheDocument()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'Available Balance 0.0038; Used amount 0.0022',
|
||||
})
|
||||
await userEvent.click(trigger)
|
||||
const detail = await screen.findByRole('dialog', { name: 'Quota ($)' })
|
||||
expect(within(detail).getByText('Available Balance')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('Total Used')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('0.0038')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
|
||||
expect(within(detail).queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each([0, 500000])(
|
||||
'shows usage for a zero balance only when used quota is nonzero (used=%s)',
|
||||
(used) => {
|
||||
async (used) => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QuotaTable remaining={0} used={used} />
|
||||
</I18nextProvider>
|
||||
)
|
||||
if (used === 0) {
|
||||
expect(screen.getByRole('cell')).toHaveTextContent(/^No Quota$/)
|
||||
expect(screen.getAllByRole('cell')[0]).toHaveTextContent(/^No Quota$/)
|
||||
expect(screen.queryByText('Used amount')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'No Quota' }))
|
||||
const detail = await screen.findByRole('dialog', { name: 'Quota ($)' })
|
||||
expect(within(detail).getAllByText('0')).toHaveLength(2)
|
||||
return
|
||||
}
|
||||
expect(screen.queryByText('No Quota')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('cell')).getByText('0')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('cell')).getByText('1')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[0]).getByText('0')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[0]).getByText('1')
|
||||
).toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves a negative balance and uses warning styling', () => {
|
||||
it('preserves negative balances in details opened with the keyboard', async () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QuotaTable remaining={-500000} used={1000000} />
|
||||
@@ -181,6 +209,11 @@ it('preserves a negative balance and uses warning styling', () => {
|
||||
)
|
||||
expect(screen.getByText('-1')).toHaveClass('text-destructive')
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
await userEvent.tab()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const detail = await screen.findByRole('dialog')
|
||||
expect(within(detail).getByText('-1')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the custom symbol only in the column header', () => {
|
||||
@@ -200,20 +233,24 @@ it('shows the custom symbol only in the column header', () => {
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: 'Available Balance (🐱)' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('cell')).not.toHaveTextContent('🐱')
|
||||
for (const cell of screen.getAllByRole('cell')) {
|
||||
expect(cell).not.toHaveTextContent('🐱')
|
||||
}
|
||||
expect(
|
||||
within(screen.getByRole('cell')).getByText('0.0038')
|
||||
within(screen.getAllByRole('cell')[0]).getByText('0.0038')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('cell')).getByText('0.0022')
|
||||
within(screen.getAllByRole('cell')[0]).getByText('0.0022')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
function UsersPage() {
|
||||
return (
|
||||
<UsersProvider>
|
||||
<UsersTable />
|
||||
</UsersProvider>
|
||||
<TooltipProvider>
|
||||
<UsersProvider>
|
||||
<UsersTable />
|
||||
</UsersProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -233,6 +270,8 @@ async function renderUsersList(emptyInvitation = false) {
|
||||
quota: 1900,
|
||||
used_quota: 1100,
|
||||
request_count: 0,
|
||||
created_at: Math.floor(Date.now() / 1000) - 86400,
|
||||
last_login_at: Math.floor(Date.now() / 1000) - 20,
|
||||
group: 'default',
|
||||
aff_count: emptyInvitation ? 0 : 2,
|
||||
aff_history_quota: emptyInvitation ? 0 : 500000,
|
||||
@@ -291,7 +330,7 @@ it('sends balance sorting to the server and keeps invitation details on two line
|
||||
expect(screen.getByText(/Invited 2 users · Earnings:/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows both labeled amounts on mobile cards in Chinese', async () => {
|
||||
it('shows balance above usage on mobile cards in Chinese', async () => {
|
||||
const originalMatchMedia = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...originalMatchMedia(query),
|
||||
@@ -306,16 +345,22 @@ it('shows both labeled amounts on mobile cards in Chinese', async () => {
|
||||
expect(screen.getByText('0.0038')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.0022')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /可用余额 0.0038/ })
|
||||
)
|
||||
const detail = await screen.findByRole('dialog', { name: '额度 ($)' })
|
||||
expect(within(detail).getByText('累计已用')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
} finally {
|
||||
await i18n.changeLanguage('en')
|
||||
}
|
||||
})
|
||||
|
||||
it('hides date columns by default and replaces empty invitation information with a dash', async () => {
|
||||
it('combines creation and last login into one column with full dates visible directly', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 8, 8, 12))
|
||||
await renderUsersList(true)
|
||||
expect(
|
||||
screen.queryByRole('columnheader', { name: /Created At/ })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: /Time/ })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('columnheader', { name: /Last Login/ })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -323,14 +368,23 @@ it('hides date columns by default and replaces empty invitation information with
|
||||
name: /long-user-name-for-table-layout/,
|
||||
})
|
||||
expect(within(row).getByText('—')).toBeInTheDocument()
|
||||
const times = within(row).getAllByRole('time')
|
||||
expect(times).toHaveLength(2)
|
||||
expect(times[0]).toHaveTextContent(
|
||||
formatTimestampToDate(Math.floor(Date.now() / 1000) - 86400)
|
||||
)
|
||||
expect(times[1]).toHaveTextContent(
|
||||
formatTimestampToDate(Math.floor(Date.now() / 1000) - 20)
|
||||
)
|
||||
expect(within(row).queryByText('Just now')).not.toBeInTheDocument()
|
||||
expect(within(row).getByText('Created')).toBeInTheDocument()
|
||||
expect(within(row).getByText('Last Login')).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' })
|
||||
)
|
||||
await userEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Time' }))
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /Created At/ })
|
||||
).toBeInTheDocument()
|
||||
screen.queryByRole('columnheader', { name: /Time/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the header unit and converted amounts together when currency settings change', () => {
|
||||
@@ -351,9 +405,15 @@ it('updates the header unit and converted amounts together when currency setting
|
||||
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('¥')
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[0]).getByText('7')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[0]).getByText('14')
|
||||
).toBeInTheDocument()
|
||||
for (const cell of screen.getAllByRole('cell')) {
|
||||
expect(cell).not.toHaveTextContent('¥')
|
||||
}
|
||||
})
|
||||
|
||||
it('labels raw quota mode as tokens without introducing a currency symbol', () => {
|
||||
@@ -368,6 +428,10 @@ it('labels raw quota mode as tokens without introducing a currency symbol', () =
|
||||
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()
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[0]).getByText('100')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[0]).getByText('200')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -18,8 +18,9 @@ For commercial licensing, please contact [email protected]
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { QuotaDetailsPopover } from '@/components/quota-details-popover'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatQuotaWithCurrency } from '@/lib/currency'
|
||||
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
|
||||
@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
|
||||
const { t } = useTranslation()
|
||||
useSystemConfigStore((state) => state.config.currency)
|
||||
|
||||
if (props.remaining === 0 && props.used === 0) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('No Quota')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5'
|
||||
/>
|
||||
)
|
||||
}
|
||||
const { meta: currency } = getCurrencyDisplay()
|
||||
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
|
||||
const hasQuota = props.remaining !== 0 || props.used !== 0
|
||||
const formattedRemaining = formatQuotaWithCurrency(props.remaining, {
|
||||
showSymbol: false,
|
||||
})
|
||||
const formattedUsed = formatQuotaWithCurrency(props.used, {
|
||||
showSymbol: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{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 })}
|
||||
<QuotaDetailsPopover
|
||||
title={`${t('Quota')} (${quotaUnit})`}
|
||||
triggerLabel={
|
||||
hasQuota
|
||||
? `${t('Available Balance')} ${formattedRemaining}; ${t('Used amount')} ${formattedUsed}`
|
||||
: t('No Quota')
|
||||
}
|
||||
details={[
|
||||
{ label: t('Available Balance'), value: formattedRemaining },
|
||||
{ label: t('Total Used'), value: formattedUsed },
|
||||
]}
|
||||
>
|
||||
{hasQuota ? (
|
||||
<span className='grid min-w-0 grid-cols-1 gap-y-1 text-sm tabular-nums'>
|
||||
<span
|
||||
className={cn(
|
||||
props.remaining < 0 && 'text-destructive',
|
||||
props.remaining === 0 && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formattedRemaining}
|
||||
</span>
|
||||
<span
|
||||
data-table-text='secondary'
|
||||
className='text-muted-foreground flex items-baseline gap-1 text-xs font-normal'
|
||||
>
|
||||
<span>{t('Used amount')}</span>
|
||||
<span>{formattedUsed}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={t('No Quota')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
className='-ml-1.5 font-normal'
|
||||
/>
|
||||
)}
|
||||
</QuotaDetailsPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ For commercial licensing, please contact [email protected]
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ActivityTimeCell } from '@/components/activity-time-cell'
|
||||
import { BadgeCell } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { LongText } from '@/components/long-text'
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { getCurrencyDisplay } from '@/lib/currency'
|
||||
import { formatQuota, formatTimestamp } from '@/lib/format'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
|
||||
import {
|
||||
@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
return (
|
||||
<TableId
|
||||
value={row.getValue('id') as number}
|
||||
className='w-[60px] text-sm'
|
||||
className='w-[60px] [font-family:inherit] text-sm'
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
return (
|
||||
<div className='flex min-w-[160px] flex-col gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LongText className='max-w-[140px] font-medium'>
|
||||
<LongText className='max-w-[140px] text-sm font-normal'>
|
||||
{username}
|
||||
</LongText>
|
||||
{remark && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<StatusBadge variant='success' copyable={false} />}
|
||||
render={
|
||||
<StatusBadge
|
||||
variant='success'
|
||||
copyable={false}
|
||||
className='font-normal'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LongText className='max-w-[80px]'>{remark}</LongText>
|
||||
</TooltipTrigger>
|
||||
@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
)}
|
||||
</div>
|
||||
{displayName && displayName !== username && (
|
||||
<LongText className='text-muted-foreground max-w-[180px] text-xs'>
|
||||
{displayName}
|
||||
</LongText>
|
||||
<div
|
||||
data-table-text='secondary'
|
||||
className='text-muted-foreground max-w-[180px] text-xs font-normal'
|
||||
>
|
||||
<LongText>{displayName}</LongText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
label={t(statusConfig.labelKey)}
|
||||
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
|
||||
copyable={false}
|
||||
className='font-normal'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
const group = row.getValue('group') as string
|
||||
return (
|
||||
<BadgeCell>
|
||||
<GroupBadge group={group} />
|
||||
<GroupBadge group={group} className='font-normal' />
|
||||
</BadgeCell>
|
||||
)
|
||||
},
|
||||
@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-w-0 space-y-1 text-xs'>
|
||||
<div
|
||||
data-table-text='secondary'
|
||||
className='min-w-0 space-y-1 text-xs font-normal'
|
||||
>
|
||||
{(affCount > 0 || affHistoryQuota !== 0) && (
|
||||
<LongText>
|
||||
{t('Invited {{count}} users', { count: affCount })} ·{' '}
|
||||
@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: t('Created At'),
|
||||
cell: ({ row }) => {
|
||||
const ts = row.getValue('created_at') as number | undefined
|
||||
return (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{ts ? formatTimestamp(ts) : '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_login_at',
|
||||
header: t('Last Login'),
|
||||
cell: ({ row }) => {
|
||||
const ts = row.getValue('last_login_at') as number | undefined
|
||||
return (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{ts ? formatTimestamp(ts) : '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
header: t('Time'),
|
||||
cell: ({ row }) => (
|
||||
<ActivityTimeCell
|
||||
createdAt={row.original.created_at ?? 0}
|
||||
lastAt={row.original.last_login_at ?? 0}
|
||||
lastLabel={t('Last Login')}
|
||||
format='absolute'
|
||||
/>
|
||||
),
|
||||
size: 260,
|
||||
minSize: 240,
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -174,7 +174,6 @@ export function UsersTable() {
|
||||
data: users,
|
||||
columns,
|
||||
enableRowSelection: true,
|
||||
initialColumnVisibility: { created_at: false, last_login_at: false },
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
|
||||
Reference in New Issue
Block a user