Files
chaos-api/web/default/src/features/system-settings/models/group-ratio-form.tsx
T
t0ng7u 583da45296 refactor(ui): Improve usage log filter responsiveness and mobile UX
Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
2026-05-25 05:35:44 +08:00

493 lines
17 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 { memo, useCallback, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye, HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
type GroupFormValues = {
GroupRatio: string
TopupGroupRatio: string
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
DefaultUseAutoGroup: boolean
GroupSpecialUsableGroup: string
}
type GroupRatioFormProps = {
form: UseFormReturn<GroupFormValues>
onSave: (values: GroupFormValues) => Promise<void>
isSaving: boolean
}
export const GroupRatioForm = memo(function GroupRatioForm({
form,
onSave,
isSaving,
}: GroupRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const [guideOpen, setGuideOpen] = useState(false)
const handleFieldChange = useCallback(
(field: keyof GroupFormValues, value: string) => {
form.setValue(field, value, {
shouldValidate: true,
shouldDirty: true,
})
},
[form]
)
const toggleEditMode = useCallback(() => {
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
return (
<div className='space-y-6'>
<div className='flex flex-wrap justify-end gap-2'>
<Button variant='outline' size='sm' onClick={() => setGuideOpen(true)}>
<HelpCircle className='mr-2 h-4 w-4' />
{t('Usage guide')}
</Button>
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('Switch to JSON')}
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
{t('Switch to Visual')}
</>
)}
</Button>
</div>
<GroupPricingGuide open={guideOpen} onOpenChange={setGuideOpen} />
<Form {...form}>
<SettingsPageActionsPortal>
<Button
type='button'
size='sm'
onClick={form.handleSubmit(onSave)}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save group ratios')}
</Button>
</SettingsPageActionsPortal>
{editMode === 'visual' ? (
<div className='space-y-6'>
<GroupRatioVisualEditor
groupRatio={form.watch('GroupRatio')}
topupGroupRatio={form.watch('TopupGroupRatio')}
userUsableGroups={form.watch('UserUsableGroups')}
groupGroupRatio={form.watch('GroupGroupRatio')}
autoGroups={form.watch('AutoGroups')}
onChange={(field, value) =>
handleFieldChange(field as keyof GroupFormValues, value)
}
/>
<GroupSpecialUsableRulesEditor
value={form.watch('GroupSpecialUsableGroup')}
onChange={(value) =>
handleFieldChange('GroupSpecialUsableGroup', value)
}
/>
<FormField
control={form.control}
name='DefaultUseAutoGroup'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Default to auto groups')}</FormLabel>
<FormDescription>
{t(
'When enabled, newly created tokens start in the first auto group.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</div>
) : (
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
<FormField
control={form.control}
name='GroupRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group ratios')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of group → ratio applied when the user selects the group explicitly.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='TopupGroupRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Top-up group ratios')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as'
)}
{` { "default": 1, "vip": 1.2 }`}.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='UserUsableGroups'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Selectable groups')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of group → description exposed when users create API keys.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupGroupRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Inter-group overrides')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t('Nested JSON: source group →')}{' '}
{`{ targetGroup: ratio }`}{' '}
{t(
'to override billing when a user in one group uses a token of another group.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutoGroups'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto assignment order')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON array of group identifiers. When enabled below, new tokens rotate through this list.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupSpecialUsableGroup'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Special usable group rules')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='DefaultUseAutoGroup'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Default to auto groups')}</FormLabel>
<FormDescription>
{t(
'When enabled, newly created tokens start in the first auto group.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</SettingsForm>
)}
</Form>
</div>
)
})
type GroupPricingGuideProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
function GuideCodeBlock({ children }: { children: string }) {
return (
<pre className='bg-muted/60 overflow-x-auto rounded-lg border px-3 py-2 text-xs leading-6 whitespace-pre-wrap'>
{children}
</pre>
)
}
function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
const { t } = useTranslation()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side='right'
className={sideDrawerContentClassName('sm:max-w-2xl')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>{t('Group pricing usage guide')}</SheetTitle>
<SheetDescription>
{t(
'Understand how user groups, token groups, ratios, and special rules work together.'
)}
</SheetDescription>
</SheetHeader>
<div className={sideDrawerFormClassName('gap-5')}>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>{t('Core concepts')}</h3>
<div className='text-muted-foreground space-y-2 text-sm leading-6'>
<p>
<span className='text-foreground font-medium'>
{t('User group')}
</span>
{': '}
{t(
'Assigned by administrators and used to represent a user level, such as default or vip.'
)}
</p>
<p>
<span className='text-foreground font-medium'>
{t('Token group')}
</span>
{': '}
{t(
'Selected when creating a token and used as the default billing group for API calls.'
)}
</p>
<p>
<span className='text-foreground font-medium'>
{t('Ratio')}
</span>
{': '}
{t(
'A billing multiplier. Lower ratios mean lower API call costs.'
)}
</p>
<p>
<span className='text-foreground font-medium'>
{t('User selectable')}
</span>
{': '}
{t(
'When enabled, users can pick this group when creating tokens.'
)}
</p>
</div>
</section>
<Accordion className='rounded-lg border px-3'>
<AccordionItem value='groups'>
<AccordionTrigger>{t('Pricing group example')}</AccordionTrigger>
<AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Use the pricing group table to manage the ratio and whether the group appears in the token creation dropdown.'
)}
</p>
<GuideCodeBlock>
{`${t('Group name')} ${t('Ratio')} ${t('User selectable')} ${t('Description')}
standard 1.0 ${t('Yes')} ${t('Standard price')}
premium 0.5 ${t('Yes')} ${t('Premium plan, half price')}
vip 0.5 ${t('No')} ${t('Assigned by administrator only')}`}
</GuideCodeBlock>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.'
)}
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value='auto'>
<AccordionTrigger>{t('Auto group behavior')}</AccordionTrigger>
<AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.'
)}
</p>
<GuideCodeBlock>{`["default", "vip"]`}</GuideCodeBlock>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'If default auto group is enabled, newly created tokens start with auto instead of an empty group.'
)}
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value='special-ratio'>
<AccordionTrigger>{t('Special ratio rules')}</AccordionTrigger>
<AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Special ratios override the token group ratio for specific user group and token group combinations.'
)}
</p>
<GuideCodeBlock>{`{
"vip": {
"standard": 0.8,
"premium": 0.3
}
}`}</GuideCodeBlock>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Only configured combinations are overridden. All other calls keep the token group base ratio.'
)}
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value='usable'>
<AccordionTrigger>
{t('Special usable group rules')}
</AccordionTrigger>
<AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Special usable group rules can add, remove, or append selectable token groups for a specific user group.'
)}
</p>
<GuideCodeBlock>{`{
"vip": {
"+:premium": "${t('Premium plan, half price')}",
"-:default": "remove",
"special": "${t('Special group')}"
}
}`}</GuideCodeBlock>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.'
)}
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</SheetContent>
</Sheet>
)
}