Files
new-api/web/default/src/features/models/components/models-columns.tsx
T
QuentinHsu 9ba251ce5f perf(web): streamline table actions and destructive dialogs (#5645)
* perf(data-table): autosize action columns

- exclude actions columns from shared table width calculations so action cells size to their content.
- remove fixed size and w-* width overrides from feature action columns to preserve content-based layout.

* perf(data-table): streamline row action controls

- expose common edit and status actions directly while moving secondary actions into overflow menus.
- add shared row action menu helpers so static and table rows use consistent action controls.
- let action columns size to their content instead of relying on fixed widths.

* fix(web): localize destructive dialog copy

- route delete, reset, and batch update confirmation text through i18n.
- add locale entries for affected channel, model, system settings, and user dialogs.

* perf(web): unify destructive dialog actions

- align delete and cleanup confirmation buttons with the shared destructive variant.
- replace custom destructive color overrides with semantic button variants.
- clean up lint errors in touched dialog files before committing.

* fix(web): add user action success translations

- add localized success messages for user delete, status, and role changes.
- keep user management toast copy available across all frontend locales.

* fix(data-table): prevent mobile badge clipping

- expose badge cell slots so mobile card styles can target nested badge wrappers.
- reset badge margins in card rows to keep provider icons fully visible on small screens.
2026-06-25 13:13:41 +08:00

479 lines
13 KiB
TypeScript
Vendored

/*
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 { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { BadgeCell, BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import {
getModelStatusConfig,
getNameRuleConfig,
getQuotaTypeConfig,
} from '../constants'
import { parseModelTags, formatEndpointsDisplay } from '../lib'
import type { Model, Vendor } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
import { DescriptionCell } from './description-cell'
function getCompactModelIcon(iconKey: string) {
const baseIconKey = iconKey.split('.')[0]
return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20)
}
/**
* Generate models columns configuration
*/
export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
const { t } = useTranslation()
// Get translated configs
const NAME_RULE_CONFIG = getNameRuleConfig(t)
const MODEL_STATUS_CONFIG = getModelStatusConfig(t)
const QUOTA_TYPE_CONFIG = getQuotaTypeConfig(t)
const vendorMap: Record<number, Vendor> = {}
vendors.forEach((v) => {
vendorMap[v.id] = v
})
return [
// Checkbox column
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
// ID column
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const id = row.getValue('id') as number
return <TableId value={id} />
},
size: 80,
},
// Icon column
{
accessorKey: 'icon',
header: t('Icon'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const model = row.original
const iconKey =
model.icon ||
vendorMap[model.vendor_id || 0]?.icon ||
model.model_name?.[0] ||
'N'
const icon = getCompactModelIcon(iconKey)
return (
<div className='ms-1 flex size-5 items-center justify-center overflow-hidden'>
{icon}
</div>
)
},
size: 70,
enableSorting: false,
},
// Model Name column
{
accessorKey: 'model_name',
header: t('Model Name'),
meta: { mobileTitle: true },
cell: ({ row }) => {
const name = row.getValue('model_name') as string
return (
<StatusBadge
label={name}
variant='neutral'
copyText={name}
size='sm'
className='-ml-1.5 font-mono'
/>
)
},
minSize: 200,
},
// Name Rule column
{
accessorKey: 'name_rule',
header: t('Match Type'),
cell: ({ row }) => {
const rule = row.getValue('name_rule') as 0 | 1 | 2 | 3
const model = row.original
const config = NAME_RULE_CONFIG[rule]
let label = config.label
if (rule !== 0 && model.matched_count) {
label = `${config.label} (${model.matched_count})`
}
const badge = (
<StatusBadge
label={label}
variant={
(config.color === 'error' ? 'danger' : config.color) as
| 'neutral'
| 'success'
| 'warning'
| 'danger'
| 'info'
}
size='sm'
className='-ml-1.5'
/>
)
// Show tooltip with matched models for non-exact rules
if (
rule !== 0 &&
model.matched_models &&
model.matched_models.length > 0
) {
const matchedBadges = model.matched_models.map((m, idx) => (
<StatusBadge key={idx} label={m} autoColor={m} size='sm' />
))
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<div className='-ml-1.5' />}>
{badge}
</TooltipTrigger>
<TooltipContent
side='top'
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
>
<div className='flex flex-wrap gap-1'>{matchedBadges}</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return badge
},
size: 140,
enableSorting: false,
},
// Status column
{
accessorKey: 'status',
header: t('Status'),
meta: { mobileBadge: true },
cell: ({ row }) => {
const status = row.getValue('status') as number
const config =
MODEL_STATUS_CONFIG[status as 0 | 1] || MODEL_STATUS_CONFIG[0]
return (
<StatusBadge
label={config.label}
variant={config.variant}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true
const status = row.getValue(id) as number
if (value.includes('enabled')) return status === 1
if (value.includes('disabled')) return status !== 1
return false
},
size: 120,
enableSorting: false,
},
// Vendor column
{
accessorKey: 'vendor_id',
header: t('Vendor'),
cell: ({ row }) => {
const vendorId = row.getValue('vendor_id') as number
const vendor = vendorMap[vendorId]
if (!vendor) {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<BadgeCell>
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
</BadgeCell>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true
return value.includes(String(row.getValue(id)))
},
size: 150,
enableSorting: false,
},
// Description column
{
accessorKey: 'description',
header: t('Description'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const description = row.getValue('description') as string
const modelName = row.getValue('model_name') as string
return (
<DescriptionCell modelName={modelName} description={description} />
)
},
size: 150,
enableSorting: false,
},
// Tags column
{
accessorKey: 'tags',
header: t('Tags'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const tags = row.getValue('tags') as string
const tagArray = parseModelTags(tags)
return (
<BadgeListCell
items={tagArray.map((tag, idx) => (
<StatusBadge key={idx} label={tag} autoColor={tag} size='sm' />
))}
/>
)
},
size: 150,
enableSorting: false,
},
// Endpoints column
{
accessorKey: 'endpoints',
header: t('Endpoints'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const endpoints = row.getValue('endpoints') as string
const endpointArray = formatEndpointsDisplay(endpoints)
return (
<BadgeListCell
items={endpointArray.map((ep, idx) => (
<StatusBadge key={idx} label={ep} autoColor={ep} size='sm' />
))}
/>
)
},
size: 150,
enableSorting: false,
},
// Bound Channels column
{
accessorKey: 'bound_channels',
header: t('Bound Channels'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const channels = row.getValue('bound_channels') as Array<{
id: number
name: string
type?: number
status?: number
}>
return (
<BadgeListCell
items={(channels ?? []).map((c, idx) => (
<StatusBadge
key={idx}
label={`${c.name} (${c.type})`}
autoColor={c.name}
size='sm'
/>
))}
/>
)
},
size: 150,
enableSorting: false,
},
// Enable Groups column
{
accessorKey: 'enable_groups',
header: t('Enable Groups'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const groups = row.getValue('enable_groups') as string[]
return (
<BadgeListCell
items={(groups ?? []).map((g) => (
<GroupBadge key={g} group={g} size='sm' />
))}
/>
)
},
size: 150,
enableSorting: false,
},
// Quota Types column
{
accessorKey: 'quota_types',
header: t('Quota Types'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const quotaTypes = row.getValue('quota_types') as number[]
return (
<BadgeListCell
items={(quotaTypes ?? []).map((qt, idx) => {
const config = QUOTA_TYPE_CONFIG[qt]
return (
<StatusBadge
key={idx}
label={config?.label || String(qt)}
variant={
(config?.color === 'error' ? 'danger' : config?.color) as
| 'neutral'
| 'success'
| 'warning'
| 'danger'
| 'info'
}
size='sm'
/>
)
})}
/>
)
},
size: 150,
enableSorting: false,
},
// Sync Official column
{
accessorKey: 'sync_official',
header: t('Official Sync'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const syncOfficial = row.getValue('sync_official') as number
return (
<StatusBadge
label={syncOfficial === 1 ? t('Official Sync') : t('No Sync')}
variant={syncOfficial === 1 ? 'success' : 'warning'}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true
const syncOfficial = row.getValue(id) as number
if (value.includes('yes')) return syncOfficial === 1
if (value.includes('no')) return syncOfficial !== 1
return false
},
size: 120,
enableSorting: false,
},
// Created Time column
{
accessorKey: 'created_time',
header: t('Created'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const timestamp = row.getValue('created_time') as number
return (
<div className='min-w-[140px] font-mono text-sm'>
{formatTimestampToDate(timestamp)}
</div>
)
},
size: 180,
},
// Updated Time column
{
accessorKey: 'updated_time',
header: t('Updated'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const timestamp = row.getValue('updated_time') as number
return (
<div className='min-w-[140px] font-mono text-sm'>
{formatTimestampToDate(timestamp)}
</div>
)
},
size: 180,
},
// Actions column
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => {
return <DataTableRowActions row={row} />
},
enableSorting: false,
enableHiding: false,
meta: { pinned: 'right' as const },
},
]
}