feat(ui): improve table controls and analytics filters

This commit is contained in:
CaIon
2026-04-30 13:57:10 +08:00
parent 8bff691089
commit 8f3c41ae77
35 changed files with 858 additions and 472 deletions
@@ -28,6 +28,7 @@ import {
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { GroupBadge } from '@/components/group-badge'
import {
StatusBadge,
dotColorMap,
@@ -876,8 +877,8 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const group = row.getValue('group') as string
const groupArray = parseGroupsList(group)
const groupBadges = groupArray.map((g, idx) => (
<StatusBadge key={idx} label={g} autoColor={g} size='sm' />
const groupBadges = groupArray.map((g) => (
<GroupBadge key={g} group={g} size='sm' />
))
return (
@@ -1035,7 +1036,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
return <DataTableRowActions row={row} />
},
size: 100,
size: 132,
enableSorting: false,
enableHiding: false,
},
@@ -26,6 +26,8 @@ import {
TableRow,
} from '@/components/ui/table'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTableToolbar,
TableSkeleton,
TableEmpty,
@@ -368,9 +370,7 @@ export function ChannelsTable() {
emptyTitle='No Channels Found'
emptyDescription='No channels available. Create your first channel to get started.'
getRowClassName={(row) =>
isDisabledChannelRow(row.original)
? 'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55'
: undefined
isDisabledChannelRow(row.original) ? DISABLED_ROW_MOBILE : undefined
}
/>
) : (
@@ -419,7 +419,7 @@ export function ChannelsTable() {
data-state={row.getIsSelected() && 'selected'}
className={cn(
isDisabledChannelRow(row.original) &&
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
DISABLED_ROW_DESKTOP
)}
>
{row.getVisibleCells().map((cell) => (
@@ -6,6 +6,7 @@ import {
Boxes,
Pencil,
TestTube,
Gauge,
DollarSign,
Download,
Copy,
@@ -14,6 +15,7 @@ import {
Key,
Trash2,
RefreshCw,
Loader2,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -25,10 +27,17 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { MODEL_FETCHABLE_TYPES } from '../constants'
import {
channelsQueryKeys,
handleDeleteChannel,
handleTestChannel,
handleToggleChannelStatus,
isChannelEnabled,
isMultiKeyChannel,
@@ -47,6 +56,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
@@ -61,6 +72,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('test-channel')
}
const handleDirectTest = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
setIsTesting(true)
try {
await handleTestChannel(channel.id, undefined, () => {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
})
} finally {
setIsTesting(false)
}
}
const handleQueryBalance = () => {
setCurrentRow(channel)
setOpen('balance-query')
@@ -86,148 +109,184 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('multi-key-manage')
}
const handleToggleStatus = () => {
handleToggleChannelStatus(channel.id, channel.status, queryClient)
const handleToggleStatus = async (
e?: React.MouseEvent<HTMLButtonElement>
) => {
e?.stopPropagation()
setIsTogglingStatus(true)
try {
await handleToggleChannelStatus(channel.id, channel.status, queryClient)
} finally {
setIsTogglingStatus(false)
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
<DropdownMenuShortcut>
<TestTube size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
<DropdownMenuShortcut>
<DollarSign size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Fetch Models */}
<DropdownMenuItem onClick={handleFetchModels}>
{t('Fetch Models')}
<DropdownMenuShortcut>
<Download size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Detect Upstream Updates (only for fetchable channel types) */}
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
<DropdownMenuItem
onClick={() => {
const meta = parseUpstreamUpdateMeta(channel.settings)
if (
meta.pendingAddModels.length > 0 ||
meta.pendingRemoveModels.length > 0
) {
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
)
} else {
upstream.detectChannelUpdates(channel)
}
}}
<div className='flex items-center justify-end gap-1'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
>
{t('Upstream Updates')}
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
}
>
{isTogglingStatus ? (
<Loader2 className='size-4 animate-spin' />
) : isEnabled ? (
<PowerOff className='size-4' />
) : (
<Power className='size-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<RefreshCw size={16} />
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Ollama Models (only for Ollama channels) */}
{channel.type === 4 && (
<DropdownMenuItem onClick={handleManageOllamaModels}>
{t('Manage Ollama Models')}
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
<DropdownMenuShortcut>
<Boxes size={16} />
<TestTube size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem onClick={handleCopy}>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
<DropdownMenuItem onClick={handleManageKeys}>
{t('Manage Keys')}
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
<DropdownMenuShortcut>
<Key size={16} />
<DollarSign size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Fetch Models */}
<DropdownMenuItem onClick={handleFetchModels}>
{t('Fetch Models')}
<DropdownMenuShortcut>
<Download size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Enable/Disable */}
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
{/* Detect Upstream Updates (only for fetchable channel types) */}
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
<DropdownMenuItem
onClick={() => {
const meta = parseUpstreamUpdateMeta(channel.settings)
if (
meta.pendingAddModels.length > 0 ||
meta.pendingRemoveModels.length > 0
) {
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
)
} else {
upstream.detectChannelUpdates(channel)
}
}}
>
{t('Upstream Updates')}
<DropdownMenuShortcut>
<PowerOff size={16} />
<RefreshCw size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
</DropdownMenuItem>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Ollama Models (only for Ollama channels) */}
{channel.type === 4 && (
<DropdownMenuItem onClick={handleManageOllamaModels}>
{t('Manage Ollama Models')}
<DropdownMenuShortcut>
<Boxes size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem onClick={handleCopy}>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
<DropdownMenuItem onClick={handleManageKeys}>
{t('Manage Keys')}
<DropdownMenuShortcut>
<Key size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmDialog
open={deleteConfirmOpen}
@@ -241,6 +300,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setDeleteConfirmOpen(false)
}}
/>
</DropdownMenu>
</div>
)
}
@@ -24,6 +24,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
editTagChannels,
@@ -388,17 +389,14 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<StatusBadge
<GroupBadge
key={group}
variant={
selectedGroups.includes(group) ? 'info' : 'neutral'
}
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
>
{group}
</StatusBadge>
/>
))}
</div>
</div>