feat(admin): 余额脚本驱动的余额/配额刷新与管理端体验修复

- 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗
This commit is contained in:
atoz03
2025-12-14 13:43:02 +08:00
parent 26ca696b91
commit f6f4b5cfec
11 changed files with 358 additions and 167 deletions

View File

@@ -1,7 +1,12 @@
<template>
<el-dialog
:append-to-body="true"
class="balance-script-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="show"
:title="`配置余额脚本 - ${account?.name || ''}`"
top="5vh"
width="720px"
@close="emitClose"
>
@@ -71,12 +76,6 @@
</div>
</div>
<div class="flex items-center gap-2">
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
<el-button @click="emitClose">取消</el-button>
</div>
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
<div class="flex items-center justify-between">
<span class="font-semibold">测试结果</span>
@@ -113,6 +112,14 @@
</details>
</div>
</div>
<template #footer>
<div class="flex items-center gap-2">
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
<el-button @click="emitClose">取消</el-button>
</div>
</template>
</el-dialog>
</template>
@@ -256,6 +263,22 @@ watch(
</script>
<style scoped>
:deep(.balance-script-dialog) {
max-height: 90vh;
display: flex;
flex-direction: column;
}
:deep(.balance-script-dialog .el-dialog__body) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
:deep(.balance-script-dialog .el-dialog__footer) {
border-top: 1px solid rgba(229, 231, 235, 0.7);
}
.input-text {
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
}

View File

@@ -42,9 +42,9 @@
<button
v-if="!hideRefresh"
class="text-xs text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
:disabled="refreshing"
:title="refreshing ? '刷新中...' : '刷新余额'"
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
:disabled="refreshing || !canRefresh"
:title="refreshTitle"
@click="refresh"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
@@ -143,6 +143,25 @@ const quotaBarClass = computed(() => {
return 'bg-green-500 dark:bg-green-600'
})
const canRefresh = computed(() => {
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
const data = balanceData.value
if (!data) return false
if (data.scriptEnabled === false) return false
return !!data.scriptConfigured
})
const refreshTitle = computed(() => {
if (refreshing.value) return '刷新中...'
if (!canRefresh.value) {
if (balanceData.value?.scriptEnabled === false) {
return '余额脚本功能已禁用'
}
return '请先配置余额脚本'
}
return '刷新余额(调用脚本配置的余额 API'
})
const primaryText = computed(() => {
if (balanceData.value?.balance?.formattedAmount) {
return balanceData.value.balance.formattedAmount
@@ -178,6 +197,7 @@ const load = async () => {
const refresh = async () => {
if (!props.accountId || !props.platform) return
if (refreshing.value) return
if (!canRefresh.value) return
refreshing.value = true
requestError.value = null

View File

@@ -143,14 +143,10 @@
<!-- 刷新余额按钮 -->
<div class="relative">
<el-tooltip
content="刷新当前页余额(触发查询,失败自动降级)"
effect="dark"
placement="bottom"
>
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading || refreshingBalances"
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
@click="refreshVisibleBalances"
>
<div
@@ -2590,9 +2586,26 @@ const closeBalanceScriptModal = () => {
selectedAccountForScript.value = null
}
const handleBalanceScriptSaved = () => {
const handleBalanceScriptSaved = async () => {
showToast('余额脚本已保存', 'success')
const account = selectedAccountForScript.value
closeBalanceScriptModal()
if (!account?.id || !account?.platform) {
return
}
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
try {
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
params: { platform: account.platform, queryApi: false }
})
if (res?.success && res.data) {
handleBalanceRefreshed(account.id, res.data)
}
} catch (error) {
console.debug('Failed to reload balance after saving script:', error)
}
}
// 计算排序后的账户列表
@@ -2865,6 +2878,25 @@ const paginatedAccounts = computed(() => {
return sortedAccounts.value.slice(start, end)
})
const canRefreshVisibleBalances = computed(() => {
const targets = paginatedAccounts.value
if (!Array.isArray(targets) || targets.length === 0) {
return false
}
return targets.some((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
})
const refreshBalanceTooltip = computed(() => {
if (accountsLoading.value) return '正在加载账户...'
if (refreshingBalances.value) return '刷新中...'
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
})
// 余额刷新成功回调
const handleBalanceRefreshed = (accountId, balanceInfo) => {
accounts.value = accounts.value.map((account) => {
@@ -2888,10 +2920,22 @@ const refreshVisibleBalances = async () => {
return
}
const eligibleTargets = targets.filter((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
if (eligibleTargets.length === 0) {
showToast('当前页没有配置余额脚本的账户', 'warning')
return
}
const skippedCount = targets.length - eligibleTargets.length
refreshingBalances.value = true
try {
const results = await Promise.all(
targets.map(async (account) => {
eligibleTargets.map(async (account) => {
try {
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
platform: account.platform
@@ -2913,6 +2957,7 @@ const refreshVisibleBalances = async () => {
const successCount = results.filter((r) => r.success).length
const failCount = results.length - successCount
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
if (Object.keys(updatedMap).length > 0) {
accounts.value = accounts.value.map((account) => {
const balanceInfo = updatedMap[account.id]
@@ -2922,9 +2967,9 @@ const refreshVisibleBalances = async () => {
}
if (failCount === 0) {
showToast(`成功刷新 ${successCount} 个账户余额`, 'success')
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
} else {
showToast(`刷新完成:${successCount} 成功,${failCount} 失败`, 'warning')
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
}
} finally {
refreshingBalances.value = false