feat: replace quota input with amount-first UI and atomic quota adjustment

- Refactor token, redemption, and user quota inputs to prioritize monetary
  amount entry, with raw quota input collapsed by default
- Add atomic quota adjustment modal for users with add/subtract/override modes,
  bypassing batch update queue for immediate DB consistency
- Make user quota fields readonly in edit form; all modifications go through
  the dedicated adjust-quota modal via POST /api/user/manage
- Add DecreaseUserQuota `db` parameter for direct DB writes, matching
  IncreaseUserQuota behavior
- Support negative quota display in amount conversion helpers
- Add i18n keys for all new UI strings across all locales
This commit is contained in:
CaIon
2026-04-09 22:44:53 +08:00
parent 0664bb3f65
commit 040e8c1da8
21 changed files with 493 additions and 149 deletions
+2
View File
@@ -29,3 +29,5 @@ data/
.gomodcache/ .gomodcache/
.gocache-temp .gocache-temp
.gopath .gopath
token_estimator_test.go
+43 -3
View File
@@ -572,9 +572,6 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
@@ -841,6 +838,8 @@ func CreateUser(c *gin.Context) {
type ManageRequest struct { type ManageRequest struct {
Id int `json:"id"` Id int `json:"id"`
Action string `json:"action"` Action string `json:"action"`
Value int `json:"value"`
Mode string `json:"mode"`
} }
// ManageUser Only admin user can do this // ManageUser Only admin user can do this
@@ -907,6 +906,47 @@ func ManageUser(c *gin.Context) {
return return
} }
user.Role = common.RoleCommonUser user.Role = common.RoleCommonUser
case "add_quota":
switch req.Mode {
case "add":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)))
case "subtract":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)))
case "override":
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)))
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
} }
if err := user.Update(false); err != nil { if err := user.Update(false); err != nil {
+1
View File
@@ -101,6 +101,7 @@ const (
MsgUserTelegramIdEmpty = "user.telegram_id_empty" MsgUserTelegramIdEmpty = "user.telegram_id_empty"
MsgUserTelegramNotBound = "user.telegram_not_bound" MsgUserTelegramNotBound = "user.telegram_not_bound"
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty" MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
MsgUserQuotaChangeZero = "user.quota_change_zero"
) )
// Quota related messages // Quota related messages
+1
View File
@@ -91,6 +91,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
user.telegram_id_empty: "Telegram ID is empty!" user.telegram_id_empty: "Telegram ID is empty!"
user.telegram_not_bound: "This Telegram account is not bound" user.telegram_not_bound: "This Telegram account is not bound"
user.linux_do_id_empty: "Linux DO ID is empty!" user.linux_do_id_empty: "Linux DO ID is empty!"
user.quota_change_zero: "Quota change amount cannot be zero"
# Quota messages # Quota messages
quota.negative: "Quota cannot be negative!" quota.negative: "Quota cannot be negative!"
+1
View File
@@ -92,6 +92,7 @@ user.wechat_id_empty: "WeChat id 为空!"
user.telegram_id_empty: "Telegram id 为空!" user.telegram_id_empty: "Telegram id 为空!"
user.telegram_not_bound: "该 Telegram 账户未绑定" user.telegram_not_bound: "该 Telegram 账户未绑定"
user.linux_do_id_empty: "Linux DO id 为空!" user.linux_do_id_empty: "Linux DO id 为空!"
user.quota_change_zero: "额度变更量不能为0"
# Quota messages # Quota messages
quota.negative: "额度不能为负数!" quota.negative: "额度不能为负数!"
+1
View File
@@ -92,6 +92,7 @@ user.wechat_id_empty: "WeChat id 為空!"
user.telegram_id_empty: "Telegram id 為空!" user.telegram_id_empty: "Telegram id 為空!"
user.telegram_not_bound: "該 Telegram 帳號未綁定" user.telegram_not_bound: "該 Telegram 帳號未綁定"
user.linux_do_id_empty: "Linux DO id 為空!" user.linux_do_id_empty: "Linux DO id 為空!"
user.quota_change_zero: "額度變更量不能為0"
# Quota messages # Quota messages
quota.negative: "額度不能為負數!" quota.negative: "額度不能為負數!"
+3 -4
View File
@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
"username": newUser.Username, "username": newUser.Username,
"display_name": newUser.DisplayName, "display_name": newUser.DisplayName,
"group": newUser.Group, "group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark, "remark": newUser.Remark,
} }
if updatePassword { if updatePassword {
@@ -896,7 +895,7 @@ func increaseUserQuota(id int, quota int) (err error) {
return err return err
} }
func DecreaseUserQuota(id int, quota int) (err error) { func DecreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 { if quota < 0 {
return errors.New("quota 不能为负数!") return errors.New("quota 不能为负数!")
} }
@@ -906,7 +905,7 @@ func DecreaseUserQuota(id int, quota int) (err error) {
common.SysLog("failed to decrease user quota: " + err.Error()) common.SysLog("failed to decrease user quota: " + err.Error())
} }
}) })
if common.BatchUpdateEnabled { if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, -quota) addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
return nil return nil
} }
@@ -928,7 +927,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
if delta > 0 { if delta > 0 {
return IncreaseUserQuota(id, delta, false) return IncreaseUserQuota(id, delta, false)
} else { } else {
return DecreaseUserQuota(id, -delta) return DecreaseUserQuota(id, -delta, false)
} }
} }
+2 -2
View File
@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
if amount <= 0 { if amount <= 0 {
return nil return nil
} }
if err := model.DecreaseUserQuota(w.userId, amount); err != nil { if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
return err return err
} }
w.consumed = amount w.consumed = amount
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
return nil return nil
} }
if delta > 0 { if delta > 0 {
return model.DecreaseUserQuota(w.userId, delta) return model.DecreaseUserQuota(w.userId, delta, false)
} }
return model.IncreaseUserQuota(w.userId, -delta, false) return model.IncreaseUserQuota(w.userId, -delta, false)
} }
+1 -1
View File
@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
} else { } else {
// Wallet // Wallet
if quota > 0 { if quota > 0 {
err = model.DecreaseUserQuota(relayInfo.UserId, quota) err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
} else { } else {
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
} }
+1 -1
View File
@@ -90,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta)) return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
} }
if delta > 0 { if delta > 0 {
return model.DecreaseUserQuota(task.UserId, delta) return model.DecreaseUserQuota(task.UserId, delta, false)
} }
return model.IncreaseUserQuota(task.UserId, -delta, false) return model.IncreaseUserQuota(task.UserId, -delta, false)
} }
@@ -25,8 +25,12 @@ import {
showError, showError,
showSuccess, showSuccess,
renderQuota, renderQuota,
renderQuotaWithPrompt, getCurrencyConfig,
} from '../../../../helpers'; } from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { import {
Button, Button,
@@ -41,6 +45,7 @@ import {
Avatar, Avatar,
Row, Row,
Col, Col,
InputNumber,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconCreditCard, IconCreditCard,
@@ -57,10 +62,12 @@ const EditRedemptionModal = (props) => {
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const formApiRef = useRef(null); const formApiRef = useRef(null);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const getInitValues = () => ({ const getInitValues = () => ({
name: '', name: '',
quota: 100000, quota: 100000,
amount: Number(quotaToDisplayAmount(100000).toFixed(6)),
count: 1, count: 1,
expired_time: null, expired_time: null,
}); });
@@ -79,6 +86,7 @@ const EditRedemptionModal = (props) => {
} else { } else {
data.expired_time = new Date(data.expired_time * 1000); data.expired_time = new Date(data.expired_time * 1000);
} }
data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6));
formApiRef.current?.setValues({ ...getInitValues(), ...data }); formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else { } else {
showError(message); showError(message);
@@ -104,7 +112,12 @@ const EditRedemptionModal = (props) => {
setLoading(true); setLoading(true);
let localInputs = { ...values }; let localInputs = { ...values };
localInputs.count = parseInt(localInputs.count) || 0; localInputs.count = parseInt(localInputs.count) || 0;
localInputs.quota = parseInt(localInputs.quota) || 0; localInputs.quota = displayAmountToQuota(localInputs.amount);
if (localInputs.quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
localInputs.name = name; localInputs.name = name;
if (!localInputs.expired_time) { if (!localInputs.expired_time) {
localInputs.expired_time = 0; localInputs.expired_time = 0;
@@ -285,37 +298,63 @@ const EditRedemptionModal = (props) => {
</div> </div>
<Row gutter={12}> <Row gutter={12}>
<Col span={12}> <Col span={24}>
<Form.AutoComplete <Form.InputNumber
field='quota' field='amount'
label={t('额')} label={t('额')}
placeholder={t('请输入额度')} prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
precision={6}
min={0}
step={0.000001}
style={{ width: '100%' }} style={{ width: '100%' }}
type='number' onChange={(val) => {
rules={[ const amount = val === '' || val == null ? 0 : val;
{ required: true, message: t('请输入额度') }, formApiRef.current?.setValue('amount', amount);
{ formApiRef.current?.setValue(
validator: (rule, v) => { 'quota',
const num = parseInt(v, 10); displayAmountToQuota(amount),
return num > 0 );
? Promise.resolve() }}
: Promise.reject(t('额度必须大于0'));
},
},
]}
extraText={renderQuotaWithPrompt(
Number(values.quota) || 0,
)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
showClear showClear
/> />
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='quota'
label={t('额度')}
placeholder={t('输入额度')}
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('quota', quota);
formApiRef.current?.setValue(
'amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
</Col> </Col>
{!isEdit && ( {!isEdit && (
<Col span={12}> <Col span={12}>
@@ -24,10 +24,14 @@ import {
showSuccess, showSuccess,
timestamp2string, timestamp2string,
renderGroupOption, renderGroupOption,
renderQuotaWithPrompt, getCurrencyConfig,
getModelCategories, getModelCategories,
selectFilter, selectFilter,
} from '../../../../helpers'; } from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { import {
Button, Button,
@@ -41,6 +45,7 @@ import {
Form, Form,
Col, Col,
Row, Row,
InputNumber,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconCreditCard, IconCreditCard,
@@ -62,11 +67,13 @@ const EditTokenModal = (props) => {
const formApiRef = useRef(null); const formApiRef = useRef(null);
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const isEdit = props.editingToken.id !== undefined; const isEdit = props.editingToken.id !== undefined;
const getInitValues = () => ({ const getInitValues = () => ({
name: '', name: '',
remain_quota: 0, remain_quota: 0,
remain_amount: 0,
expired_time: -1, expired_time: -1,
unlimited_quota: true, unlimited_quota: true,
model_limits_enabled: false, model_limits_enabled: false,
@@ -162,6 +169,9 @@ const EditTokenModal = (props) => {
} else { } else {
data.model_limits = []; data.model_limits = [];
} }
data.remain_amount = Number(
quotaToDisplayAmount(data.remain_quota || 0).toFixed(6),
);
if (formApiRef.current) { if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data }); formApiRef.current.setValues({ ...getInitValues(), ...data });
} }
@@ -209,7 +219,14 @@ const EditTokenModal = (props) => {
setLoading(true); setLoading(true);
if (isEdit) { if (isEdit) {
let { tokenCount: _tc, ...localInputs } = values; let { tokenCount: _tc, ...localInputs } = values;
localInputs.remain_quota = parseInt(localInputs.remain_quota); localInputs.remain_quota = localInputs.unlimited_quota
? 0
: displayAmountToQuota(localInputs.remain_amount);
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
if (localInputs.expired_time !== -1) { if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time); let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) { if (isNaN(time)) {
@@ -245,7 +262,14 @@ const EditTokenModal = (props) => {
} else { } else {
localInputs.name = baseName; localInputs.name = baseName;
} }
localInputs.remain_quota = parseInt(localInputs.remain_quota); localInputs.remain_quota = localInputs.unlimited_quota
? 0
: displayAmountToQuota(localInputs.remain_amount);
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
break;
}
if (localInputs.expired_time !== -1) { if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time); let time = Date.parse(localInputs.expired_time);
@@ -497,28 +521,63 @@ const EditTokenModal = (props) => {
</div> </div>
<Row gutter={12}> <Row gutter={12}>
<Col span={24}> <Col span={24}>
<Form.AutoComplete <Form.InputNumber
field='remain_quota' field='remain_amount'
label={t('额')} label={t('额')}
placeholder={t('请输入额度')} prefix={getCurrencyConfig().symbol}
type='number' placeholder={t('输入金额')}
precision={6}
disabled={values.unlimited_quota} disabled={values.unlimited_quota}
extraText={renderQuotaWithPrompt(values.remain_quota)} min={0}
rules={ step={0.000001}
values.unlimited_quota onChange={(val) => {
? [] const amount = val === '' || val == null ? 0 : val;
: [{ required: true, message: t('请输入额度') }] formApiRef.current?.setValue('remain_amount', amount);
} formApiRef.current?.setValue(
data={[ 'remain_quota',
{ value: 500000, label: '1$' }, displayAmountToQuota(amount),
{ value: 5000000, label: '10$' }, );
{ value: 25000000, label: '50$' }, }}
{ value: 50000000, label: '100$' }, style={{ width: '100%' }}
{ value: 250000000, label: '500$' }, showClear
{ value: 500000000, label: '1000$' },
]}
/> />
</Col> </Col>
<Col span={24}>
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='remain_quota'
label={t('额度')}
placeholder={t('输入额度')}
disabled={values.unlimited_quota}
min={0}
step={500000}
rules={
values.unlimited_quota
? []
: [{ required: true, message: t('请输入额度') }]
}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('remain_quota', quota);
formApiRef.current?.setValue(
'remain_amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
</Col>
<Col span={24}> <Col span={24}>
<Form.Switch <Form.Switch
field='unlimited_quota' field='unlimited_quota'
@@ -24,7 +24,6 @@ import {
showError, showError,
showSuccess, showSuccess,
renderQuota, renderQuota,
renderQuotaWithPrompt,
getCurrencyConfig, getCurrencyConfig,
} from '../../../../helpers'; } from '../../../../helpers';
import { import {
@@ -46,6 +45,8 @@ import {
Row, Row,
Col, Col,
InputNumber, InputNumber,
RadioGroup,
Radio,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconUser, IconUser,
@@ -53,7 +54,7 @@ import {
IconClose, IconClose,
IconLink, IconLink,
IconUserGroup, IconUserGroup,
IconPlus, IconEdit,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import UserBindingManagementModal from './UserBindingManagementModal'; import UserBindingManagementModal from './UserBindingManagementModal';
@@ -63,13 +64,18 @@ const EditUserModal = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const userId = props.editingUser.id; const userId = props.editingUser.id;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false); const [adjustModalOpen, setAdjustModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState(''); const [adjustQuotaLocal, setAdjustQuotaLocal] = useState('');
const [addAmountLocal, setAddAmountLocal] = useState(''); const [adjustAmountLocal, setAdjustAmountLocal] = useState('');
const [adjustMode, setAdjustMode] = useState('add');
const [adjustLoading, setAdjustLoading] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [groupOptions, setGroupOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]);
const [bindingModalVisible, setBindingModalVisible] = useState(false); const [bindingModalVisible, setBindingModalVisible] = useState(false);
const formApiRef = useRef(null); const formApiRef = useRef(null);
const [showAdjustQuotaRaw, setShowAdjustQuotaRaw] = useState(false);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const [inputs, setInputs] = useState(null);
const isEdit = Boolean(userId); const isEdit = Boolean(userId);
@@ -85,6 +91,7 @@ const EditUserModal = (props) => {
linux_do_id: '', linux_do_id: '',
email: '', email: '',
quota: 0, quota: 0,
quota_amount: 0,
group: 'default', group: 'default',
remark: '', remark: '',
}); });
@@ -107,13 +114,22 @@ const EditUserModal = (props) => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
data.password = ''; data.password = '';
formApiRef.current?.setValues({ ...getInitValues(), ...data }); data.quota_amount = Number(
quotaToDisplayAmount(data.quota || 0).toFixed(6),
);
setInputs({ ...getInitValues(), ...data });
} else { } else {
showError(message); showError(message);
} }
setLoading(false); setLoading(false);
}; };
useEffect(() => {
if (inputs && formApiRef.current) {
formApiRef.current.setValues(inputs);
}
}, [inputs]);
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
if (userId) fetchGroups(); if (userId) fetchGroups();
@@ -132,8 +148,8 @@ const EditUserModal = (props) => {
const submit = async (values) => { const submit = async (values) => {
setLoading(true); setLoading(true);
let payload = { ...values }; let payload = { ...values };
if (typeof payload.quota === 'string') delete payload.quota;
payload.quota = parseInt(payload.quota) || 0; delete payload.quota_amount;
if (userId) { if (userId) {
payload.id = parseInt(userId); payload.id = parseInt(userId);
} }
@@ -150,11 +166,60 @@ const EditUserModal = (props) => {
setLoading(false); setLoading(false);
}; };
/* --------------------- quota helper -------------------- */ /* --------------------- atomic quota adjust -------------------- */
const addLocalQuota = () => { const adjustQuota = async () => {
const current = parseInt(formApiRef.current?.getValue('quota') || 0); const quotaVal = parseInt(adjustQuotaLocal) || 0;
const delta = parseInt(addQuotaLocal) || 0; if (quotaVal <= 0 && adjustMode !== 'override') return;
formApiRef.current?.setValue('quota', current + delta); if (adjustMode === 'override' && (adjustQuotaLocal === '' || adjustQuotaLocal == null)) return;
setAdjustLoading(true);
try {
const res = await API.post('/api/user/manage', {
id: parseInt(userId),
action: 'add_quota',
mode: adjustMode,
value: adjustMode === 'override' ? quotaVal : Math.abs(quotaVal),
});
const { success, message } = res.data;
if (success) {
showSuccess(t('调整额度成功'));
setAdjustModalOpen(false);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
const userRes = await API.get(`/api/user/${userId}`);
if (userRes.data.success) {
const data = userRes.data.data;
data.password = '';
data.quota_amount = Number(
quotaToDisplayAmount(data.quota || 0).toFixed(6),
);
setInputs({ ...getInitValues(), ...data });
}
props.refresh();
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
setAdjustLoading(false);
};
const getPreviewText = () => {
const current = formApiRef.current?.getValue('quota') || 0;
const val = parseInt(adjustQuotaLocal) || 0;
let result;
switch (adjustMode) {
case 'add':
result = current + Math.abs(val);
return `${t('当前额度')}${renderQuota(current)}+${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
case 'subtract':
result = current - Math.abs(val);
return `${t('当前额度')}${renderQuota(current)}-${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
case 'override':
return `${t('当前额度')}${renderQuota(current)}${renderQuota(val)}`;
default:
return '';
}
}; };
/* --------------------------- UI --------------------------- */ /* --------------------------- UI --------------------------- */
@@ -305,24 +370,47 @@ const EditUserModal = (props) => {
<Col span={10}> <Col span={10}>
<Form.InputNumber <Form.InputNumber
field='quota' field='quota_amount'
label={t('剩余额度')} label={t('金额')}
placeholder={t('请输入新的剩余额度')} prefix={getCurrencyConfig().symbol}
step={500000} precision={6}
extraText={renderQuotaWithPrompt(values.quota || 0)} step={0.000001}
rules={[{ required: true, message: t('请输入额度') }]}
style={{ width: '100%' }} style={{ width: '100%' }}
readonly
/> />
</Col> </Col>
<Col span={14}> <Col span={14}>
<Form.Slot label={t('添加额度')}> <Form.Slot label={t('调整额度')}>
<Button <Button
icon={<IconPlus />} icon={<IconEdit />}
onClick={() => setIsModalOpen(true)} onClick={() => setAdjustModalOpen(true)}
/> >
{t('调整额度')}
</Button>
</Form.Slot> </Form.Slot>
</Col> </Col>
<Col span={24}>
<div
className='text-xs cursor-pointer'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='quota'
label={t('额度')}
placeholder={t('请输入额度')}
style={{ width: '100%' }}
readonly
/>
</div>
</Col>
</Row> </Row>
</Card> </Card>
)} )}
@@ -372,81 +460,102 @@ const EditUserModal = (props) => {
formApiRef={formApiRef} formApiRef={formApiRef}
/> />
{/* 添加额度模态框 */} {/* 调整额度模态框 */}
<Modal <Modal
centered centered
visible={addQuotaModalOpen} visible={adjustModalOpen}
onOk={() => { onOk={adjustQuota}
addLocalQuota();
setIsModalOpen(false);
setAddQuotaLocal('');
setAddAmountLocal('');
}}
onCancel={() => { onCancel={() => {
setIsModalOpen(false); setAdjustModalOpen(false);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
setAdjustMode('add');
}} }}
confirmLoading={adjustLoading}
closable={null} closable={null}
title={ title={
<div className='flex items-center'> <div className='flex items-center'>
<IconPlus className='mr-2' /> <IconEdit className='mr-2' />
{t('添加额度')} {t('调整额度')}
</div> </div>
} }
> >
<div className='mb-4'> <div className='mb-4'>
{(() => { <Text type='secondary' className='block mb-2'>
const current = formApiRef.current?.getValue('quota') || 0; {getPreviewText()}
return ( </Text>
<Text type='secondary' className='block mb-2'>
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
</Text>
);
})()}
</div> </div>
{getCurrencyConfig().type !== 'TOKENS' && ( <div className='mb-3'>
<div className='mb-3'> <div className='mb-1'>
<div className='mb-1'> <Text size='small'>{t('操作')}</Text>
<Text size='small'>{t('金额')}</Text>
<Text size='small' type='tertiary'>
{' '}
({t('仅用于换算,实际保存的是额度')})
</Text>
</div>
<InputNumber
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
value={addAmountLocal}
precision={2}
onChange={(val) => {
setAddAmountLocal(val);
setAddQuotaLocal(
val != null && val !== ''
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
: '',
);
}}
style={{ width: '100%' }}
showClear
/>
</div> </div>
)} <RadioGroup
<div> type='button'
value={adjustMode}
onChange={(e) => {
setAdjustMode(e.target.value);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
}}
style={{ width: '100%' }}
>
<Radio value='add'>{t('添加')}</Radio>
<Radio value='subtract'>{t('减少')}</Radio>
<Radio value='override'>{t('覆盖')}</Radio>
</RadioGroup>
</div>
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('金额')}</Text>
</div>
<InputNumber
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
value={adjustAmountLocal}
precision={6}
min={adjustMode === 'override' ? undefined : 0}
step={0.000001}
onChange={(val) => {
const amount = val === '' || val == null ? '' : val;
setAdjustAmountLocal(amount);
setAdjustQuotaLocal(
amount === ''
? ''
: adjustMode === 'override'
? displayAmountToQuota(amount)
: displayAmountToQuota(Math.abs(amount)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
<div
className='text-xs cursor-pointer mt-2'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowAdjustQuotaRaw((v) => !v)}
>
{showAdjustQuotaRaw
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showAdjustQuotaRaw ? 'block' : 'none' }} className='mt-2'>
<div className='mb-1'> <div className='mb-1'>
<Text size='small'>{t('额度')}</Text> <Text size='small'>{t('额度')}</Text>
</div> </div>
<InputNumber <InputNumber
placeholder={t('输入额度')} placeholder={t('输入额度')}
value={addQuotaLocal} value={adjustQuotaLocal}
min={adjustMode === 'override' ? undefined : 0}
onChange={(val) => { onChange={(val) => {
setAddQuotaLocal(val); const quota = val === '' || val == null ? '' : val;
setAddAmountLocal( setAdjustQuotaLocal(quota);
val != null && val !== '' setAdjustAmountLocal(
? Number( quota === ''
( ? ''
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val) : adjustMode === 'override'
).toFixed(2), ? Number(quotaToDisplayAmount(quota).toFixed(6))
) : Number(quotaToDisplayAmount(Math.abs(quota)).toFixed(6)),
: '',
); );
}} }}
style={{ width: '100%' }} style={{ width: '100%' }}
+29 -7
View File
@@ -1,3 +1,21 @@
/*
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 { getCurrencyConfig } from './render'; import { getCurrencyConfig } from './render';
export const getQuotaPerUnit = () => { export const getQuotaPerUnit = () => {
@@ -7,19 +25,23 @@ export const getQuotaPerUnit = () => {
export const quotaToDisplayAmount = (quota) => { export const quotaToDisplayAmount = (quota) => {
const q = Number(quota || 0); const q = Number(quota || 0);
if (!Number.isFinite(q) || q <= 0) return 0; if (!Number.isFinite(q) || q === 0) return 0;
const sign = Math.sign(q);
const abs = Math.abs(q);
const { type, rate } = getCurrencyConfig(); const { type, rate } = getCurrencyConfig();
if (type === 'TOKENS') return q; if (type === 'TOKENS') return q;
const usd = q / getQuotaPerUnit(); const usd = abs / getQuotaPerUnit();
if (type === 'USD') return usd; if (type === 'USD') return sign * usd;
return usd * (rate || 1); return sign * usd * (rate || 1);
}; };
export const displayAmountToQuota = (amount) => { export const displayAmountToQuota = (amount) => {
const val = Number(amount || 0); const val = Number(amount || 0);
if (!Number.isFinite(val) || val <= 0) return 0; if (!Number.isFinite(val) || val === 0) return 0;
const sign = Math.sign(val);
const abs = Math.abs(val);
const { type, rate } = getCurrencyConfig(); const { type, rate } = getCurrencyConfig();
if (type === 'TOKENS') return Math.round(val); if (type === 'TOKENS') return Math.round(val);
const usd = type === 'USD' ? val : val / (rate || 1); const usd = type === 'USD' ? abs : abs / (rate || 1);
return Math.round(usd * getQuotaPerUnit()); return sign * Math.round(usd * getQuotaPerUnit());
}; };
+10
View File
@@ -825,6 +825,8 @@
"原密码": "Original Password", "原密码": "Original Password",
"原生格式": "Native format", "原生格式": "Native format",
"原生额度": "Raw quota", "原生额度": "Raw quota",
"使用原生额度输入": "Use raw quota input",
"收起原生额度输入": "Hide raw quota input",
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Deduplication completed: {{before}} keys before deduplication, {{after}} keys after deduplication", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Deduplication completed: {{before}} keys before deduplication, {{after}} keys after deduplication",
"参与官方同步": "Participate in official sync", "参与官方同步": "Participate in official sync",
"参数": "parameter", "参数": "parameter",
@@ -2166,6 +2168,14 @@
"添加键值对": "Add key-value pair", "添加键值对": "Add key-value pair",
"添加问答": "Add FAQ", "添加问答": "Add FAQ",
"添加额度": "Add quota", "添加额度": "Add quota",
"减少": "Subtract",
"覆盖": "Override",
"调整额度": "Adjust Quota",
"调整额度成功": "Quota adjusted successfully",
"当前额度": "Current quota",
"变更": "Change",
"预计结果": "Estimated result",
"正数为增加,负数为减少": "Positive to add, negative to subtract",
"清理不活跃缓存": "Clean up inactive cache", "清理不活跃缓存": "Clean up inactive cache",
"清理失败": "Cleanup failed", "清理失败": "Cleanup failed",
"清理方式": "Cleanup Mode", "清理方式": "Cleanup Mode",
+10
View File
@@ -821,6 +821,8 @@
"原密码": "Mot de passe original", "原密码": "Mot de passe original",
"原生格式": "Format natif", "原生格式": "Format natif",
"原生额度": "Quota brut", "原生额度": "Quota brut",
"使用原生额度输入": "Saisir le quota brut",
"收起原生额度输入": "Masquer la saisie du quota brut",
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Doublons supprimés : {{before}} clés avant, {{after}} clés après", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Doublons supprimés : {{before}} clés avant, {{after}} clés après",
"参与官方同步": "Participer à la synchronisation officielle", "参与官方同步": "Participer à la synchronisation officielle",
"参数": "paramètre", "参数": "paramètre",
@@ -2144,6 +2146,14 @@
"添加键值对": "Ajouter une paire clé-valeur", "添加键值对": "Ajouter une paire clé-valeur",
"添加问答": "Ajouter une FAQ", "添加问答": "Ajouter une FAQ",
"添加额度": "Ajouter un quota", "添加额度": "Ajouter un quota",
"减少": "Soustraire",
"覆盖": "Remplacer",
"调整额度": "Ajuster le quota",
"调整额度成功": "Quota ajusté avec succès",
"当前额度": "Quota actuel",
"变更": "Modification",
"预计结果": "Résultat estimé",
"正数为增加,负数为减少": "Positif pour ajouter, négatif pour soustraire",
"清理不活跃缓存": "Nettoyer le cache inactif", "清理不活跃缓存": "Nettoyer le cache inactif",
"清理失败": "Échec du nettoyage", "清理失败": "Échec du nettoyage",
"清理方式": "Mode de nettoyage", "清理方式": "Mode de nettoyage",
+10
View File
@@ -812,6 +812,8 @@
"原密码": "現在のパスワード", "原密码": "現在のパスワード",
"原生格式": "ネイティブ形式", "原生格式": "ネイティブ形式",
"原生额度": "生クォータ", "原生额度": "生クォータ",
"使用原生额度输入": "生クォータで入力",
"收起原生额度输入": "生クォータ入力を非表示",
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "重複排除完了:重複排除前 {{before}} 個のAPIキー、重複排除後 {{after}} 個のAPIキー", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "重複排除完了:重複排除前 {{before}} 個のAPIキー、重複排除後 {{after}} 個のAPIキー",
"参与官方同步": "公式との同期", "参与官方同步": "公式との同期",
"参数": "パラメータ", "参数": "パラメータ",
@@ -2127,6 +2129,14 @@
"添加键值对": "キー/値ペア追加", "添加键值对": "キー/値ペア追加",
"添加问答": "FAQ追加", "添加问答": "FAQ追加",
"添加额度": "残高追加", "添加额度": "残高追加",
"减少": "減少",
"覆盖": "上書き",
"调整额度": "残高調整",
"调整额度成功": "残高の調整に成功しました",
"当前额度": "現在の残高",
"变更": "変更",
"预计结果": "予想結果",
"正数为增加,负数为减少": "正の数で追加、負の数で減少",
"清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ", "清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ",
"清理失败": "クリーンアップに失敗しました", "清理失败": "クリーンアップに失敗しました",
"清理方式": "クリーンアップモード", "清理方式": "クリーンアップモード",
+10
View File
@@ -827,6 +827,8 @@
"原密码": "Старый пароль", "原密码": "Старый пароль",
"原生格式": "Нативный формат", "原生格式": "Нативный формат",
"原生额度": "Исходный лимит", "原生额度": "Исходный лимит",
"使用原生额度输入": "Ввод в исходных единицах",
"收起原生额度输入": "Скрыть ввод в исходных единицах",
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Дедупликация завершена: до дедупликации {{before}} ключей, после дедупликации {{after}} ключей", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Дедупликация завершена: до дедупликации {{before}} ключей, после дедупликации {{after}} ключей",
"参与官方同步": "Участвовать в официальной синхронизации", "参与官方同步": "Участвовать в официальной синхронизации",
"参数": "Параметры", "参数": "Параметры",
@@ -2156,6 +2158,14 @@
"添加键值对": "Добавить пару ключ-значение", "添加键值对": "Добавить пару ключ-значение",
"添加问答": "Добавить вопрос-ответ", "添加问答": "Добавить вопрос-ответ",
"添加额度": "Добавить лимит", "添加额度": "Добавить лимит",
"减少": "Уменьшить",
"覆盖": "Заменить",
"调整额度": "Скорректировать квоту",
"调整额度成功": "Квота успешно скорректирована",
"当前额度": "Текущая квота",
"变更": "Изменение",
"预计结果": "Ожидаемый результат",
"正数为增加,负数为减少": "Положительное для увеличения, отрицательное для уменьшения",
"清理不活跃缓存": "Очистить неактивный кэш", "清理不活跃缓存": "Очистить неактивный кэш",
"清理失败": "Ошибка очистки", "清理失败": "Ошибка очистки",
"清理方式": "Режим очистки", "清理方式": "Режим очистки",
+10
View File
@@ -813,6 +813,8 @@
"原密码": "Mật khẩu cũ", "原密码": "Mật khẩu cũ",
"原生格式": "Định dạng gốc", "原生格式": "Định dạng gốc",
"原生额度": "Hạn mức gốc", "原生额度": "Hạn mức gốc",
"使用原生额度输入": "Nhập hạn mức gốc",
"收起原生额度输入": "Ẩn nhập hạn mức gốc",
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Hoàn tất loại bỏ trùng lặp: {{before}} khóa trước khi loại bỏ, {{after}} khóa sau khi loại bỏ", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Hoàn tất loại bỏ trùng lặp: {{before}} khóa trước khi loại bỏ, {{after}} khóa sau khi loại bỏ",
"参与官方同步": "Tham gia đồng bộ chính thức", "参与官方同步": "Tham gia đồng bộ chính thức",
"参数": "tham số", "参数": "tham số",
@@ -2221,6 +2223,14 @@
"添加键值对": "Thêm cặp khóa-giá trị", "添加键值对": "Thêm cặp khóa-giá trị",
"添加问答": "Thêm hỏi đáp", "添加问答": "Thêm hỏi đáp",
"添加额度": "Thêm hạn ngạch", "添加额度": "Thêm hạn ngạch",
"减少": "Giảm",
"覆盖": "Ghi đè",
"调整额度": "Điều chỉnh hạn ngạch",
"调整额度成功": "Điều chỉnh hạn ngạch thành công",
"当前额度": "Hạn ngạch hiện tại",
"变更": "Thay đổi",
"预计结果": "Kết quả dự kiến",
"正数为增加,负数为减少": "Số dương để tăng, số âm để giảm",
"清理": "Dọn dẹp", "清理": "Dọn dẹp",
"清理不活跃缓存": "Xóa cache không hoạt động", "清理不活跃缓存": "Xóa cache không hoạt động",
"清理历史日志": "Dọn dẹp nhật ký lịch sử", "清理历史日志": "Dọn dẹp nhật ký lịch sử",
+10
View File
@@ -1605,6 +1605,14 @@
"添加键值对": "添加键值对", "添加键值对": "添加键值对",
"添加问答": "添加问答", "添加问答": "添加问答",
"添加额度": "添加额度", "添加额度": "添加额度",
"减少": "减少",
"覆盖": "覆盖",
"调整额度": "调整额度",
"调整额度成功": "调整额度成功",
"当前额度": "当前额度",
"变更": "变更",
"预计结果": "预计结果",
"正数为增加,负数为减少": "正数为增加,负数为减少",
"清理方式": "清理方式", "清理方式": "清理方式",
"清理日志文件": "清理日志文件", "清理日志文件": "清理日志文件",
"清空": "清空", "清空": "清空",
@@ -2737,6 +2745,8 @@
"请输入总额度": "请输入总额度", "请输入总额度": "请输入总额度",
"0 表示不限": "0 表示不限", "0 表示不限": "0 表示不限",
"原生额度": "原生额度", "原生额度": "原生额度",
"使用原生额度输入": "使用原生额度输入",
"收起原生额度输入": "收起原生额度输入",
"升级分组": "升级分组", "升级分组": "升级分组",
"不升级": "不升级", "不升级": "不升级",
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。", "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。",
+10
View File
@@ -719,6 +719,8 @@
"原密码": "原密碼", "原密码": "原密碼",
"原生格式": "原生格式", "原生格式": "原生格式",
"原生额度": "原生額度", "原生额度": "原生額度",
"使用原生额度输入": "使用原生額度輸入",
"收起原生额度输入": "收起原生額度輸入",
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "去重完成:去重前 {{before}} 個密鑰,去重後 {{after}} 個密鑰", "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "去重完成:去重前 {{before}} 個密鑰,去重後 {{after}} 個密鑰",
"参与官方同步": "參與官方同步", "参与官方同步": "參與官方同步",
"参数": "參數", "参数": "參數",
@@ -1905,6 +1907,14 @@
"添加键值对": "添加鍵值對", "添加键值对": "添加鍵值對",
"添加问答": "添加問答", "添加问答": "添加問答",
"添加额度": "添加額度", "添加额度": "添加額度",
"减少": "減少",
"覆盖": "覆蓋",
"调整额度": "調整額度",
"调整额度成功": "調整額度成功",
"当前额度": "當前額度",
"变更": "變更",
"预计结果": "預計結果",
"正数为增加,负数为减少": "正數為增加,負數為減少",
"清理不活跃缓存": "清理不活躍快取", "清理不活跃缓存": "清理不活躍快取",
"清理失败": "清理失敗", "清理失败": "清理失敗",
"清理方式": "清理方式", "清理方式": "清理方式",