Merge remote-tracking branch 'origin/alpha' into alpha
This commit is contained in:
@@ -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 {
|
dropIndexIfExists("models", "uk_model_name")
|
||||||
// 旧索引可能不存在,忽略删除错误即可
|
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||||
_ = DB.Exec("ALTER TABLE models DROP INDEX uk_model_name;").Error
|
|
||||||
_ = DB.Exec("ALTER TABLE vendors DROP INDEX uk_vendor_name;").Error
|
|
||||||
}
|
|
||||||
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 {
|
dropIndexIfExists("models", "uk_model_name")
|
||||||
_ = DB.Exec("ALTER TABLE models DROP INDEX uk_model_name;").Error
|
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||||
_ = DB.Exec("ALTER TABLE vendors DROP INDEX uk_vendor_name;").Error
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
const entries = Object.entries(obj);
|
||||||
key,
|
return entries.map(([key, value], index) => {
|
||||||
value
|
// 如果上一次转换后同位置的键一致,则沿用其 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
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -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
|
// Render status column only (no usage)
|
||||||
const renderStatus = (text, record, manageToken, t) => {
|
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;
|
return (
|
||||||
const remain = parseInt(record.remain_quota) || 0;
|
<Tag color={tagColor} shape='circle' size='small'>
|
||||||
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}
|
|
||||||
>
|
|
||||||
{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;
|
try {
|
||||||
|
const raw = localStorage.getItem('chats');
|
||||||
if (shouldUseCustom) {
|
const parsed = JSON.parse(raw);
|
||||||
try {
|
if (Array.isArray(parsed)) {
|
||||||
chats = JSON.parse(chats);
|
for (let i = 0; i < parsed.length; i++) {
|
||||||
if (Array.isArray(chats)) {
|
const item = parsed[i];
|
||||||
for (let i = 0; i < chats.length; i++) {
|
const name = Object.keys(item)[0];
|
||||||
let chat = {};
|
if (!name) continue;
|
||||||
chat.node = 'item';
|
chatsArray.push({
|
||||||
for (let key in chats[i]) {
|
node: 'item',
|
||||||
if (chats[i].hasOwnProperty(key)) {
|
key: i,
|
||||||
chat.key = i;
|
name,
|
||||||
chat.name = key;
|
onClick: () => onOpenLink(name, item[name], record),
|
||||||
chat.onClick = () => {
|
});
|
||||||
onOpenLink(key, chats[i][key], record);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chatsArray.push(chat);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} 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('分组'),
|
||||||
|
|||||||
@@ -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'
|
size='small'
|
||||||
prefixIcon={
|
|
||||||
<Switch
|
|
||||||
size='small'
|
|
||||||
checked={enabled}
|
|
||||||
onChange={handleToggle}
|
|
||||||
disabled={isDeleted}
|
|
||||||
aria-label='user status switch'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
suffixIcon={quotaSuffix}
|
|
||||||
>
|
>
|
||||||
{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',
|
||||||
|
|||||||
Reference in New Issue
Block a user