fix(web/default): batch fix new UI issues #4880 #4893 #4817 #4877 #4898

- 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:
CaIon 2026-05-16 14:48:49 +08:00
parent 18282e610d
commit 3caa6e467b
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
10 changed files with 158 additions and 86 deletions

View 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>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -318,6 +318,7 @@ export function ApiKeysTable() {
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
singleSelect: true,
},
],
}}

View File

@ -173,6 +173,7 @@ export function RedemptionsTable() {
columnId: 'status',
title: t('Status'),
options: redemptionStatusOptions,
singleSelect: true,
},
],
}}

View File

@ -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(

View File

@ -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,
},
],
}}

View File

@ -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}`
}