Commit 29c3dcb9 by CaIon

feat(table): support card layout

parent 68585568
...@@ -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,16 +399,33 @@ function renderMobile<TData>( ...@@ -381,16 +399,33 @@ 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 ?? (
<MobileCardList let mobileContent = props.mobile
table={props.table} if (mobileContent === undefined) {
isLoading={props.isLoading} mobileContent = props.mobileCardView ? (
emptyTitle={props.emptyTitle} <DataTableCardGrid
emptyDescription={props.emptyDescription} table={props.table}
getRowKey={props.mobileProps?.getRowKey} isLoading={props.isLoading}
getRowClassName={mobileGetRowClassName} 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> return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
} }
......
...@@ -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 (
......
...@@ -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}
......
...@@ -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<
......
...@@ -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'>{nameCell}</div> <div className='min-w-0 overflow-hidden'>{typeCell}</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}
{actionsCell} <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>
</div> </div>
{/* Body: labelled fields for every remaining column */} {/* Body: labelled fields for the remaining columns. Three fields share a
<div className='grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3'> 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>
) )
} }
/*
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 { ...@@ -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 (
<StatusBadge <TooltipProvider>
label={usedLabel} <Tooltip>
variant='neutral' <TooltipTrigger
size='sm' render={
copyable={false} <StatusBadge
showDot={false} label={`${t('Used:')} ${usedDisplay}`}
className='-ml-1.5' 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 }) { ...@@ -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
......
...@@ -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...'),
......
...@@ -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 { useContext, useState } 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>
......
...@@ -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'
)} )}
......
...@@ -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'
} }
......
...@@ -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": "Alibaba Bailian",
"Alipay": "Alipay", "Alipay": "Alipay",
"All": "All", "All": "All",
"All categories": "All categories", "All categories": "All categories",
......
...@@ -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": "Alibaba Bailian",
"Alipay": "Alipay", "Alipay": "Alipay",
"All": "Tout", "All": "Tout",
"All categories": "Toutes catégories", "All categories": "Toutes catégories",
......
...@@ -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": "Alibaba Bailian",
"Alipay": "Alipay", "Alipay": "Alipay",
"All": "すべて", "All": "すべて",
"All categories": "すべてのカテゴリ", "All categories": "すべてのカテゴリ",
......
...@@ -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": "Alibaba Bailian",
"Alipay": "Alipay", "Alipay": "Alipay",
"All": "Все", "All": "Все",
"All categories": "Все категории", "All categories": "Все категории",
......
...@@ -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": "Alibaba 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",
......
...@@ -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 控制台",
......
...@@ -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: Required<CurrencyFormatOptions> = { const DEFAULT_FORMAT_OPTIONS: ResolvedCurrencyFormatOptions = {
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
): Required<CurrencyFormatOptions> { ): ResolvedCurrencyFormatOptions {
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: Required<CurrencyFormatOptions>, options: ResolvedCurrencyFormatOptions,
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,
......
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