models-columns.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. import { type ColumnDef } from '@tanstack/react-table'
  2. import { useTranslation } from 'react-i18next'
  3. import { formatTimestampToDate } from '@/lib/format'
  4. import { getLobeIcon } from '@/lib/lobe-icon'
  5. import { Checkbox } from '@/components/ui/checkbox'
  6. import {
  7. Tooltip,
  8. TooltipContent,
  9. TooltipProvider,
  10. TooltipTrigger,
  11. } from '@/components/ui/tooltip'
  12. import { DataTableColumnHeader } from '@/components/data-table/column-header'
  13. import { GroupBadge } from '@/components/group-badge'
  14. import { StatusBadge } from '@/components/status-badge'
  15. import {
  16. getModelStatusConfig,
  17. getNameRuleConfig,
  18. getQuotaTypeConfig,
  19. } from '../constants'
  20. import { parseModelTags, formatEndpointsDisplay } from '../lib'
  21. import type { Model, Vendor } from '../types'
  22. import { DataTableRowActions } from './data-table-row-actions'
  23. import { DescriptionCell } from './description-cell'
  24. /**
  25. * Render limited items with "and X more" indicator
  26. */
  27. function renderLimitedItems(
  28. items: React.ReactNode[],
  29. maxDisplay: number = 2
  30. ): React.ReactNode {
  31. if (items.length === 0)
  32. return <span className='text-muted-foreground text-xs'>-</span>
  33. const displayed = items.slice(0, maxDisplay)
  34. const remaining = items.length - maxDisplay
  35. return (
  36. <div className='flex max-w-full items-center gap-1 overflow-x-auto'>
  37. {displayed}
  38. {remaining > 0 && (
  39. <StatusBadge
  40. label={`+${remaining}`}
  41. variant='neutral'
  42. size='sm'
  43. copyable={false}
  44. className='flex-shrink-0'
  45. />
  46. )}
  47. </div>
  48. )
  49. }
  50. /**
  51. * Generate models columns configuration
  52. */
  53. export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
  54. const { t } = useTranslation()
  55. // Get translated configs
  56. const NAME_RULE_CONFIG = getNameRuleConfig(t)
  57. const MODEL_STATUS_CONFIG = getModelStatusConfig(t)
  58. const QUOTA_TYPE_CONFIG = getQuotaTypeConfig(t)
  59. const vendorMap: Record<number, Vendor> = {}
  60. vendors.forEach((v) => {
  61. vendorMap[v.id] = v
  62. })
  63. return [
  64. // Checkbox column
  65. {
  66. id: 'select',
  67. header: ({ table }) => (
  68. <Checkbox
  69. checked={
  70. table.getIsAllPageRowsSelected() ||
  71. (table.getIsSomePageRowsSelected() && 'indeterminate')
  72. }
  73. onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
  74. aria-label='Select all'
  75. />
  76. ),
  77. cell: ({ row }) => (
  78. <Checkbox
  79. checked={row.getIsSelected()}
  80. onCheckedChange={(value) => row.toggleSelected(!!value)}
  81. aria-label='Select row'
  82. />
  83. ),
  84. enableSorting: false,
  85. enableHiding: false,
  86. size: 40,
  87. },
  88. // ID column
  89. {
  90. accessorKey: 'id',
  91. meta: { label: t('ID'), mobileHidden: true },
  92. header: ({ column }) => (
  93. <DataTableColumnHeader column={column} title='ID' />
  94. ),
  95. cell: ({ row }) => {
  96. const id = row.getValue('id') as number
  97. return (
  98. <StatusBadge
  99. label={String(id)}
  100. variant='neutral'
  101. copyText={String(id)}
  102. size='sm'
  103. className='font-mono'
  104. />
  105. )
  106. },
  107. size: 80,
  108. },
  109. // Icon column
  110. {
  111. accessorKey: 'icon',
  112. meta: { label: t('Icon'), mobileHidden: true },
  113. header: t('Icon'),
  114. cell: ({ row }) => {
  115. const model = row.original
  116. const iconKey =
  117. model.icon ||
  118. vendorMap[model.vendor_id || 0]?.icon ||
  119. model.model_name?.[0] ||
  120. 'N'
  121. const icon = getLobeIcon(iconKey, 20)
  122. return <div className='flex items-center justify-center'>{icon}</div>
  123. },
  124. size: 70,
  125. enableSorting: false,
  126. },
  127. // Model Name column
  128. {
  129. accessorKey: 'model_name',
  130. meta: { label: t('Model Name'), mobileTitle: true },
  131. header: ({ column }) => (
  132. <DataTableColumnHeader column={column} title={t('Model Name')} />
  133. ),
  134. cell: ({ row }) => {
  135. const name = row.getValue('model_name') as string
  136. return (
  137. <StatusBadge
  138. label={name}
  139. variant='neutral'
  140. copyText={name}
  141. size='sm'
  142. className='font-mono'
  143. />
  144. )
  145. },
  146. minSize: 200,
  147. },
  148. // Name Rule column
  149. {
  150. accessorKey: 'name_rule',
  151. meta: { label: t('Match Type') },
  152. header: ({ column }) => (
  153. <DataTableColumnHeader column={column} title={t('Match Type')} />
  154. ),
  155. cell: ({ row }) => {
  156. const rule = row.getValue('name_rule') as 0 | 1 | 2 | 3
  157. const model = row.original
  158. const config = NAME_RULE_CONFIG[rule]
  159. let label = config.label
  160. if (rule !== 0 && model.matched_count) {
  161. label = `${config.label} (${model.matched_count})`
  162. }
  163. const badge = (
  164. <StatusBadge
  165. label={label}
  166. variant={
  167. (config.color === 'error' ? 'danger' : config.color) as
  168. | 'neutral'
  169. | 'success'
  170. | 'warning'
  171. | 'danger'
  172. | 'info'
  173. }
  174. size='sm'
  175. />
  176. )
  177. // Show tooltip with matched models for non-exact rules
  178. if (
  179. rule !== 0 &&
  180. model.matched_models &&
  181. model.matched_models.length > 0
  182. ) {
  183. const matchedBadges = model.matched_models.map((m, idx) => (
  184. <StatusBadge key={idx} label={m} autoColor={m} size='sm' />
  185. ))
  186. return (
  187. <TooltipProvider>
  188. <Tooltip>
  189. <TooltipTrigger asChild>
  190. <div>{badge}</div>
  191. </TooltipTrigger>
  192. <TooltipContent
  193. side='top'
  194. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  195. >
  196. <div className='flex flex-wrap gap-1'>{matchedBadges}</div>
  197. </TooltipContent>
  198. </Tooltip>
  199. </TooltipProvider>
  200. )
  201. }
  202. return badge
  203. },
  204. size: 140,
  205. enableSorting: false,
  206. },
  207. // Status column
  208. {
  209. accessorKey: 'status',
  210. meta: { label: t('Status'), mobileBadge: true },
  211. header: t('Status'),
  212. cell: ({ row }) => {
  213. const status = row.getValue('status') as number
  214. const config =
  215. MODEL_STATUS_CONFIG[status as 0 | 1] || MODEL_STATUS_CONFIG[0]
  216. return (
  217. <StatusBadge
  218. label={config.label}
  219. variant={config.variant}
  220. showDot={config.showDot}
  221. size='sm'
  222. copyable={false}
  223. />
  224. )
  225. },
  226. filterFn: (row, id, value) => {
  227. if (!value || value.length === 0 || value.includes('all')) return true
  228. const status = row.getValue(id) as number
  229. if (value.includes('enabled')) return status === 1
  230. if (value.includes('disabled')) return status !== 1
  231. return false
  232. },
  233. size: 120,
  234. enableSorting: false,
  235. },
  236. // Vendor column
  237. {
  238. accessorKey: 'vendor_id',
  239. meta: { label: t('Vendor') },
  240. header: t('Vendor'),
  241. cell: ({ row }) => {
  242. const vendorId = row.getValue('vendor_id') as number
  243. const vendor = vendorMap[vendorId]
  244. if (!vendor) {
  245. return <span className='text-muted-foreground text-xs'>-</span>
  246. }
  247. const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null
  248. return (
  249. <div className='flex items-center gap-1.5'>
  250. {icon}
  251. <StatusBadge
  252. label={vendor.name}
  253. autoColor={vendor.name}
  254. size='sm'
  255. />
  256. </div>
  257. )
  258. },
  259. filterFn: (row, id, value) => {
  260. if (!value || value.length === 0 || value.includes('all')) return true
  261. return value.includes(String(row.getValue(id)))
  262. },
  263. size: 150,
  264. enableSorting: false,
  265. },
  266. // Description column
  267. {
  268. accessorKey: 'description',
  269. meta: { label: t('Description'), mobileHidden: true },
  270. header: t('Description'),
  271. cell: ({ row }) => {
  272. const description = row.getValue('description') as string
  273. const modelName = row.getValue('model_name') as string
  274. return (
  275. <DescriptionCell modelName={modelName} description={description} />
  276. )
  277. },
  278. size: 150,
  279. enableSorting: false,
  280. },
  281. // Tags column
  282. {
  283. accessorKey: 'tags',
  284. meta: { label: t('Tags'), mobileHidden: true },
  285. header: t('Tags'),
  286. cell: ({ row }) => {
  287. const tags = row.getValue('tags') as string
  288. const tagArray = parseModelTags(tags)
  289. if (tagArray.length === 0) {
  290. return <span className='text-muted-foreground text-xs'>-</span>
  291. }
  292. const tagBadges = tagArray.map((tag, idx) => (
  293. <StatusBadge key={idx} label={tag} autoColor={tag} size='sm' />
  294. ))
  295. return (
  296. <TooltipProvider>
  297. <Tooltip>
  298. <TooltipTrigger asChild>
  299. <div>{renderLimitedItems(tagBadges, 2)}</div>
  300. </TooltipTrigger>
  301. {tagArray.length > 2 && (
  302. <TooltipContent
  303. side='top'
  304. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  305. >
  306. <div className='flex flex-wrap gap-1'>{tagBadges}</div>
  307. </TooltipContent>
  308. )}
  309. </Tooltip>
  310. </TooltipProvider>
  311. )
  312. },
  313. size: 150,
  314. enableSorting: false,
  315. },
  316. // Endpoints column
  317. {
  318. accessorKey: 'endpoints',
  319. meta: { label: t('Endpoints'), mobileHidden: true },
  320. header: t('Endpoints'),
  321. cell: ({ row }) => {
  322. const endpoints = row.getValue('endpoints') as string
  323. const endpointArray = formatEndpointsDisplay(endpoints)
  324. if (endpointArray.length === 0) {
  325. return <span className='text-muted-foreground text-xs'>-</span>
  326. }
  327. const endpointBadges = endpointArray.map((ep, idx) => (
  328. <StatusBadge key={idx} label={ep} autoColor={ep} size='sm' />
  329. ))
  330. return (
  331. <TooltipProvider>
  332. <Tooltip>
  333. <TooltipTrigger asChild>
  334. <div>{renderLimitedItems(endpointBadges, 2)}</div>
  335. </TooltipTrigger>
  336. {endpointArray.length > 2 && (
  337. <TooltipContent
  338. side='top'
  339. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  340. >
  341. <div className='flex flex-wrap gap-1'>{endpointBadges}</div>
  342. </TooltipContent>
  343. )}
  344. </Tooltip>
  345. </TooltipProvider>
  346. )
  347. },
  348. size: 150,
  349. enableSorting: false,
  350. },
  351. // Bound Channels column
  352. {
  353. accessorKey: 'bound_channels',
  354. meta: { label: t('Bound Channels'), mobileHidden: true },
  355. header: t('Bound Channels'),
  356. cell: ({ row }) => {
  357. const channels = row.getValue('bound_channels') as Array<{
  358. id: number
  359. name: string
  360. type?: number
  361. status?: number
  362. }>
  363. if (!channels || channels.length === 0) {
  364. return <span className='text-muted-foreground text-xs'>-</span>
  365. }
  366. const channelBadges = channels.map((c, idx) => (
  367. <StatusBadge
  368. key={idx}
  369. label={`${c.name} (${c.type})`}
  370. autoColor={c.name}
  371. size='sm'
  372. />
  373. ))
  374. return (
  375. <TooltipProvider>
  376. <Tooltip>
  377. <TooltipTrigger asChild>
  378. <div>{renderLimitedItems(channelBadges, 2)}</div>
  379. </TooltipTrigger>
  380. {channels.length > 2 && (
  381. <TooltipContent
  382. side='top'
  383. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  384. >
  385. <div className='flex flex-wrap gap-1'>{channelBadges}</div>
  386. </TooltipContent>
  387. )}
  388. </Tooltip>
  389. </TooltipProvider>
  390. )
  391. },
  392. size: 150,
  393. enableSorting: false,
  394. },
  395. // Enable Groups column
  396. {
  397. accessorKey: 'enable_groups',
  398. meta: { label: t('Enable Groups'), mobileHidden: true },
  399. header: ({ column }) => (
  400. <DataTableColumnHeader column={column} title={t('Enable Groups')} />
  401. ),
  402. cell: ({ row }) => {
  403. const groups = row.getValue('enable_groups') as string[]
  404. if (!groups || groups.length === 0) {
  405. return <span className='text-muted-foreground text-xs'>-</span>
  406. }
  407. const groupBadges = groups.map((g) => (
  408. <GroupBadge key={g} group={g} size='sm' />
  409. ))
  410. return (
  411. <TooltipProvider>
  412. <Tooltip>
  413. <TooltipTrigger asChild>
  414. <div>{renderLimitedItems(groupBadges, 2)}</div>
  415. </TooltipTrigger>
  416. {groups.length > 2 && (
  417. <TooltipContent
  418. side='top'
  419. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  420. >
  421. <div className='flex flex-wrap gap-1'>{groupBadges}</div>
  422. </TooltipContent>
  423. )}
  424. </Tooltip>
  425. </TooltipProvider>
  426. )
  427. },
  428. size: 150,
  429. enableSorting: false,
  430. },
  431. // Quota Types column
  432. {
  433. accessorKey: 'quota_types',
  434. meta: { label: t('Quota Types'), mobileHidden: true },
  435. header: t('Quota Types'),
  436. cell: ({ row }) => {
  437. const quotaTypes = row.getValue('quota_types') as number[]
  438. if (!quotaTypes || quotaTypes.length === 0) {
  439. return <span className='text-muted-foreground text-xs'>-</span>
  440. }
  441. const quotaBadges = quotaTypes.map((qt, idx) => {
  442. const config = QUOTA_TYPE_CONFIG[qt]
  443. return (
  444. <StatusBadge
  445. key={idx}
  446. label={config?.label || String(qt)}
  447. variant={
  448. (config?.color === 'error' ? 'danger' : config?.color) as
  449. | 'neutral'
  450. | 'success'
  451. | 'warning'
  452. | 'danger'
  453. | 'info'
  454. }
  455. size='sm'
  456. />
  457. )
  458. })
  459. return (
  460. <TooltipProvider>
  461. <Tooltip>
  462. <TooltipTrigger asChild>
  463. <div>{renderLimitedItems(quotaBadges, 2)}</div>
  464. </TooltipTrigger>
  465. {quotaTypes.length > 2 && (
  466. <TooltipContent
  467. side='top'
  468. className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
  469. >
  470. <div className='flex flex-wrap gap-1'>{quotaBadges}</div>
  471. </TooltipContent>
  472. )}
  473. </Tooltip>
  474. </TooltipProvider>
  475. )
  476. },
  477. size: 150,
  478. enableSorting: false,
  479. },
  480. // Sync Official column
  481. {
  482. accessorKey: 'sync_official',
  483. meta: { label: t('Official Sync'), mobileHidden: true },
  484. header: t('Official Sync'),
  485. cell: ({ row }) => {
  486. const syncOfficial = row.getValue('sync_official') as number
  487. return (
  488. <StatusBadge
  489. label={syncOfficial === 1 ? t('Official Sync') : t('No Sync')}
  490. variant={syncOfficial === 1 ? 'success' : 'warning'}
  491. size='sm'
  492. copyable={false}
  493. />
  494. )
  495. },
  496. filterFn: (row, id, value) => {
  497. if (!value || value.length === 0 || value.includes('all')) return true
  498. const syncOfficial = row.getValue(id) as number
  499. if (value.includes('yes')) return syncOfficial === 1
  500. if (value.includes('no')) return syncOfficial !== 1
  501. return false
  502. },
  503. size: 120,
  504. enableSorting: false,
  505. },
  506. // Created Time column
  507. {
  508. accessorKey: 'created_time',
  509. meta: { label: t('Created'), mobileHidden: true },
  510. header: ({ column }) => (
  511. <DataTableColumnHeader column={column} title={t('Created')} />
  512. ),
  513. cell: ({ row }) => {
  514. const timestamp = row.getValue('created_time') as number
  515. return (
  516. <div className='min-w-[140px] font-mono text-sm'>
  517. {formatTimestampToDate(timestamp)}
  518. </div>
  519. )
  520. },
  521. size: 180,
  522. },
  523. // Updated Time column
  524. {
  525. accessorKey: 'updated_time',
  526. meta: { label: t('Updated'), mobileHidden: true },
  527. header: ({ column }) => (
  528. <DataTableColumnHeader column={column} title={t('Updated')} />
  529. ),
  530. cell: ({ row }) => {
  531. const timestamp = row.getValue('updated_time') as number
  532. return (
  533. <div className='min-w-[140px] font-mono text-sm'>
  534. {formatTimestampToDate(timestamp)}
  535. </div>
  536. )
  537. },
  538. size: 180,
  539. },
  540. // Actions column
  541. {
  542. id: 'actions',
  543. cell: ({ row }) => {
  544. return <DataTableRowActions row={row} />
  545. },
  546. size: 100,
  547. enableSorting: false,
  548. enableHiding: false,
  549. },
  550. ]
  551. }