- Add singleSelect to status/role filters in API keys, users, and redemptions tables (#4880) - Fix affiliate link 404 by changing /register to /sign-up (#4893) - Open FetchModelsDialog in channel creation mode via customFetcher prop (#4817) - Add TruncatedText component with tooltip for long channel names, token names, and usernames (#4877) - Elevate forgot-password link z-index to prevent label click interception (#4898)
This commit is contained in:
parent
18282e610d
commit
3caa6e467b
38
web/default/src/components/truncated-text.tsx
vendored
Normal file
38
web/default/src/components/truncated-text.tsx
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface TruncatedTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
maxWidth?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export function TruncatedText({
|
||||
text,
|
||||
className,
|
||||
maxWidth = 'max-w-[200px]',
|
||||
side = 'top',
|
||||
}: TruncatedTextProps) {
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className={cn('block truncate', maxWidth, className)} />
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className='max-w-xs break-all'>
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@ -313,7 +313,7 @@ export function UserAuthForm({
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
|
||||
@ -36,6 +36,7 @@ import {
|
||||
} from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn, truncateText } from '@/lib/utils'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@ -556,7 +557,11 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium'>{truncateText(name, 30)}</span>
|
||||
<TruncatedText
|
||||
text={name}
|
||||
className='font-medium'
|
||||
maxWidth='max-w-[180px]'
|
||||
/>
|
||||
{isPassThrough && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
|
||||
@ -65,6 +65,8 @@ type FetchModelsDialogProps = {
|
||||
onModelsSelected?: (models: string[]) => void
|
||||
redirectModels?: string[]
|
||||
redirectSourceModels?: string[]
|
||||
customFetcher?: () => Promise<string[]>
|
||||
existingModelsOverride?: string[]
|
||||
}
|
||||
|
||||
export function FetchModelsDialog({
|
||||
@ -73,6 +75,8 @@ export function FetchModelsDialog({
|
||||
onModelsSelected,
|
||||
redirectModels = [],
|
||||
redirectSourceModels = [],
|
||||
customFetcher,
|
||||
existingModelsOverride,
|
||||
}: FetchModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
@ -85,8 +89,10 @@ export function FetchModelsDialog({
|
||||
|
||||
// Parse existing models
|
||||
const existingModels = useMemo(
|
||||
() => parseModelsString(currentRow?.models || ''),
|
||||
[currentRow?.models]
|
||||
() =>
|
||||
existingModelsOverride ??
|
||||
parseModelsString(currentRow?.models || ''),
|
||||
[existingModelsOverride, currentRow?.models]
|
||||
)
|
||||
|
||||
// Categorize models with redirect models
|
||||
@ -121,26 +127,33 @@ export function FetchModelsDialog({
|
||||
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentRow) {
|
||||
if (open && (currentRow || customFetcher)) {
|
||||
handleFetchModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id])
|
||||
}, [open, currentRow?.id, customFetcher])
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
if (!currentRow) return
|
||||
if (!currentRow && !customFetcher) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const response = await fetchUpstreamModels(currentRow.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
if (customFetcher) {
|
||||
const list = await customFetcher()
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
const response = await fetchUpstreamModels(currentRow!.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
@ -153,8 +166,6 @@ export function FetchModelsDialog({
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentRow) return
|
||||
|
||||
// If onModelsSelected callback is provided, use it (form filling mode)
|
||||
if (onModelsSelected) {
|
||||
onModelsSelected(selectedModels)
|
||||
@ -164,6 +175,7 @@ export function FetchModelsDialog({
|
||||
}
|
||||
|
||||
// Otherwise, directly save to API (standalone mode)
|
||||
if (!currentRow) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const modelsString = selectedModels.join(',')
|
||||
@ -357,12 +369,16 @@ export function FetchModelsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Fetch Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow?.name}</strong>
|
||||
{currentRow
|
||||
? <>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow.name}</strong>
|
||||
</>
|
||||
: t('Fetch available models from upstream')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!currentRow ? (
|
||||
{!currentRow && !customFetcher ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No channel selected')}
|
||||
</div>
|
||||
|
||||
@ -301,7 +301,6 @@ export function ChannelMutateDrawer({
|
||||
const { setOpen } = useChannels()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false)
|
||||
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
|
||||
const [channelKey, setChannelKey] = useState<string | null>(null)
|
||||
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
||||
@ -767,43 +766,29 @@ export function ChannelMutateDrawer({
|
||||
return
|
||||
}
|
||||
|
||||
// For editing mode, open FetchModelsDialog to let user select
|
||||
if (isEditing && currentRow) {
|
||||
setFetchModelsDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// For creation mode, fetch and fill all models
|
||||
const key = form.getValues('key')
|
||||
if (!key?.trim()) {
|
||||
toast.error(t('Please enter API key first'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsFetchingModels(true)
|
||||
try {
|
||||
const response = await fetchModels({
|
||||
type,
|
||||
key,
|
||||
base_url: form.getValues('base_url') || '',
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
updateModels(response.data, true)
|
||||
toast.success(
|
||||
t('Fetched {{count}} model(s) from upstream', {
|
||||
count: response.data.length,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.error(t('No models fetched from upstream'))
|
||||
// For creation mode, validate key before opening dialog
|
||||
if (!isEditing) {
|
||||
const key = form.getValues('key')
|
||||
if (!key?.trim()) {
|
||||
toast.error(t('Please enter API key first'))
|
||||
return
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(getErrorMessage(error) || t('Failed to fetch models'))
|
||||
} finally {
|
||||
setIsFetchingModels(false)
|
||||
}
|
||||
}, [isEditing, currentRow, form, t, updateModels])
|
||||
|
||||
setFetchModelsDialogOpen(true)
|
||||
}, [isEditing, form, t])
|
||||
|
||||
const createModeFetcher = useCallback(async (): Promise<string[]> => {
|
||||
const response = await fetchModels({
|
||||
type: form.getValues('type'),
|
||||
key: form.getValues('key'),
|
||||
base_url: form.getValues('base_url') || '',
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
return response.data
|
||||
}
|
||||
throw new Error(response.message || 'No models fetched from upstream')
|
||||
}, [form])
|
||||
|
||||
// Handle adding custom models
|
||||
const handleAddCustomModels = useCallback(() => {
|
||||
@ -2234,13 +2219,8 @@ export function ChannelMutateDrawer({
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
{t('Fetch from Upstream')}
|
||||
</Button>
|
||||
)}
|
||||
@ -3390,19 +3370,20 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fetch Models Dialog (for editing mode) */}
|
||||
{isEditing && currentRow && (
|
||||
<FetchModelsDialog
|
||||
open={fetchModelsDialogOpen}
|
||||
onOpenChange={setFetchModelsDialogOpen}
|
||||
onModelsSelected={(models) => {
|
||||
// Fill selected models to form
|
||||
form.setValue('models', formatModelsArray(models))
|
||||
}}
|
||||
redirectModels={redirectModelList}
|
||||
redirectSourceModels={redirectModelKeyList}
|
||||
/>
|
||||
)}
|
||||
{/* Fetch Models Dialog */}
|
||||
<FetchModelsDialog
|
||||
open={fetchModelsDialogOpen}
|
||||
onOpenChange={setFetchModelsDialogOpen}
|
||||
onModelsSelected={(models) => {
|
||||
form.setValue('models', formatModelsArray(models))
|
||||
}}
|
||||
redirectModels={redirectModelList}
|
||||
redirectSourceModels={redirectModelKeyList}
|
||||
customFetcher={!isEditing ? createModeFetcher : undefined}
|
||||
existingModelsOverride={
|
||||
!isEditing ? parseModelsString(form.getValues('models') || '') : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<SecureVerificationDialog
|
||||
open={verificationOpen}
|
||||
|
||||
@ -318,6 +318,7 @@ export function ApiKeysTable() {
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@ -173,6 +173,7 @@ export function RedemptionsTable() {
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: redemptionStatusOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@ -431,9 +431,22 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className='text-muted-foreground truncate text-sm hover:underline'>
|
||||
{sensitiveVisible ? log.username : '••••'}
|
||||
</span>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='text-muted-foreground max-w-[100px] truncate text-sm hover:underline' />
|
||||
}
|
||||
>
|
||||
{sensitiveVisible ? log.username : '••••'}
|
||||
</TooltipTrigger>
|
||||
{sensitiveVisible && log.username.length > 12 && (
|
||||
<TooltipContent side='top'>
|
||||
{log.username}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
@ -468,15 +481,30 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
if (groupRatioText) metaParts.push(groupRatioText)
|
||||
|
||||
return (
|
||||
<div className='flex max-w-[150px] flex-col gap-0.5'>
|
||||
<StatusBadge
|
||||
label={displayName}
|
||||
icon={KeyRound}
|
||||
copyText={sensitiveVisible ? tokenName : undefined}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
<div className='flex max-w-[200px] flex-col gap-0.5'>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className='max-w-full' />
|
||||
}
|
||||
>
|
||||
<StatusBadge
|
||||
label={displayName}
|
||||
icon={KeyRound}
|
||||
copyText={sensitiveVisible ? tokenName : undefined}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{sensitiveVisible && tokenName.length > 16 && (
|
||||
<TooltipContent side='top' className='max-w-xs break-all'>
|
||||
{tokenName}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{metaParts.length > 0 && (
|
||||
<span className='text-muted-foreground/60 truncate text-[11px]'>
|
||||
{metaParts.join(' · ')}
|
||||
@ -486,7 +514,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
},
|
||||
meta: { label: t('Token') },
|
||||
size: 130,
|
||||
size: 160,
|
||||
})
|
||||
|
||||
columns.push(
|
||||
|
||||
@ -187,11 +187,13 @@ export function UsersTable() {
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: getUserStatusOptions(t),
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'role',
|
||||
title: t('Role'),
|
||||
options: getUserRoleOptions(t),
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@ -25,5 +25,5 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function generateAffiliateLink(affCode: string): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return `${window.location.origin}/register?aff=${affCode}`
|
||||
return `${window.location.origin}/sign-up?aff=${affCode}`
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user