From a5cbef1a610a817cc3d700e729c7aac9e519b35b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 9 Aug 2025 14:08:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20style(JSONEditor):=20add=20AGPL?= =?UTF-8?q?-3.0=20license=20header,=20clean=20imports=20&=20refine=20Banne?= =?UTF-8?q?r=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added full GNU Affero General Public License v3 header at the top of `JSONEditor.js`. * Removed unused `IconCode` and `IconRefresh` imports to eliminate dead code. * Set `closeIcon={null}` and applied `!rounded-md` class for `Banner`, improving visual consistency and preventing unintended dismissal. * Normalized whitespace and line-breaks for better readability and lint compliance. --- web/src/components/common/ui/JSONEditor.js | 84 +++++++++++++++------- 1 file changed, 57 insertions(+), 27 deletions(-) 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 && ( Date: Sat, 9 Aug 2025 15:44:08 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20fix(db):=20allow=20re-adding?= =?UTF-8?q?=20models=20&=20vendors=20after=20soft=20delete;=20add=20safe?= =?UTF-8?q?=20index=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace legacy single-column unique indexes with composite unique indexes on (name, deleted_at) and introduce a safe index drop utility to eliminate duplicate-key errors and noisy MySQL 1091 warnings. WHAT • model/model_meta.go - Model.ModelName → `uniqueIndex:uk_model_name,priority:1` - Model.DeletedAt → `index; uniqueIndex:uk_model_name,priority:2` • model/vendor_meta.go - Vendor.Name → `uniqueIndex:uk_vendor_name,priority:1` - Vendor.DeletedAt → `index; uniqueIndex:uk_vendor_name,priority:2` • model/main.go - Add `dropIndexIfExists(table, index)`: • Checks `information_schema.statistics` • Drops index only when present (avoids Error 1091) - Invoke helper in `migrateDB` & `migrateDBFast` - Remove direct `ALTER TABLE … DROP INDEX …` calls WHY • Users received `Error 1062 (23000)` when re-creating a soft-deleted model/vendor because the old unique index enforced uniqueness on name alone. • Directly dropping nonexistent indexes caused MySQL `Error 1091` noise. HOW • Composite unique indexes `(model_name, deleted_at)` / `(name, deleted_at)` respect GORM soft deletes. • Safe helper ensures idempotent migrations across environments. RESULT • Users can now delete and re-add the same model or vendor without manual SQL. • Startup migration runs quietly across MySQL, PostgreSQL, and SQLite. • No behavior changes for existing data beyond index updates. TEST 1. Add model “deepseek-chat” → delete (soft) → re-add → success. 2. Add vendor “DeepSeek” → delete (soft) → re-add → success. 3. Restart service twice → no duplicate key or 1091 errors. --- model/main.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) 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 From 44da9c9a28d70bb9eaa6bbf84b1ead279e988d15 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 9 Aug 2025 16:47:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20style(ui):=20Replace=20switches?= =?UTF-8?q?=20with=20buttons;=20add=20quota=20column=20with=20Popover;=20c?= =?UTF-8?q?leanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tokens/Users tables: - Replaced status Switch with explicit Enable/Disable buttons in the operation column - Unified button styles with Channels/Models (Disable: danger + small; Enable: default + small) - Status column now shows a small Tag only; standardized labels (Enabled/Disabled/etc.); removed usage info - New "Remaining/Total Quota" column: - Wrapped in a white Tag; shows Remaining/Total with a progress bar - Replaced Tooltip with Popover; contents use Typography.Paragraph with copyable values - Copyable content excludes percentages (only numeric quota values are copied) - Added padding to Popover content for better readability - Tokens specifics: - For unlimited quota, show a white Tag "Unlimited quota" with a Popover that displays copyable "Used quota" - Cleanup: - Removed Switch imports/handlers and unused code paths - Eliminated console logs and redundant flags; simplified chats parsing - Removed quota calculations from status renderers Files: - web/src/components/table/tokens/TokensColumnDefs.js - web/src/components/table/users/UsersColumnDefs.js --- .../table/tokens/TokensColumnDefs.js | 173 ++++++++++-------- .../components/table/users/UsersColumnDefs.js | 125 +++++++------ 2 files changed, 167 insertions(+), 131 deletions(-) diff --git a/web/src/components/table/tokens/TokensColumnDefs.js b/web/src/components/table/tokens/TokensColumnDefs.js index ffa5ff79..4c8125fc 100644 --- a/web/src/components/table/tokens/TokensColumnDefs.js +++ b/web/src/components/table/tokens/TokensColumnDefs.js @@ -28,7 +28,8 @@ import { Avatar, Tooltip, Progress, - Switch, + Popover, + Typography, Input, Modal } from '@douyinfe/semi-ui'; @@ -46,21 +47,22 @@ import { IconEyeClosed, } 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 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,13 +236,65 @@ 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) { + if (true) { try { chats = JSON.parse(chats); if (Array.isArray(chats)) { @@ -318,7 +314,6 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit } } } catch (e) { - console.log(e); showError(t('聊天链接配置错误,请联系管理员')); } } @@ -359,6 +354,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit + {record.status === 1 ? ( + + ) : ( + + )} + + ) : ( + + )}