feat(table): support card layout
This commit is contained in:
@@ -131,6 +131,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.
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -50,16 +50,16 @@ export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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 (
|
||||
|
||||
+5
-1
@@ -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}
|
||||
|
||||
+6
-6
@@ -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<
|
||||
|
||||
+75
-45
@@ -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>
|
||||
|
||||
{/* Body: labelled fields for every remaining column */}
|
||||
<div className='grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3'>
|
||||
{/* 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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,
|
||||
@@ -266,11 +270,17 @@ 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'
|
||||
)}
|
||||
|
||||
+27
-5
@@ -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'
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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": "すべてのカテゴリ",
|
||||
|
||||
Vendored
+1
-1
@@ -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": "Все категории",
|
||||
|
||||
Vendored
+1
-1
@@ -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",
|
||||
|
||||
Vendored
+2
-2
@@ -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 控制台",
|
||||
|
||||
Vendored
+39
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user