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
29c3dcb9
authored
Jun 19, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(table): support card layout
parent
68585568
Show whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
304 additions
and
95 deletions
+304
-95
web/default/src/components/data-table/layout/data-table-page.tsx
+38
-3
web/default/src/components/data-table/toolbar/view-mode-toggle.tsx
+5
-5
web/default/src/components/provider-badge.tsx
+5
-1
web/default/src/components/status-badge.tsx
+6
-6
web/default/src/features/channels/components/channel-card.tsx
+74
-44
web/default/src/features/channels/components/channel-row-actions-context.ts
+29
-0
web/default/src/features/channels/components/channels-columns.tsx
+45
-12
web/default/src/features/channels/components/channels-table.tsx
+1
-0
web/default/src/features/channels/components/data-table-row-actions.tsx
+27
-4
web/default/src/features/channels/components/numeric-spinner-input.tsx
+1
-1
web/default/src/features/channels/lib/channel-utils.ts
+27
-5
web/default/src/i18n/locales/en.json
+1
-1
web/default/src/i18n/locales/fr.json
+1
-1
web/default/src/i18n/locales/ja.json
+1
-1
web/default/src/i18n/locales/ru.json
+1
-1
web/default/src/i18n/locales/vi.json
+1
-1
web/default/src/i18n/locales/zh.json
+2
-2
web/default/src/lib/currency.ts
+39
-7
No files found.
web/default/src/components/data-table/layout/data-table-page.tsx
View file @
29c3dcb9
...
@@ -132,6 +132,17 @@ export type DataTablePageProps<TData> = {
...
@@ -132,6 +132,17 @@ export type DataTablePageProps<TData> = {
hideMobile
?:
boolean
hideMobile
?:
boolean
/**
/**
* Render the card view on mobile instead of the default {@link MobileCardList}.
* When enabled, the mobile layout reuses the same {@link DataTableCardGrid}
* (and therefore `renderCard` / `cardGridClassName`) as the desktop card view,
* stacked in a single column. Falls back to the generic card content when no
* `renderCard` is provided. Ignored when a custom `mobile` slot is supplied.
*
* Defaults to `false`, so existing pages keep the list-style mobile layout.
*/
mobileCardView
?:
boolean
/**
* Row className resolver — applied to both desktop `TableRow` and mobile card.
* Row className resolver — applied to both desktop `TableRow` and mobile card.
* Composes with the default `data-state="selected"` styling on desktop.
* Composes with the default `data-state="selected"` styling on desktop.
* The `ctx.isMobile` flag is provided so consumers can return the
* The `ctx.isMobile` flag is provided so consumers can return the
...
@@ -247,7 +258,9 @@ export type DataTablePageProps<TData> = {
...
@@ -247,7 +258,9 @@ export type DataTablePageProps<TData> = {
viewModeStorageKey
?:
string
viewModeStorageKey
?:
string
/**
/**
* Initial (uncontrolled) view mode. Defaults to `'table'`.
* Initial (uncontrolled) view mode. When unset, defaults to `'card'` if
* `enableCardView` is `true`, otherwise `'table'`. A persisted selection
* (via `viewModeStorageKey`) always takes precedence over this default.
*/
*/
defaultViewMode
?:
DataTableViewMode
defaultViewMode
?:
DataTableViewMode
...
@@ -292,7 +305,12 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
...
@@ -292,7 +305,12 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const
[
internalViewMode
,
setInternalViewMode
]
=
useDataTableViewMode
({
const
[
internalViewMode
,
setInternalViewMode
]
=
useDataTableViewMode
({
storageKey
:
props
.
viewModeStorageKey
,
storageKey
:
props
.
viewModeStorageKey
,
defaultMode
:
props
.
defaultViewMode
,
// When card view is enabled, prefer it as the default unless the consumer
// explicitly opts into a different initial mode. A persisted choice (via
// `viewModeStorageKey`) still takes precedence over this default.
defaultMode
:
props
.
defaultViewMode
??
(
props
.
enableCardView
?
DATA_TABLE_VIEW_MODES
.
CARD
:
undefined
),
})
})
const
viewMode
=
props
.
viewMode
??
internalViewMode
const
viewMode
=
props
.
viewMode
??
internalViewMode
const
setViewMode
=
props
.
onViewModeChange
??
setInternalViewMode
const
setViewMode
=
props
.
onViewModeChange
??
setInternalViewMode
...
@@ -381,7 +399,23 @@ function renderMobile<TData>(
...
@@ -381,7 +399,23 @@ function renderMobile<TData>(
(
ownGetRowClassName
(
ownGetRowClassName
?
(
row
:
Row
<
TData
>
)
=>
ownGetRowClassName
(
row
,
{
isMobile
:
true
})
?
(
row
:
Row
<
TData
>
)
=>
ownGetRowClassName
(
row
,
{
isMobile
:
true
})
:
undefined
)
:
undefined
)
const
mobileContent
=
props
.
mobile
??
(
let
mobileContent
=
props
.
mobile
if
(
mobileContent
===
undefined
)
{
mobileContent
=
props
.
mobileCardView
?
(
<
DataTableCardGrid
table=
{
props
.
table
}
isLoading=
{
props
.
isLoading
}
emptyTitle=
{
props
.
emptyTitle
}
emptyDescription=
{
props
.
emptyDescription
}
emptyIcon=
{
props
.
emptyIcon
}
renderCard=
{
props
.
renderCard
}
gridClassName=
{
props
.
cardGridClassName
??
'grid grid-cols-1 gap-3'
}
skeletonKeyPrefix=
{
props
.
skeletonKeyPrefix
}
getRowKey=
{
props
.
mobileProps
?.
getRowKey
}
getRowClassName=
{
mobileGetRowClassName
}
/>
)
:
(
<
MobileCardList
<
MobileCardList
table=
{
props
.
table
}
table=
{
props
.
table
}
isLoading=
{
props
.
isLoading
}
isLoading=
{
props
.
isLoading
}
...
@@ -391,6 +425,7 @@ function renderMobile<TData>(
...
@@ -391,6 +425,7 @@ function renderMobile<TData>(
getRowClassName=
{
mobileGetRowClassName
}
getRowClassName=
{
mobileGetRowClassName
}
/>
/>
)
)
}
return
<
div
className=
'min-h-0 flex-1 overflow-y-auto'
>
{
mobileContent
}
</
div
>
return
<
div
className=
'min-h-0 flex-1 overflow-y-auto'
>
{
mobileContent
}
</
div
>
}
}
...
...
web/default/src/components/data-table/toolbar/view-mode-toggle.tsx
View file @
29c3dcb9
...
@@ -51,15 +51,15 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
...
@@ -51,15 +51,15 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
const
segments
:
Segment
[]
=
[
const
segments
:
Segment
[]
=
[
{
{
value
:
DATA_TABLE_VIEW_MODES
.
TABLE
,
icon
:
Table2
,
tooltip
:
t
(
'Table view'
),
},
{
value
:
DATA_TABLE_VIEW_MODES
.
CARD
,
value
:
DATA_TABLE_VIEW_MODES
.
CARD
,
icon
:
Grid2X2
,
icon
:
Grid2X2
,
tooltip
:
t
(
'Card view'
),
tooltip
:
t
(
'Card view'
),
},
},
{
value
:
DATA_TABLE_VIEW_MODES
.
TABLE
,
icon
:
Table2
,
tooltip
:
t
(
'Table view'
),
},
]
]
return
(
return
(
...
...
web/default/src/components/provider-badge.tsx
View file @
29c3dcb9
...
@@ -24,6 +24,8 @@ type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
...
@@ -24,6 +24,8 @@ type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
iconKey
?:
string
|
null
iconKey
?:
string
|
null
iconSize
?:
number
iconSize
?:
number
label
:
string
label
:
string
/** Color the label text by provider name. Set false for a neutral label. */
colorText
?:
boolean
}
}
export
function
ProviderBadge
({
export
function
ProviderBadge
({
...
@@ -31,6 +33,7 @@ export function ProviderBadge({
...
@@ -31,6 +33,7 @@ export function ProviderBadge({
iconKey
,
iconKey
,
iconSize
=
14
,
iconSize
=
14
,
label
,
label
,
colorText
=
true
,
...
badgeProps
...
badgeProps
}:
ProviderBadgeProps
)
{
}:
ProviderBadgeProps
)
{
const
icon
=
iconKey
?
getLobeIcon
(
iconKey
,
iconSize
)
:
null
const
icon
=
iconKey
?
getLobeIcon
(
iconKey
,
iconSize
)
:
null
...
@@ -43,7 +46,8 @@ export function ProviderBadge({
...
@@ -43,7 +46,8 @@ export function ProviderBadge({
{
icon
&&
<
span
className=
'flex shrink-0 items-center'
>
{
icon
}
</
span
>
}
{
icon
&&
<
span
className=
'flex shrink-0 items-center'
>
{
icon
}
</
span
>
}
<
StatusBadge
<
StatusBadge
label=
{
label
}
label=
{
label
}
autoColor=
{
label
}
autoColor=
{
colorText
?
label
:
undefined
}
variant=
{
colorText
?
undefined
:
'neutral'
}
size=
'sm'
size=
'sm'
className=
{
cn
(
'min-w-0 shrink overflow-hidden'
,
!
icon
&&
'pl-0'
)
}
className=
{
cn
(
'min-w-0 shrink overflow-hidden'
,
!
icon
&&
'pl-0'
)
}
{
...
badgeProps
}
{
...
badgeProps
}
...
...
web/default/src/components/status-badge.tsx
View file @
29c3dcb9
...
@@ -86,15 +86,15 @@ export const StatusBadgeTypeContext =
...
@@ -86,15 +86,15 @@ export const StatusBadgeTypeContext =
React
.
createContext
<
StatusBadgeType
>
(
'badge'
)
React
.
createContext
<
StatusBadgeType
>
(
'badge'
)
const
sizeMap
=
{
const
sizeMap
=
{
sm
:
'h-5 gap-1 px-1.5 text-
xs
leading-none'
,
sm
:
'h-5 gap-1 px-1.5 text-
sm
leading-none'
,
md
:
'h-5 gap-1 px-1.5 text-
xs
leading-none'
,
md
:
'h-5 gap-1 px-1.5 text-
sm
leading-none'
,
lg
:
'h-6 gap-1.5 px-2 text-
xs
leading-none'
,
lg
:
'h-6 gap-1.5 px-2 text-
sm
leading-none'
,
}
as
const
}
as
const
const
textSizeMap
=
{
const
textSizeMap
=
{
sm
:
'gap-1 text-
xs
leading-none'
,
sm
:
'gap-1 text-
sm
leading-none'
,
md
:
'gap-1 text-
xs
leading-none'
,
md
:
'gap-1 text-
sm
leading-none'
,
lg
:
'gap-1.5 text-
xs
leading-none'
,
lg
:
'gap-1.5 text-
sm
leading-none'
,
}
as
const
}
as
const
export
interface
StatusBadgeProps
extends
Omit
<
export
interface
StatusBadgeProps
extends
Omit
<
...
...
web/default/src/features/channels/components/channel-card.tsx
View file @
29c3dcb9
...
@@ -18,35 +18,27 @@ For commercial licensing, please contact support@quantumnous.com
...
@@ -18,35 +18,27 @@ For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
flexRender
,
type
Row
}
from
'@tanstack/react-table'
import
{
flexRender
,
type
Row
}
from
'@tanstack/react-table'
import
{
useTranslation
}
from
'react-i18next'
import
{
useTranslation
}
from
'react-i18next'
import
{
cn
}
from
'@/lib/utils'
import
{
GroupBadge
}
from
'@/components/group-badge'
import
{
isTagAggregateRow
}
from
'../lib'
import
{
CHANNEL_STATUS
}
from
'../constants'
import
{
isTagAggregateRow
,
parseGroupsList
}
from
'../lib'
import
type
{
Channel
}
from
'../types'
import
type
{
Channel
}
from
'../types'
import
{
ChannelRowActionsLayoutContext
}
from
'./channel-row-actions-context'
/**
/**
* Field columns rendered in the card body (in display order). The header
* Field columns rendered in the labelled grid (in display order). The first
* columns (`select`, `name`, `status`, `actions`) are laid out separately.
* row (`select`, `type`, `status`, `actions`), the channel name row
* `models` spans the full width because it can hold many badges.
* (`#id` label + `name`, aligned with `priority`/`weight`), and the
* full-width `group` row are laid out separately around the grid.
*/
*/
const
FIELD_COLUMN_IDS
=
[
const
FIELD_COLUMN_IDS
=
[
'balance'
,
'response_time'
,
'test_time'
]
as
const
'type'
,
'id'
,
'group'
,
'balance'
,
'priority'
,
'weight'
,
'response_time'
,
'test_time'
,
'tag'
,
'models'
,
]
as
const
/**
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so
all table
information and interactions are
* renderer via `flexRender`, so
the table's
information and interactions are
* preserved: row selection,
name/remark + warning icons, status (with tooltips)
,
* preserved: row selection,
provider/multi-key/IO.NET type badge, id
,
*
provider/multi-key/IO.NET badges, groups, models, tag, inline priority/weight
*
name/remark + warning icons, status (with tooltips), groups, inline
*
spinners, balance refresh, response/test times, tag expand-collapse, and the
*
priority/weight spinners, balance refresh, response/test times, tag
* per-row (or per-tag) actions menu.
*
expand-collapse, and the
per-row (or per-tag) actions menu.
*/
*/
export
function
ChannelCard
({
row
}:
{
row
:
Row
<
Channel
>
})
{
export
function
ChannelCard
({
row
}:
{
row
:
Row
<
Channel
>
})
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
...
@@ -62,51 +54,76 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
...
@@ -62,51 +54,76 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
}
}
const
fieldLabels
:
Record
<
string
,
string
>
=
{
const
fieldLabels
:
Record
<
string
,
string
>
=
{
type
:
t
(
'Type'
),
id
:
t
(
'ID'
),
group
:
t
(
'Groups'
),
balance
:
t
(
'Used / Remaining'
),
balance
:
t
(
'Used / Remaining'
),
priority
:
t
(
'Priority'
),
weight
:
t
(
'Weight'
),
response_time
:
t
(
'Response'
),
response_time
:
t
(
'Response'
),
test_time
:
t
(
'Last Tested'
),
test_time
:
t
(
'Last Tested'
),
tag
:
t
(
'Tag'
),
models
:
t
(
'Models'
),
}
}
const
groups
=
parseGroupsList
(
row
.
original
.
group
??
''
)
const
selectCell
=
renderCell
(
'select'
)
const
selectCell
=
renderCell
(
'select'
)
const
typeCell
=
renderCell
(
'type'
)
const
nameCell
=
renderCell
(
'name'
)
const
nameCell
=
renderCell
(
'name'
)
const
statusCell
=
renderCell
(
'status'
)
const
statusCell
=
renderCell
(
'status'
)
const
actionsCell
=
renderCell
(
'actions'
)
const
actionsCell
=
renderCell
(
'actions'
)
const
priorityCell
=
renderCell
(
'priority'
)
const
weightCell
=
renderCell
(
'weight'
)
// In card view the enable/disable state is already conveyed by the inline
// power toggle, so the plain "Enabled"/"Disabled" badge is redundant. Keep
// only the informative states (e.g. auto-disabled, unknown) and tag rows.
const
showStatusBadge
=
isTagRow
||
(
row
.
original
.
status
!==
CHANNEL_STATUS
.
ENABLED
&&
row
.
original
.
status
!==
CHANNEL_STATUS
.
MANUAL_DISABLED
)
return
(
return
(
<
div
className=
'flex flex-col gap-3'
>
<
div
className=
'flex flex-col gap-3'
>
{
/*
Header: selection + name/remark
, with status badge + actions menu */
}
{
/*
Row 1: selection + type
, with status badge + actions menu */
}
<
div
className=
'flex items-
start
justify-between gap-2'
>
<
div
className=
'flex items-
center
justify-between gap-2'
>
<
div
className=
'flex min-w-0 flex-1 items-
start
gap-2'
>
<
div
className=
'flex min-w-0 flex-1 items-
center
gap-2'
>
{
!
isTagRow
&&
selectCell
&&
(
{
!
isTagRow
&&
selectCell
&&
(
<
div
className=
'pt-0.5'
>
{
selectCell
}
</
div
>
<
span
className=
'flex-shrink-0'
>
{
selectCell
}
</
span
>
)
}
)
}
<
div
className=
'min-w-0
flex-1'
>
{
nam
eCell
}
</
div
>
<
div
className=
'min-w-0
overflow-hidden'
>
{
typ
eCell
}
</
div
>
</
div
>
</
div
>
<
div
className=
'flex flex-shrink-0 items-center gap-1.5'
>
<
div
className=
'flex flex-shrink-0 items-center gap-1.5'
>
{
statusCell
}
{
showStatusBadge
&&
statusCell
}
<
ChannelRowActionsLayoutContext
.
Provider
value=
'card'
>
{
actionsCell
}
{
actionsCell
}
</
ChannelRowActionsLayoutContext
.
Provider
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Body: labelled fields for every remaining column */
}
{
/* Row 2: channel id + name (left) aligned with priority/weight (right) */
}
<
div
className=
'grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3'
>
<
div
className=
'flex items-start justify-between gap-3'
>
<
div
className=
'min-w-0 flex-1 overflow-hidden text-sm'
>
{
!
isTagRow
&&
(
<
div
className=
'text-muted-foreground text-[11px] font-medium tracking-wide uppercase select-none'
>
#
{
row
.
original
.
id
}
</
div
>
)
}
{
nameCell
}
</
div
>
<
div
className=
'grid flex-shrink-0 grid-cols-2 items-center gap-x-3 text-center'
>
<
span
className=
'text-muted-foreground text-[11px] font-medium tracking-wide uppercase select-none'
>
{
t
(
'Priority'
)
}
</
span
>
<
span
className=
'text-muted-foreground text-[11px] font-medium tracking-wide uppercase select-none'
>
{
t
(
'Weight'
)
}
</
span
>
<
div
className=
'flex justify-center'
>
{
priorityCell
}
</
div
>
<
div
className=
'flex justify-center'
>
{
weightCell
}
</
div
>
</
div
>
</
div
>
{
/* Body: labelled fields for the remaining columns. Three fields share a
single row at every width (the card is wide enough even on phones). */
}
<
div
className=
'grid grid-cols-3 gap-x-4 gap-y-3'
>
{
FIELD_COLUMN_IDS
.
map
((
id
)
=>
{
{
FIELD_COLUMN_IDS
.
map
((
id
)
=>
{
const
content
=
renderCell
(
id
)
const
content
=
renderCell
(
id
)
return
(
return
(
<
div
<
div
key=
{
id
}
className=
'min-w-0'
>
key=
{
id
}
className=
{
cn
(
'min-w-0'
,
id
===
'models'
&&
'col-span-2 sm:col-span-3'
)
}
>
<
div
className=
'text-muted-foreground mb-1 text-[11px] font-medium tracking-wide uppercase select-none'
>
<
div
className=
'text-muted-foreground mb-1 text-[11px] font-medium tracking-wide uppercase select-none'
>
{
fieldLabels
[
id
]
}
{
fieldLabels
[
id
]
}
</
div
>
</
div
>
...
@@ -117,6 +134,19 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
...
@@ -117,6 +134,19 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
)
)
})
}
})
}
</
div
>
</
div
>
{
/* Last row: groups span the full width, showing every group (no label) */
}
<
div
className=
'min-w-0'
>
{
groups
.
length
>
0
?
(
<
div
className=
'-ml-1.5 flex flex-wrap gap-1'
>
{
groups
.
map
((
g
)
=>
(
<
GroupBadge
key=
{
g
}
group=
{
g
}
size=
'sm'
/>
))
}
</
div
>
)
:
(
<
span
className=
'text-muted-foreground text-sm'
>
-
</
span
>
)
}
</
div
>
</
div
>
</
div
>
)
)
}
}
web/default/src/features/channels/components/channel-row-actions-context.ts
0 → 100644
View file @
29c3dcb9
/*
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
{
createContext
}
from
'react'
/**
* Where the channel row actions are being rendered. In the card view we
* surface the "Test Connection" action as an inline button next to the quick
* test; in the table it stays inside the overflow menu.
*/
export
type
ChannelRowActionsLayout
=
'table'
|
'card'
export
const
ChannelRowActionsLayoutContext
=
createContext
<
ChannelRowActionsLayout
>
(
'table'
)
web/default/src/features/channels/components/channels-columns.tsx
View file @
29c3dcb9
...
@@ -30,7 +30,11 @@ import {
...
@@ -30,7 +30,11 @@ import {
}
from
'lucide-react'
}
from
'lucide-react'
import
{
useTranslation
}
from
'react-i18next'
import
{
useTranslation
}
from
'react-i18next'
import
{
toast
}
from
'sonner'
import
{
toast
}
from
'sonner'
import
{
getCurrencyLabel
}
from
'@/lib/currency'
import
{
formatCurrencyFromUSD
,
formatQuotaWithCurrency
,
getCurrencyLabel
,
}
from
'@/lib/currency'
import
{
import
{
formatTimestampToDate
,
formatTimestampToDate
,
formatQuota
as
formatQuotaValue
,
formatQuota
as
formatQuotaValue
,
...
@@ -267,10 +271,16 @@ function WeightCell({ channel }: { channel: Channel }) {
...
@@ -267,10 +271,16 @@ function WeightCell({ channel }: { channel: Channel }) {
}
}
/**
/**
* Inline balance/used values longer than this switch to locale-aware compact
* notation (e.g. "$28万"); the precise value stays available in the tooltip.
*/
const
MAX_INLINE_BALANCE_CHARS
=
8
/**
* Balance cell component with click to update
* Balance cell component with click to update
*/
*/
function
BalanceCell
({
channel
}:
{
channel
:
Channel
})
{
function
BalanceCell
({
channel
}:
{
channel
:
Channel
})
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
queryClient
=
useQueryClient
()
const
queryClient
=
useQueryClient
()
const
isTagRow
=
isTagAggregateRow
(
channel
)
const
isTagRow
=
isTagAggregateRow
(
channel
)
const
balance
=
channel
.
balance
||
0
const
balance
=
channel
.
balance
||
0
...
@@ -284,22 +294,43 @@ function BalanceCell({ channel }: { channel: Channel }) {
...
@@ -284,22 +294,43 @@ function BalanceCell({ channel }: { channel: Channel }) {
const
withSuffix
=
(
value
:
string
)
=>
const
withSuffix
=
(
value
:
string
)
=>
tokenSuffix
&&
value
!==
'-'
?
`
${
value
}${
tokenSuffix
}
`
:
value
tokenSuffix
&&
value
!==
'-'
?
`
${
value
}${
tokenSuffix
}
`
:
value
const
usedDisplay
=
withSuffix
(
formatQuotaValue
(
usedQuota
))
const
locale
=
i18n
.
resolvedLanguage
||
i18n
.
language
const
remainingDisplay
=
withSuffix
(
formatBalance
(
balance
))
// Precise values are kept for the tooltip; long values are shown compactly inline.
const
usedLabel
=
`
${
t
(
'Used:'
)}
${
usedDisplay
}
`
const
usedFull
=
withSuffix
(
formatQuotaValue
(
usedQuota
))
const
remainingLabel
=
`
${
t
(
'Remaining:'
)}
${
remainingDisplay
}
`
const
remainingFull
=
withSuffix
(
formatBalance
(
balance
))
const
usedDisplay
=
usedFull
.
length
>
MAX_INLINE_BALANCE_CHARS
?
withSuffix
(
formatQuotaWithCurrency
(
usedQuota
,
{
compact
:
true
,
locale
}))
:
usedFull
const
remainingDisplay
=
remainingFull
.
length
>
MAX_INLINE_BALANCE_CHARS
?
withSuffix
(
formatCurrencyFromUSD
(
balance
,
{
compact
:
true
,
locale
}))
:
remainingFull
const
usedLabel
=
`
${
t
(
'Used:'
)}
${
usedFull
}
`
const
remainingLabel
=
`
${
t
(
'Remaining:'
)}
${
remainingFull
}
`
// Tag row: only show cumulative used quota
// Tag row: only show cumulative used quota
if
(
isTagRow
)
{
if
(
isTagRow
)
{
return
(
return
(
<
TooltipProvider
>
<
Tooltip
>
<
TooltipTrigger
render=
{
<
StatusBadge
<
StatusBadge
label=
{
usedLabel
}
label=
{
`${t('Used:')} ${usedDisplay}`
}
variant=
'neutral'
variant=
'neutral'
size=
'sm'
size=
'sm'
copyable=
{
false
}
copyable=
{
false
}
showDot=
{
false
}
showDot=
{
false
}
className=
'-ml-1.5
'
className=
'-ml-1.5 cursor-help
'
/>
/>
}
/>
<
TooltipContent
>
<
p
>
{
usedLabel
}
</
p
>
</
TooltipContent
>
</
Tooltip
>
</
TooltipProvider
>
)
)
}
}
...
@@ -424,7 +455,8 @@ function BalanceCell({ channel }: { channel: Channel }) {
...
@@ -424,7 +455,8 @@ function BalanceCell({ channel }: { channel: Channel }) {
* Generate channels columns configuration
* Generate channels columns configuration
*/
*/
export
function
useChannelsColumns
():
ColumnDef
<
Channel
>
[]
{
export
function
useChannelsColumns
():
ColumnDef
<
Channel
>
[]
{
const
{
t
}
=
useTranslation
()
const
{
t
,
i18n
}
=
useTranslation
()
const
locale
=
i18n
.
resolvedLanguage
||
i18n
.
language
return
[
return
[
// Checkbox column
// Checkbox column
{
{
...
@@ -645,8 +677,10 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
...
@@ -645,8 +677,10 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
}
}
>
>
<
ProviderBadge
<
ProviderBadge
iconKey=
{
iconName
}
iconKey=
{
`${iconName}.Color`
}
iconSize=
{
18
}
label=
{
typeName
}
label=
{
typeName
}
colorText=
{
false
}
copyable=
{
false
}
copyable=
{
false
}
showDot=
{
false
}
showDot=
{
false
}
className=
'max-w-full min-w-0 overflow-hidden'
className=
'max-w-full min-w-0 overflow-hidden'
...
@@ -818,7 +852,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
...
@@ -818,7 +852,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant=
{
config
.
variant
}
variant=
{
config
.
variant
}
size=
'sm'
size=
'sm'
copyable=
{
false
}
copyable=
{
false
}
className=
'-ml-1.5'
/>
/>
)
)
},
},
...
@@ -970,7 +1003,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
...
@@ -970,7 +1003,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
return
<
span
className=
'text-muted-foreground text-xs'
>
-
</
span
>
return
<
span
className=
'text-muted-foreground text-xs'
>
-
</
span
>
}
}
const
timeText
=
formatRelativeTime
(
testTime
)
const
timeText
=
formatRelativeTime
(
testTime
,
locale
)
const
fullDate
=
formatTimestampToDate
(
testTime
)
const
fullDate
=
formatTimestampToDate
(
testTime
)
// For valid timestamps, show tooltip with full date
// For valid timestamps, show tooltip with full date
...
...
web/default/src/features/channels/components/channels-table.tsx
View file @
29c3dcb9
...
@@ -361,6 +361,7 @@ export function ChannelsTable() {
...
@@ -361,6 +361,7 @@ export function ChannelsTable() {
viewModeStorageKey=
{
CHANNELS_VIEW_MODE_STORAGE_KEY
}
viewModeStorageKey=
{
CHANNELS_VIEW_MODE_STORAGE_KEY
}
renderCard=
{
(
row
)
=>
<
ChannelCard
row=
{
row
}
/>
}
renderCard=
{
(
row
)
=>
<
ChannelCard
row=
{
row
}
/>
}
cardGridClassName=
'grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3'
cardGridClassName=
'grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3'
mobileCardView
applyHeaderSize
applyHeaderSize
toolbarProps=
{
{
toolbarProps=
{
{
searchPlaceholder
:
t
(
'Filter by name, ID, or key...'
),
searchPlaceholder
:
t
(
'Filter by name, ID, or key...'
),
...
...
web/default/src/features/channels/components/data-table-row-actions.tsx
View file @
29c3dcb9
...
@@ -16,14 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
...
@@ -16,14 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
useState
}
from
'react'
import
{
use
Context
,
use
State
}
from
'react'
import
{
useQueryClient
}
from
'@tanstack/react-query'
import
{
useQueryClient
}
from
'@tanstack/react-query'
import
{
type
Row
}
from
'@tanstack/react-table'
import
{
type
Row
}
from
'@tanstack/react-table'
import
{
import
{
MoreHorizontal
,
MoreHorizontal
,
Boxes
,
Boxes
,
Pencil
,
Pencil
,
TestTube
,
PlugZap
,
Gauge
,
Gauge
,
DollarSign
,
DollarSign
,
Download
,
Download
,
...
@@ -62,6 +62,7 @@ import {
...
@@ -62,6 +62,7 @@ import {
}
from
'../lib'
}
from
'../lib'
import
{
parseUpstreamUpdateMeta
}
from
'../lib/upstream-update-utils'
import
{
parseUpstreamUpdateMeta
}
from
'../lib/upstream-update-utils'
import
type
{
Channel
}
from
'../types'
import
type
{
Channel
}
from
'../types'
import
{
ChannelRowActionsLayoutContext
}
from
'./channel-row-actions-context'
import
{
useChannels
}
from
'./channels-provider'
import
{
useChannels
}
from
'./channels-provider'
interface
DataTableRowActionsProps
{
interface
DataTableRowActionsProps
{
...
@@ -70,6 +71,7 @@ interface DataTableRowActionsProps {
...
@@ -70,6 +71,7 @@ interface DataTableRowActionsProps {
export
function
DataTableRowActions
({
row
}:
DataTableRowActionsProps
)
{
export
function
DataTableRowActions
({
row
}:
DataTableRowActionsProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
const
layout
=
useContext
(
ChannelRowActionsLayoutContext
)
const
channel
=
row
.
original
const
channel
=
row
.
original
const
{
setOpen
,
setCurrentRow
,
upstream
}
=
useChannels
()
const
{
setOpen
,
setCurrentRow
,
upstream
}
=
useChannels
()
const
queryClient
=
useQueryClient
()
const
queryClient
=
useQueryClient
()
...
@@ -166,6 +168,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
...
@@ -166,6 +168,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<
TooltipContent
>
{
t
(
'Test Connection'
)
}
</
TooltipContent
>
<
TooltipContent
>
{
t
(
'Test Connection'
)
}
</
TooltipContent
>
</
Tooltip
>
</
Tooltip
>
{
layout
===
'card'
&&
(
<
Tooltip
>
<
TooltipTrigger
render=
{
<
Button
variant=
'ghost'
size=
'icon-sm'
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
()
handleTest
()
}
}
aria
-
label=
{
t
(
'Test Channel Connection'
)
}
/>
}
>
<
PlugZap
className=
'size-4'
/>
</
TooltipTrigger
>
<
TooltipContent
>
{
t
(
'Test Channel Connection'
)
}
</
TooltipContent
>
</
Tooltip
>
)
}
<
Tooltip
>
<
Tooltip
>
<
TooltipTrigger
<
TooltipTrigger
render=
{
render=
{
...
@@ -178,7 +201,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
...
@@ -178,7 +201,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
className=
{
className=
{
isEnabled
isEnabled
?
'text-destructive hover:text-destructive'
?
'text-destructive hover:text-destructive'
:
'text-
emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400
'
:
'text-
success hover:text-success
'
}
}
/>
/>
}
}
...
@@ -221,7 +244,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
...
@@ -221,7 +244,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<
DropdownMenuItem
onClick=
{
handleTest
}
>
<
DropdownMenuItem
onClick=
{
handleTest
}
>
{
t
(
'Test Connection'
)
}
{
t
(
'Test Connection'
)
}
<
DropdownMenuShortcut
>
<
DropdownMenuShortcut
>
<
TestTube
size=
{
16
}
/>
<
PlugZap
size=
{
16
}
/>
</
DropdownMenuShortcut
>
</
DropdownMenuShortcut
>
</
DropdownMenuItem
>
</
DropdownMenuItem
>
...
...
web/default/src/features/channels/components/numeric-spinner-input.tsx
View file @
29c3dcb9
...
@@ -126,7 +126,7 @@ export function NumericSpinnerInput({
...
@@ -126,7 +126,7 @@ export function NumericSpinnerInput({
)
}
)
}
<
div
<
div
className=
{
cn
(
className=
{
cn
(
'group/spinner
inline-flex h-7 items-center gap-0 rounded-md
transition-colors'
,
'group/spinner
border-input inline-flex h-7 items-center gap-0 rounded-md border
transition-colors'
,
!
disabled
&&
'hover:bg-muted/60'
,
!
disabled
&&
'hover:bg-muted/60'
,
editing
&&
'bg-muted/60 ring-primary/30 ring-1'
editing
&&
'bg-muted/60 ring-primary/30 ring-1'
)
}
)
}
...
...
web/default/src/features/channels/lib/channel-utils.ts
View file @
29c3dcb9
...
@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
...
@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
formatCurrencyFromUSD
,
formatQuotaWithCurrency
}
from
'@/lib/currency'
import
{
formatCurrencyFromUSD
,
formatQuotaWithCurrency
}
from
'@/lib/currency'
import
dayjs
from
'@/lib/dayjs'
import
{
formatTimestampToDate
}
from
'@/lib/format'
import
{
formatTimestampToDate
}
from
'@/lib/format'
import
{
import
{
CHANNEL_STATUS_CONFIG
,
CHANNEL_STATUS_CONFIG
,
...
@@ -361,14 +360,37 @@ export function getResponseTimeConfig(timeMs: number) {
...
@@ -361,14 +360,37 @@ export function getResponseTimeConfig(timeMs: number) {
// ============================================================================
// ============================================================================
/**
/**
* Format Unix timestamp to relative time
* Format a Unix timestamp (seconds) as a compact, locale-aware relative time.
* e.g., "2 hours ago", "3 days ago"
* Uses `Intl.RelativeTimeFormat` with the `narrow` style so the label stays
* short inside table cells, e.g. "4h ago" / "42m ago" (en) or "4小时前" (zh),
* instead of the verbose "4 hours ago".
*/
*/
export
function
formatRelativeTime
(
timestamp
:
number
):
string
{
export
function
formatRelativeTime
(
timestamp
:
number
,
locale
?:
Intl
.
LocalesArgument
):
string
{
if
(
!
timestamp
||
timestamp
===
0
)
return
'Never'
if
(
!
timestamp
||
timestamp
===
0
)
return
'Never'
try
{
try
{
return
dayjs
(
timestamp
*
1000
).
fromNow
()
const
diffSec
=
timestamp
-
Date
.
now
()
/
1000
const
absSec
=
Math
.
abs
(
diffSec
)
const
rtf
=
new
Intl
.
RelativeTimeFormat
(
locale
,
{
numeric
:
'always'
,
style
:
'narrow'
,
})
const
MINUTE
=
60
const
HOUR
=
60
*
MINUTE
const
DAY
=
24
*
HOUR
const
MONTH
=
30
*
DAY
const
YEAR
=
365
*
DAY
if
(
absSec
<
MINUTE
)
return
rtf
.
format
(
Math
.
round
(
diffSec
),
'second'
)
if
(
absSec
<
HOUR
)
return
rtf
.
format
(
Math
.
round
(
diffSec
/
MINUTE
),
'minute'
)
if
(
absSec
<
DAY
)
return
rtf
.
format
(
Math
.
round
(
diffSec
/
HOUR
),
'hour'
)
if
(
absSec
<
MONTH
)
return
rtf
.
format
(
Math
.
round
(
diffSec
/
DAY
),
'day'
)
if
(
absSec
<
YEAR
)
return
rtf
.
format
(
Math
.
round
(
diffSec
/
MONTH
),
'month'
)
return
rtf
.
format
(
Math
.
round
(
diffSec
/
YEAR
),
'year'
)
}
catch
{
}
catch
{
return
'Unknown'
return
'Unknown'
}
}
...
...
web/default/src/i18n/locales/en.json
View file @
29c3dcb9
...
@@ -259,7 +259,7 @@
...
@@ -259,7 +259,7 @@
"AIGC2D"
:
"AIGC2D"
,
"AIGC2D"
:
"AIGC2D"
,
"AILS"
:
"AILS"
,
"AILS"
:
"AILS"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
,
"Ali"
:
"Ali"
,
"Ali"
:
"Ali
baba Bailian
"
,
"Alipay"
:
"Alipay"
,
"Alipay"
:
"Alipay"
,
"All"
:
"All"
,
"All"
:
"All"
,
"All categories"
:
"All categories"
,
"All categories"
:
"All categories"
,
...
...
web/default/src/i18n/locales/fr.json
View file @
29c3dcb9
...
@@ -259,7 +259,7 @@
...
@@ -259,7 +259,7 @@
"AIGC2D"
:
"AIGC2D"
,
"AIGC2D"
:
"AIGC2D"
,
"AILS"
:
"AILS"
,
"AILS"
:
"AILS"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region"
,
"Ali"
:
"Ali"
,
"Ali"
:
"Ali
baba Bailian
"
,
"Alipay"
:
"Alipay"
,
"Alipay"
:
"Alipay"
,
"All"
:
"Tout"
,
"All"
:
"Tout"
,
"All categories"
:
"Toutes catégories"
,
"All categories"
:
"Toutes catégories"
,
...
...
web/default/src/i18n/locales/ja.json
View file @
29c3dcb9
...
@@ -259,7 +259,7 @@
...
@@ -259,7 +259,7 @@
"AIGC2D"
:
"AIGC2D"
,
"AIGC2D"
:
"AIGC2D"
,
"AILS"
:
"AILS"
,
"AILS"
:
"AILS"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"AK/SKモード: AccessKey | SecretAccessKey | Regionを使用"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"AK/SKモード: AccessKey | SecretAccessKey | Regionを使用"
,
"Ali"
:
"Ali"
,
"Ali"
:
"Ali
baba Bailian
"
,
"Alipay"
:
"Alipay"
,
"Alipay"
:
"Alipay"
,
"All"
:
"すべて"
,
"All"
:
"すべて"
,
"All categories"
:
"すべてのカテゴリ"
,
"All categories"
:
"すべてのカテゴリ"
,
...
...
web/default/src/i18n/locales/ru.json
View file @
29c3dcb9
...
@@ -259,7 +259,7 @@
...
@@ -259,7 +259,7 @@
"AIGC2D"
:
"AIGC2D"
,
"AIGC2D"
:
"AIGC2D"
,
"AILS"
:
"AILS"
,
"AILS"
:
"AILS"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"Режим AK/SK: используйте AccessKey|SecretAccessKey|Region"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"Режим AK/SK: используйте AccessKey|SecretAccessKey|Region"
,
"Ali"
:
"Ali"
,
"Ali"
:
"Ali
baba Bailian
"
,
"Alipay"
:
"Alipay"
,
"Alipay"
:
"Alipay"
,
"All"
:
"Все"
,
"All"
:
"Все"
,
"All categories"
:
"Все категории"
,
"All categories"
:
"Все категории"
,
...
...
web/default/src/i18n/locales/vi.json
View file @
29c3dcb9
...
@@ -259,7 +259,7 @@
...
@@ -259,7 +259,7 @@
"AIGC2D"
:
"AIGC2D"
,
"AIGC2D"
:
"AIGC2D"
,
"AILS"
:
"AILS"
,
"AILS"
:
"AILS"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region"
,
"Ali"
:
"Ali"
,
"Ali"
:
"Ali
baba Bailian
"
,
"Alipay"
:
"Alipay"
,
"Alipay"
:
"Alipay"
,
"All"
:
"All"
,
"All"
:
"All"
,
"All categories"
:
"Tất cả danh mục"
,
"All categories"
:
"Tất cả danh mục"
,
...
...
web/default/src/i18n/locales/zh.json
View file @
29c3dcb9
...
@@ -259,7 +259,7 @@
...
@@ -259,7 +259,7 @@
"AIGC2D"
:
"AIGC2D"
,
"AIGC2D"
:
"AIGC2D"
,
"AILS"
:
"AILS"
,
"AILS"
:
"AILS"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"AK/SK 模式:使用 AccessKey|SecretAccessKey|Region"
,
"AK/SK mode: use AccessKey|SecretAccessKey|Region"
:
"AK/SK 模式:使用 AccessKey|SecretAccessKey|Region"
,
"Ali"
:
"阿里"
,
"Ali"
:
"阿里
百炼
"
,
"Alipay"
:
"支付宝"
,
"Alipay"
:
"支付宝"
,
"All"
:
"全部"
,
"All"
:
"全部"
,
"All categories"
:
"全部分类"
,
"All categories"
:
"全部分类"
,
...
@@ -4616,7 +4616,7 @@
...
@@ -4616,7 +4616,7 @@
"Visual indicator color for the API card"
:
"API 卡的可视指示器颜色"
,
"Visual indicator color for the API card"
:
"API 卡的可视指示器颜色"
,
"Visual Mode"
:
"可视模式"
,
"Visual Mode"
:
"可视模式"
,
"Visual Parameter Override"
:
"可视化参数覆盖"
,
"Visual Parameter Override"
:
"可视化参数覆盖"
,
"VolcEngine"
:
"
字节火山方舟、豆包通用
"
,
"VolcEngine"
:
"
火山方舟
"
,
"vs. previous"
:
"相较上期"
,
"vs. previous"
:
"相较上期"
,
"Waffo Aggregator Gateway"
:
"Waffo 聚合网关"
,
"Waffo Aggregator Gateway"
:
"Waffo 聚合网关"
,
"Waffo Pancake Dashboard"
:
"Waffo Pancake 控制台"
,
"Waffo Pancake Dashboard"
:
"Waffo Pancake 控制台"
,
...
...
web/default/src/lib/currency.ts
View file @
29c3dcb9
...
@@ -94,6 +94,20 @@ export interface CurrencyFormatOptions {
...
@@ -94,6 +94,20 @@ export interface CurrencyFormatOptions {
abbreviate
?:
boolean
abbreviate
?:
boolean
/** Minimal absolute value to display when rounding would produce zero */
/** Minimal absolute value to display when rounding would produce zero */
minimumNonZero
?:
number
minimumNonZero
?:
number
/**
* Use locale-aware compact notation for large values (e.g. "$28万" in zh,
* "$280K" in en). The currency symbol is preserved.
*/
compact
?:
boolean
/** Locale used for number formatting (defaults to the runtime locale) */
locale
?:
Intl
.
LocalesArgument
|
undefined
}
type
ResolvedCurrencyFormatOptions
=
Omit
<
Required
<
CurrencyFormatOptions
>
,
'locale'
>
&
{
locale
:
Intl
.
LocalesArgument
|
undefined
}
}
type
DisplayMeta
=
type
DisplayMeta
=
...
@@ -114,11 +128,13 @@ type DisplayMeta =
...
@@ -114,11 +128,13 @@ type DisplayMeta =
quotaPerUnit
:
number
quotaPerUnit
:
number
}
}
const
DEFAULT_FORMAT_OPTIONS
:
Re
quired
<
CurrencyFormatOptions
>
=
{
const
DEFAULT_FORMAT_OPTIONS
:
Re
solvedCurrencyFormatOptions
=
{
digitsLarge
:
2
,
digitsLarge
:
2
,
digitsSmall
:
4
,
digitsSmall
:
4
,
abbreviate
:
true
,
abbreviate
:
true
,
minimumNonZero
:
0
,
minimumNonZero
:
0
,
compact
:
false
,
locale
:
undefined
,
}
}
const
DISPLAY_TYPE_VALUES
=
[
'USD'
,
'CNY'
,
'TOKENS'
,
'CUSTOM'
]
as
const
const
DISPLAY_TYPE_VALUES
=
[
'USD'
,
'CNY'
,
'TOKENS'
,
'CUSTOM'
]
as
const
...
@@ -211,7 +227,7 @@ function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta {
...
@@ -211,7 +227,7 @@ function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta {
function
mergeOptions
(
function
mergeOptions
(
options
?:
CurrencyFormatOptions
options
?:
CurrencyFormatOptions
):
Re
quired
<
CurrencyFormatOptions
>
{
):
Re
solvedCurrencyFormatOptions
{
if
(
!
options
)
return
DEFAULT_FORMAT_OPTIONS
if
(
!
options
)
return
DEFAULT_FORMAT_OPTIONS
return
{
return
{
digitsLarge
:
options
.
digitsLarge
??
DEFAULT_FORMAT_OPTIONS
.
digitsLarge
,
digitsLarge
:
options
.
digitsLarge
??
DEFAULT_FORMAT_OPTIONS
.
digitsLarge
,
...
@@ -219,6 +235,8 @@ function mergeOptions(
...
@@ -219,6 +235,8 @@ function mergeOptions(
abbreviate
:
options
.
abbreviate
??
DEFAULT_FORMAT_OPTIONS
.
abbreviate
,
abbreviate
:
options
.
abbreviate
??
DEFAULT_FORMAT_OPTIONS
.
abbreviate
,
minimumNonZero
:
minimumNonZero
:
options
.
minimumNonZero
??
DEFAULT_FORMAT_OPTIONS
.
minimumNonZero
,
options
.
minimumNonZero
??
DEFAULT_FORMAT_OPTIONS
.
minimumNonZero
,
compact
:
options
.
compact
??
DEFAULT_FORMAT_OPTIONS
.
compact
,
locale
:
options
.
locale
??
DEFAULT_FORMAT_OPTIONS
.
locale
,
}
}
}
}
...
@@ -260,10 +278,16 @@ function adjustForMinimum(
...
@@ -260,10 +278,16 @@ function adjustForMinimum(
function
formatCurrencyValue
(
function
formatCurrencyValue
(
value
:
number
,
value
:
number
,
options
:
Re
quired
<
CurrencyFormatOptions
>
,
options
:
Re
solvedCurrencyFormatOptions
,
meta
:
DisplayMeta
meta
:
DisplayMeta
):
string
{
):
string
{
if
(
meta
.
kind
===
'tokens'
)
{
if
(
meta
.
kind
===
'tokens'
)
{
if
(
options
.
compact
)
{
return
new
Intl
.
NumberFormat
(
options
.
locale
,
{
notation
:
'compact'
,
maximumFractionDigits
:
1
,
}).
format
(
value
)
}
return
formatNumberWithSuffix
(
return
formatNumberWithSuffix
(
value
,
value
,
options
.
digitsLarge
,
options
.
digitsLarge
,
...
@@ -277,19 +301,21 @@ function formatCurrencyValue(
...
@@ -277,19 +301,21 @@ function formatCurrencyValue(
const
adjustedValue
=
adjustForMinimum
(
value
,
digits
,
options
.
minimumNonZero
)
const
adjustedValue
=
adjustForMinimum
(
value
,
digits
,
options
.
minimumNonZero
)
if
(
meta
.
kind
===
'currency'
)
{
if
(
meta
.
kind
===
'currency'
)
{
const
formatted
=
new
Intl
.
NumberFormat
(
undefined
,
{
const
formatted
=
new
Intl
.
NumberFormat
(
options
.
locale
,
{
style
:
'currency'
,
style
:
'currency'
,
currency
:
meta
.
currencyCode
,
currency
:
meta
.
currencyCode
,
currencyDisplay
:
'narrowSymbol'
,
currencyDisplay
:
'narrowSymbol'
,
notation
:
options
.
compact
?
'compact'
:
'standard'
,
minimumFractionDigits
:
0
,
minimumFractionDigits
:
0
,
maximumFractionDigits
:
digits
,
maximumFractionDigits
:
options
.
compact
?
1
:
digits
,
}).
format
(
adjustedValue
)
}).
format
(
adjustedValue
)
return
formatted
return
formatted
}
}
const
decimal
=
new
Intl
.
NumberFormat
(
undefined
,
{
const
decimal
=
new
Intl
.
NumberFormat
(
options
.
locale
,
{
notation
:
options
.
compact
?
'compact'
:
'standard'
,
minimumFractionDigits
:
0
,
minimumFractionDigits
:
0
,
maximumFractionDigits
:
digits
,
maximumFractionDigits
:
options
.
compact
?
1
:
digits
,
}).
format
(
adjustedValue
)
}).
format
(
adjustedValue
)
return
`
${
meta
.
symbol
}
${
decimal
}
`
return
`
${
meta
.
symbol
}
${
decimal
}
`
...
@@ -358,6 +384,12 @@ export function formatCurrencyFromUSD(
...
@@ -358,6 +384,12 @@ export function formatCurrencyFromUSD(
if
(
meta
.
kind
===
'tokens'
)
{
if
(
meta
.
kind
===
'tokens'
)
{
const
tokens
=
amountUSD
*
config
.
quotaPerUnit
const
tokens
=
amountUSD
*
config
.
quotaPerUnit
if
(
merged
.
compact
)
{
return
new
Intl
.
NumberFormat
(
merged
.
locale
,
{
notation
:
'compact'
,
maximumFractionDigits
:
1
,
}).
format
(
tokens
)
}
return
formatNumberWithSuffix
(
return
formatNumberWithSuffix
(
tokens
,
tokens
,
0
,
0
,
...
...
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