fix(admin): persist image quota by setting remaining directly
Use PUT setImageQuota so admins can lower remaining below the stored plan limit; grant-only bonus updates silently no-op when bonus is already zero. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1233,6 +1233,25 @@ export function createAdminApp(services) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminApi.put('/users/:userId/image-quota', requireAdmin, async (req, res) => {
|
||||||
|
if (!subscriptionService?.setImageQuota) {
|
||||||
|
return res.status(503).json({ message: '图片额度服务未启用' });
|
||||||
|
}
|
||||||
|
const remaining = req.body?.remaining;
|
||||||
|
const total = req.body?.total;
|
||||||
|
const note = String(req.body?.note ?? '').trim();
|
||||||
|
const result = await subscriptionService.setImageQuota(
|
||||||
|
req.params.userId,
|
||||||
|
{
|
||||||
|
remaining: remaining === undefined || remaining === null ? null : Math.floor(Number(remaining)),
|
||||||
|
total: total === undefined || total === null ? null : Math.floor(Number(total)),
|
||||||
|
},
|
||||||
|
{ operatorId: req.currentUser.id, note },
|
||||||
|
);
|
||||||
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
adminApi.post('/users/:userId/image-quota/grant', requireAdmin, async (req, res) => {
|
adminApi.post('/users/:userId/image-quota/grant', requireAdmin, async (req, res) => {
|
||||||
if (!subscriptionService?.grantImageQuota) {
|
if (!subscriptionService?.grantImageQuota) {
|
||||||
return res.status(503).json({ message: '图片额度服务未启用' });
|
return res.status(503).json({ message: '图片额度服务未启用' });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||||
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, grantUserImageQuota } from '../../api/client';
|
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota } from '../../api/client';
|
||||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||||
import { PolicySettings } from '../../components/PolicySettings';
|
import { PolicySettings } from '../../components/PolicySettings';
|
||||||
import { SkillSettings } from '../../components/SkillSettings';
|
import { SkillSettings } from '../../components/SkillSettings';
|
||||||
@@ -25,7 +25,7 @@ export function UserDetailPage() {
|
|||||||
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
|
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
|
||||||
const [imageQuota, setImageQuota] = useState<ImageQuotaView | null>(null);
|
const [imageQuota, setImageQuota] = useState<ImageQuotaView | null>(null);
|
||||||
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
|
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
|
||||||
const [imageTotal, setImageTotal] = useState('');
|
const [imageRemaining, setImageRemaining] = useState('');
|
||||||
const [imageGrantNote, setImageGrantNote] = useState('');
|
const [imageGrantNote, setImageGrantNote] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,12 +84,12 @@ export function UserDetailPage() {
|
|||||||
}, [user?.id, user?.role]);
|
}, [user?.id, user?.role]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageQuota || imageQuota.unlimited || imageQuota.total == null) {
|
if (!imageQuota || imageQuota.unlimited || imageQuota.remaining == null) {
|
||||||
setImageTotal('');
|
setImageRemaining('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setImageTotal(String(imageQuota.total));
|
setImageRemaining(String(imageQuota.remaining));
|
||||||
}, [imageQuota?.total, imageQuota?.unlimited]);
|
}, [imageQuota?.remaining, imageQuota?.unlimited]);
|
||||||
|
|
||||||
const handleRecharge = async (e: React.FormEvent) => {
|
const handleRecharge = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -123,25 +123,19 @@ export function UserDetailPage() {
|
|||||||
setLocalError('当前为无限额度套餐,无法在此调整');
|
setLocalError('当前为无限额度套餐,无法在此调整');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetTotal = Math.floor(Number(imageTotal));
|
const targetRemaining = Math.floor(Number(imageRemaining));
|
||||||
if (!Number.isFinite(targetTotal) || targetTotal < 0) {
|
if (!Number.isFinite(targetRemaining) || targetRemaining < 0) {
|
||||||
setLocalError('请输入非负整数总额度');
|
setLocalError('请输入非负整数剩余额度');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (targetTotal < imageQuota.used) {
|
if (targetRemaining === imageQuota.remaining) {
|
||||||
setLocalError(`总额度不能低于已用量(${imageQuota.used} 张)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetBonus = targetTotal - imageQuota.limit;
|
|
||||||
const delta = targetBonus - imageQuota.bonus;
|
|
||||||
if (delta === 0) {
|
|
||||||
setMessage('额度未变化');
|
setMessage('额度未变化');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await grantUserImageQuota(user.id, delta, imageGrantNote.trim());
|
const result = await setUserImageQuota(user.id, { remaining: targetRemaining }, imageGrantNote.trim());
|
||||||
setImageQuota(result.quota);
|
setImageQuota(result.quota);
|
||||||
setMessage('图片额度已更新');
|
setMessage(result.unchanged ? '额度未变化' : '图片额度已更新');
|
||||||
setImageGrantNote('');
|
setImageGrantNote('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLocalError(err instanceof Error ? err.message : '图片额度设置失败');
|
setLocalError(err instanceof Error ? err.message : '图片额度设置失败');
|
||||||
@@ -273,16 +267,16 @@ export function UserDetailPage() {
|
|||||||
<p className="muted">{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}</p>
|
<p className="muted">{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}</p>
|
||||||
)}
|
)}
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
直接设置本周期图片总额度(套餐额度 + 额外充值)。已用量不变,修改后会反映在「充值」部分。
|
直接设置本周期剩余可用张数。若目标低于当前套餐额度,会自动下调该用户的周期额度上限。
|
||||||
</p>
|
</p>
|
||||||
<form className="admin-form" onSubmit={handleImageQuotaSave}>
|
<form className="admin-form" onSubmit={handleImageQuotaSave}>
|
||||||
<input
|
<input
|
||||||
placeholder="图片总额度(张)"
|
placeholder="剩余额度(张)"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
value={imageTotal}
|
value={imageRemaining}
|
||||||
onChange={(e) => setImageTotal(e.target.value)}
|
onChange={(e) => setImageRemaining(e.target.value)}
|
||||||
disabled={!imageQuota || imageQuota.unlimited}
|
disabled={!imageQuota || imageQuota.unlimited}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1373,6 +1373,22 @@ export async function fetchUserImageQuota(userId: string) {
|
|||||||
}>(`/admin-api/users/${userId}/image-quota`);
|
}>(`/admin-api/users/${userId}/image-quota`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setUserImageQuota(
|
||||||
|
userId: string,
|
||||||
|
payload: { remaining?: number; total?: number },
|
||||||
|
note = '',
|
||||||
|
) {
|
||||||
|
return portalFetch<{
|
||||||
|
ok: boolean;
|
||||||
|
unchanged?: boolean;
|
||||||
|
subscription: AdminSubscription;
|
||||||
|
quota: ImageQuotaView;
|
||||||
|
}>(`/admin-api/users/${userId}/image-quota`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ...payload, note }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function grantUserImageQuota(userId: string, delta: number, note = '') {
|
export async function grantUserImageQuota(userId: string, delta: number, note = '') {
|
||||||
return portalFetch<{
|
return portalFetch<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user