瀏覽代碼

fix:修改平台角色租户管理页面中一些loading态,以及root隐藏判断逻辑,系统设置中一些隐藏项

韩洋 1 月之前
父節點
當前提交
2238cdd403

+ 2 - 103
default/src/features/keys/components/api-keys-mutate-drawer.tsx

@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
 */
 import { zodResolver } from '@hookform/resolvers/zod'
 import { useQuery } from '@tanstack/react-query'
-import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
+import { KeyRound, WalletCards } from 'lucide-react'
 import { useEffect, useState } from 'react'
 import { useForm, type SubmitErrorHandler } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
@@ -34,13 +34,7 @@ import {
   sideDrawerHeaderClassName,
   sideDrawerSwitchItemClassName,
 } from '@/components/drawer-layout'
-import { MultiSelect } from '@/components/multi-select'
 import { Button } from '@/components/ui/button'
-import {
-  Collapsible,
-  CollapsibleContent,
-  CollapsibleTrigger,
-} from '@/components/ui/collapsible'
 import {
   Form,
   FormControl,
@@ -61,11 +55,9 @@ import {
   SheetTitle,
 } from '@/components/ui/sheet'
 import { Switch } from '@/components/ui/switch'
-import { Textarea } from '@/components/ui/textarea'
 import { useStatus } from '@/hooks/use-status'
-import { getUserModels, getUserGroups } from '@/lib/api'
+import { getUserGroups } from '@/lib/api'
 import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
-import { cn } from '@/lib/utils'
 
 import { createApiKey, updateApiKey, getApiKey } from '../api'
 import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
@@ -99,17 +91,8 @@ export function ApiKeysMutateDrawer({
   const { triggerRefresh } = useApiKeys()
   const { status } = useStatus()
   const [isSubmitting, setIsSubmitting] = useState(false)
-  const [advancedOpen, setAdvancedOpen] = useState(false)
   const defaultUseAutoGroup = status?.default_use_auto_group === true
 
-  // Fetch models
-  const { data: modelsData } = useQuery({
-    queryKey: ['user-models'],
-    queryFn: getUserModels,
-    enabled: open,
-    staleTime: 0,
-  })
-
   // Fetch groups
   const { data: groupsData } = useQuery({
     queryKey: ['user-groups'],
@@ -118,7 +101,6 @@ export function ApiKeysMutateDrawer({
     staleTime: 0,
   })
 
-  const models = modelsData?.data || []
   const groupsRaw = groupsData?.data || {}
   const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
     ([key, info]) => ({
@@ -501,89 +483,6 @@ export function ApiKeysMutateDrawer({
               />
             </SideDrawerSection>
 
-            <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
-              <SideDrawerSection>
-                <CollapsibleTrigger
-                  render={
-                    <button
-                      type='button'
-                      className='hover:bg-muted/40 flex w-full items-center gap-3 rounded-md py-1.5 text-left transition-colors'
-                    />
-                  }
-                >
-                  <SideDrawerSectionHeader
-                    className='flex-1'
-                    title={t('Advanced Settings')}
-                    description={t('Set API key access restrictions')}
-                    icon={<Settings2 className='size-4' />}
-                  />
-                  <ChevronDown
-                    className={cn(
-                      'text-muted-foreground size-4 shrink-0 transition-transform',
-                      advancedOpen && 'rotate-180'
-                    )}
-                  />
-                </CollapsibleTrigger>
-                <CollapsibleContent>
-                  <div className='flex flex-col gap-4 pt-2'>
-                    <FormField
-                      control={form.control}
-                      name='model_limits'
-                      render={({ field }) => (
-                        <FormItem>
-                          <FormLabel>{t('Model Limits')}</FormLabel>
-                          <FormControl>
-                            <MultiSelect
-                              options={models.map((m) => ({
-                                label: m,
-                                value: m,
-                              }))}
-                              selected={field.value}
-                              onChange={field.onChange}
-                              placeholder={t(
-                                'Select models (empty for allow all)'
-                              )}
-                            />
-                          </FormControl>
-                          <FormDescription>
-                            {t('Limit which models can be used with this key')}
-                          </FormDescription>
-                          <FormMessage />
-                        </FormItem>
-                      )}
-                    />
-
-                    <FormField
-                      control={form.control}
-                      name='allow_ips'
-                      render={({ field }) => (
-                        <FormItem>
-                          <FormLabel>
-                            {t('IP Whitelist (supports CIDR)')}
-                          </FormLabel>
-                          <FormControl>
-                            <Textarea
-                              {...field}
-                              className='min-h-20 resize-none'
-                              placeholder={t(
-                                'One IP per line (empty for no restriction)'
-                              )}
-                              rows={3}
-                            />
-                          </FormControl>
-                          <FormDescription>
-                            {t(
-                              'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
-                            )}
-                          </FormDescription>
-                          <FormMessage />
-                        </FormItem>
-                      )}
-                    />
-                  </div>
-                </CollapsibleContent>
-              </SideDrawerSection>
-            </Collapsible>
           </form>
         </Form>
         <SheetFooter className={sideDrawerFooterClassName()}>

+ 25 - 5
default/src/features/roles/index.tsx

@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 import { zodResolver } from '@hookform/resolvers/zod'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
 import type { ColumnDef } from '@tanstack/react-table'
-import { Eye, Pencil, Trash2, Plus } from 'lucide-react'
+import { Eye, Loader2, Pencil, Trash2, Plus } from 'lucide-react'
 import { useState } from 'react'
 import { useForm } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
@@ -334,6 +334,7 @@ export function RolesSettings() {
   const [editOpen, setEditOpen] = useState(false)
   const [deleteOpen, setDeleteOpen] = useState(false)
   const [currentRole, setCurrentRole] = useState<Role | null>(null)
+  const [isSubmitting, setIsSubmitting] = useState(false)
   const [refreshTrigger, setRefreshTrigger] = useState(0)
   const canViewRole = hasPermissionCode(currentUser, PERMISSION_CODES.ROLE_VIEW)
   const canCreateRole = hasPermissionCode(
@@ -595,8 +596,15 @@ export function RolesSettings() {
                     >
                       {t('Cancel')}
                     </SheetClose>
-                    <Button form='role-edit-form' type='submit'>
-                      {t('Save changes')}
+                    <Button
+                      form='role-edit-form'
+                      type='submit'
+                      disabled={isSubmitting}
+                    >
+                      {isSubmitting && (
+                        <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                      )}
+                      {isSubmitting ? t('Saving...') : t('Save changes')}
                     </Button>
                   </SheetFooter>
                 </SheetContent>
@@ -699,6 +707,9 @@ export function RolesSettings() {
   })
 
   const onSubmit = async (data: RoleFormValues) => {
+    if (isSubmitting) return
+    setIsSubmitting(true)
+
     const submitData = {
       ...data,
       permissions: sanitizePermissionValues(
@@ -754,6 +765,8 @@ export function RolesSettings() {
       setRefreshTrigger((prev) => prev + 1)
     } catch {
       toast.error(t('An unexpected error occurred'))
+    } finally {
+      setIsSubmitting(false)
     }
   }
 
@@ -1091,8 +1104,15 @@ export function RolesSettings() {
                   >
                     {t('Cancel')}
                   </SheetClose>
-                  <Button form='role-create-form' type='submit'>
-                    {t('Create')}
+                  <Button
+                    form='role-create-form'
+                    type='submit'
+                    disabled={isSubmitting}
+                  >
+                    {isSubmitting && (
+                      <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                    )}
+                    {isSubmitting ? t('Creating...') : t('Create')}
                   </Button>
                 </SheetFooter>
               </SheetContent>

+ 2 - 2
default/src/features/system-settings/billing/section-registry.tsx

@@ -61,10 +61,10 @@ const BILLING_SECTIONS = [
   },
   {
     id: 'group-pricing',
-    titleKey: 'Group Pricing',
+    titleKey: 'Group Management',
     build: (settings: BillingSettings) => (
       <RatioSettingsCard
-        titleKey='Group Pricing'
+        titleKey='Group Management'
         modelDefaults={getModelDefaults(settings)}
         groupDefaults={getGroupDefaults(settings)}
         toolPricesDefault={settings['tool_price_setting.prices']}

+ 79 - 68
default/src/features/system-settings/models/group-ratio-form.tsx

@@ -62,6 +62,9 @@ import { safeJsonParse } from '../utils/json-parser'
 import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
 import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
 
+const showGroupSpecialUsableRules = false
+const showDefaultUseAutoGroup = false
+
 type GroupFormValues = {
   GroupRatio: string
   TopupGroupRatio: string
@@ -175,36 +178,40 @@ export const GroupRatioForm = memo(function GroupRatioForm({
               }
             />
 
-            <GroupSpecialUsableRulesEditor
-              value={form.watch('GroupSpecialUsableGroup')}
-              groupOptions={groupNames}
-              onChange={(value) =>
-                handleFieldChange('GroupSpecialUsableGroup', value)
-              }
-            />
+            {showGroupSpecialUsableRules && (
+              <GroupSpecialUsableRulesEditor
+                value={form.watch('GroupSpecialUsableGroup')}
+                groupOptions={groupNames}
+                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>
-              )}
-            />
+            {showDefaultUseAutoGroup && (
+              <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)}>
@@ -306,47 +313,51 @@ export const GroupRatioForm = memo(function GroupRatioForm({
               )}
             />
 
-            <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>
+            {showGroupSpecialUsableRules && (
+              <FormField
+                control={form.control}
+                name='GroupSpecialUsableGroup'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Special usable group rules')}</FormLabel>
+                    <FormControl>
+                      <Textarea rows={8} {...field} />
+                    </FormControl>
                     <FormDescription>
                       {t(
-                        'When enabled, newly created tokens start in the first auto group.'
+                        'Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.'
                       )}
                     </FormDescription>
-                  </SettingsSwitchContent>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </SettingsSwitchItem>
-              )}
-            />
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            )}
+
+            {showDefaultUseAutoGroup && (
+              <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>
@@ -394,7 +405,7 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
         className={sideDrawerContentClassName('sm:max-w-2xl')}
       >
         <SheetHeader className={sideDrawerHeaderClassName()}>
-          <SheetTitle>{t('Group pricing usage guide')}</SheetTitle>
+          <SheetTitle>{t('Group management usage guide')}</SheetTitle>
           <SheetDescription>
             {t(
               'Understand how user groups, token groups, ratios, and special rules work together.'
@@ -481,7 +492,7 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
 
             <div className='overflow-hidden rounded-lg border'>
               <div className='bg-muted/40 border-b px-3 py-1.5 text-xs font-medium'>
-                {t('Pricing groups')}
+                {t('Group Management')}
               </div>
               <table className='w-full text-sm'>
                 <thead>

+ 80 - 77
default/src/features/system-settings/models/group-ratio-visual-editor.tsx

@@ -97,6 +97,7 @@ type RegistryEntry = {
 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'
+const showGroupPricingAdvancedSections = false
 
 let groupPricingIdCounter = 0
 function createGroupPricingId() {
@@ -283,7 +284,6 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
     [registry]
   )
 
-  // Auto groups
   const autoGroupsList = useMemo(() => {
     return safeJsonParse<string[]>(autoGroups, {
       fallback: [],
@@ -333,85 +333,88 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
         onShowDetail={setDetailGroup}
       />
 
-      <GroupOverrideRules
-        registry={registry}
-        groupGroupRatio={groupGroupRatio}
-        onChange={onChange}
-      />
+      {showGroupPricingAdvancedSections && (
+        <>
+          <GroupOverrideRules
+            registry={registry}
+            groupGroupRatio={groupGroupRatio}
+            onChange={onChange}
+          />
 
-      {/* Auto Groups */}
-      <Card className={sectionCardClassName}>
-        <CardHeader className={sectionHeaderClassName}>
-          <CardTitle>{t('Auto assignment order')}</CardTitle>
-          <CardDescription>
-            {t(
-              'Priority order for tokens in the auto group. The system tries groups from top to bottom.'
-            )}
-          </CardDescription>
-        </CardHeader>
-        <CardContent>
-          <div className='space-y-4'>
-            <GroupNameSelect
-              options={autoGroupCandidates}
-              value={null}
-              placeholder={t('Add group')}
-              onValueChange={handleAutoGroupAdd}
-            />
-            {autoGroupsList.length > 0 && (
-              <div className='space-y-2'>
-                {autoGroupsList.map((group, index) => (
-                  <div
-                    key={group}
-                    className='flex items-center gap-2 rounded-md border p-3'
-                  >
-                    <GripVertical className='text-muted-foreground h-4 w-4' />
-                    <span className='font-medium'>{group}</span>
-                    {!registryNames.includes(group) && <UnknownGroupBadge />}
-                    <div className='ml-auto flex gap-1'>
-                      <Button
-                        variant='ghost'
-                        size='sm'
-                        disabled={index === 0}
-                        onClick={() => handleAutoGroupMove(index, 'up')}
-                      >
-                        ↑
-                      </Button>
-                      <Button
-                        variant='ghost'
-                        size='sm'
-                        disabled={index === autoGroupsList.length - 1}
-                        onClick={() => handleAutoGroupMove(index, 'down')}
-                      >
-                        ↓
-                      </Button>
-                      <Button
-                        variant='ghost'
-                        size='sm'
-                        onClick={() => handleAutoGroupDelete(index)}
+          <Card className={sectionCardClassName}>
+            <CardHeader className={sectionHeaderClassName}>
+              <CardTitle>{t('Auto assignment order')}</CardTitle>
+              <CardDescription>
+                {t(
+                  'Priority order for tokens in the auto group. The system tries groups from top to bottom.'
+                )}
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className='space-y-4'>
+                <GroupNameSelect
+                  options={autoGroupCandidates}
+                  value={null}
+                  placeholder={t('Add group')}
+                  onValueChange={handleAutoGroupAdd}
+                />
+                {autoGroupsList.length > 0 && (
+                  <div className='space-y-2'>
+                    {autoGroupsList.map((group, index) => (
+                      <div
+                        key={group}
+                        className='flex items-center gap-2 rounded-md border p-3'
                       >
-                        <Trash2 className='h-4 w-4' />
-                      </Button>
-                    </div>
+                        <GripVertical className='text-muted-foreground h-4 w-4' />
+                        <span className='font-medium'>{group}</span>
+                        {!registryNames.includes(group) && <UnknownGroupBadge />}
+                        <div className='ml-auto flex gap-1'>
+                          <Button
+                            variant='ghost'
+                            size='sm'
+                            disabled={index === 0}
+                            onClick={() => handleAutoGroupMove(index, 'up')}
+                          >
+                            ↑
+                          </Button>
+                          <Button
+                            variant='ghost'
+                            size='sm'
+                            disabled={index === autoGroupsList.length - 1}
+                            onClick={() => handleAutoGroupMove(index, 'down')}
+                          >
+                            ↓
+                          </Button>
+                          <Button
+                            variant='ghost'
+                            size='sm'
+                            onClick={() => handleAutoGroupDelete(index)}
+                          >
+                            <Trash2 className='h-4 w-4' />
+                          </Button>
+                        </div>
+                      </div>
+                    ))}
                   </div>
-                ))}
+                )}
               </div>
-            )}
-          </div>
-        </CardContent>
-      </Card>
-
-      <GroupDetailSheet
-        groupName={detailGroup}
-        onOpenChange={(open) => {
-          if (!open) setDetailGroup(null)
-        }}
-        registry={registry}
-        topupGroupRatio={topupGroupRatio}
-        userUsableGroups={userUsableGroups}
-        groupGroupRatio={groupGroupRatio}
-        autoGroups={autoGroupsList}
-        groupSpecialUsableGroup={groupSpecialUsableGroup}
-      />
+            </CardContent>
+          </Card>
+
+          <GroupDetailSheet
+            groupName={detailGroup}
+            onOpenChange={(open) => {
+              if (!open) setDetailGroup(null)
+            }}
+            registry={registry}
+            topupGroupRatio={topupGroupRatio}
+            userUsableGroups={userUsableGroups}
+            groupGroupRatio={groupGroupRatio}
+            autoGroups={autoGroupsList}
+            groupSpecialUsableGroup={groupSpecialUsableGroup}
+          />
+        </>
+      )}
     </div>
   )
 })
@@ -523,7 +526,7 @@ function GroupPricingTable({
       <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>
+            <CardTitle>{t('Group Management')}</CardTitle>
             <CardDescription>
               {t(
                 'All group names live here. Ratio applies when calls are billed as this group; top-up ratio applies to users whose account is in this group.'

+ 1 - 94
default/src/features/system-settings/models/model-ratio-form.tsx

@@ -17,14 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 import { useQuery } from '@tanstack/react-query'
-import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
 import { memo, useCallback, useEffect, useRef, useState } from 'react'
 import type { UseFormReturn } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
 
 import { JsonCodeEditor } from '@/components/json-code-editor'
-import { Button } from '@/components/ui/button'
 import {
   Form,
   FormControl,
@@ -34,14 +32,9 @@ import {
   FormLabel,
   FormMessage,
 } from '@/components/ui/form'
-import { Switch } from '@/components/ui/switch'
 import { getEnabledModels } from '@/features/channels/api'
 
-import {
-  SettingsForm,
-  SettingsSwitchContent,
-  SettingsSwitchItem,
-} from '../components/settings-form-layout'
+import { SettingsForm } from '../components/settings-form-layout'
 import {
   ModelRatioVisualEditor,
   type ModelRatioVisualEditorHandle,
@@ -218,45 +211,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
 
   return (
     <div className='space-y-6'>
-      {!isUnsetVariant && (
-        <div className='flex flex-wrap justify-end gap-2'>
-          <Button
-            type='button'
-            variant='destructive'
-            size='sm'
-            onClick={onReset}
-            disabled={isResetting}
-          >
-            <RotateCcw data-icon='inline-start' />
-            {t('Reset prices')}
-          </Button>
-          {editMode === 'json' && (
-            <Button
-              type='button'
-              size='sm'
-              onClick={handleSave}
-              disabled={isSaving}
-            >
-              <Save data-icon='inline-start' />
-              {isSaving ? t('Saving...') : t('Save model prices')}
-            </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>
-      )}
-
       <Form {...form}>
         {editMode === 'visual' ? (
           <div className='space-y-6'>
@@ -301,31 +255,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
                 handleFieldChange(formField, value)
               }}
             />
-
-            {!isUnsetVariant && (
-              <FormField
-                control={form.control}
-                name='ExposeRatioEnabled'
-                render={({ field }) => (
-                  <SettingsSwitchItem>
-                    <SettingsSwitchContent>
-                      <FormLabel>{t('Expose ratio API')}</FormLabel>
-                      <FormDescription>
-                        {t(
-                          'Allow clients to query configured ratios via `/api/ratio`.'
-                        )}
-                      </FormDescription>
-                    </SettingsSwitchContent>
-                    <FormControl>
-                      <Switch
-                        checked={field.value}
-                        onCheckedChange={field.onChange}
-                      />
-                    </FormControl>
-                  </SettingsSwitchItem>
-                )}
-              />
-            )}
           </div>
         ) : (
           <SettingsForm onSubmit={form.handleSubmit(onSave)}>
@@ -341,28 +270,6 @@ export const ModelRatioForm = memo(function ModelRatioForm({
               ))}
             </div>
 
-            <FormField
-              control={form.control}
-              name='ExposeRatioEnabled'
-              render={({ field }) => (
-                <SettingsSwitchItem>
-                  <SettingsSwitchContent>
-                    <FormLabel>{t('Expose ratio API')}</FormLabel>
-                    <FormDescription>
-                      {t(
-                        'Allow clients to query configured ratios via `/api/ratio`.'
-                      )}
-                    </FormDescription>
-                  </SettingsSwitchContent>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </SettingsSwitchItem>
-              )}
-            />
           </SettingsForm>
         )}
       </Form>

+ 78 - 52
default/src/features/tenants/index.tsx

@@ -23,6 +23,7 @@ import {
   Building2,
   Eye,
   Info,
+  Loader2,
   Pencil,
   Plus,
   Power,
@@ -248,6 +249,7 @@ export function Tenants() {
   const [inviteOpen, setInviteOpen] = useState(false)
   const [invitePlatformId, setInvitePlatformId] = useState('')
   const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null)
+  const [isSubmitting, setIsSubmitting] = useState(false)
   const [submitAction, setSubmitAction] = useState<'save' | 'saveInvite'>(
     'save'
   )
@@ -457,16 +459,56 @@ export function Tenants() {
   }
 
   const handleSubmit = async (data: TenantFormData) => {
-    if (currentTenant) {
-      const uniqueId = currentTenant.code || String(currentTenant.id)
+    if (isSubmitting) return
+    setIsSubmitting(true)
+
+    try {
+      if (currentTenant) {
+        const uniqueId = currentTenant.code || String(currentTenant.id)
+
+        try {
+          const result = await updateDirectChildTenant(uniqueId, {
+            tenant_name: data.name,
+            contact_person: data.contact_name || undefined,
+            contact_phone: data.contact_phone || undefined,
+            contact_email: data.contact_email || undefined,
+            group_name: data.group,
+            quota_limit: data.quota_limit === '' ? undefined : data.quota_limit,
+            start_time: toApiDateTime(data.starts_at, '00:00:00'),
+            end_time: toApiDateTime(data.ends_at, '23:59:59'),
+            module_codes: data.permission_keys,
+          })
+
+          if (!result.success) {
+            toast.error(result.message || t('Failed to update tenant'))
+            return
+          }
+
+          toast.success(t('Tenant updated successfully'))
+          const savedTenant = result.data ?? currentTenant
+          setEditOpen(false)
+          await Promise.all([
+            queryClient.invalidateQueries({
+              queryKey: ['tenants', 'direct-children'],
+            }),
+            queryClient.invalidateQueries({
+              queryKey: ['tenants', 'direct-children', uniqueId],
+            }),
+          ])
+          if (submitAction === 'saveInvite') openInvitationDialog(savedTenant)
+        } catch {
+          toast.error(t('Failed to update tenant'))
+        }
+        return
+      }
 
       try {
-        const result = await updateDirectChildTenant(uniqueId, {
+        const result = await createDirectChildTenant({
           tenant_name: data.name,
+          group_name: data.group,
           contact_person: data.contact_name || undefined,
           contact_phone: data.contact_phone || undefined,
           contact_email: data.contact_email || undefined,
-          group_name: data.group,
           quota_limit: data.quota_limit === '' ? undefined : data.quota_limit,
           start_time: toApiDateTime(data.starts_at, '00:00:00'),
           end_time: toApiDateTime(data.ends_at, '23:59:59'),
@@ -474,56 +516,24 @@ export function Tenants() {
         })
 
         if (!result.success) {
-          toast.error(result.message || t('Failed to update tenant'))
+          toast.error(result.message || t('Failed to create tenant'))
           return
         }
 
-        toast.success(t('Tenant updated successfully'))
-        const savedTenant = result.data ?? currentTenant
-        setEditOpen(false)
-        await Promise.all([
-          queryClient.invalidateQueries({
-            queryKey: ['tenants', 'direct-children'],
-          }),
-          queryClient.invalidateQueries({
-            queryKey: ['tenants', 'direct-children', uniqueId],
-          }),
-        ])
-        if (submitAction === 'saveInvite') openInvitationDialog(savedTenant)
+        toast.success(t('Tenant created successfully'))
+        const savedTenant = result.data
+        setCreateOpen(false)
+        await queryClient.invalidateQueries({
+          queryKey: ['tenants', 'direct-children'],
+        })
+        if (submitAction === 'saveInvite' && savedTenant) {
+          openInvitationDialog(savedTenant)
+        }
       } catch {
-        toast.error(t('Failed to update tenant'))
+        toast.error(t('Failed to create tenant'))
       }
-      return
-    }
-
-    try {
-      const result = await createDirectChildTenant({
-        tenant_name: data.name,
-        group_name: data.group,
-        contact_person: data.contact_name || undefined,
-        contact_phone: data.contact_phone || undefined,
-        contact_email: data.contact_email || undefined,
-        quota_limit: data.quota_limit === '' ? undefined : data.quota_limit,
-        start_time: toApiDateTime(data.starts_at, '00:00:00'),
-        end_time: toApiDateTime(data.ends_at, '23:59:59'),
-        module_codes: data.permission_keys,
-      })
-
-      if (!result.success) {
-        toast.error(result.message || t('Failed to create tenant'))
-        return
-      }
-
-      toast.success(t('Tenant created successfully'))
-      const savedTenant = result.data
-      setCreateOpen(false)
-      await queryClient.invalidateQueries({
-        queryKey: ['tenants', 'direct-children'],
-      })
-      if (submitAction === 'saveInvite' && savedTenant)
-        openInvitationDialog(savedTenant)
-    } catch {
-      toast.error(t('Failed to create tenant'))
+    } finally {
+      setIsSubmitting(false)
     }
   }
 
@@ -1155,9 +1165,13 @@ export function Tenants() {
                     <Button
                       form='tenant-edit-form'
                       type='submit'
+                      disabled={isSubmitting}
                       onClick={() => setSubmitAction('save')}
                     >
-                      {t('Save changes')}
+                      {isSubmitting && (
+                        <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                      )}
+                      {isSubmitting ? t('Saving...') : t('Save changes')}
                     </Button>
                   </SheetFooter>
                 </SheetContent>
@@ -1290,16 +1304,28 @@ export function Tenants() {
                   <Button
                     form='tenant-create-form'
                     type='submit'
+                    disabled={isSubmitting}
                     onClick={() => setSubmitAction('save')}
                   >
-                    {t('Create')}
+                    {isSubmitting && submitAction === 'save' && (
+                      <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                    )}
+                    {isSubmitting && submitAction === 'save'
+                      ? t('Creating...')
+                      : t('Create')}
                   </Button>
                   <Button
                     form='tenant-create-form'
                     type='submit'
+                    disabled={isSubmitting}
                     onClick={() => setSubmitAction('saveInvite')}
                   >
-                    {t('Save and invite administrator')}
+                    {isSubmitting && submitAction === 'saveInvite' && (
+                      <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                    )}
+                    {isSubmitting && submitAction === 'saveInvite'
+                      ? t('Saving...')
+                      : t('Save and invite administrator')}
                   </Button>
                 </SheetFooter>
               </SheetContent>

+ 2 - 2
default/src/features/users/components/data-table-row-actions.tsx

@@ -35,9 +35,9 @@ import { useAuthStore } from '@/stores/auth-store'
 import { manageUser } from '../api'
 import {
   USER_STATUS,
-  USER_ROLE,
   ERROR_MESSAGES,
   isUserDeleted,
+  isUserRoot,
 } from '../constants'
 import { getUserActionMessage } from '../lib'
 import type { User, ManageUserAction } from '../types'
@@ -98,7 +98,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
   }
 
   const isDisabled = user.status === USER_STATUS.DISABLED
-  const isRoot = user.role === USER_ROLE.ROOT
+  const isRoot = isUserRoot(user)
 
   if (isUserDeleted(user)) {
     return null

+ 3 - 3
default/src/features/users/components/users-mutate-drawer.tsx

@@ -62,7 +62,7 @@ import { Textarea } from '@/components/ui/textarea'
 import { getRoles } from '@/features/roles/api'
 
 import { createUser, updateUser, getUser } from '../api'
-import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
+import { ERROR_MESSAGES, SUCCESS_MESSAGES, isUserRoot } from '../constants'
 import {
   userFormSchema,
   type UserFormValues,
@@ -89,7 +89,7 @@ export function UsersMutateDrawer({
 }: UsersMutateDrawerProps) {
   const { t } = useTranslation()
   const isUpdate = !!currentRow
-  const isRootUser = currentRow?.role === 100
+  const isRootUser = isUserRoot(currentRow)
   const { triggerRefresh } = useUsers()
   const [isSubmitting, setIsSubmitting] = useState(false)
 
@@ -424,7 +424,7 @@ export function UsersMutateDrawer({
             {t('Close')}
           </SheetClose>
           <Button form='user-form' type='submit' disabled={isSubmitting}>
-            {isSubmitting ? t('Saving...') : t('Save changes')}
+            {isSubmitting ? t('Saving...') : t('Save')}
           </Button>
         </SheetFooter>
       </SheetContent>

+ 7 - 0
default/src/features/users/constants.ts

@@ -28,6 +28,13 @@ export const isUserDeleted = (user: UserType): boolean => {
   return user.DeletedAt != null
 }
 
+export const isUserRoot = (user?: UserType | null): boolean => {
+  const relationRoles = user?.ext_user_relation_role
+  if (!relationRoles) return false
+  const relations = Array.isArray(relationRoles) ? relationRoles : [relationRoles]
+  return relations.some((relation) => relation.ext_role?.type === 'root')
+}
+
 // ============================================================================
 // User Status Configuration
 // ============================================================================

+ 1 - 0
default/src/features/users/lib/user-form.ts

@@ -37,6 +37,7 @@ export const PASSWORD_CHINESE_GLOBAL_PATTERN = /[\u4E00-\u9FFF]/g
 export const userFormSchema = z.object({
   username: z
     .string()
+    .min(1, i18next.t('Please enter your username'))
     .min(3, i18next.t('Username must be at least 3 characters'))
     .max(
       USERNAME_MAX_LENGTH,

+ 1 - 0
default/src/features/users/types.ts

@@ -37,6 +37,7 @@ const userRelationRoleSchema = z.object({
     .object({
       role_name: z.string().optional(),
       id: z.union([z.number(), z.string()]).optional(),
+      type: z.string().optional(),
     })
     .nullable()
     .optional(),

+ 7 - 2
default/src/i18n/locales/en.json

@@ -2150,8 +2150,10 @@
     "Group Name": "Group Name",
     "Group name cannot be changed when editing.": "Group name cannot be changed when editing.",
     "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.",
-    "Group Pricing": "Group Pricing",
-    "Group pricing usage guide": "Group pricing usage guide",
+    "Group Management": "Group Management",
+    "Group management usage guide": "Group management usage guide",
+    "Group Pricing": "Group Management",
+    "Group pricing usage guide": "Group management usage guide",
     "group ratio": "group ratio",
     "Group Ratio": "Group Ratio",
     "Group ratios": "Group ratios",
@@ -4947,6 +4949,9 @@
     "Username": "Username",
     "Username confirmation does not match": "Username confirmation does not match",
     "Username Field": "Username Field",
+    "Username can only contain letters, numbers, and underscores": "Username can only contain letters, numbers, and underscores",
+    "Username must be at least 3 characters": "Username must be at least 3 characters",
+    "Username must be at most {{max}} characters": "Username must be at most {{max}} characters",
     "Username or Email": "Username or Email",
     "Users": "Users",
     "Users call the model on the left. The platform forwards the request to the upstream model on the right.": "Users call the model on the left. The platform forwards the request to the upstream model on the right.",

+ 7 - 2
default/src/i18n/locales/zh.json

@@ -2150,8 +2150,10 @@
     "Group Name": "分组名称",
     "Group name cannot be changed when editing.": "编辑时无法更改组名称。",
     "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "该表达式不是标准分档计费表达式,无法展开分组价格。",
-    "Group Pricing": "分组定价",
-    "Group pricing usage guide": "分组定价使用教程",
+    "Group Management": "分组管理",
+    "Group management usage guide": "分组管理使用教程",
+    "Group Pricing": "分组管理",
+    "Group pricing usage guide": "分组管理使用教程",
     "group ratio": "分组倍率",
     "Group Ratio": "分组倍率",
     "Group ratios": "分组比例",
@@ -4948,6 +4950,9 @@
     "Username": "用户名",
     "Username confirmation does not match": "用户名确认不匹配",
     "Username Field": "用户名字段",
+    "Username can only contain letters, numbers, and underscores": "用户名只能包含字母、数字和下划线",
+    "Username must be at least 3 characters": "用户名至少需要 3 个字符",
+    "Username must be at most {{max}} characters": "用户名最多 {{max}} 个字符",
     "Username or Email": "用户名或电子邮件",
     "Users": "用户",
     "Users call the model on the left. The platform forwards the request to the upstream model on the right.": "用户调用左侧的模型。平台将请求转发给右侧的上游模型。",