Jelajahi Sumber

Merge remote-tracking branch 'origin/alpha' into alpha

CaIon 7 bulan lalu
induk
melakukan
919eacd907

+ 20 - 9
model/main.go

@@ -64,6 +64,22 @@ var DB *gorm.DB
 
 
 var LOG_DB *gorm.DB
 var LOG_DB *gorm.DB
 
 
+// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
+func dropIndexIfExists(tableName string, indexName string) {
+    if !common.UsingMySQL {
+        return
+    }
+    var count int64
+    // Check index existence via information_schema
+    err := DB.Raw(
+        "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
+        tableName, indexName,
+    ).Scan(&count).Error
+    if err == nil && count > 0 {
+        _ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
+    }
+}
+
 func createRootAccountIfNeed() error {
 func createRootAccountIfNeed() error {
 	var user User
 	var user User
 	//if user.Status != common.UserStatusEnabled {
 	//if user.Status != common.UserStatusEnabled {
@@ -236,11 +252,8 @@ func InitLogDB() (err error) {
 
 
 func migrateDB() error {
 func migrateDB() error {
 	// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
 	// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
-	if common.UsingMySQL {
-		// 旧索引可能不存在,忽略删除错误即可
-		_ = DB.Exec("ALTER TABLE models DROP INDEX uk_model_name;").Error
-		_ = DB.Exec("ALTER TABLE vendors DROP INDEX uk_vendor_name;").Error
-	}
+	dropIndexIfExists("models", "uk_model_name")
+	dropIndexIfExists("vendors", "uk_vendor_name")
 	if !common.UsingPostgreSQL {
 	if !common.UsingPostgreSQL {
 		return migrateDBFast()
 		return migrateDBFast()
 	}
 	}
@@ -271,10 +284,8 @@ func migrateDB() error {
 
 
 func migrateDBFast() error {
 func migrateDBFast() error {
 	// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
 	// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
-	if common.UsingMySQL {
-		_ = DB.Exec("ALTER TABLE models DROP INDEX uk_model_name;").Error
-		_ = DB.Exec("ALTER TABLE vendors DROP INDEX uk_vendor_name;").Error
-	}
+	dropIndexIfExists("models", "uk_model_name")
+	dropIndexIfExists("vendors", "uk_vendor_name")
 
 
 	var wg sync.WaitGroup
 	var wg sync.WaitGroup
 
 

+ 57 - 27
web/src/components/common/ui/JSONEditor.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 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 React, { useState, useEffect, useCallback, useMemo } from 'react';
 import React, { useState, useEffect, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
@@ -18,15 +37,19 @@ import {
   Tooltip,
   Tooltip,
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
 import {
 import {
-  IconCode,
   IconPlus,
   IconPlus,
   IconDelete,
   IconDelete,
-  IconRefresh,
   IconAlertTriangle,
   IconAlertTriangle,
 } from '@douyinfe/semi-icons';
 } from '@douyinfe/semi-icons';
 
 
 const { Text } = Typography;
 const { Text } = Typography;
 
 
+// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
+const generateUniqueId = (() => {
+  let counter = 0;
+  return () => `kv_${counter++}`;
+})();
+
 const JSONEditor = ({
 const JSONEditor = ({
   value = '',
   value = '',
   onChange,
   onChange,
@@ -46,13 +69,20 @@ const JSONEditor = ({
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   // 将对象转换为键值对数组(包含唯一ID)
   // 将对象转换为键值对数组(包含唯一ID)
-  const objectToKeyValueArray = useCallback((obj) => {
+  const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
     if (!obj || typeof obj !== 'object') return [];
     if (!obj || typeof obj !== 'object') return [];
-    return Object.entries(obj).map(([key, value], index) => ({
-      id: `${Date.now()}_${index}_${Math.random()}`, // 唯一ID
-      key,
-      value
-    }));
+
+    const entries = Object.entries(obj);
+    return entries.map(([key, value], index) => {
+      // 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
+      const prev = prevPairs[index];
+      const shouldReuseId = prev && prev.key === key;
+      return {
+        id: shouldReuseId ? prev.id : generateUniqueId(),
+        key,
+        value,
+      };
+    });
   }, []);
   }, []);
 
 
   // 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
   // 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
@@ -102,14 +132,14 @@ const JSONEditor = ({
     }
     }
     return 'visual';
     return 'visual';
   });
   });
-  
+
   const [jsonError, setJsonError] = useState('');
   const [jsonError, setJsonError] = useState('');
 
 
   // 计算重复的键
   // 计算重复的键
   const duplicateKeys = useMemo(() => {
   const duplicateKeys = useMemo(() => {
     const keyCount = {};
     const keyCount = {};
     const duplicates = new Set();
     const duplicates = new Set();
-    
+
     keyValuePairs.forEach(pair => {
     keyValuePairs.forEach(pair => {
       if (pair.key) {
       if (pair.key) {
         keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
         keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
@@ -118,7 +148,7 @@ const JSONEditor = ({
         }
         }
       }
       }
     });
     });
-    
+
     return duplicates;
     return duplicates;
   }, [keyValuePairs]);
   }, [keyValuePairs]);
 
 
@@ -131,11 +161,11 @@ const JSONEditor = ({
       } else if (typeof value === 'object' && value !== null) {
       } else if (typeof value === 'object' && value !== null) {
         parsed = value;
         parsed = value;
       }
       }
-      
+
       // 只在外部值真正改变时更新,避免循环更新
       // 只在外部值真正改变时更新,避免循环更新
       const currentObj = keyValueArrayToObject(keyValuePairs);
       const currentObj = keyValueArrayToObject(keyValuePairs);
       if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
       if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
-        setKeyValuePairs(objectToKeyValueArray(parsed));
+        setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
       }
       }
       setJsonError('');
       setJsonError('');
     } catch (error) {
     } catch (error) {
@@ -158,7 +188,7 @@ const JSONEditor = ({
     setKeyValuePairs(newPairs);
     setKeyValuePairs(newPairs);
     const jsonObject = keyValueArrayToObject(newPairs);
     const jsonObject = keyValueArrayToObject(newPairs);
     const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
     const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
-    
+
     setJsonError('');
     setJsonError('');
 
 
     // 通过formApi设置值
     // 通过formApi设置值
@@ -175,7 +205,7 @@ const JSONEditor = ({
     if (newValue && newValue.trim()) {
     if (newValue && newValue.trim()) {
       try {
       try {
         const parsed = JSON.parse(newValue);
         const parsed = JSON.parse(newValue);
-        setKeyValuePairs(objectToKeyValueArray(parsed));
+        setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
         setJsonError('');
         setJsonError('');
         onChange?.(newValue);
         onChange?.(newValue);
       } catch (error) {
       } catch (error) {
@@ -186,7 +216,7 @@ const JSONEditor = ({
       setJsonError('');
       setJsonError('');
       onChange?.('');
       onChange?.('');
     }
     }
-  }, [onChange, objectToKeyValueArray]);
+  }, [onChange, objectToKeyValueArray, keyValuePairs]);
 
 
   // 切换编辑模式
   // 切换编辑模式
   const toggleEditMode = useCallback(() => {
   const toggleEditMode = useCallback(() => {
@@ -204,7 +234,7 @@ const JSONEditor = ({
         } else if (typeof value === 'object' && value !== null) {
         } else if (typeof value === 'object' && value !== null) {
           parsed = value;
           parsed = value;
         }
         }
-        setKeyValuePairs(objectToKeyValueArray(parsed));
+        setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
         setJsonError('');
         setJsonError('');
         setEditMode('visual');
         setEditMode('visual');
       } catch (error) {
       } catch (error) {
@@ -225,7 +255,7 @@ const JSONEditor = ({
       newKey = `field_${counter}`;
       newKey = `field_${counter}`;
     }
     }
     newPairs.push({
     newPairs.push({
-      id: `${Date.now()}_${Math.random()}`,
+      id: generateUniqueId(),
       key: newKey,
       key: newKey,
       value: ''
       value: ''
     });
     });
@@ -240,7 +270,7 @@ const JSONEditor = ({
 
 
   // 更新键名
   // 更新键名
   const updateKey = useCallback((id, newKey) => {
   const updateKey = useCallback((id, newKey) => {
-    const newPairs = keyValuePairs.map(pair => 
+    const newPairs = keyValuePairs.map(pair =>
       pair.id === id ? { ...pair, key: newKey } : pair
       pair.id === id ? { ...pair, key: newKey } : pair
     );
     );
     handleVisualChange(newPairs);
     handleVisualChange(newPairs);
@@ -264,11 +294,11 @@ const JSONEditor = ({
       }
       }
 
 
       setManualText(templateString);
       setManualText(templateString);
-      setKeyValuePairs(objectToKeyValueArray(template));
+      setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
       onChange?.(templateString);
       onChange?.(templateString);
       setJsonError('');
       setJsonError('');
     }
     }
-  }, [template, onChange, formApi, field, objectToKeyValueArray]);
+  }, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
 
 
   // 渲染值输入控件(支持嵌套)
   // 渲染值输入控件(支持嵌套)
   const renderValueInput = (pairId, value) => {
   const renderValueInput = (pairId, value) => {
@@ -327,7 +357,7 @@ const JSONEditor = ({
           let convertedValue = newValue;
           let convertedValue = newValue;
           if (newValue === 'true') convertedValue = true;
           if (newValue === 'true') convertedValue = true;
           else if (newValue === 'false') convertedValue = false;
           else if (newValue === 'false') convertedValue = false;
-          else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
+          else if (!isNaN(newValue) && newValue !== '') {
             const num = Number(newValue);
             const num = Number(newValue);
             // 检查是否为整数
             // 检查是否为整数
             if (Number.isInteger(num)) {
             if (Number.isInteger(num)) {
@@ -373,9 +403,9 @@ const JSONEditor = ({
 
 
         {keyValuePairs.map((pair, index) => {
         {keyValuePairs.map((pair, index) => {
           const isDuplicate = duplicateKeys.has(pair.key);
           const isDuplicate = duplicateKeys.has(pair.key);
-          const isLastDuplicate = isDuplicate && 
+          const isLastDuplicate = isDuplicate &&
             keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
             keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
-          
+
           return (
           return (
             <Row key={pair.id} gutter={8} align="middle">
             <Row key={pair.id} gutter={8} align="middle">
               <Col span={6}>
               <Col span={6}>
@@ -389,14 +419,14 @@ const JSONEditor = ({
                   {isDuplicate && (
                   {isDuplicate && (
                     <Tooltip
                     <Tooltip
                       content={
                       content={
-                        isLastDuplicate 
+                        isLastDuplicate
                           ? t('这是重复键中的最后一个,其值将被使用')
                           ? t('这是重复键中的最后一个,其值将被使用')
                           : t('重复的键名,此值将被后面的同名键覆盖')
                           : t('重复的键名,此值将被后面的同名键覆盖')
                       }
                       }
                     >
                     >
                       <IconAlertTriangle
                       <IconAlertTriangle
                         className="absolute right-2 top-1/2 transform -translate-y-1/2"
                         className="absolute right-2 top-1/2 transform -translate-y-1/2"
-                        style={{ 
+                        style={{
                           color: isLastDuplicate ? '#ff7d00' : '#faad14',
                           color: isLastDuplicate ? '#ff7d00' : '#faad14',
                           fontSize: '14px'
                           fontSize: '14px'
                         }}
                         }}
@@ -471,7 +501,7 @@ const JSONEditor = ({
                 updateValue(defaultPair.id, value);
                 updateValue(defaultPair.id, value);
               } else {
               } else {
                 const newPairs = [...keyValuePairs, {
                 const newPairs = [...keyValuePairs, {
-                  id: `${Date.now()}_${Math.random()}`,
+                  id: generateUniqueId(),
                   key: 'default',
                   key: 'default',
                   value: value
                   value: value
                 }];
                 }];

+ 114 - 96
web/src/components/table/tokens/TokensColumnDefs.js

@@ -28,7 +28,8 @@ import {
   Avatar,
   Avatar,
   Tooltip,
   Tooltip,
   Progress,
   Progress,
-  Switch,
+  Popover,
+  Typography,
   Input,
   Input,
   Modal
   Modal
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
@@ -46,21 +47,22 @@ import {
   IconEyeClosed,
   IconEyeClosed,
 } from '@douyinfe/semi-icons';
 } from '@douyinfe/semi-icons';
 
 
+// progress color helper
+const getProgressColor = (pct) => {
+  if (pct === 100) return 'var(--semi-color-success)';
+  if (pct <= 10) return 'var(--semi-color-danger)';
+  if (pct <= 30) return 'var(--semi-color-warning)';
+  return undefined;
+};
+
 // Render functions
 // Render functions
 function renderTimestamp(timestamp) {
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
   return <>{timestamp2string(timestamp)}</>;
 }
 }
 
 
-// Render status column with switch and progress bar
-const renderStatus = (text, record, manageToken, t) => {
+// Render status column only (no usage)
+const renderStatus = (text, record, t) => {
   const enabled = text === 1;
   const enabled = text === 1;
-  const handleToggle = (checked) => {
-    if (checked) {
-      manageToken(record.id, 'enable', record);
-    } else {
-      manageToken(record.id, 'disable', record);
-    }
-  };
 
 
   let tagColor = 'black';
   let tagColor = 'black';
   let tagText = t('未知状态');
   let tagText = t('未知状态');
@@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, t) => {
     tagText = t('已耗尽');
     tagText = t('已耗尽');
   }
   }
 
 
-  const used = parseInt(record.used_quota) || 0;
-  const remain = parseInt(record.remain_quota) || 0;
-  const total = used + remain;
-  const percent = total > 0 ? (remain / total) * 100 : 0;
-
-  const getProgressColor = (pct) => {
-    if (pct === 100) return 'var(--semi-color-success)';
-    if (pct <= 10) return 'var(--semi-color-danger)';
-    if (pct <= 30) return 'var(--semi-color-warning)';
-    return undefined;
-  };
-
-  const quotaSuffix = record.unlimited_quota ? (
-    <div className='text-xs'>{t('无限额度')}</div>
-  ) : (
-    <div className='flex flex-col items-end'>
-      <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
-      <Progress
-        percent={percent}
-        stroke={getProgressColor(percent)}
-        aria-label='quota usage'
-        format={() => `${percent.toFixed(0)}%`}
-        style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
-      />
-    </div>
-  );
-
-  const content = (
-    <Tag
-      color={tagColor}
-      shape='circle'
-      size='large'
-      prefixIcon={
-        <Switch
-          size='small'
-          checked={enabled}
-          onChange={handleToggle}
-          aria-label='token status switch'
-        />
-      }
-      suffixIcon={quotaSuffix}
-    >
+  return (
+    <Tag color={tagColor} shape='circle' size='small'>
       {tagText}
       {tagText}
     </Tag>
     </Tag>
   );
   );
-
-  const tooltipContent = record.unlimited_quota ? (
-    <div className='text-xs'>
-      <div>{t('已用额度')}: {renderQuota(used)}</div>
-    </div>
-  ) : (
-    <div className='text-xs'>
-      <div>{t('已用额度')}: {renderQuota(used)}</div>
-      <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
-      <div>{t('总额度')}: {renderQuota(total)}</div>
-    </div>
-  );
-
-  return (
-    <Tooltip content={tooltipContent}>
-      {content}
-    </Tooltip>
-  );
 };
 };
 
 
 // Render group column
 // Render group column
@@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => {
   return <Space wrap>{ipTags}</Space>;
   return <Space wrap>{ipTags}</Space>;
 };
 };
 
 
+// Render separate quota usage column
+const renderQuotaUsage = (text, record, t) => {
+  const { Paragraph } = Typography;
+  const used = parseInt(record.used_quota) || 0;
+  const remain = parseInt(record.remain_quota) || 0;
+  const total = used + remain;
+  if (record.unlimited_quota) {
+    const popoverContent = (
+      <div className='text-xs p-2'>
+        <Paragraph copyable={{ content: renderQuota(used) }}>
+          {t('已用额度')}: {renderQuota(used)}
+        </Paragraph>
+      </div>
+    );
+    return (
+      <Popover content={popoverContent} position='top'>
+        <Tag color='white' shape='circle'>
+          {t('无限额度')}
+        </Tag>
+      </Popover>
+    );
+  }
+  const percent = total > 0 ? (remain / total) * 100 : 0;
+  const popoverContent = (
+    <div className='text-xs p-2'>
+      <Paragraph copyable={{ content: renderQuota(used) }}>
+        {t('已用额度')}: {renderQuota(used)}
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(remain) }}>
+        {t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(total) }}>
+        {t('总额度')}: {renderQuota(total)}
+      </Paragraph>
+    </div>
+  );
+  return (
+    <Popover content={popoverContent} position='top'>
+      <Tag color='white' shape='circle'>
+        <div className='flex flex-col items-end'>
+          <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
+          <Progress
+            percent={percent}
+            stroke={getProgressColor(percent)}
+            aria-label='quota usage'
+            format={() => `${percent.toFixed(0)}%`}
+            style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
+          />
+        </div>
+      </Tag>
+    </Popover>
+  );
+};
+
 // Render operations column
 // Render operations column
 const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
 const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
-  let chats = localStorage.getItem('chats');
   let chatsArray = [];
   let chatsArray = [];
-  let shouldUseCustom = true;
-
-  if (shouldUseCustom) {
-    try {
-      chats = JSON.parse(chats);
-      if (Array.isArray(chats)) {
-        for (let i = 0; i < chats.length; i++) {
-          let chat = {};
-          chat.node = 'item';
-          for (let key in chats[i]) {
-            if (chats[i].hasOwnProperty(key)) {
-              chat.key = i;
-              chat.name = key;
-              chat.onClick = () => {
-                onOpenLink(key, chats[i][key], record);
-              };
-            }
-          }
-          chatsArray.push(chat);
-        }
+  try {
+    const raw = localStorage.getItem('chats');
+    const parsed = JSON.parse(raw);
+    if (Array.isArray(parsed)) {
+      for (let i = 0; i < parsed.length; i++) {
+        const item = parsed[i];
+        const name = Object.keys(item)[0];
+        if (!name) continue;
+        chatsArray.push({
+          node: 'item',
+          key: i,
+          name,
+          onClick: () => onOpenLink(name, item[name], record),
+        });
       }
       }
-    } catch (e) {
-      console.log(e);
-      showError(t('聊天链接配置错误,请联系管理员'));
     }
     }
+  } catch (_) {
+    showError(t('聊天链接配置错误,请联系管理员'));
   }
   }
 
 
   return (
   return (
@@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
             } else {
             } else {
               onOpenLink(
               onOpenLink(
                 'default',
                 'default',
-                chats[0][Object.keys(chats[0])[0]],
+                chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '',
                 record,
                 record,
               );
               );
             }
             }
@@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
         </Dropdown>
         </Dropdown>
       </SplitButtonGroup>
       </SplitButtonGroup>
 
 
+      {record.status === 1 ? (
+        <Button
+          type='danger'
+          size="small"
+          onClick={async () => {
+            await manageToken(record.id, 'disable', record);
+            await refresh();
+          }}
+        >
+          {t('禁用')}
+        </Button>
+      ) : (
+        <Button
+          size="small"
+          onClick={async () => {
+            await manageToken(record.id, 'enable', record);
+            await refresh();
+          }}
+        >
+          {t('启用')}
+        </Button>
+      )}
+
       <Button
       <Button
         type='tertiary'
         type='tertiary'
         size="small"
         size="small"
@@ -412,7 +425,12 @@ export const getTokensColumns = ({
       title: t('状态'),
       title: t('状态'),
       dataIndex: 'status',
       dataIndex: 'status',
       key: 'status',
       key: 'status',
-      render: (text, record) => renderStatus(text, record, manageToken, t),
+      render: (text, record) => renderStatus(text, record, t),
+    },
+    {
+      title: t('剩余额度/总额度'),
+      key: 'quota_usage',
+      render: (text, record) => renderQuotaUsage(text, record, t),
     },
     },
     {
     {
       title: t('分组'),
       title: t('分组'),

+ 69 - 56
web/src/components/table/users/UsersColumnDefs.js

@@ -24,7 +24,8 @@ import {
   Tag,
   Tag,
   Tooltip,
   Tooltip,
   Progress,
   Progress,
-  Switch,
+  Popover,
+  Typography,
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
 import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
 import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
 
 
@@ -89,7 +90,6 @@ const renderUsername = (text, record) => {
  * Render user statistics
  * Render user statistics
  */
  */
 const renderStatistics = (text, record, showEnableDisableModal, t) => {
 const renderStatistics = (text, record, showEnableDisableModal, t) => {
-  const enabled = record.status === 1;
   const isDeleted = record.DeletedAt !== null;
   const isDeleted = record.DeletedAt !== null;
 
 
   // Determine tag text & color like original status column
   // Determine tag text & color like original status column
@@ -100,60 +100,17 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
     tagText = t('已注销');
     tagText = t('已注销');
   } else if (record.status === 1) {
   } else if (record.status === 1) {
     tagColor = 'green';
     tagColor = 'green';
-    tagText = t('已激活');
+    tagText = t('已启用');
   } else if (record.status === 2) {
   } else if (record.status === 2) {
     tagColor = 'red';
     tagColor = 'red';
-    tagText = t('已禁');
+    tagText = t('已禁');
   }
   }
 
 
-  const handleToggle = (checked) => {
-    if (checked) {
-      showEnableDisableModal(record, 'enable');
-    } else {
-      showEnableDisableModal(record, 'disable');
-    }
-  };
-
-  const used = parseInt(record.used_quota) || 0;
-  const remain = parseInt(record.quota) || 0;
-  const total = used + remain;
-  const percent = total > 0 ? (remain / total) * 100 : 0;
-
-  const getProgressColor = (pct) => {
-    if (pct === 100) return 'var(--semi-color-success)';
-    if (pct <= 10) return 'var(--semi-color-danger)';
-    if (pct <= 30) return 'var(--semi-color-warning)';
-    return undefined;
-  };
-
-  const quotaSuffix = (
-    <div className='flex flex-col items-end'>
-      <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
-      <Progress
-        percent={percent}
-        stroke={getProgressColor(percent)}
-        aria-label='quota usage'
-        format={() => `${percent.toFixed(0)}%`}
-        style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
-      />
-    </div>
-  );
-
   const content = (
   const content = (
     <Tag
     <Tag
       color={tagColor}
       color={tagColor}
       shape='circle'
       shape='circle'
-      size='large'
-      prefixIcon={
-        <Switch
-          size='small'
-          checked={enabled}
-          onChange={handleToggle}
-          disabled={isDeleted}
-          aria-label='user status switch'
-        />
-      }
-      suffixIcon={quotaSuffix}
+      size='small'
     >
     >
       {tagText}
       {tagText}
     </Tag>
     </Tag>
@@ -161,9 +118,6 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
 
 
   const tooltipContent = (
   const tooltipContent = (
     <div className='text-xs'>
     <div className='text-xs'>
-      <div>{t('已用额度')}: {renderQuota(used)}</div>
-      <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
-      <div>{t('总额度')}: {renderQuota(total)}</div>
       <div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
       <div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
     </div>
     </div>
   );
   );
@@ -175,6 +129,43 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
   );
   );
 };
 };
 
 
+// Render separate quota usage column
+const renderQuotaUsage = (text, record, t) => {
+  const { Paragraph } = Typography;
+  const used = parseInt(record.used_quota) || 0;
+  const remain = parseInt(record.quota) || 0;
+  const total = used + remain;
+  const percent = total > 0 ? (remain / total) * 100 : 0;
+  const popoverContent = (
+    <div className='text-xs p-2'>
+      <Paragraph copyable={{ content: renderQuota(used) }}>
+        {t('已用额度')}: {renderQuota(used)}
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(remain) }}>
+        {t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(total) }}>
+        {t('总额度')}: {renderQuota(total)}
+      </Paragraph>
+    </div>
+  );
+  return (
+    <Popover content={popoverContent} position='top'>
+      <Tag color='white' shape='circle'>
+        <div className='flex flex-col items-end'>
+          <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
+          <Progress
+            percent={percent}
+            aria-label='quota usage'
+            format={() => `${percent.toFixed(0)}%`}
+            style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
+          />
+        </div>
+      </Tag>
+    </Popover>
+  );
+};
+
 /**
 /**
  * Render invite information
  * Render invite information
  */
  */
@@ -204,6 +195,7 @@ const renderOperations = (text, record, {
   setShowEditUser,
   setShowEditUser,
   showPromoteModal,
   showPromoteModal,
   showDemoteModal,
   showDemoteModal,
+  showEnableDisableModal,
   showDeleteModal,
   showDeleteModal,
   t
   t
 }) => {
 }) => {
@@ -213,6 +205,22 @@ const renderOperations = (text, record, {
 
 
   return (
   return (
     <Space>
     <Space>
+      {record.status === 1 ? (
+        <Button
+          type='danger'
+          size="small"
+          onClick={() => showEnableDisableModal(record, 'disable')}
+        >
+          {t('禁用')}
+        </Button>
+      ) : (
+        <Button
+          size="small"
+          onClick={() => showEnableDisableModal(record, 'enable')}
+        >
+          {t('启用')}
+        </Button>
+      )}
       <Button
       <Button
         type='tertiary'
         type='tertiary'
         size="small"
         size="small"
@@ -270,6 +278,16 @@ export const getUsersColumns = ({
       dataIndex: 'username',
       dataIndex: 'username',
       render: (text, record) => renderUsername(text, record),
       render: (text, record) => renderUsername(text, record),
     },
     },
+    {
+      title: t('状态'),
+      dataIndex: 'info',
+      render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
+    },
+    {
+      title: t('剩余额度/总额度'),
+      key: 'quota_usage',
+      render: (text, record) => renderQuotaUsage(text, record, t),
+    },
     {
     {
       title: t('分组'),
       title: t('分组'),
       dataIndex: 'group',
       dataIndex: 'group',
@@ -284,11 +302,6 @@ export const getUsersColumns = ({
         return <div>{renderRole(text, t)}</div>;
         return <div>{renderRole(text, t)}</div>;
       },
       },
     },
     },
-    {
-      title: t('状态'),
-      dataIndex: 'info',
-      render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
-    },
     {
     {
       title: t('邀请信息'),
       title: t('邀请信息'),
       dataIndex: 'invite',
       dataIndex: 'invite',