Commit 29c3dcb9 by CaIon

feat(table): support card layout

parent 68585568
......@@ -132,6 +132,17 @@ export type DataTablePageProps<TData> = {
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.
* Composes with the default `data-state="selected"` styling on desktop.
* The `ctx.isMobile` flag is provided so consumers can return the
......@@ -247,7 +258,9 @@ export type DataTablePageProps<TData> = {
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
......@@ -292,7 +305,12 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const [internalViewMode, setInternalViewMode] = useDataTableViewMode({
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 setViewMode = props.onViewModeChange ?? setInternalViewMode
......@@ -381,16 +399,33 @@ function renderMobile<TData>(
(ownGetRowClassName
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
: undefined)
const mobileContent = props.mobile ?? (
<MobileCardList
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
getRowKey={props.mobileProps?.getRowKey}
getRowClassName={mobileGetRowClassName}
/>
)
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
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
getRowKey={props.mobileProps?.getRowKey}
getRowClassName={mobileGetRowClassName}
/>
)
}
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
}
......
......@@ -51,15 +51,15 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
const segments: Segment[] = [
{
value: DATA_TABLE_VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
{
value: DATA_TABLE_VIEW_MODES.CARD,
icon: Grid2X2,
tooltip: t('Card view'),
},
{
value: DATA_TABLE_VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
]
return (
......
......@@ -24,6 +24,8 @@ type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
iconKey?: string | null
iconSize?: number
label: string
/** Color the label text by provider name. Set false for a neutral label. */
colorText?: boolean
}
export function ProviderBadge({
......@@ -31,6 +33,7 @@ export function ProviderBadge({
iconKey,
iconSize = 14,
label,
colorText = true,
...badgeProps
}: ProviderBadgeProps) {
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
......@@ -43,7 +46,8 @@ export function ProviderBadge({
{icon && <span className='flex shrink-0 items-center'>{icon}</span>}
<StatusBadge
label={label}
autoColor={label}
autoColor={colorText ? label : undefined}
variant={colorText ? undefined : 'neutral'}
size='sm'
className={cn('min-w-0 shrink overflow-hidden', !icon && 'pl-0')}
{...badgeProps}
......
......@@ -86,15 +86,15 @@ export const StatusBadgeTypeContext =
React.createContext<StatusBadgeType>('badge')
const sizeMap = {
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
md: 'h-5 gap-1 px-1.5 text-xs leading-none',
lg: 'h-6 gap-1.5 px-2 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-sm leading-none',
lg: 'h-6 gap-1.5 px-2 text-sm leading-none',
} as const
const textSizeMap = {
sm: 'gap-1 text-xs leading-none',
md: 'gap-1 text-xs leading-none',
lg: 'gap-1.5 text-xs leading-none',
sm: 'gap-1 text-sm leading-none',
md: 'gap-1 text-sm leading-none',
lg: 'gap-1.5 text-sm leading-none',
} as const
export interface StatusBadgeProps extends Omit<
......
......@@ -18,35 +18,27 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { isTagAggregateRow } from '../lib'
import { GroupBadge } from '@/components/group-badge'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
/**
* Field columns rendered in the card body (in display order). The header
* columns (`select`, `name`, `status`, `actions`) are laid out separately.
* `models` spans the full width because it can hold many badges.
* Field columns rendered in the labelled grid (in display order). The first
* row (`select`, `type`, `status`, `actions`), the channel name row
* (`#id` label + `name`, aligned with `priority`/`weight`), and the
* full-width `group` row are laid out separately around the grid.
*/
const FIELD_COLUMN_IDS = [
'type',
'id',
'group',
'balance',
'priority',
'weight',
'response_time',
'test_time',
'tag',
'models',
] as const
const FIELD_COLUMN_IDS = ['balance', 'response_time', 'test_time'] as const
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so all table information and interactions are
* preserved: row selection, name/remark + warning icons, status (with tooltips),
* provider/multi-key/IO.NET badges, groups, models, tag, inline priority/weight
* spinners, balance refresh, response/test times, tag expand-collapse, and the
* per-row (or per-tag) actions menu.
* renderer via `flexRender`, so the table's information and interactions are
* preserved: row selection, provider/multi-key/IO.NET type badge, id,
* name/remark + warning icons, status (with tooltips), groups, inline
* priority/weight spinners, balance refresh, response/test times, tag
* expand-collapse, and the per-row (or per-tag) actions menu.
*/
export function ChannelCard({ row }: { row: Row<Channel> }) {
const { t } = useTranslation()
......@@ -62,51 +54,76 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
}
const fieldLabels: Record<string, string> = {
type: t('Type'),
id: t('ID'),
group: t('Groups'),
balance: t('Used / Remaining'),
priority: t('Priority'),
weight: t('Weight'),
response_time: t('Response'),
test_time: t('Last Tested'),
tag: t('Tag'),
models: t('Models'),
}
const groups = parseGroupsList(row.original.group ?? '')
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const nameCell = renderCell('name')
const statusCell = renderCell('status')
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 (
<div className='flex flex-col gap-3'>
{/* Header: selection + name/remark, with status badge + actions menu */}
<div className='flex items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-start gap-2'>
{/* Row 1: selection + type, with status badge + actions menu */}
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<div className='pt-0.5'>{selectCell}</div>
<span className='flex-shrink-0'>{selectCell}</span>
)}
<div className='min-w-0 flex-1'>{nameCell}</div>
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
</div>
<div className='flex flex-shrink-0 items-center gap-1.5'>
{statusCell}
{actionsCell}
{showStatusBadge && statusCell}
<ChannelRowActionsLayoutContext.Provider value='card'>
{actionsCell}
</ChannelRowActionsLayoutContext.Provider>
</div>
</div>
{/* Row 2: channel id + name (left) aligned with priority/weight (right) */}
<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 every remaining column */}
<div className='grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3'>
{/* 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) => {
const content = renderCell(id)
return (
<div
key={id}
className={cn(
'min-w-0',
id === 'models' && 'col-span-2 sm:col-span-3'
)}
>
<div key={id} className='min-w-0'>
<div className='text-muted-foreground mb-1 text-[11px] font-medium tracking-wide uppercase select-none'>
{fieldLabels[id]}
</div>
......@@ -117,6 +134,19 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
)
})}
</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>
)
}
/*
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')
......@@ -30,7 +30,11 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getCurrencyLabel } from '@/lib/currency'
import {
formatCurrencyFromUSD,
formatQuotaWithCurrency,
getCurrencyLabel,
} from '@/lib/currency'
import {
formatTimestampToDate,
formatQuota as formatQuotaValue,
......@@ -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
*/
function BalanceCell({ channel }: { channel: Channel }) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
......@@ -284,22 +294,43 @@ function BalanceCell({ channel }: { channel: Channel }) {
const withSuffix = (value: string) =>
tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value
const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
const remainingDisplay = withSuffix(formatBalance(balance))
const usedLabel = `${t('Used:')} ${usedDisplay}`
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
const locale = i18n.resolvedLanguage || i18n.language
// Precise values are kept for the tooltip; long values are shown compactly inline.
const usedFull = withSuffix(formatQuotaValue(usedQuota))
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
if (isTagRow) {
return (
<StatusBadge
label={usedLabel}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='-ml-1.5'
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Used:')} ${usedDisplay}`}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='-ml-1.5 cursor-help'
/>
}
/>
<TooltipContent>
<p>{usedLabel}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
......@@ -424,7 +455,8 @@ function BalanceCell({ channel }: { channel: Channel }) {
* Generate channels columns configuration
*/
export function useChannelsColumns(): ColumnDef<Channel>[] {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const locale = i18n.resolvedLanguage || i18n.language
return [
// Checkbox column
{
......@@ -645,8 +677,10 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
}
>
<ProviderBadge
iconKey={iconName}
iconKey={`${iconName}.Color`}
iconSize={18}
label={typeName}
colorText={false}
copyable={false}
showDot={false}
className='max-w-full min-w-0 overflow-hidden'
......@@ -818,7 +852,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
variant={config.variant}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
......@@ -970,7 +1003,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
return <span className='text-muted-foreground text-xs'>-</span>
}
const timeText = formatRelativeTime(testTime)
const timeText = formatRelativeTime(testTime, locale)
const fullDate = formatTimestampToDate(testTime)
// For valid timestamps, show tooltip with full date
......
......@@ -361,6 +361,7 @@ export function ChannelsTable() {
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row) => <ChannelCard row={row} />}
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3'
mobileCardView
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
......
......@@ -16,14 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useContext, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type Row } from '@tanstack/react-table'
import {
MoreHorizontal,
Boxes,
Pencil,
TestTube,
PlugZap,
Gauge,
DollarSign,
Download,
......@@ -62,6 +62,7 @@ import {
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
interface DataTableRowActionsProps {
......@@ -70,6 +71,7 @@ interface DataTableRowActionsProps {
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const layout = useContext(ChannelRowActionsLayoutContext)
const channel = row.original
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
......@@ -166,6 +168,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<TooltipContent>{t('Test Connection')}</TooltipContent>
</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>
<TooltipTrigger
render={
......@@ -178,7 +201,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
className={
isEnabled
? '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) {
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
<DropdownMenuShortcut>
<TestTube size={16} />
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
......
......@@ -126,7 +126,7 @@ export function NumericSpinnerInput({
)}
<div
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',
editing && 'bg-muted/60 ring-primary/30 ring-1'
)}
......
......@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { formatTimestampToDate } from '@/lib/format'
import {
CHANNEL_STATUS_CONFIG,
......@@ -361,14 +360,37 @@ export function getResponseTimeConfig(timeMs: number) {
// ============================================================================
/**
* Format Unix timestamp to relative time
* e.g., "2 hours ago", "3 days ago"
* Format a Unix timestamp (seconds) as a compact, locale-aware relative time.
* 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'
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 {
return 'Unknown'
}
......
......@@ -259,7 +259,7 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK mode: use AccessKey|SecretAccessKey|Region",
"Ali": "Ali",
"Ali": "Alibaba Bailian",
"Alipay": "Alipay",
"All": "All",
"All categories": "All categories",
......
......@@ -259,7 +259,7 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region",
"Ali": "Ali",
"Ali": "Alibaba Bailian",
"Alipay": "Alipay",
"All": "Tout",
"All categories": "Toutes catégories",
......
......@@ -259,7 +259,7 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SKモード: AccessKey | SecretAccessKey | Regionを使用",
"Ali": "Ali",
"Ali": "Alibaba Bailian",
"Alipay": "Alipay",
"All": "すべて",
"All categories": "すべてのカテゴリ",
......
......@@ -259,7 +259,7 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "Режим AK/SK: используйте AccessKey|SecretAccessKey|Region",
"Ali": "Ali",
"Ali": "Alibaba Bailian",
"Alipay": "Alipay",
"All": "Все",
"All categories": "Все категории",
......
......@@ -259,7 +259,7 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region",
"Ali": "Ali",
"Ali": "Alibaba Bailian",
"Alipay": "Alipay",
"All": "All",
"All categories": "Tất cả danh mục",
......
......@@ -259,7 +259,7 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region",
"Ali": "阿里",
"Ali": "阿里百炼",
"Alipay": "支付宝",
"All": "全部",
"All categories": "全部分类",
......@@ -4616,7 +4616,7 @@
"Visual indicator color for the API card": "API 卡的可视指示器颜色",
"Visual Mode": "可视模式",
"Visual Parameter Override": "可视化参数覆盖",
"VolcEngine": "字节火山方舟、豆包通用",
"VolcEngine": "火山方舟",
"vs. previous": "相较上期",
"Waffo Aggregator Gateway": "Waffo 聚合网关",
"Waffo Pancake Dashboard": "Waffo Pancake 控制台",
......
......@@ -94,6 +94,20 @@ export interface CurrencyFormatOptions {
abbreviate?: boolean
/** Minimal absolute value to display when rounding would produce zero */
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 =
......@@ -114,11 +128,13 @@ type DisplayMeta =
quotaPerUnit: number
}
const DEFAULT_FORMAT_OPTIONS: Required<CurrencyFormatOptions> = {
const DEFAULT_FORMAT_OPTIONS: ResolvedCurrencyFormatOptions = {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
minimumNonZero: 0,
compact: false,
locale: undefined,
}
const DISPLAY_TYPE_VALUES = ['USD', 'CNY', 'TOKENS', 'CUSTOM'] as const
......@@ -211,7 +227,7 @@ function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta {
function mergeOptions(
options?: CurrencyFormatOptions
): Required<CurrencyFormatOptions> {
): ResolvedCurrencyFormatOptions {
if (!options) return DEFAULT_FORMAT_OPTIONS
return {
digitsLarge: options.digitsLarge ?? DEFAULT_FORMAT_OPTIONS.digitsLarge,
......@@ -219,6 +235,8 @@ function mergeOptions(
abbreviate: options.abbreviate ?? DEFAULT_FORMAT_OPTIONS.abbreviate,
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(
function formatCurrencyValue(
value: number,
options: Required<CurrencyFormatOptions>,
options: ResolvedCurrencyFormatOptions,
meta: DisplayMeta
): string {
if (meta.kind === 'tokens') {
if (options.compact) {
return new Intl.NumberFormat(options.locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value)
}
return formatNumberWithSuffix(
value,
options.digitsLarge,
......@@ -277,19 +301,21 @@ function formatCurrencyValue(
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') {
const formatted = new Intl.NumberFormat(undefined, {
const formatted = new Intl.NumberFormat(options.locale, {
style: 'currency',
currency: meta.currencyCode,
currencyDisplay: 'narrowSymbol',
notation: options.compact ? 'compact' : 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: digits,
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
return formatted
}
const decimal = new Intl.NumberFormat(undefined, {
const decimal = new Intl.NumberFormat(options.locale, {
notation: options.compact ? 'compact' : 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: digits,
maximumFractionDigits: options.compact ? 1 : digits,
}).format(adjustedValue)
return `${meta.symbol} ${decimal}`
......@@ -358,6 +384,12 @@ export function formatCurrencyFromUSD(
if (meta.kind === 'tokens') {
const tokens = amountUSD * config.quotaPerUnit
if (merged.compact) {
return new Intl.NumberFormat(merged.locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(tokens)
}
return formatNumberWithSuffix(
tokens,
0,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment