feat: refine channel management UI

This commit is contained in:
CaIon
2026-06-28 16:36:32 +08:00
parent 1d166532fe
commit 25f998595d
19 changed files with 1232 additions and 649 deletions
+82 -74
View File
@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { memo } from 'react'
import { flexRender, type Row } from '@tanstack/react-table'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import type { Channel } from '../types'
@@ -87,86 +89,92 @@ function ChannelCardComponent({
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
return (
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* 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 && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
<ChannelRowActionsLayoutContext.Provider value='card'>
{actionsCell}
</ChannelRowActionsLayoutContext.Provider>
</div>
</div>
{/* Body: left column (id/name + balance) paired with a right-aligned
column (priority/weight + response/test time). */}
<div className='flex items-start justify-between gap-3'>
{/* Left column */}
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
<ChannelRowActionsLayoutContext.Provider value='card'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* 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 && (
<span className='shrink-0'>{selectCell}</span>
)}
{nameCell}
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
{actionsCell}
</div>
</div>
{/* Body: left column (id/name + balance) paired with a right-aligned
column (priority/weight + response/test time). */}
<div className='flex items-start justify-between gap-3'>
{/* Left column */}
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
)}
{nameCell}
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>
{fieldLabels.balance}
</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</div>
</div>
</div>
{/* Right column (sits on the right, content left-aligned). A single
grid with content-sized columns keeps Priority/Weight and
Response/Last Tested aligned without wasting horizontal space. */}
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.test_time}
</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
{/* Right column (sits on the right, content left-aligned). A single
grid with content-sized columns keeps Priority/Weight and
Response/Last Tested aligned without wasting horizontal space. */}
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>{fieldLabels.test_time}</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</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}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
</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}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
</div>
</div>
</ChannelRowActionsLayoutContext.Provider>
)
}
@@ -19,9 +19,8 @@ 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.
* Where channel row-derived controls are being rendered. Card view can tune
* compact display details while table view keeps the full desktop treatment.
*/
export type ChannelRowActionsLayout = 'table' | 'card'
@@ -1,13 +1,3 @@
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
@@ -27,7 +17,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
ListOrdered,
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
import { useState, useMemo, useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -51,16 +51,12 @@ import {
formatQuotaWithCurrency,
getCurrencyLabel,
} from '@/lib/currency'
import {
formatTimestampToDate,
formatQuota as formatQuotaValue,
} from '@/lib/format'
import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils'
import { getCodexUsage } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import {
formatBalance,
formatRelativeTime,
formatResponseTime,
getBalanceVariant,
@@ -79,6 +75,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'
import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions'
@@ -299,6 +296,7 @@ const SENSITIVE_MASK = '••••'
function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext)
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0
@@ -313,18 +311,43 @@ function BalanceCell({ channel }: { channel: Channel }) {
tokenSuffix && value !== '-' ? `${value}${tokenSuffix}` : value
const locale = i18n.resolvedLanguage || i18n.language
const balanceFormatOptions = {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
showSymbol: layout !== 'card',
} as const
// Precise values are kept for the tooltip; long values are shown compactly inline.
const usedFull = withSuffix(formatQuotaValue(usedQuota))
const remainingFull = withSuffix(formatBalance(balance))
const usedFull = withSuffix(
formatQuotaWithCurrency(usedQuota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
showSymbol: layout !== 'card',
})
)
const remainingFull = withSuffix(
formatCurrencyFromUSD(balance, balanceFormatOptions)
)
const usedDisplay =
usedFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(
formatQuotaWithCurrency(usedQuota, { compact: true, locale })
formatQuotaWithCurrency(usedQuota, {
compact: true,
locale,
showSymbol: layout !== 'card',
})
)
: usedFull
const remainingDisplay =
remainingFull.length > MAX_INLINE_BALANCE_CHARS
? withSuffix(formatCurrencyFromUSD(balance, { compact: true, locale }))
? withSuffix(
formatCurrencyFromUSD(balance, {
compact: true,
locale,
showSymbol: layout !== 'card',
})
)
: remainingFull
const usedLabel = `${t('Used:')} ${usedFull}`
const remainingLabel = `${t('Remaining:')} ${remainingFull}`
@@ -488,9 +511,14 @@ function BalanceCell({ channel }: { channel: Channel }) {
/**
* Generate channels columns configuration
*/
export function useChannelsColumns(): ColumnDef<Channel>[] {
export function useChannelsColumns(
options: {
enableSelection?: boolean
} = {}
): ColumnDef<Channel>[] {
const { t, i18n } = useTranslation()
const { sensitiveVisible } = useChannels()
const enableSelection = options.enableSelection ?? true
const locale = i18n.resolvedLanguage || i18n.language
// The column definitions only depend on the translation function, the active
// locale, and sensitive-data visibility. Memoizing keeps the array (and every
@@ -499,38 +527,42 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
return useMemo<ColumnDef<Channel>[]>(
() => [
// Checkbox column
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label='Select all'
/>
),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
...(enableSelection
? [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={t('Select all')}
/>
),
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
// Don't show checkbox for tag rows
if (isTagRow) {
return null
}
// Don't show checkbox for tag rows
if (isTagRow) {
return null
}
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
)
},
enableSorting: false,
enableHiding: false,
size: 40,
},
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
/>
)
},
enableSorting: false,
enableHiding: false,
size: 40,
} satisfies ColumnDef<Channel>,
]
: []),
// ID column
{
@@ -543,7 +575,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
},
size: 80,
},
// Name column
{
accessorKey: 'name',
@@ -1119,6 +1150,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
meta: { pinned: 'right' as const },
},
],
[t, locale, sensitiveVisible]
[enableSelection, t, locale, sensitiveVisible]
)
}
@@ -16,7 +16,6 @@ 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 { useQueryClient } from '@tanstack/react-query'
import {
Plus,
@@ -26,18 +25,16 @@ import {
Tags,
TestTube,
DollarSign,
ListChecks,
SortAsc,
RefreshCw,
ArrowUpFromLine,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,7 +51,13 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
handleDeleteAllDisabled,
handleFixAbilities,
@@ -72,10 +75,14 @@ export function ChannelsPrimaryButtons() {
setEnableTagMode,
idSort,
setIdSort,
batchMode,
setBatchMode,
upstream,
} = useChannels()
const queryClient = useQueryClient()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showConsistencyDialog, setShowConsistencyDialog] = useState(false)
const [isRepairingConsistency, setIsRepairingConsistency] = useState(false)
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
@@ -93,10 +100,29 @@ export function ChannelsPrimaryButtons() {
setIdSort(checked)
}
const handleBatchModeToggle = (checked: boolean) => {
setBatchMode(checked)
}
return (
<>
<div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<ListChecks className='text-muted-foreground h-4 w-4' />
<Label
htmlFor='channel-batch-mode'
className='cursor-pointer text-sm'
>
{t('Batch Operations')}
</Label>
<Switch
id='channel-batch-mode'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
@@ -152,6 +178,15 @@ export function ChannelsPrimaryButtons() {
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{/* Mobile-only: toggle switches */}
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={batchMode}
onCheckedChange={handleBatchModeToggle}
>
<ListChecks className='mr-2 h-4 w-4' />
{t('Batch Operations')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={enableTagMode}
@@ -219,14 +254,12 @@ export function ChannelsPrimaryButtons() {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
handleFixAbilities(queryClient, (_result) => {
// eslint-disable-next-line no-console
console.log('Fix abilities result:', _result)
})
onSelect={(e) => {
e.preventDefault()
setShowConsistencyDialog(true)
}}
>
{t('Fix Abilities')}
{t('Repair Channel Consistency')}
<DropdownMenuShortcut>
<Settings2 className='h-4 w-4' />
</DropdownMenuShortcut>
@@ -269,6 +302,29 @@ export function ChannelsPrimaryButtons() {
setShowDeleteDialog(false)
}}
/>
<ConfirmDialog
open={showConsistencyDialog}
onOpenChange={setShowConsistencyDialog}
title={t('Repair channel consistency?')}
desc={t(
'This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?'
)}
confirmText={t('Repair')}
isLoading={isRepairingConsistency}
handleConfirm={async () => {
setIsRepairingConsistency(true)
try {
await handleFixAbilities(queryClient, (_result) => {
// eslint-disable-next-line no-console
console.log('Repair channel consistency result:', _result)
})
setShowConsistencyDialog(false)
} finally {
setIsRepairingConsistency(false)
}
}}
/>
</>
)
}
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
import { useQueryClient } from '@tanstack/react-query'
import React, {
createContext,
useContext,
@@ -24,7 +25,7 @@ import React, {
useCallback,
useMemo,
} from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
import { channelsQueryKeys } from '../lib'
import type { Channel } from '../types'
@@ -59,6 +60,8 @@ type ChannelsContextType = {
setEnableTagMode: (enabled: boolean) => void
idSort: boolean
setIdSort: (enabled: boolean) => void
batchMode: boolean
setBatchMode: (enabled: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
upstream: UpstreamUpdateState
@@ -86,6 +89,7 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
const [idSort, setIdSort] = useState(() => {
return localStorage.getItem('channels-id-sort') === 'true'
})
const [batchMode, setBatchMode] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const queryClient = useQueryClient()
@@ -109,6 +113,8 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
setEnableTagMode,
idSort,
setIdSort,
batchMode,
setBatchMode,
sensitiveVisible,
setSensitiveVisible,
upstream,
@@ -119,6 +125,7 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
currentTag,
enableTagMode,
idSort,
batchMode,
sensitiveVisible,
upstream,
]
@@ -16,22 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import type { OnChangeFn, SortingState, Row } from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { useMediaQuery } from '@/hooks'
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
@@ -39,6 +30,17 @@ import {
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getLobeIcon } from '@/lib/lobe-icon'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
@@ -53,14 +55,13 @@ import {
getChannelTypeLabel,
} from '../lib'
import type { Channel, ChannelSortBy } from '../types'
import { useChannelsColumns } from './channels-columns'
import { ChannelCard } from './channel-card'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY =
'channels:column-visibility'
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY = 'channels:column-visibility'
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
@@ -83,6 +84,7 @@ export function ChannelsTable() {
const {
enableTagMode,
idSort,
batchMode,
sensitiveVisible,
setSensitiveVisible,
} = useChannels()
@@ -268,7 +270,7 @@ export function ChannelsTable() {
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns()
const columns = useChannelsColumns({ enableSelection: batchMode })
// React Table instance
const { table } = useDataTable({
@@ -284,7 +286,9 @@ export function ChannelsTable() {
columnFilters,
pagination,
globalFilter,
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
enableRowSelection: batchMode
? (row: Row<Channel>) => !isTagAggregateRow(row.original)
: false,
onSortingChange: handleSortingChange,
onColumnFiltersChange,
onPaginationChange,
@@ -297,6 +301,12 @@ export function ChannelsTable() {
ensurePageInRange,
})
useEffect(() => {
if (!batchMode) {
table.resetRowSelection()
}
}, [batchMode, table])
// Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {}
@@ -441,7 +451,7 @@ export function ChannelsTable() {
}
return DISABLED_ROW_DESKTOP
}}
bulkActions={<DataTableBulkActions table={table} />}
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
/>
)
}
@@ -164,24 +164,26 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
@@ -262,6 +264,15 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{layout === 'card' && (
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
File diff suppressed because it is too large Load Diff
+31 -27
View File
@@ -19,7 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import {
copyChannel,
deleteChannel,
@@ -129,7 +131,7 @@ export async function handleEnableChannel(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -154,7 +156,7 @@ export async function handleDisableChannel(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -192,7 +194,7 @@ export async function handleDeleteChannel(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
@@ -224,7 +226,7 @@ export async function handleUpdateChannelField(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -258,7 +260,7 @@ export async function handleUpdateTagField(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
@@ -355,7 +357,7 @@ export async function handleCopyChannel(
} else {
toast.error(response.message || i18next.t('Failed to copy channel'))
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to copy channel'))
}
}
@@ -425,7 +427,7 @@ export async function handleBatchDelete(
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
} catch {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
@@ -444,10 +446,7 @@ export async function handleBatchEnable(
}
try {
const response = await batchUpdateChannelStatus(
ids,
CHANNEL_STATUS.ENABLED
)
const response = await batchUpdateChannelStatus(ids, CHANNEL_STATUS.ENABLED)
const successCount = response.success ? response.data || 0 : 0
const failCount = ids.length - successCount
@@ -466,7 +465,7 @@ export async function handleBatchEnable(
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to enable channels'))
}
}
@@ -509,7 +508,7 @@ export async function handleBatchDisable(
})
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to disable channels'))
}
}
@@ -537,7 +536,7 @@ export async function handleBatchSetTag(
} else {
toast.error(response.message || i18next.t('Failed to set tag'))
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to set tag'))
}
}
@@ -567,7 +566,7 @@ export async function handleEnableTagChannels(
response.message || i18next.t('Failed to enable tag channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to enable tag channels'))
}
}
@@ -593,7 +592,7 @@ export async function handleDisableTagChannels(
response.message || i18next.t('Failed to disable tag channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to disable tag channels'))
}
}
@@ -624,13 +623,13 @@ export async function handleDeleteAllDisabled(
response.message || i18next.t('Failed to delete disabled channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to delete disabled channels'))
}
}
/**
* Fix channel abilities
* Repair channel consistency
*/
export async function handleFixAbilities(
queryClient?: QueryClient,
@@ -640,18 +639,23 @@ export async function handleFixAbilities(
const response = await fixChannelAbilities()
if (response.success && response.data) {
toast.success(
i18next.t('Fixed abilities: {{success}} succeeded, {{fails}} failed', {
success: response.data.success,
fails: response.data.fails,
})
i18next.t(
'Channel consistency repaired: {{success}} succeeded, {{fails}} failed',
{
success: response.data.success,
fails: response.data.fails,
}
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
} else {
toast.error(response.message || i18next.t('Failed to fix abilities'))
toast.error(
response.message || i18next.t('Failed to repair channel consistency')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to fix abilities'))
} catch {
toast.error(i18next.t('Failed to repair channel consistency'))
}
}
@@ -677,7 +681,7 @@ export async function handleTestAllChannels(
response.message || i18next.t('Failed to start testing all channels')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to test all channels'))
}
}
@@ -704,7 +708,7 @@ export async function handleUpdateAllBalances(
response.message || i18next.t('Failed to update all balances')
)
}
} catch (_error) {
} catch {
toast.error(i18next.t('Failed to update all balances'))
}
}