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
@@ -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}>
@@ -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'
@@ -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>
)}
<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>
<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%' }}