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

This commit is contained in:
CaIon
2025-08-09 17:02:20 +08:00
4 changed files with 260 additions and 188 deletions

View File

@@ -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

View File

@@ -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
}]; }];

View File

@@ -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('分组'),

View File

@@ -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',