Просмотр исходного кода

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
CaIon 3 недель назад
Родитель
Сommit
040e8c1da8

+ 2 - 0
.gitignore

@@ -29,3 +29,5 @@ data/
 .gomodcache/
 .gocache-temp
 .gopath
+
+token_estimator_test.go

+ 43 - 3
controller/user.go

@@ -572,9 +572,6 @@ func UpdateUser(c *gin.Context) {
 		common.ApiError(c, err)
 		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{
 		"success": true,
 		"message": "",
@@ -841,6 +838,8 @@ func CreateUser(c *gin.Context) {
 type ManageRequest struct {
 	Id     int    `json:"id"`
 	Action string `json:"action"`
+	Value  int    `json:"value"`
+	Mode   string `json:"mode"`
 }
 
 // ManageUser Only admin user can do this
@@ -907,6 +906,47 @@ func ManageUser(c *gin.Context) {
 			return
 		}
 		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 {

+ 1 - 0
i18n/keys.go

@@ -101,6 +101,7 @@ const (
 	MsgUserTelegramIdEmpty           = "user.telegram_id_empty"
 	MsgUserTelegramNotBound          = "user.telegram_not_bound"
 	MsgUserLinuxDOIdEmpty            = "user.linux_do_id_empty"
+	MsgUserQuotaChangeZero           = "user.quota_change_zero"
 )
 
 // Quota related messages

+ 1 - 0
i18n/locales/en.yaml

@@ -91,6 +91,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
 user.telegram_id_empty: "Telegram ID is empty!"
 user.telegram_not_bound: "This Telegram account is not bound"
 user.linux_do_id_empty: "Linux DO ID is empty!"
+user.quota_change_zero: "Quota change amount cannot be zero"
 
 # Quota messages
 quota.negative: "Quota cannot be negative!"

+ 1 - 0
i18n/locales/zh-CN.yaml

@@ -92,6 +92,7 @@ user.wechat_id_empty: "WeChat id 为空!"
 user.telegram_id_empty: "Telegram id 为空!"
 user.telegram_not_bound: "该 Telegram 账户未绑定"
 user.linux_do_id_empty: "Linux DO id 为空!"
+user.quota_change_zero: "额度变更量不能为0"
 
 # Quota messages
 quota.negative: "额度不能为负数!"

+ 1 - 0
i18n/locales/zh-TW.yaml

@@ -92,6 +92,7 @@ user.wechat_id_empty: "WeChat id 為空!"
 user.telegram_id_empty: "Telegram id 為空!"
 user.telegram_not_bound: "該 Telegram 帳號未綁定"
 user.linux_do_id_empty: "Linux DO id 為空!"
+user.quota_change_zero: "額度變更量不能為0"
 
 # Quota messages
 quota.negative: "額度不能為負數!"

+ 3 - 4
model/user.go

@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
 		"username":     newUser.Username,
 		"display_name": newUser.DisplayName,
 		"group":        newUser.Group,
-		"quota":        newUser.Quota,
 		"remark":       newUser.Remark,
 	}
 	if updatePassword {
@@ -896,7 +895,7 @@ func increaseUserQuota(id int, quota int) (err error) {
 	return err
 }
 
-func DecreaseUserQuota(id int, quota int) (err error) {
+func DecreaseUserQuota(id int, quota int, db bool) (err error) {
 	if quota < 0 {
 		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())
 		}
 	})
-	if common.BatchUpdateEnabled {
+	if !db && common.BatchUpdateEnabled {
 		addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
 		return nil
 	}
@@ -928,7 +927,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
 	if delta > 0 {
 		return IncreaseUserQuota(id, delta, false)
 	} else {
-		return DecreaseUserQuota(id, -delta)
+		return DecreaseUserQuota(id, -delta, false)
 	}
 }
 

+ 2 - 2
service/funding_source.go

@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
 	if amount <= 0 {
 		return nil
 	}
-	if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
+	if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
 		return err
 	}
 	w.consumed = amount
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
 		return nil
 	}
 	if delta > 0 {
-		return model.DecreaseUserQuota(w.userId, delta)
+		return model.DecreaseUserQuota(w.userId, delta, false)
 	}
 	return model.IncreaseUserQuota(w.userId, -delta, false)
 }

+ 1 - 1
service/quota.go

@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
 	} else {
 		// Wallet
 		if quota > 0 {
-			err = model.DecreaseUserQuota(relayInfo.UserId, quota)
+			err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
 		} else {
 			err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
 		}

+ 1 - 1
service/task_billing.go

@@ -90,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
 		return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
 	}
 	if delta > 0 {
-		return model.DecreaseUserQuota(task.UserId, delta)
+		return model.DecreaseUserQuota(task.UserId, delta, false)
 	}
 	return model.IncreaseUserQuota(task.UserId, -delta, false)
 }

+ 69 - 30
web/src/components/table/redemptions/modals/EditRedemptionModal.jsx

@@ -25,8 +25,12 @@ import {
   showError,
   showSuccess,
   renderQuota,
-  renderQuotaWithPrompt,
+  getCurrencyConfig,
 } from '../../../../helpers';
+import {
+  quotaToDisplayAmount,
+  displayAmountToQuota,
+} from '../../../../helpers/quota';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile';
 import {
   Button,
@@ -41,6 +45,7 @@ import {
   Avatar,
   Row,
   Col,
+  InputNumber,
 } from '@douyinfe/semi-ui';
 import {
   IconCreditCard,
@@ -57,10 +62,12 @@ const EditRedemptionModal = (props) => {
   const [loading, setLoading] = useState(isEdit);
   const isMobile = useIsMobile();
   const formApiRef = useRef(null);
+  const [showQuotaInput, setShowQuotaInput] = useState(false);
 
   const getInitValues = () => ({
     name: '',
     quota: 100000,
+    amount: Number(quotaToDisplayAmount(100000).toFixed(6)),
     count: 1,
     expired_time: null,
   });
@@ -79,6 +86,7 @@ const EditRedemptionModal = (props) => {
       } else {
         data.expired_time = new Date(data.expired_time * 1000);
       }
+      data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6));
       formApiRef.current?.setValues({ ...getInitValues(), ...data });
     } else {
       showError(message);
@@ -104,7 +112,12 @@ const EditRedemptionModal = (props) => {
     setLoading(true);
     let localInputs = { ...values };
     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;
     if (!localInputs.expired_time) {
       localInputs.expired_time = 0;
@@ -285,37 +298,63 @@ const EditRedemptionModal = (props) => {
                   </div>
 
                   <Row gutter={12}>
-                    <Col span={12}>
-                      <Form.AutoComplete
-                        field='quota'
-                        label={t('额度')}
-                        placeholder={t('请输入额度')}
+                    <Col span={24}>
+                      <Form.InputNumber
+                        field='amount'
+                        label={t('金额')}
+                        prefix={getCurrencyConfig().symbol}
+                        placeholder={t('输入金额')}
+                        precision={6}
+                        min={0}
+                        step={0.000001}
                         style={{ width: '100%' }}
-                        type='number'
-                        rules={[
-                          { required: true, message: t('请输入额度') },
-                          {
-                            validator: (rule, v) => {
-                              const num = parseInt(v, 10);
-                              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$' },
-                        ]}
+                        onChange={(val) => {
+                          const amount = val === '' || val == null ? 0 : val;
+                          formApiRef.current?.setValue('amount', amount);
+                          formApiRef.current?.setValue(
+                            'quota',
+                            displayAmountToQuota(amount),
+                          );
+                        }}
                         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>
                     {!isEdit && (
                       <Col span={12}>

+ 81 - 22
web/src/components/table/tokens/modals/EditTokenModal.jsx

@@ -24,10 +24,14 @@ import {
   showSuccess,
   timestamp2string,
   renderGroupOption,
-  renderQuotaWithPrompt,
+  getCurrencyConfig,
   getModelCategories,
   selectFilter,
 } from '../../../../helpers';
+import {
+  quotaToDisplayAmount,
+  displayAmountToQuota,
+} from '../../../../helpers/quota';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile';
 import {
   Button,
@@ -41,6 +45,7 @@ import {
   Form,
   Col,
   Row,
+  InputNumber,
 } from '@douyinfe/semi-ui';
 import {
   IconCreditCard,
@@ -62,11 +67,13 @@ const EditTokenModal = (props) => {
   const formApiRef = useRef(null);
   const [models, setModels] = useState([]);
   const [groups, setGroups] = useState([]);
+  const [showQuotaInput, setShowQuotaInput] = useState(false);
   const isEdit = props.editingToken.id !== undefined;
 
   const getInitValues = () => ({
     name: '',
     remain_quota: 0,
+    remain_amount: 0,
     expired_time: -1,
     unlimited_quota: true,
     model_limits_enabled: false,
@@ -162,6 +169,9 @@ const EditTokenModal = (props) => {
       } else {
         data.model_limits = [];
       }
+      data.remain_amount = Number(
+        quotaToDisplayAmount(data.remain_quota || 0).toFixed(6),
+      );
       if (formApiRef.current) {
         formApiRef.current.setValues({ ...getInitValues(), ...data });
       }
@@ -209,7 +219,14 @@ const EditTokenModal = (props) => {
     setLoading(true);
     if (isEdit) {
       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) {
         let time = Date.parse(localInputs.expired_time);
         if (isNaN(time)) {
@@ -245,7 +262,14 @@ const EditTokenModal = (props) => {
         } else {
           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) {
           let time = Date.parse(localInputs.expired_time);
@@ -497,28 +521,63 @@ const EditTokenModal = (props) => {
                 </div>
                 <Row gutter={12}>
                   <Col span={24}>
-                    <Form.AutoComplete
-                      field='remain_quota'
-                      label={t('额度')}
-                      placeholder={t('请输入额度')}
-                      type='number'
+                    <Form.InputNumber
+                      field='remain_amount'
+                      label={t('金额')}
+                      prefix={getCurrencyConfig().symbol}
+                      placeholder={t('输入金额')}
+                      precision={6}
                       disabled={values.unlimited_quota}
-                      extraText={renderQuotaWithPrompt(values.remain_quota)}
-                      rules={
-                        values.unlimited_quota
-                          ? []
-                          : [{ required: true, message: t('请输入额度') }]
-                      }
-                      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$' },
-                      ]}
+                      min={0}
+                      step={0.000001}
+                      onChange={(val) => {
+                        const amount = val === '' || val == null ? 0 : val;
+                        formApiRef.current?.setValue('remain_amount', amount);
+                        formApiRef.current?.setValue(
+                          'remain_quota',
+                          displayAmountToQuota(amount),
+                        );
+                      }}
+                      style={{ width: '100%' }}
+                      showClear
                     />
                   </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}>
                     <Form.Switch
                       field='unlimited_quota'

+ 188 - 79
web/src/components/table/users/modals/EditUserModal.jsx

@@ -24,7 +24,6 @@ import {
   showError,
   showSuccess,
   renderQuota,
-  renderQuotaWithPrompt,
   getCurrencyConfig,
 } from '../../../../helpers';
 import {
@@ -46,6 +45,8 @@ import {
   Row,
   Col,
   InputNumber,
+  RadioGroup,
+  Radio,
 } from '@douyinfe/semi-ui';
 import {
   IconUser,
@@ -53,7 +54,7 @@ import {
   IconClose,
   IconLink,
   IconUserGroup,
-  IconPlus,
+  IconEdit,
 } from '@douyinfe/semi-icons';
 import UserBindingManagementModal from './UserBindingManagementModal';
 
@@ -63,13 +64,18 @@ const EditUserModal = (props) => {
   const { t } = useTranslation();
   const userId = props.editingUser.id;
   const [loading, setLoading] = useState(true);
-  const [addQuotaModalOpen, setIsModalOpen] = useState(false);
-  const [addQuotaLocal, setAddQuotaLocal] = useState('');
-  const [addAmountLocal, setAddAmountLocal] = useState('');
+  const [adjustModalOpen, setAdjustModalOpen] = useState(false);
+  const [adjustQuotaLocal, setAdjustQuotaLocal] = useState('');
+  const [adjustAmountLocal, setAdjustAmountLocal] = useState('');
+  const [adjustMode, setAdjustMode] = useState('add');
+  const [adjustLoading, setAdjustLoading] = useState(false);
   const isMobile = useIsMobile();
   const [groupOptions, setGroupOptions] = useState([]);
   const [bindingModalVisible, setBindingModalVisible] = useState(false);
   const formApiRef = useRef(null);
+  const [showAdjustQuotaRaw, setShowAdjustQuotaRaw] = useState(false);
+  const [showQuotaInput, setShowQuotaInput] = useState(false);
+  const [inputs, setInputs] = useState(null);
 
   const isEdit = Boolean(userId);
 
@@ -85,6 +91,7 @@ const EditUserModal = (props) => {
     linux_do_id: '',
     email: '',
     quota: 0,
+    quota_amount: 0,
     group: 'default',
     remark: '',
   });
@@ -107,13 +114,22 @@ const EditUserModal = (props) => {
     const { success, message, data } = res.data;
     if (success) {
       data.password = '';
-      formApiRef.current?.setValues({ ...getInitValues(), ...data });
+      data.quota_amount = Number(
+        quotaToDisplayAmount(data.quota || 0).toFixed(6),
+      );
+      setInputs({ ...getInitValues(), ...data });
     } else {
       showError(message);
     }
     setLoading(false);
   };
 
+  useEffect(() => {
+    if (inputs && formApiRef.current) {
+      formApiRef.current.setValues(inputs);
+    }
+  }, [inputs]);
+
   useEffect(() => {
     loadUser();
     if (userId) fetchGroups();
@@ -132,8 +148,8 @@ const EditUserModal = (props) => {
   const submit = async (values) => {
     setLoading(true);
     let payload = { ...values };
-    if (typeof payload.quota === 'string')
-      payload.quota = parseInt(payload.quota) || 0;
+    delete payload.quota;
+    delete payload.quota_amount;
     if (userId) {
       payload.id = parseInt(userId);
     }
@@ -150,11 +166,60 @@ const EditUserModal = (props) => {
     setLoading(false);
   };
 
-  /* --------------------- quota helper -------------------- */
-  const addLocalQuota = () => {
-    const current = parseInt(formApiRef.current?.getValue('quota') || 0);
-    const delta = parseInt(addQuotaLocal) || 0;
-    formApiRef.current?.setValue('quota', current + delta);
+  /* --------------------- atomic quota adjust -------------------- */
+  const adjustQuota = async () => {
+    const quotaVal = parseInt(adjustQuotaLocal) || 0;
+    if (quotaVal <= 0 && adjustMode !== 'override') return;
+    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 --------------------------- */
@@ -305,24 +370,47 @@ const EditUserModal = (props) => {
 
                       <Col span={10}>
                         <Form.InputNumber
-                          field='quota'
-                          label={t('剩余额度')}
-                          placeholder={t('请输入新的剩余额度')}
-                          step={500000}
-                          extraText={renderQuotaWithPrompt(values.quota || 0)}
-                          rules={[{ required: true, message: t('请输入额度') }]}
+                          field='quota_amount'
+                          label={t('金额')}
+                          prefix={getCurrencyConfig().symbol}
+                          precision={6}
+                          step={0.000001}
                           style={{ width: '100%' }}
+                          readonly
                         />
                       </Col>
 
                       <Col span={14}>
-                        <Form.Slot label={t('添加额度')}>
+                        <Form.Slot label={t('调整额度')}>
                           <Button
-                            icon={<IconPlus />}
-                            onClick={() => setIsModalOpen(true)}
-                          />
+                            icon={<IconEdit />}
+                            onClick={() => setAdjustModalOpen(true)}
+                          >
+                            {t('调整额度')}
+                          </Button>
                         </Form.Slot>
                       </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>
                   </Card>
                 )}
@@ -372,81 +460,102 @@ const EditUserModal = (props) => {
         formApiRef={formApiRef}
       />
 
-      {/* 添加额度模态框 */}
+      {/* 调整额度模态框 */}
       <Modal
         centered
-        visible={addQuotaModalOpen}
-        onOk={() => {
-          addLocalQuota();
-          setIsModalOpen(false);
-          setAddQuotaLocal('');
-          setAddAmountLocal('');
-        }}
+        visible={adjustModalOpen}
+        onOk={adjustQuota}
         onCancel={() => {
-          setIsModalOpen(false);
+          setAdjustModalOpen(false);
+          setAdjustQuotaLocal('');
+          setAdjustAmountLocal('');
+          setAdjustMode('add');
         }}
+        confirmLoading={adjustLoading}
         closable={null}
         title={
           <div className='flex items-center'>
-            <IconPlus className='mr-2' />
-            {t('添加额度')}
+            <IconEdit className='mr-2' />
+            {t('调整额度')}
           </div>
         }
       >
         <div className='mb-4'>
-          {(() => {
-            const current = formApiRef.current?.getValue('quota') || 0;
-            return (
-              <Text type='secondary' className='block mb-2'>
-                {`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
-              </Text>
-            );
-          })()}
+          <Text type='secondary' className='block mb-2'>
+            {getPreviewText()}
+          </Text>
         </div>
-        {getCurrencyConfig().type !== 'TOKENS' && (
-          <div className='mb-3'>
-            <div className='mb-1'>
-              <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 className='mb-3'>
+          <div className='mb-1'>
+            <Text size='small'>{t('操作')}</Text>
+          </div>
+          <RadioGroup
+            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>
-        )}
-        <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'>
             <Text size='small'>{t('额度')}</Text>
           </div>
           <InputNumber
             placeholder={t('输入额度')}
-            value={addQuotaLocal}
+            value={adjustQuotaLocal}
+            min={adjustMode === 'override' ? undefined : 0}
             onChange={(val) => {
-              setAddQuotaLocal(val);
-              setAddAmountLocal(
-                val != null && val !== ''
-                  ? Number(
-                      (
-                        quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
-                      ).toFixed(2),
-                    )
-                  : '',
+              const quota = val === '' || val == null ? '' : val;
+              setAdjustQuotaLocal(quota);
+              setAdjustAmountLocal(
+                quota === ''
+                  ? ''
+                  : adjustMode === 'override'
+                    ? Number(quotaToDisplayAmount(quota).toFixed(6))
+                    : Number(quotaToDisplayAmount(Math.abs(quota)).toFixed(6)),
               );
             }}
             style={{ width: '100%' }}

+ 29 - 7
web/src/helpers/quota.js

@@ -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';
 
 export const getQuotaPerUnit = () => {
@@ -7,19 +25,23 @@ export const getQuotaPerUnit = () => {
 
 export const quotaToDisplayAmount = (quota) => {
   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();
   if (type === 'TOKENS') return q;
-  const usd = q / getQuotaPerUnit();
-  if (type === 'USD') return usd;
-  return usd * (rate || 1);
+  const usd = abs / getQuotaPerUnit();
+  if (type === 'USD') return sign * usd;
+  return sign * usd * (rate || 1);
 };
 
 export const displayAmountToQuota = (amount) => {
   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();
   if (type === 'TOKENS') return Math.round(val);
-  const usd = type === 'USD' ? val : val / (rate || 1);
-  return Math.round(usd * getQuotaPerUnit());
+  const usd = type === 'USD' ? abs : abs / (rate || 1);
+  return sign * Math.round(usd * getQuotaPerUnit());
 };

+ 10 - 0
web/src/i18n/locales/en.json

@@ -825,6 +825,8 @@
     "原密码": "Original Password",
     "原生格式": "Native format",
     "原生额度": "Raw quota",
+    "使用原生额度输入": "Use raw quota input",
+    "收起原生额度输入": "Hide raw quota input",
     "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Deduplication completed: {{before}} keys before deduplication, {{after}} keys after deduplication",
     "参与官方同步": "Participate in official sync",
     "参数": "parameter",
@@ -2166,6 +2168,14 @@
     "添加键值对": "Add key-value pair",
     "添加问答": "Add FAQ",
     "添加额度": "Add quota",
+    "减少": "Subtract",
+    "覆盖": "Override",
+    "调整额度": "Adjust Quota",
+    "调整额度成功": "Quota adjusted successfully",
+    "当前额度": "Current quota",
+    "变更": "Change",
+    "预计结果": "Estimated result",
+    "正数为增加,负数为减少": "Positive to add, negative to subtract",
     "清理不活跃缓存": "Clean up inactive cache",
     "清理失败": "Cleanup failed",
     "清理方式": "Cleanup Mode",

+ 10 - 0
web/src/i18n/locales/fr.json

@@ -821,6 +821,8 @@
     "原密码": "Mot de passe original",
     "原生格式": "Format natif",
     "原生额度": "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",
     "参与官方同步": "Participer à la synchronisation officielle",
     "参数": "paramètre",
@@ -2144,6 +2146,14 @@
     "添加键值对": "Ajouter une paire clé-valeur",
     "添加问答": "Ajouter une FAQ",
     "添加额度": "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",
     "清理失败": "Échec du nettoyage",
     "清理方式": "Mode de nettoyage",

+ 10 - 0
web/src/i18n/locales/ja.json

@@ -812,6 +812,8 @@
     "原密码": "現在のパスワード",
     "原生格式": "ネイティブ形式",
     "原生额度": "生クォータ",
+    "使用原生额度输入": "生クォータで入力",
+    "收起原生额度输入": "生クォータ入力を非表示",
     "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "重複排除完了:重複排除前 {{before}} 個のAPIキー、重複排除後 {{after}} 個のAPIキー",
     "参与官方同步": "公式との同期",
     "参数": "パラメータ",
@@ -2127,6 +2129,14 @@
     "添加键值对": "キー/値ペア追加",
     "添加问答": "FAQ追加",
     "添加额度": "残高追加",
+    "减少": "減少",
+    "覆盖": "上書き",
+    "调整额度": "残高調整",
+    "调整额度成功": "残高の調整に成功しました",
+    "当前额度": "現在の残高",
+    "变更": "変更",
+    "预计结果": "予想結果",
+    "正数为增加,负数为减少": "正の数で追加、負の数で減少",
     "清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ",
     "清理失败": "クリーンアップに失敗しました",
     "清理方式": "クリーンアップモード",

+ 10 - 0
web/src/i18n/locales/ru.json

@@ -827,6 +827,8 @@
     "原密码": "Старый пароль",
     "原生格式": "Нативный формат",
     "原生额度": "Исходный лимит",
+    "使用原生额度输入": "Ввод в исходных единицах",
+    "收起原生额度输入": "Скрыть ввод в исходных единицах",
     "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Дедупликация завершена: до дедупликации {{before}} ключей, после дедупликации {{after}} ключей",
     "参与官方同步": "Участвовать в официальной синхронизации",
     "参数": "Параметры",
@@ -2156,6 +2158,14 @@
     "添加键值对": "Добавить пару ключ-значение",
     "添加问答": "Добавить вопрос-ответ",
     "添加额度": "Добавить лимит",
+    "减少": "Уменьшить",
+    "覆盖": "Заменить",
+    "调整额度": "Скорректировать квоту",
+    "调整额度成功": "Квота успешно скорректирована",
+    "当前额度": "Текущая квота",
+    "变更": "Изменение",
+    "预计结果": "Ожидаемый результат",
+    "正数为增加,负数为减少": "Положительное для увеличения, отрицательное для уменьшения",
     "清理不活跃缓存": "Очистить неактивный кэш",
     "清理失败": "Ошибка очистки",
     "清理方式": "Режим очистки",

+ 10 - 0
web/src/i18n/locales/vi.json

@@ -813,6 +813,8 @@
     "原密码": "Mật khẩu cũ",
     "原生格式": "Định dạng 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ỏ",
     "参与官方同步": "Tham gia đồng bộ chính thức",
     "参数": "tham số",
@@ -2221,6 +2223,14 @@
     "添加键值对": "Thêm cặp khóa-giá trị",
     "添加问答": "Thêm hỏi đáp",
     "添加额度": "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",
     "清理不活跃缓存": "Xóa cache không hoạt động",
     "清理历史日志": "Dọn dẹp nhật ký lịch sử",

+ 10 - 0
web/src/i18n/locales/zh-CN.json

@@ -1605,6 +1605,14 @@
     "添加键值对": "添加键值对",
     "添加问答": "添加问答",
     "添加额度": "添加额度",
+    "减少": "减少",
+    "覆盖": "覆盖",
+    "调整额度": "调整额度",
+    "调整额度成功": "调整额度成功",
+    "当前额度": "当前额度",
+    "变更": "变更",
+    "预计结果": "预计结果",
+    "正数为增加,负数为减少": "正数为增加,负数为减少",
     "清理方式": "清理方式",
     "清理日志文件": "清理日志文件",
     "清空": "清空",
@@ -2737,6 +2745,8 @@
     "请输入总额度": "请输入总额度",
     "0 表示不限": "0 表示不限",
     "原生额度": "原生额度",
+    "使用原生额度输入": "使用原生额度输入",
+    "收起原生额度输入": "收起原生额度输入",
     "升级分组": "升级分组",
     "不升级": "不升级",
     "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。",

+ 10 - 0
web/src/i18n/locales/zh-TW.json

@@ -719,6 +719,8 @@
     "原密码": "原密碼",
     "原生格式": "原生格式",
     "原生额度": "原生額度",
+    "使用原生额度输入": "使用原生額度輸入",
+    "收起原生额度输入": "收起原生額度輸入",
     "去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "去重完成:去重前 {{before}} 個密鑰,去重後 {{after}} 個密鑰",
     "参与官方同步": "參與官方同步",
     "参数": "參數",
@@ -1905,6 +1907,14 @@
     "添加键值对": "添加鍵值對",
     "添加问答": "添加問答",
     "添加额度": "添加額度",
+    "减少": "減少",
+    "覆盖": "覆蓋",
+    "调整额度": "調整額度",
+    "调整额度成功": "調整額度成功",
+    "当前额度": "當前額度",
+    "变更": "變更",
+    "预计结果": "預計結果",
+    "正数为增加,负数为减少": "正數為增加,負數為減少",
     "清理不活跃缓存": "清理不活躍快取",
     "清理失败": "清理失敗",
     "清理方式": "清理方式",