Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
ea7cb0ba
authored
Sep 08, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor(web): unify table cells and quota details
parent
ebe4c368
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
494 additions
and
292 deletions
+494
-292
web/src/components/activity-time-cell.tsx
+138
-0
web/src/components/data-table/core/data-table-view.tsx
+4
-7
web/src/components/group-badge.tsx
+3
-3
web/src/components/masked-value-display.tsx
+18
-9
web/src/components/quota-details-popover.tsx
+82
-0
web/src/components/ui/table.tsx
+20
-6
web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
+1
-1
web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
+1
-1
web/src/features/keys/components/__tests__/api-key-listing.test.tsx
+1
-1
web/src/features/keys/components/api-key-quota-cell.tsx
+46
-87
web/src/features/keys/components/api-key-timestamp-cell.tsx
+9
-85
web/src/features/keys/components/api-keys-cells.tsx
+2
-9
web/src/features/redemption-codes/components/redemptions-columns.tsx
+3
-3
web/src/features/users/components/__tests__/quota-display.test.tsx
+89
-25
web/src/features/users/components/user-quota-cell.tsx
+43
-21
web/src/features/users/components/users-columns.tsx
+34
-33
web/src/features/users/components/users-table.tsx
+0
-1
No files found.
web/src/components/activity-time-cell.tsx
0 → 100644
View file @
ea7cb0ba
/*
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
{
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}+
(?:[
.,
\u
00a0
\u
202f
]\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
>
)
}
web/src/components/data-table/core/data-table-view.tsx
View file @
ea7cb0ba
...
...
@@ -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
)
}
</
t
able
>
</
T
able
>
</
div
>
</
div
>
)
...
...
web/src/components/group-badge.tsx
View file @
ea7cb0ba
...
...
@@ -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
)
}
>
...
...
web/src/components/masked-value-display.tsx
View file @
ea7cb0ba
...
...
@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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
...
...
web/src/components/quota-details-popover.tsx
0 → 100644
View file @
ea7cb0ba
/*
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
{
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
>
)
}
web/src/components/ui/table.tsx
View file @
ea7cb0ba
...
...
@@ -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
>
)
}
...
...
web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx
View file @
ea7cb0ba
...
...
@@ -113,7 +113,7 @@ describe('API key group table cell', () => {
color
,
border
,
'rounded-full'
,
'
font-mono
'
,
'
tabular-nums
'
,
'h-5'
,
'min-w-12'
)
...
...
web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx
View file @
ea7cb0ba
...
...
@@ -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'
)
...
...
web/src/features/keys/components/__tests__/api-key-listing.test.tsx
View file @
ea7cb0ba
...
...
@@ -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'
...
...
web/src/features/keys/components/api-key-quota-cell.tsx
View file @
ea7cb0ba
...
...
@@ -18,13 +18,7 @@ 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
{
QuotaDetailsPopover
}
from
'@/components/quota-details-popover'
import
{
Progress
}
from
'@/components/ui/progress'
import
{
toIntlLocale
}
from
'@/i18n/languages'
import
{
formatQuotaWithCurrency
,
getCurrencyDisplay
}
from
'@/lib/currency'
...
...
@@ -78,34 +72,60 @@ 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=
{
<
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'
)
}
className=
{
cn
(
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transpa
rent'
,
props
.
variant
===
'card'
?
'py-0'
:
'py-0.5'
'w-full [&_[data-slot=progress-indicator]]:bg-cur
rent'
,
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-xs
'
,
'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'
...
...
@@ -123,7 +143,7 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
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
&&
'
tabular-nums'
,
!
props
.
apiKey
.
unlimited_quota
&&
remaining
<
0
&&
'text-destructive'
,
...
...
@@ -132,81 +152,20 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
'text-muted-foreground'
)
}
>
{
props
.
apiKey
.
unlimited_quota
?
t
(
'Unlimited'
)
:
formattedRemaining
}
{
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'
,
'text-muted-foreground min-w-0 truncate text-right
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
>
</
QuotaDetailsPopover
>
)
}
web/src/features/keys/components/api-key-timestamp-cell.tsx
View file @
ea7cb0ba
...
...
@@ -18,108 +18,32 @@ 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
{
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}+
(?:[
.,
\u
00a0
\u
202f
]\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
}
<
ActivityTimeCell
createdAt=
{
props
.
apiKey
.
created_time
}
lastAt=
{
accessedTime
}
lastLabel=
{
t
(
'Last Used'
)
}
lastClassName=
{
isStale
?
'text-warning'
:
'text-muted-foreground'
}
now=
{
props
.
now
}
locale=
{
locale
}
justNowLabel=
{
t
(
'Just now'
)
}
className=
{
cn
(
isStale
?
'text-warning'
:
'text-muted-foreground'
,
props
.
layout
===
'columns'
&&
'whitespace-normal'
)
}
layout=
{
props
.
layout
}
/>
</
div
>
)
}
web/src/features/keys/components/api-keys-cells.tsx
View file @
ea7cb0ba
...
...
@@ -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
...
...
web/src/features/redemption-codes/components/redemptions-columns.tsx
View file @
ea7cb0ba
...
...
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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
...
...
web/src/features/users/components/__tests__/quota-display.test.tsx
View file @
ea7cb0ba
...
...
@@ -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
.
get
ByRole
(
'cell'
)
).
toHaveTextContent
(
/^No Quota$/
)
expect
(
screen
.
get
AllByRole
(
'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
.
get
ByRole
(
'cell'
)
).
getByText
(
'0.0038'
)
within
(
screen
.
get
AllByRole
(
'cell'
)[
0
]
).
getByText
(
'0.0038'
)
).
toBeInTheDocument
()
expect
(
within
(
screen
.
get
ByRole
(
'cell'
)
).
getByText
(
'0.0022'
)
within
(
screen
.
get
AllByRole
(
'cell'
)[
0
]
).
getByText
(
'0.0022'
)
).
toBeInTheDocument
()
})
function
UsersPage
()
{
return
(
<
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 b
oth labeled amounts
on mobile cards in Chinese'
,
async
()
=>
{
it
(
'shows b
alance 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
()
})
web/src/features/users/components/user-quota-cell.tsx
View file @
ea7cb0ba
...
...
@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
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
<
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
(
'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'
>
{
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
className=
'font-mono'
>
{
formatQuotaWithCurrency
(
props
.
used
,
{
showSymbol
:
false
})
}
<
span
>
{
formattedUsed
}
</
span
>
</
span
>
</
span
>
</
div
>
</
div
>
)
:
(
<
StatusBadge
label=
{
t
(
'No Quota'
)
}
variant=
'neutral'
copyable=
{
false
}
className=
'-ml-1.5 font-normal'
/>
)
}
</
QuotaDetailsPopover
>
)
}
web/src/features/users/components/users-columns.tsx
View file @
ea7cb0ba
...
...
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
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
},
},
{
...
...
web/src/features/users/components/users-table.tsx
View file @
ea7cb0ba
...
...
@@ -174,7 +174,6 @@ export function UsersTable() {
data
:
users
,
columns
,
enableRowSelection
:
true
,
initialColumnVisibility
:
{
created_at
:
false
,
last_login_at
:
false
},
columnFilters
,
globalFilter
,
pagination
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment