diff --git a/model/main.go b/model/main.go index 08e3553a..49ccc56f 100644 --- a/model/main.go +++ b/model/main.go @@ -64,6 +64,22 @@ var 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 { var user User //if user.Status != common.UserStatusEnabled { @@ -236,11 +252,8 @@ func InitLogDB() (err 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 { return migrateDBFast() } @@ -271,10 +284,8 @@ func migrateDB() 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 diff --git a/web/src/components/common/ui/JSONEditor.js b/web/src/components/common/ui/JSONEditor.js index 5d4d4d32..4acbe270 100644 --- a/web/src/components/common/ui/JSONEditor.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -18,15 +37,19 @@ import { Tooltip, } from '@douyinfe/semi-ui'; import { - IconCode, IconPlus, IconDelete, - IconRefresh, IconAlertTriangle, } from '@douyinfe/semi-icons'; const { Text } = Typography; +// 唯一 ID 生成器,确保在组件生命周期内稳定且递增 +const generateUniqueId = (() => { + let counter = 0; + return () => `kv_${counter++}`; +})(); + const JSONEditor = ({ value = '', onChange, @@ -46,13 +69,20 @@ const JSONEditor = ({ const { t } = useTranslation(); // 将对象转换为键值对数组(包含唯一ID) - const objectToKeyValueArray = useCallback((obj) => { + const objectToKeyValueArray = useCallback((obj, prevPairs = []) => { 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'; }); - + const [jsonError, setJsonError] = useState(''); // 计算重复的键 const duplicateKeys = useMemo(() => { const keyCount = {}; const duplicates = new Set(); - + keyValuePairs.forEach(pair => { if (pair.key) { keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; @@ -118,7 +148,7 @@ const JSONEditor = ({ } } }); - + return duplicates; }, [keyValuePairs]); @@ -131,11 +161,11 @@ const JSONEditor = ({ } else if (typeof value === 'object' && value !== null) { parsed = value; } - + // 只在外部值真正改变时更新,避免循环更新 const currentObj = keyValueArrayToObject(keyValuePairs); if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) { - setKeyValuePairs(objectToKeyValueArray(parsed)); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); } setJsonError(''); } catch (error) { @@ -158,7 +188,7 @@ const JSONEditor = ({ setKeyValuePairs(newPairs); const jsonObject = keyValueArrayToObject(newPairs); const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2); - + setJsonError(''); // 通过formApi设置值 @@ -175,7 +205,7 @@ const JSONEditor = ({ if (newValue && newValue.trim()) { try { const parsed = JSON.parse(newValue); - setKeyValuePairs(objectToKeyValueArray(parsed)); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); setJsonError(''); onChange?.(newValue); } catch (error) { @@ -186,7 +216,7 @@ const JSONEditor = ({ setJsonError(''); onChange?.(''); } - }, [onChange, objectToKeyValueArray]); + }, [onChange, objectToKeyValueArray, keyValuePairs]); // 切换编辑模式 const toggleEditMode = useCallback(() => { @@ -204,7 +234,7 @@ const JSONEditor = ({ } else if (typeof value === 'object' && value !== null) { parsed = value; } - setKeyValuePairs(objectToKeyValueArray(parsed)); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); setJsonError(''); setEditMode('visual'); } catch (error) { @@ -225,7 +255,7 @@ const JSONEditor = ({ newKey = `field_${counter}`; } newPairs.push({ - id: `${Date.now()}_${Math.random()}`, + id: generateUniqueId(), key: newKey, value: '' }); @@ -240,7 +270,7 @@ const JSONEditor = ({ // 更新键名 const updateKey = useCallback((id, newKey) => { - const newPairs = keyValuePairs.map(pair => + const newPairs = keyValuePairs.map(pair => pair.id === id ? { ...pair, key: newKey } : pair ); handleVisualChange(newPairs); @@ -264,11 +294,11 @@ const JSONEditor = ({ } setManualText(templateString); - setKeyValuePairs(objectToKeyValueArray(template)); + setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs)); onChange?.(templateString); setJsonError(''); } - }, [template, onChange, formApi, field, objectToKeyValueArray]); + }, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]); // 渲染值输入控件(支持嵌套) const renderValueInput = (pairId, value) => { @@ -327,7 +357,7 @@ const JSONEditor = ({ let convertedValue = newValue; if (newValue === 'true') convertedValue = true; else if (newValue === 'false') convertedValue = false; - else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') { + else if (!isNaN(newValue) && newValue !== '') { const num = Number(newValue); // 检查是否为整数 if (Number.isInteger(num)) { @@ -373,9 +403,9 @@ const JSONEditor = ({ {keyValuePairs.map((pair, index) => { const isDuplicate = duplicateKeys.has(pair.key); - const isLastDuplicate = isDuplicate && + const isLastDuplicate = isDuplicate && keyValuePairs.slice(index + 1).every(p => p.key !== pair.key); - + return ( @@ -389,14 +419,14 @@ const JSONEditor = ({ {isDuplicate && ( { + 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 function renderTimestamp(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 handleToggle = (checked) => { - if (checked) { - manageToken(record.id, 'enable', record); - } else { - manageToken(record.id, 'disable', record); - } - }; let tagColor = 'black'; let tagText = t('未知状态'); @@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, 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 ? ( -
{t('无限额度')}
- ) : ( -
- {`${renderQuota(remain)} / ${renderQuota(total)}`} - `${percent.toFixed(0)}%`} - style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} - /> -
- ); - - const content = ( - - } - suffixIcon={quotaSuffix} - > + return ( + {tagText} ); - - const tooltipContent = record.unlimited_quota ? ( -
-
{t('已用额度')}: {renderQuota(used)}
-
- ) : ( -
-
{t('已用额度')}: {renderQuota(used)}
-
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
-
{t('总额度')}: {renderQuota(total)}
-
- ); - - return ( - - {content} - - ); }; // Render group column @@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => { return {ipTags}; }; +// 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 = ( +
+ + {t('已用额度')}: {renderQuota(used)} + +
+ ); + return ( + + + {t('无限额度')} + + + ); + } + const percent = total > 0 ? (remain / total) * 100 : 0; + const popoverContent = ( +
+ + {t('已用额度')}: {renderQuota(used)} + + + {t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%) + + + {t('总额度')}: {renderQuota(total)} + +
+ ); + return ( + + +
+ {`${renderQuota(remain)} / ${renderQuota(total)}`} + `${percent.toFixed(0)}%`} + style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} + /> +
+
+
+ ); +}; + // Render operations column const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => { - let chats = localStorage.getItem('chats'); 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 ( @@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit } else { onOpenLink( 'default', - chats[0][Object.keys(chats[0])[0]], + chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '', record, ); } @@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit + {record.status === 1 ? ( + + ) : ( + + )} + + ) : ( + + )}