fix(admin,api): 上分超额提示而非静默截断,并返回中文业务错误

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 15:47:45 +08:00
parent 414998ce36
commit 22535d4c27
8 changed files with 97 additions and 29 deletions

View File

@@ -2,6 +2,7 @@ import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import api from '../api';
import { useAdminLocale } from './useAdminLocale';
import { resolveApiError } from '../i18n/form-validation';
import {
depositAmountCap,
parsePlayerAvailable,
@@ -36,6 +37,20 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
const transferAmountDisabled = computed(() => transferAmountRange.value.max === 0);
/** 有上限时超出额度(勿用 el-input-number :max否则会静默截断到上限 */
const transferAmountExceedsCap = computed(() => {
const max = transferAmountRange.value.max;
if (max === undefined) return false;
return transferAmount.value > max;
});
const transferAmountCapError = computed(() => {
if (!transferAmountExceedsCap.value) return '';
return transferType.value === 'deposit'
? t('err.insufficient_credit')
: t('transfer.context.withdraw_exceed');
});
function transferTitle() {
const name = transferTarget.value?.username ?? transferTarget.value?.id ?? '';
return transferType.value === 'deposit'
@@ -63,8 +78,7 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
transferAmount.value = cap > 0 ? Math.min(100, cap) : 0;
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
transferVisible.value = false;
} finally {
transferContextLoading.value = false;
@@ -105,10 +119,7 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
transferVisible.value = false;
await onSuccess?.();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
const fallback =
transferType.value === 'deposit' ? t('msg.topup_failed') : t('msg.transfer_failed');
ElMessage.error(err.response?.data?.error ?? fallback);
ElMessage.error(resolveApiError(e, t, transferType.value === 'deposit' ? 'msg.topup_failed' : 'msg.transfer_failed'));
} finally {
transferLoading.value = false;
}
@@ -125,6 +136,8 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
transferContextLoading,
transferAmountRange,
transferAmountDisabled,
transferAmountExceedsCap,
transferAmountCapError,
transferTitle,
openTransfer,
submitTransfer,

View File

@@ -15,7 +15,18 @@ export function resolveFormError(e: unknown, t: (key: string) => string): string
return t('msg.form_invalid');
}
/** 从 API 错误响应提取可读文案Nest 全局过滤器返回 `error` 字段) */
/** Nest 默认 4xx 响应里 error 常为 "Bad Request" 等泛化文案,真实原因在 message */
const GENERIC_HTTP_ERRORS = new Set([
'Bad Request',
'Unauthorized',
'Forbidden',
'Not Found',
'Conflict',
'Unprocessable Entity',
'Internal Server Error',
]);
/** 从 API 错误响应提取可读文案(兼容 GlobalExceptionFilter 与 Nest 默认格式) */
export function resolveApiError(
err: unknown,
t: (key: string) => string,
@@ -23,8 +34,13 @@ export function resolveApiError(
): string {
const data = (err as { response?: { data?: { error?: string | string[]; message?: string | string[] } } })
?.response?.data;
const raw = data?.error ?? data?.message;
if (Array.isArray(raw)) return raw.join('');
if (typeof raw === 'string' && raw.trim()) return raw;
const msgRaw = data?.message;
const errRaw = data?.error;
const message = Array.isArray(msgRaw) ? msgRaw.join('') : msgRaw;
const error = Array.isArray(errRaw) ? errRaw.join('') : errRaw;
if (typeof message === 'string' && message.trim()) {
if (!error || GENERIC_HTTP_ERRORS.has(error)) return message;
}
if (typeof error === 'string' && error.trim() && !GENERIC_HTTP_ERRORS.has(error)) return error;
return t(fallbackKey);
}

View File

@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
import api from '../api';
import { clearStaffSession } from '../stores/auth';
@@ -43,6 +43,7 @@ import AgentCreditContext from '../components/AgentCreditContext.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
import {
fetchAdminAgentCreditContext,
maxCreditIncreaseAmount,
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
@@ -571,8 +572,7 @@ async function openCredit(userId: string) {
try {
creditContext.value = await fetchAdminAgentCreditContext(userId);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
creditVisible.value = false;
} finally {
creditContextLoading.value = false;
@@ -584,6 +584,11 @@ async function submitCredit() {
ElMessage.warning(t('msg.credit_zero'));
return;
}
const maxInc = maxCreditIncreaseAmount(creditContext.value);
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/admin/agents/${editingId.value}/credit`, {
@@ -596,8 +601,7 @@ async function submitCredit() {
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}
@@ -737,6 +741,8 @@ const {
transferContextLoading,
transferAmountRange,
transferAmountDisabled,
transferAmountExceedsCap,
transferAmountCapError,
transferTitle,
openTransfer,
submitTransfer,
@@ -1340,11 +1346,10 @@ function creditTypeLabel(type: string) {
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
<el-input-number
v-model="transferAmount"
:min="transferAmountRange.min"
:max="transferAmountRange.max"
:disabled="transferAmountDisabled"
:step="10"
:precision="2"
@@ -1360,7 +1365,7 @@ function creditTypeLabel(type: string) {
<el-button
type="primary"
:loading="transferLoading"
:disabled="transferAmountDisabled"
:disabled="transferAmountDisabled || transferAmountExceedsCap"
@click="submitTransfer"
>
{{ t('common.confirm') }}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
import api from '../api';
const { t } = useAdminLocale();
@@ -27,6 +27,7 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import {
fetchAdminAgentCreditContext,
maxCreditIncreaseAmount,
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
@@ -201,6 +202,11 @@ async function submitCredit() {
ElMessage.warning(t('msg.credit_zero'));
return;
}
const maxInc = maxCreditIncreaseAmount(creditContext.value);
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/admin/agents/${editingId.value}/credit`, {
@@ -212,8 +218,7 @@ async function submitCredit() {
creditVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}

View File

@@ -60,6 +60,8 @@ const {
transferContextLoading,
transferAmountRange,
transferAmountDisabled,
transferAmountExceedsCap,
transferAmountCapError,
transferTitle,
openTransfer,
submitTransfer,
@@ -757,11 +759,10 @@ function statusLabel(s: string) {
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
<el-input-number
v-model="transferAmount"
:min="transferAmountRange.min"
:max="transferAmountRange.max"
:disabled="transferAmountDisabled"
:step="10"
:precision="2"
@@ -777,7 +778,7 @@ function statusLabel(s: string) {
<el-button
type="primary"
:loading="transferLoading"
:disabled="transferAmountDisabled"
:disabled="transferAmountDisabled || transferAmountExceedsCap"
@click="submitTransfer"
>
{{ t('common.confirm') }}

View File

@@ -143,6 +143,20 @@ const transferAmountDisabled = computed(() => {
return max === 0;
});
/** 勿用 el-input-number :max否则会静默截断到上限 */
const transferAmountExceedsCap = computed(() => {
const max = transferAmountRange.value.max;
if (max === undefined) return false;
return transferAmount.value > max;
});
const transferAmountCapError = computed(() => {
if (!transferAmountExceedsCap.value) return '';
return transferType.value === 'deposit'
? t('err.insufficient_credit')
: t('transfer.context.withdraw_exceed');
});
/* ─── Init ─── */
onMounted(async () => {
await loadProfile();
@@ -818,17 +832,20 @@ function statusTagType(s: string) {
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
<el-input-number
v-model="transferAmount"
:min="transferAmountRange.min" :max="transferAmountRange.max"
:disabled="transferAmountDisabled" :step="10" :precision="2" style="width: 100%"
:min="transferAmountRange.min"
:disabled="transferAmountDisabled"
:step="10"
:precision="2"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled || transferAmountExceedsCap" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>