fix(default): improve billing settings forms

This commit is contained in:
CaIon
2026-05-06 20:14:35 +08:00
parent 446a8420f5
commit dede1e2968
17 changed files with 1291 additions and 832 deletions
@@ -51,10 +51,10 @@ export function ProfileSettingsCard({
icon={<Settings className='h-4 w-4' />}
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
<TabsList className='grid h-10 w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1'>
<TabsTrigger
value='bindings'
className='h-auto gap-2 rounded-lg px-3 py-2'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
>
<Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
@@ -62,7 +62,7 @@ export function ProfileSettingsCard({
</TabsTrigger>
<TabsTrigger
value='settings'
className='h-auto gap-2 rounded-lg px-3 py-2'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>
@@ -45,9 +45,13 @@ const BILLING_SECTIONS = [
QuotaForInviter: settings.QuotaForInviter,
QuotaForInvitee: settings.QuotaForInvitee,
TopUpLink: settings.TopUpLink,
'general_setting.docs_link': settings['general_setting.docs_link'],
'quota_setting.enable_free_model_pre_consume':
settings['quota_setting.enable_free_model_pre_consume'],
general_setting: {
docs_link: settings['general_setting.docs_link'],
},
quota_setting: {
enable_free_model_pre_consume:
settings['quota_setting.enable_free_model_pre_consume'],
},
}}
/>
),
@@ -1,4 +1,5 @@
import * as z from 'zod'
import type { ChangeEvent } from 'react'
import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
@@ -25,9 +26,13 @@ const quotaSchema = z.object({
PreConsumedQuota: z.coerce.number().min(0),
QuotaForInviter: z.coerce.number().min(0),
QuotaForInvitee: z.coerce.number().min(0),
TopUpLink: z.string().url().optional().or(z.literal('')),
'general_setting.docs_link': z.string().url().optional().or(z.literal('')),
'quota_setting.enable_free_model_pre_consume': z.boolean(),
TopUpLink: z.string(),
general_setting: z.object({
docs_link: z.string(),
}),
quota_setting: z.object({
enable_free_model_pre_consume: z.boolean(),
}),
})
type QuotaFormValues = z.infer<typeof quotaSchema>
@@ -41,6 +46,13 @@ export function QuotaSettingsSection({
}: QuotaSettingsSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const handleNumberChange =
(onChange: (value: number | string) => void) =>
(event: ChangeEvent<HTMLInputElement>) => {
onChange(
event.target.value === '' ? '' : event.currentTarget.valueAsNumber
)
}
const { form, handleSubmit, isDirty, isSubmitting } =
useSettingsForm<QuotaFormValues>({
@@ -79,8 +91,8 @@ export function QuotaSettingsSection({
<FormControl>
<Input
type='number'
value={field.value as number}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
@@ -103,8 +115,8 @@ export function QuotaSettingsSection({
<FormControl>
<Input
type='number'
value={field.value as number}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
@@ -127,8 +139,8 @@ export function QuotaSettingsSection({
<FormControl>
<Input
type='number'
value={field.value as number}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
@@ -151,8 +163,8 @@ export function QuotaSettingsSection({
<FormControl>
<Input
type='number'
value={field.value as number}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
@@ -1,8 +1,14 @@
import { memo, useCallback, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react'
import { Code2, Eye, HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import {
Form,
FormControl,
@@ -14,6 +20,13 @@ import {
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
@@ -40,6 +53,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
}: GroupRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const [guideOpen, setGuideOpen] = useState(false)
const handleFieldChange = useCallback(
(field: keyof GroupFormValues, value: string) => {
@@ -57,7 +71,15 @@ export const GroupRatioForm = memo(function GroupRatioForm({
return (
<div className='space-y-6'>
<div className='flex justify-end'>
<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' ? (
<>
@@ -73,6 +95,8 @@ export const GroupRatioForm = memo(function GroupRatioForm({
</Button>
</div>
<GroupPricingGuide open={guideOpen} onOpenChange={setGuideOpen} />
<Form {...form}>
{editMode === 'visual' ? (
<div className='space-y-6'>
@@ -276,3 +300,165 @@ export const GroupRatioForm = memo(function GroupRatioForm({
</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='w-full gap-0 p-0 sm:max-w-2xl'>
<SheetHeader className='border-b p-4'>
<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='space-y-5 overflow-y-auto p-4'>
<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>
)
}
@@ -1,4 +1,4 @@
import { useState, useMemo, memo } from 'react'
import { useState, useMemo, useEffect, useCallback, memo } from 'react'
import { Pencil, Plus, Trash2, GripVertical, ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -14,6 +14,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -48,8 +49,11 @@ type SimpleGroup = {
value: string
}
type UsableGroup = {
type GroupPricingRow = {
_id: string
name: string
ratio: number
selectable: boolean
description: string
}
@@ -58,6 +62,90 @@ type GroupOverride = {
ratio: number
}
const sectionCardClassName =
'relative shadow-sm ring-0 before:pointer-events-none before:absolute before:inset-0 before:rounded-xl before:border before:border-border/90'
const sectionHeaderClassName = 'border-b bg-muted/20'
let groupPricingIdCounter = 0
function createGroupPricingId() {
groupPricingIdCounter += 1
return `gpr_${groupPricingIdCounter}`
}
function normalizeRatio(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 1
}
function buildGroupPricingRows(
groupRatio: string,
userUsableGroups: string
): GroupPricingRow[] {
const ratioMap = safeJsonParse<Record<string, number>>(groupRatio, {
fallback: {},
context: 'group ratios',
})
const usableMap = safeJsonParse<Record<string, string>>(userUsableGroups, {
fallback: {},
context: 'user usable groups',
})
const names = new Set([...Object.keys(ratioMap), ...Object.keys(usableMap)])
return Array.from(names).map((name) => ({
_id: createGroupPricingId(),
name,
ratio: normalizeRatio(ratioMap[name]),
selectable: Object.prototype.hasOwnProperty.call(usableMap, name),
description: String(usableMap[name] ?? ''),
}))
}
function serializeGroupPricingRows(rows: GroupPricingRow[]) {
const groupRatio: Record<string, number> = {}
const userUsableGroups: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
if (!name) continue
groupRatio[name] = normalizeRatio(row.ratio)
if (row.selectable) {
userUsableGroups[name] = row.description
}
}
return {
GroupRatio: JSON.stringify(groupRatio, null, 2),
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
}
}
function groupPricingSignature(rows: GroupPricingRow[]): string {
const serialized = serializeGroupPricingRows(rows)
return JSON.stringify({
groupRatio: safeJsonParse(serialized.GroupRatio, {
fallback: {},
silent: true,
}),
userUsableGroups: safeJsonParse(serialized.UserUsableGroups, {
fallback: {},
silent: true,
}),
})
}
function sourceGroupPricingSignature(
groupRatio: string,
userUsableGroups: string
): string {
return JSON.stringify({
groupRatio: safeJsonParse(groupRatio, { fallback: {}, silent: true }),
userUsableGroups: safeJsonParse(userUsableGroups, {
fallback: {},
silent: true,
}),
})
}
export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
groupRatio,
topupGroupRatio,
@@ -73,9 +161,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
>(null)
const [simpleEditData, setSimpleEditData] = useState<SimpleGroup | null>(null)
const [usableDialogOpen, setUsableDialogOpen] = useState(false)
const [usableEditData, setUsableEditData] = useState<UsableGroup | null>(null)
const [autoGroupDialogOpen, setAutoGroupDialogOpen] = useState(false)
const [autoGroupInput, setAutoGroupInput] = useState('')
@@ -89,18 +174,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
const [userGroupDialogOpen, setUserGroupDialogOpen] = useState(false)
const [userGroupInput, setUserGroupInput] = useState('')
// Parse group ratios
const groupRatioList = useMemo(() => {
const map = safeJsonParse<Record<string, number>>(groupRatio, {
fallback: {},
context: 'group ratios',
})
return Object.entries(map).map(([name, value]) => ({
name,
value: String(value),
}))
}, [groupRatio])
// Parse topup group ratios
const topupRatioList = useMemo(() => {
const map = safeJsonParse<Record<string, number>>(topupGroupRatio, {
@@ -113,18 +186,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
}))
}, [topupGroupRatio])
// Parse usable groups
const usableGroupsList = useMemo(() => {
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
fallback: {},
context: 'user usable groups',
})
return Object.entries(map).map(([name, description]) => ({
name,
description: String(description),
}))
}, [userUsableGroups])
// Parse auto groups
const autoGroupsList = useMemo(() => {
return safeJsonParse<string[]>(autoGroups, {
@@ -204,42 +265,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
onChange(field, JSON.stringify(map, null, 2))
}
// Usable groups handlers
const handleUsableAdd = () => {
setUsableEditData(null)
setUsableDialogOpen(true)
}
const handleUsableEdit = (group: UsableGroup) => {
setUsableEditData(group)
setUsableDialogOpen(true)
}
const handleUsableSave = (name: string, description: string) => {
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
fallback: {},
silent: true,
})
if (usableEditData && usableEditData.name !== name) {
delete map[usableEditData.name]
}
map[name] = description
onChange('UserUsableGroups', JSON.stringify(map, null, 2))
setUsableDialogOpen(false)
}
const handleUsableDelete = (name: string) => {
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
fallback: {},
silent: true,
})
delete map[name]
onChange('UserUsableGroups', JSON.stringify(map, null, 2))
}
// Auto groups handlers
const handleAutoGroupAdd = () => {
setAutoGroupInput('')
@@ -366,75 +391,16 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
}
return (
<div className='space-y-6'>
{/* Group Ratios */}
<Card>
<CardHeader>
<CardTitle>{t('Group ratios')}</CardTitle>
<CardDescription>
{t('Base multipliers applied when users select specific groups.')}
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<Button onClick={() => handleSimpleAdd('groupRatio')} size='sm'>
<Plus className='mr-2 h-4 w-4' />
{t('Add group')}
</Button>
{groupRatioList.length > 0 && (
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Group name')}</TableHead>
<TableHead>{t('Ratio')}</TableHead>
<TableHead className='text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupRatioList.map((group) => (
<TableRow key={group.name}>
<TableCell className='font-medium'>
{group.name}
</TableCell>
<TableCell>{group.value}</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleEdit('groupRatio', group)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleDelete('groupRatio', group.name)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
<div className='space-y-4'>
<GroupPricingTable
groupRatio={groupRatio}
userUsableGroups={userUsableGroups}
onChange={onChange}
/>
{/* Topup Group Ratios */}
<Card>
<CardHeader>
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Top-up group ratios')}</CardTitle>
<CardDescription>
{t('Multipliers for recharge pricing based on user groups.')}
@@ -504,8 +470,8 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
</Card>
{/* Inter-group ratio overrides */}
<Card>
<CardHeader>
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Inter-group ratio overrides')}</CardTitle>
<CardDescription>
{t(
@@ -625,70 +591,9 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
</CardContent>
</Card>
{/* Usable Groups */}
<Card>
<CardHeader>
<CardTitle>{t('Selectable groups')}</CardTitle>
<CardDescription>
{t('Groups that users can select when creating API keys.')}
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<Button onClick={handleUsableAdd} size='sm'>
<Plus className='mr-2 h-4 w-4' />
{t('Add group')}
</Button>
{usableGroupsList.length > 0 && (
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Group name')}</TableHead>
<TableHead>{t('Description')}</TableHead>
<TableHead className='text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usableGroupsList.map((group) => (
<TableRow key={group.name}>
<TableCell className='font-medium'>
{group.name}
</TableCell>
<TableCell>{group.description}</TableCell>
<TableCell className='text-right'>
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleUsableEdit(group)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleUsableDelete(group.name)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
{/* Auto Groups */}
<Card>
<CardHeader>
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Auto assignment order')}</CardTitle>
<CardDescription>
{t(
@@ -753,14 +658,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
type={simpleDialogType}
/>
{/* Usable Group Dialog */}
<UsableGroupDialog
open={usableDialogOpen}
onOpenChange={setUsableDialogOpen}
onSave={handleUsableSave}
editData={usableEditData}
/>
{/* Auto Group Dialog */}
<Dialog open={autoGroupDialogOpen} onOpenChange={setAutoGroupDialogOpen}>
<DialogContent>
@@ -835,6 +732,233 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
)
})
type GroupPricingTableProps = {
groupRatio: string
userUsableGroups: string
onChange: (field: string, value: string) => void
}
function GroupPricingTable({
groupRatio,
userUsableGroups,
onChange,
}: GroupPricingTableProps) {
const { t } = useTranslation()
const [rows, setRows] = useState<GroupPricingRow[]>(() =>
buildGroupPricingRows(groupRatio, userUsableGroups)
)
useEffect(() => {
const incomingSignature = sourceGroupPricingSignature(
groupRatio,
userUsableGroups
)
setRows((currentRows) => {
if (groupPricingSignature(currentRows) === incomingSignature) {
return currentRows
}
return buildGroupPricingRows(groupRatio, userUsableGroups)
})
}, [groupRatio, userUsableGroups])
const emitRows = useCallback(
(nextRows: GroupPricingRow[]) => {
setRows(nextRows)
const serialized = serializeGroupPricingRows(nextRows)
onChange('GroupRatio', serialized.GroupRatio)
onChange('UserUsableGroups', serialized.UserUsableGroups)
},
[onChange]
)
const updateRow = useCallback(
(
id: string,
field: Exclude<keyof GroupPricingRow, '_id'>,
value: string | number | boolean
) => {
emitRows(
rows.map((row) => (row._id === id ? { ...row, [field]: value } : row))
)
},
[emitRows, rows]
)
const addRow = useCallback(() => {
const existingNames = new Set(rows.map((row) => row.name))
let index = 1
let name = `group_${index}`
while (existingNames.has(name)) {
index += 1
name = `group_${index}`
}
emitRows([
...rows,
{
_id: createGroupPricingId(),
name,
ratio: 1,
selectable: true,
description: '',
},
])
}, [emitRows, rows])
const removeRow = useCallback(
(id: string) => {
emitRows(rows.filter((row) => row._id !== id))
},
[emitRows, rows]
)
const duplicateNames = useMemo(() => {
const counts = new Map<string, number>()
for (const row of rows) {
const name = row.name.trim()
if (!name) continue
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([name]) => name)
}, [rows])
return (
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div>
<CardTitle>{t('Pricing groups')}</CardTitle>
<CardDescription>
{t('Edit billing ratios and user-selectable groups in one table.')}
</CardDescription>
</div>
<Button onClick={addRow} size='sm' className='sm:self-start'>
<Plus className='mr-2 h-4 w-4' />
{t('Add group')}
</Button>
</div>
</CardHeader>
<CardContent>
<div className='space-y-3'>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='min-w-40'>{t('Group name')}</TableHead>
<TableHead className='w-28'>{t('Ratio')}</TableHead>
<TableHead className='w-28 text-center'>
{t('User selectable')}
</TableHead>
<TableHead className='min-w-56'>{t('Description')}</TableHead>
<TableHead className='w-16 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className='text-muted-foreground h-20 text-center text-sm'
>
{t('No groups yet. Add a group to get started.')}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row._id}>
<TableCell>
<Input
value={row.name}
onChange={(event) =>
updateRow(row._id, 'name', event.target.value)
}
aria-invalid={duplicateNames.includes(
row.name.trim()
)}
/>
</TableCell>
<TableCell>
<Input
type='number'
min={0}
step={0.1}
value={String(row.ratio)}
onChange={(event) =>
updateRow(
row._id,
'ratio',
normalizeRatio(event.target.value)
)
}
/>
</TableCell>
<TableCell>
<div className='flex justify-center'>
<Checkbox
checked={row.selectable}
onCheckedChange={(checked) =>
updateRow(
row._id,
'selectable',
checked === true
)
}
aria-label={t('User selectable')}
/>
</div>
</TableCell>
<TableCell>
{row.selectable ? (
<Input
value={row.description}
placeholder={t('Group description')}
onChange={(event) =>
updateRow(
row._id,
'description',
event.target.value
)
}
/>
) : (
<span className='text-muted-foreground px-3 text-sm'>
-
</span>
)}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='sm'
onClick={() => removeRow(row._id)}
aria-label={t('Delete')}
>
<Trash2 className='h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{duplicateNames.length > 0 && (
<p className='text-destructive text-sm'>
{t('Duplicate group names: {{names}}', {
names: duplicateNames.join(', '),
})}
</p>
)}
</div>
</CardContent>
</Card>
)
}
// Simple Group Dialog Component
type SimpleGroupDialogProps = {
open: boolean
@@ -857,6 +981,17 @@ function SimpleGroupDialog({
const title = type === 'groupRatio' ? t('group ratio') : t('top-up ratio')
useEffect(() => {
if (!open) {
setName('')
setValue('')
return
}
setName(editData?.name ?? '')
setValue(editData?.value ?? '')
}, [editData, open])
const handleSave = () => {
if (!name.trim() || !value.trim()) return
onSave(name.trim(), value.trim())
@@ -865,19 +1000,7 @@ function SimpleGroupDialog({
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open)
if (open && editData) {
setName(editData.name)
setValue(editData.value)
} else {
setName('')
setValue('')
}
}}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -926,88 +1049,6 @@ function SimpleGroupDialog({
)
}
// Usable Group Dialog Component
type UsableGroupDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (name: string, description: string) => void
editData: UsableGroup | null
}
function UsableGroupDialog({
open,
onOpenChange,
onSave,
editData,
}: UsableGroupDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const handleSave = () => {
if (!name.trim() || !description.trim()) return
onSave(name.trim(), description.trim())
setName('')
setDescription('')
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open)
if (open && editData) {
setName(editData.name)
setDescription(editData.description)
} else {
setName('')
setDescription('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editData ? t('Edit selectable group') : t('Add selectable group')}
</DialogTitle>
<DialogDescription>
{t(
'Configure a group that users can select when creating API keys.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('vip')}
disabled={!!editData}
/>
</div>
<div className='space-y-2'>
<Label>{t('Description')}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('VIP users with premium access')}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSave}>
{editData ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// Group Override Dialog Component
type GroupOverrideDialogProps = {
open: boolean
@@ -1028,6 +1069,17 @@ function GroupOverrideDialog({
const [targetGroup, setTargetGroup] = useState('')
const [ratio, setRatio] = useState('')
useEffect(() => {
if (!open) {
setTargetGroup('')
setRatio('')
return
}
setTargetGroup(editData?.targetGroup ?? '')
setRatio(editData ? String(editData.ratio) : '')
}, [editData, open])
const handleSave = () => {
if (!targetGroup.trim() || !ratio.trim()) return
const parsedRatio = parseFloat(ratio)
@@ -1039,19 +1091,7 @@ function GroupOverrideDialog({
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open)
if (open && editData) {
setTargetGroup(editData.targetGroup)
setRatio(String(editData.ratio))
} else {
setTargetGroup('')
setRatio('')
}
}}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -27,6 +27,9 @@ import { StatusBadge } from '@/components/status-badge'
const OP_ADD = 'add' as const
const OP_REMOVE = 'remove' as const
const OP_APPEND = 'append' as const
const sectionCardClassName =
'relative shadow-sm ring-0 before:pointer-events-none before:absolute before:inset-0 before:rounded-xl before:border before:border-border/90'
const sectionHeaderClassName = 'border-b bg-muted/20'
type OpType = typeof OP_ADD | typeof OP_REMOVE | typeof OP_APPEND
@@ -344,8 +347,8 @@ export function GroupSpecialUsableRulesEditor(
}, [rules])
return (
<Card>
<CardHeader>
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Special usable group rules')}</CardTitle>
<CardDescription>
{t(
@@ -132,7 +132,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<div className='flex flex-wrap gap-4'>
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save model ratios')}
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
<Button
type='button'
@@ -140,7 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
onClick={onReset}
disabled={isResetting}
>
{t('Reset ratios')}
{t('Reset prices')}
</Button>
</div>
</div>
@@ -323,7 +323,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<div className='flex flex-wrap gap-4'>
<Button type='submit' disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save model ratios')}
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
<Button
type='button'
@@ -331,7 +331,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
onClick={onReset}
disabled={isResetting}
>
{t('Reset ratios')}
{t('Reset prices')}
</Button>
</div>
</form>
@@ -207,7 +207,7 @@ export function RatioSettingsCard({
mutationFn: resetModelRatios,
onSuccess: (data) => {
if (data.success) {
toast.success(t('Model ratios reset successfully'))
toast.success(t('Model prices reset successfully'))
queryClient.invalidateQueries({ queryKey: ['system-options'] })
setConfirmOpen(false)
} else {
@@ -422,7 +422,7 @@ export function RatioSettingsCard({
}, [resetMutate])
const tabLabels: Record<RatioTabId, string> = {
models: 'Model ratios',
models: 'Model prices',
groups: 'Group ratios',
'tool-prices': 'Tool prices',
'upstream-sync': 'Upstream price sync',
@@ -480,26 +480,30 @@ export function RatioSettingsCard({
return (
<SettingsSection title={t(titleKey)} description={t(descriptionKey)}>
<Tabs defaultValue={defaultTab} className='space-y-6'>
<TabsList className={`grid w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
{visibleTabs.length === 1 ? (
renderTabContent(defaultTab)
) : (
<Tabs defaultValue={defaultTab} className='space-y-6'>
<TabsList className={`grid w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>
)}
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Reset all model ratios?')}
title={t('Reset all model prices?')}
desc={t(
'This will clear custom pricing ratios and revert to upstream defaults.'
)}