+1
-1
@@ -131,7 +131,7 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-full font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
textColorMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
|
||||
+10
-3
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Select as SelectPrimitive } from '@base-ui/react/select'
|
||||
import {
|
||||
UnfoldMoreIcon,
|
||||
@@ -97,8 +98,9 @@ function SelectContent({
|
||||
SelectPrimitive.Positioner.Props,
|
||||
'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const content = (
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
@@ -121,8 +123,13 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return content
|
||||
}
|
||||
|
||||
return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
|
||||
+4
-1
@@ -40,6 +40,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
@@ -418,7 +419,7 @@ function SidebarGroupLabel({
|
||||
props: mergeProps<'div'>(
|
||||
{
|
||||
className: cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
className
|
||||
),
|
||||
},
|
||||
@@ -556,6 +557,7 @@ function SidebarMenuButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
@@ -565,6 +567,7 @@ function SidebarMenuButton({
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ChevronRight,
|
||||
ListOrdered,
|
||||
Shuffle,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -301,15 +302,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
|
||||
const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
|
||||
const remainingDisplay = withSuffix(formatBalance(balance))
|
||||
const usedLabel = `${t('Used:')} ${usedDisplay}`
|
||||
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
|
||||
|
||||
// Tag row: only show cumulative used quota
|
||||
if (isTagRow) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={`Used: ${usedDisplay}`}
|
||||
label={usedLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -354,14 +358,13 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t('Used:')} {usedDisplay}
|
||||
</p>
|
||||
<p>{usedLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -384,6 +387,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-pointer'
|
||||
onClick={handleClickUpdate}
|
||||
/>
|
||||
@@ -393,7 +397,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
<p>
|
||||
{channel.type === 57
|
||||
? t('Click to view Codex usage')
|
||||
: `${t('Remaining:')} ${remainingDisplay}`}
|
||||
: remainingLabel}
|
||||
</p>
|
||||
{channel.type !== 57 && <p>{t('Click to update balance')}</p>}
|
||||
</TooltipContent>
|
||||
@@ -494,7 +498,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const name = row.getValue('name') as string
|
||||
const channel = row.original
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
|
||||
// Tag row with expand/collapse
|
||||
if (isTagRow) {
|
||||
@@ -531,6 +534,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Regular channel row
|
||||
const settings = parseChannelSettings(channel.setting)
|
||||
const isPassThrough = settings.pass_through_body_enabled === true
|
||||
const hasParamOverride = Boolean(channel.param_override?.trim())
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -557,13 +561,19 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isMultiKey && (
|
||||
<StatusBadge
|
||||
label={`${channel.channel_info.multi_key_size} keys`}
|
||||
variant='purple'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
{hasParamOverride && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' />
|
||||
}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{t('Override request parameters')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<UpstreamUpdateTags channel={channel} />
|
||||
</div>
|
||||
|
||||
+5
-13
@@ -2754,23 +2754,15 @@ export function ChannelMutateDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<JsonEditor
|
||||
<Textarea
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
keyPlaceholder='temperature'
|
||||
valuePlaceholder='0.7'
|
||||
keyLabel='Parameter'
|
||||
valueLabel='Value'
|
||||
emptyMessage={t(
|
||||
'No parameter overrides configured.'
|
||||
rows={8}
|
||||
placeholder={t(
|
||||
'Override request parameters. Cannot override stream parameter.'
|
||||
)}
|
||||
template={{
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
top_p: 1,
|
||||
}}
|
||||
valueType='any'
|
||||
className='max-h-72 min-h-40 resize-y overflow-auto font-mono text-xs'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
+50
-35
@@ -26,6 +26,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
Copy,
|
||||
CreditCard,
|
||||
FileText,
|
||||
KeyRound,
|
||||
@@ -38,13 +39,14 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { motion, useReducedMotion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getUserModels } from '@/lib/api'
|
||||
import { MOTION_TRANSITION } from '@/lib/motion'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
@@ -104,8 +106,8 @@ interface RequestExample {
|
||||
endpoint: string
|
||||
model: string
|
||||
keyName: string
|
||||
keyId?: number
|
||||
displayKey: string
|
||||
curl: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
@@ -179,7 +181,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 bg-[linear-gradient(112deg,oklch(0.97_0.04_250/.92)_0%,oklch(0.95_0.08_315/.82)_38%,oklch(0.96_0.12_92/.78)_74%,oklch(0.94_0.1_132/.62)_100%)] dark:opacity-25',
|
||||
'pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_48%_120%_at_78%_0%,color-mix(in_oklch,var(--primary)_8%,transparent)_0%,transparent_62%),linear-gradient(112deg,color-mix(in_oklch,var(--card)_98%,var(--primary)_2%)_0%,color-mix(in_oklch,var(--card)_94%,var(--muted)_6%)_48%,color-mix(in_oklch,var(--background)_92%,var(--accent)_8%)_100%)] dark:opacity-65',
|
||||
props.compact
|
||||
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
|
||||
: 'opacity-85'
|
||||
@@ -188,7 +190,7 @@ function SetupGuideBackdrop(props: { compact?: boolean }) {
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono text-lime-100/75 sm:block dark:text-lime-200/25',
|
||||
'text-foreground/5 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block dark:text-foreground/8',
|
||||
props.compact ? 'w-1/2 opacity-45' : 'w-[58%] opacity-75'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
@@ -275,12 +277,41 @@ function RequestPreview(props: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const shouldReduceMotion = useReducedMotion()
|
||||
const previewLines = props.example.curl.split('\n').map((line) => {
|
||||
if (line.includes('Authorization: Bearer')) {
|
||||
return ` -H "Authorization: Bearer ${props.example.displayKey}" \\`
|
||||
}
|
||||
return line
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
const { copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
const previewCurl = buildCurlCommand({
|
||||
endpoint: props.example.endpoint,
|
||||
apiKey: props.example.displayKey,
|
||||
model: props.example.model,
|
||||
})
|
||||
const previewLines = previewCurl.split('\n')
|
||||
const handleCopyRequest = async () => {
|
||||
if (!props.example.keyId || isCopying) return
|
||||
|
||||
setIsCopying(true)
|
||||
try {
|
||||
const result = await fetchTokenKey(props.example.keyId)
|
||||
const key = result.success && result.data?.key ? result.data.key : ''
|
||||
if (!key) {
|
||||
toast.error(result.message || t('Failed to copy to clipboard'))
|
||||
return
|
||||
}
|
||||
|
||||
const realCurl = buildCurlCommand({
|
||||
endpoint: props.example.endpoint,
|
||||
apiKey: `sk-${key}`,
|
||||
model: props.example.model,
|
||||
})
|
||||
const copied = await copyToClipboard(realCurl)
|
||||
if (copied) {
|
||||
toast.success(t('Copied to clipboard'))
|
||||
} else {
|
||||
toast.error(t('Failed to copy to clipboard'))
|
||||
}
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -315,17 +346,17 @@ function RequestPreview(props: {
|
||||
</div>
|
||||
</div>
|
||||
{props.example.ready ? (
|
||||
<CopyButton
|
||||
value={props.example.curl}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
tooltip={t('Copy ready-to-run curl')}
|
||||
successTooltip={t('Copied!')}
|
||||
disabled={isCopying}
|
||||
onClick={handleCopyRequest}
|
||||
aria-label={t('Copy ready-to-run curl')}
|
||||
>
|
||||
{t('Copy')}
|
||||
</CopyButton>
|
||||
<Copy data-icon='inline-start' />
|
||||
{isCopying ? t('Loading') : t('Copy')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size='sm' variant='outline' render={<Link to='/keys' />}>
|
||||
{t('Create API Key')}
|
||||
@@ -463,17 +494,6 @@ export function OverviewDashboard() {
|
||||
[apiKeysQuery.data]
|
||||
)
|
||||
|
||||
const realKeyQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview', 'token-key', preferredKey?.id],
|
||||
queryFn: async () => {
|
||||
if (!preferredKey?.id) return ''
|
||||
const result = await fetchTokenKey(preferredKey.id)
|
||||
return result.success && result.data?.key ? `sk-${result.data.key}` : ''
|
||||
},
|
||||
enabled: Boolean(preferredKey?.id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const startSteps = useMemo<StartStep[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -561,23 +581,18 @@ export function OverviewDashboard() {
|
||||
const requestExample = useMemo<RequestExample>(() => {
|
||||
const endpoint = normalizeEndpoint(apiInfoItems[0]?.url)
|
||||
const model = modelsQuery.data?.[0] ?? 'gpt-4o-mini'
|
||||
const apiKey = realKeyQuery.data ?? ''
|
||||
const keyName = preferredKey?.name ?? t('No API key yet')
|
||||
const ready = Boolean(apiKey && model)
|
||||
const ready = Boolean(preferredKey?.id && model)
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
model,
|
||||
keyName,
|
||||
displayKey: formatDisplayKey(apiKey),
|
||||
keyId: preferredKey?.id,
|
||||
displayKey: preferredKey ? formatDisplayKey(`sk-${preferredKey.key}`) : 'sk-...',
|
||||
ready,
|
||||
curl: buildCurlCommand({
|
||||
endpoint,
|
||||
apiKey: apiKey || 'sk-...',
|
||||
model,
|
||||
}),
|
||||
}
|
||||
}, [apiInfoItems, modelsQuery.data, preferredKey, realKeyQuery.data, t])
|
||||
}, [apiInfoItems, modelsQuery.data, preferredKey, t])
|
||||
|
||||
const completedStepCount = startSteps.filter((step) => step.completed).length
|
||||
const setupComplete = completedStepCount === startSteps.length
|
||||
|
||||
@@ -150,7 +150,7 @@ export function createDurationColumn<T>(config: {
|
||||
variant={variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className={cn('font-mono', durationBgMap[variant])}
|
||||
className={cn('rounded-md font-mono', durationBgMap[variant])}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
+29
-11
@@ -90,6 +90,12 @@ function getGroupRatioText(other: LogOtherData | null): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
|
||||
const match = value.match(/^([^0-9+\-.,\s]+)(.+)$/)
|
||||
if (!match) return { prefix: '', amount: value }
|
||||
return { prefix: match[1], amount: match[2] }
|
||||
}
|
||||
|
||||
function buildDetailSegments(
|
||||
log: UsageLog,
|
||||
other: LogOtherData | null,
|
||||
@@ -277,6 +283,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
variant={config.color as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='!text-xs [&_span]:!text-xs'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -295,6 +302,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
columns.push(
|
||||
{
|
||||
id: 'channel',
|
||||
accessorFn: (row) => row.channel,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Channel')} />
|
||||
),
|
||||
@@ -395,10 +403,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Channel'), mobileHidden: true },
|
||||
meta: { label: t('Channel') },
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
accessorFn: (row) => row.username,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
@@ -419,7 +428,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
setUserInfoDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Avatar className='ring-border/60 size-6 ring-1'>
|
||||
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-[11px] font-semibold',
|
||||
@@ -451,7 +460,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User'), mobileHidden: true },
|
||||
meta: { label: t('User') },
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -555,7 +564,9 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
? log.completion_tokens / useTime
|
||||
: null
|
||||
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
||||
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : 'neutral'
|
||||
const frtVariant = frt
|
||||
? getFirstResponseTimeColor(frt / 1000)
|
||||
: 'neutral'
|
||||
|
||||
const timingBgMap: Record<string, string> = {
|
||||
success:
|
||||
@@ -576,7 +587,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
variant={timeVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className={cn('font-mono', timingBgMap[timeVariant])}
|
||||
className={cn('rounded-md font-mono', timingBgMap[timeVariant])}
|
||||
/>
|
||||
{log.is_stream &&
|
||||
(frt != null && frt > 0 ? (
|
||||
@@ -586,7 +597,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={cn('font-mono', timingBgMap[frtVariant])}
|
||||
className={cn(
|
||||
'rounded-md font-mono',
|
||||
timingBgMap[frtVariant]
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
@@ -595,7 +609,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={timingBgMap.neutral}
|
||||
className={cn('rounded-md font-mono', timingBgMap.neutral)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -641,7 +655,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Timing'), mobileHidden: true },
|
||||
meta: { label: t('Timing') },
|
||||
},
|
||||
|
||||
{
|
||||
@@ -692,7 +706,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { label: 'Tokens', mobileHidden: true },
|
||||
meta: { label: 'Tokens' },
|
||||
},
|
||||
|
||||
{
|
||||
@@ -734,11 +748,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
}
|
||||
|
||||
const quotaStr = formatLogQuota(quota)
|
||||
const quotaDisplay = splitQuotaDisplay(quotaStr)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='border-border/80 bg-muted/60 inline-flex w-fit items-center rounded-md border px-1.5 py-0.5 [font-family:var(--font-body)] font-semibold tabular-nums'>
|
||||
{quotaStr}
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 text-sm leading-none [font-family:var(--font-body)] font-semibold tabular-nums'>
|
||||
{quotaDisplay.prefix && (
|
||||
<span className='mr-1'>{quotaDisplay.prefix}</span>
|
||||
)}
|
||||
<span>{quotaDisplay.amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
+2
-2
@@ -231,7 +231,7 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Image'), mobileHidden: true },
|
||||
meta: { label: t('Image') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prompt',
|
||||
@@ -268,7 +268,7 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: { label: t('Prompt'), mobileHidden: true },
|
||||
meta: { label: t('Prompt') },
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
},
|
||||
|
||||
+3
-2
@@ -123,6 +123,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
if (isAdmin) {
|
||||
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), {
|
||||
id: 'user',
|
||||
accessorFn: (row) => row.username || row.user_id,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('User')} />
|
||||
),
|
||||
@@ -142,7 +143,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
setUserInfoDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Avatar className='ring-border/60 size-6 ring-1'>
|
||||
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-[11px] font-semibold',
|
||||
@@ -161,7 +162,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: { label: t('User'), mobileHidden: true },
|
||||
meta: { label: t('User') },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,12 @@ function ModelBadgeContent(props: ModelBadgeProps) {
|
||||
showDot={!provider}
|
||||
autoColor={provider ? undefined : props.modelName}
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/30 h-auto min-h-6 gap-1.5 rounded-md border px-2 py-0.5 [font-family:var(--font-body)] break-all whitespace-normal',
|
||||
'border-border/60 bg-muted/30 h-6 max-w-full gap-1.5 rounded-md border px-2 [font-family:var(--font-body)]',
|
||||
provider && 'text-foreground',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span className='flex min-w-0 items-center gap-1.5'>
|
||||
<span className='flex max-w-full min-w-0 items-center gap-1.5'>
|
||||
{provider && (
|
||||
<span
|
||||
className='flex size-3.5 shrink-0 items-center justify-center'
|
||||
@@ -116,7 +116,7 @@ function ModelBadgeContent(props: ModelBadgeProps) {
|
||||
{getLobeIcon(provider.icon, 14)}
|
||||
</span>
|
||||
)}
|
||||
<span>{props.modelName}</span>
|
||||
<span className='truncate'>{props.modelName}</span>
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
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 {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dotColorMap, textColorMap, type StatusVariant } from '@/components/status-badge'
|
||||
import type { LogCategory } from '../types'
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
import { getLogTypeConfig } from '../lib/utils'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
logCategory: LogCategory
|
||||
}
|
||||
|
||||
function UsageLogsMobileSkeleton() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='space-y-2.5 border-b border-border/40 p-3 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-5 w-40 rounded-md' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{[1, 2, 3, 4, 5, 6].map((j) => (
|
||||
<div key={j} className='min-w-0 space-y-1'>
|
||||
<Skeleton className='h-3 w-10 rounded' />
|
||||
<Skeleton className='h-4 w-full rounded' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactCell<TData>({
|
||||
cell,
|
||||
fallback = '-',
|
||||
className,
|
||||
primaryOnly = false,
|
||||
}: {
|
||||
cell?: Cell<TData, unknown>
|
||||
fallback?: string
|
||||
className?: string
|
||||
primaryOnly?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
|
||||
primaryOnly &&
|
||||
'[&_.flex-col>*:not(:first-child)]:hidden [&_.flex-col]:min-w-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cell ? (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
) : (
|
||||
<span className='text-muted-foreground/50'>{fallback}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryField<TData>({
|
||||
label,
|
||||
cell,
|
||||
className,
|
||||
valueClassName,
|
||||
primaryOnly = false,
|
||||
}: {
|
||||
label: string
|
||||
cell?: Cell<TData, unknown>
|
||||
className?: string
|
||||
valueClassName?: string
|
||||
primaryOnly?: boolean
|
||||
}) {
|
||||
if (!cell) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 rounded-md bg-muted/20 px-2 py-1.5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{label}
|
||||
</div>
|
||||
<CompactCell
|
||||
cell={cell}
|
||||
primaryOnly={primaryOnly}
|
||||
className={valueClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileLogTimeStatus({
|
||||
createdAt,
|
||||
type,
|
||||
}: {
|
||||
createdAt: unknown
|
||||
type: unknown
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const timestamp = typeof createdAt === 'number' ? createdAt : undefined
|
||||
const logType = typeof type === 'number' ? type : undefined
|
||||
const config = getLogTypeConfig(logType ?? LOG_TYPE_ENUM.UNKNOWN)
|
||||
const variant = config.color as StatusVariant
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='font-mono text-xs leading-tight tabular-nums'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs leading-none font-medium',
|
||||
textColorMap[variant]
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap[variant])}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span>{t(config.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommonLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const modelCell = cells.get('model_name')
|
||||
const quotaCell = cells.get('quota')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={modelCell} className='flex-1' />
|
||||
<CompactCell
|
||||
cell={quotaCell}
|
||||
className='shrink-0 text-right [&_span]:!h-6 [&_span]:!px-2 [&_span]:!text-sm [&_span]:!leading-none'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
|
||||
<div className='min-w-0 rounded-md bg-muted/20 px-2 py-1.5'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{t('Time')}
|
||||
</div>
|
||||
<MobileLogTimeStatus
|
||||
createdAt={cells.get('created_at')?.row.original?.created_at}
|
||||
type={cells.get('created_at')?.row.original?.type}
|
||||
/>
|
||||
</div>
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Token')}
|
||||
cell={cells.get('token_name')}
|
||||
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Timing')}
|
||||
cell={cells.get('use_time')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Tokens')}
|
||||
cell={cells.get('prompt_tokens')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Details')}
|
||||
cell={cells.get('content')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const taskIdCell = cells.get('task_id')
|
||||
const statusCell = cells.get('status')
|
||||
const submitTimeCell = cells.get('submit_time')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={taskIdCell} className='flex-1' />
|
||||
<CompactCell cell={statusCell} className='shrink-0 text-right' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('User')}
|
||||
cell={cells.get('user')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Result')}
|
||||
cell={cells.get('fail_reason')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const actionCell = cells.get('action')
|
||||
const codeCell = cells.get('code')
|
||||
const submitTimeCell = cells.get('submit_time')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={actionCell} className='flex-1' />
|
||||
<CompactCell cell={codeCell} className='shrink-0 text-right' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Task ID')}
|
||||
cell={cells.get('mj_id')}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Duration')}
|
||||
cell={cells.get('duration')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Image')}
|
||||
cell={cells.get('image_url')}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Prompt')}
|
||||
cell={cells.get('prompt')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Fail Reason')}
|
||||
cell={cells.get('fail_reason')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsageLogsMobileList<TData>({
|
||||
table,
|
||||
isLoading = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
logCategory,
|
||||
}: UsageLogsMobileListProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Logs Found')
|
||||
const resolvedEmptyDescription =
|
||||
emptyDescription ??
|
||||
t('No usage logs available. Logs will appear here once API calls are made.')
|
||||
|
||||
if (isLoading) {
|
||||
return <UsageLogsMobileSkeleton />
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<Empty className="border-none p-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Database className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
{rows.map((row) => {
|
||||
const cells = new Map(
|
||||
row.getVisibleCells().map((cell) => [cell.column.id, cell])
|
||||
)
|
||||
|
||||
const logType = (row.original as Record<string, unknown>).type as
|
||||
| number
|
||||
| undefined
|
||||
const tintClass = logType != null ? (logTypeRowTint[logType] ?? '') : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-l-2 border-l-transparent border-b border-border/40 p-3 transition-colors last:border-b-0',
|
||||
tintClass
|
||||
)}
|
||||
>
|
||||
{logCategory === 'common' && (
|
||||
<CommonLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'task' && (
|
||||
<TaskLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'drawing' && (
|
||||
<DrawingLogsCard cells={cells} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { fetchLogsByCategory } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
import { CommonLogsFilterBar } from './common-logs-filter-bar'
|
||||
import { TaskLogsFilterBar } from './task-logs-filter-bar'
|
||||
import { UsageLogsMobileList } from './usage-logs-mobile-card'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -190,6 +191,13 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
'[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]'
|
||||
)}
|
||||
tableHeaderClassName='bg-muted/30 sticky top-0 z-10'
|
||||
mobile={
|
||||
<UsageLogsMobileList
|
||||
table={table}
|
||||
isLoading={isLoadingData}
|
||||
logCategory={logCategory}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
isCommon ? (
|
||||
<CommonLogsFilterBar table={table} />
|
||||
|
||||
Vendored
+1
@@ -2778,6 +2778,7 @@
|
||||
"Override auto-discovered endpoint": "Override auto-discovered endpoint",
|
||||
"Override request headers": "Override request headers",
|
||||
"Override request headers (JSON format)": "Override request headers (JSON format)",
|
||||
"Override request parameters": "Override request parameters",
|
||||
"Override request parameters (JSON format)": "Override request parameters (JSON format)",
|
||||
"Override request parameters. Cannot override": "Override request parameters. Cannot override",
|
||||
"Override request parameters. Cannot override stream parameter.": "Override request parameters. Cannot override stream parameter.",
|
||||
|
||||
Vendored
+1
@@ -2778,6 +2778,7 @@
|
||||
"Override auto-discovered endpoint": "Remplacer le point de terminaison auto-découvert",
|
||||
"Override request headers": "Remplacer les en-têtes de requête",
|
||||
"Override request headers (JSON format)": "Surcharge des en-têtes de requête (format JSON)",
|
||||
"Override request parameters": "Remplacer les paramètres de requête",
|
||||
"Override request parameters (JSON format)": "Remplacer les paramètres de requête (format JSON)",
|
||||
"Override request parameters. Cannot override": "Remplacer les paramètres de requête. Impossible de remplacer",
|
||||
"Override request parameters. Cannot override stream parameter.": "Remplace les paramètres de requête. Impossible de remplacer le paramètre stream.",
|
||||
|
||||
Vendored
+1
@@ -2778,6 +2778,7 @@
|
||||
"Override auto-discovered endpoint": "自動検出されたエンドポイントを上書きする",
|
||||
"Override request headers": "リクエストヘッダーを上書きする",
|
||||
"Override request headers (JSON format)": "リクエストヘッダーのオーバーライド (JSON 形式)",
|
||||
"Override request parameters": "リクエストパラメータを上書き",
|
||||
"Override request parameters (JSON format)": "リクエストパラメータの上書き (JSON形式)",
|
||||
"Override request parameters. Cannot override": "リクエストパラメーターを上書きします。上書きできません",
|
||||
"Override request parameters. Cannot override stream parameter.": "リクエストパラメータを上書きします。stream パラメータは上書きできません。",
|
||||
|
||||
Vendored
+1
@@ -2778,6 +2778,7 @@
|
||||
"Override auto-discovered endpoint": "Переопределить автоматически обнаруженную конечную точку",
|
||||
"Override request headers": "Переопределить заголовки запроса",
|
||||
"Override request headers (JSON format)": "Переопределение заголовков запроса (формат JSON)",
|
||||
"Override request parameters": "Переопределить параметры запроса",
|
||||
"Override request parameters (JSON format)": "Переопределить параметры запроса (формат JSON)",
|
||||
"Override request parameters. Cannot override": "Переопределить параметры запроса. Невозможно переопределить",
|
||||
"Override request parameters. Cannot override stream parameter.": "Переопределяет параметры запроса. Параметр stream переопределить нельзя.",
|
||||
|
||||
Vendored
+1
@@ -2778,6 +2778,7 @@
|
||||
"Override auto-discovered endpoint": "Ghi đè điểm cuối tự động phát hiện",
|
||||
"Override request headers": "Ghi đè tiêu đề yêu cầu",
|
||||
"Override request headers (JSON format)": "Ghi đè tiêu đề yêu cầu (định dạng JSON)",
|
||||
"Override request parameters": "Ghi đè tham số yêu cầu",
|
||||
"Override request parameters (JSON format)": "Ghi đè tham số yêu cầu (định dạng JSON)",
|
||||
"Override request parameters. Cannot override": "Ghi đè tham số yêu cầu. Không thể ghi đè",
|
||||
"Override request parameters. Cannot override stream parameter.": "Ghi đè tham số yêu cầu. Không thể ghi đè tham số stream.",
|
||||
|
||||
Vendored
+1
@@ -2778,6 +2778,7 @@
|
||||
"Override auto-discovered endpoint": "覆盖自动发现的端点",
|
||||
"Override request headers": "覆盖请求标头",
|
||||
"Override request headers (JSON format)": "覆盖请求头(JSON 格式)",
|
||||
"Override request parameters": "覆盖请求参数",
|
||||
"Override request parameters (JSON format)": "覆盖请求参数 (JSON 格式)",
|
||||
"Override request parameters. Cannot override": "覆盖请求参数。无法覆盖",
|
||||
"Override request parameters. Cannot override stream parameter.": "覆盖请求参数。无法覆盖 stream 参数。",
|
||||
|
||||
Vendored
+1
@@ -315,6 +315,7 @@ export const STATIC_I18N_KEYS = [
|
||||
'Regex Replace',
|
||||
'Return Error',
|
||||
'Param Override',
|
||||
'Override request parameters',
|
||||
|
||||
// Profile / 2FA
|
||||
'Backed up',
|
||||
|
||||
Vendored
+1
-1
@@ -292,7 +292,7 @@ function formatCurrencyValue(
|
||||
maximumFractionDigits: digits,
|
||||
}).format(adjustedValue)
|
||||
|
||||
return `${meta.symbol}${decimal}`
|
||||
return `${meta.symbol} ${decimal}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user