feat(admin): 支持定时测试自动恢复并统一账号恢复入口

- 为定时测试计划增加 auto_recover 配置,补齐前后端类型、接口、仓储与数据库迁移
- 在定时测试成功后自动恢复账号 error、rate-limit 等可恢复运行时状态
- 新增 /admin/accounts/:id/recover-state 接口,合并原有重置状态与清限流操作
- 更新账号管理菜单与定时测试面板,补充自动恢复开关、说明提示和状态展示
- 补充账号恢复、限流清理与仓储同步相关测试
This commit is contained in:
kyx236
2026-03-08 06:59:53 +08:00
parent 03bf348530
commit 0c29468f90
22 changed files with 525 additions and 85 deletions

View File

@@ -240,6 +240,16 @@ export async function clearRateLimit(id: number): Promise<Account> {
return data
}
/**
* Recover account runtime state in one call
* @param id - Account ID
* @returns Updated account
*/
export async function recoverState(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/recover-state`)
return data
}
/**
* Reset account quota usage
* @param id - Account ID
@@ -588,6 +598,7 @@ export const accountsAPI = {
getTodayStats,
getBatchTodayStats,
clearRateLimit,
recoverState,
resetAccountQuota,
getTempUnschedulableStatus,
resetTempUnschedulable,

View File

@@ -29,6 +29,10 @@
</div>
<div v-else class="space-y-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
{{ t('admin.accounts.recoverStateHint') }}
</div>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.accountName') }}
@@ -131,7 +135,7 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('admin.accounts.tempUnschedulable.reset') }}
{{ t('admin.accounts.recoverState') }}
</button>
</div>
</template>
@@ -154,7 +158,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
reset: []
reset: [account: Account]
}>()
const { t } = useI18n()
@@ -225,12 +229,12 @@ const handleReset = async () => {
if (!props.account) return
resetting.value = true
try {
await adminAPI.accounts.resetTempUnschedulable(props.account.id)
appStore.showSuccess(t('admin.accounts.tempUnschedulable.resetSuccess'))
emit('reset')
const updated = await adminAPI.accounts.recoverState(props.account.id)
appStore.showSuccess(t('admin.accounts.recoverStateSuccess'))
emit('reset', updated)
handleClose()
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.tempUnschedulable.resetFailed'))
appStore.showError(error?.message || t('admin.accounts.recoverStateFailed'))
} finally {
resetting.value = false
}

View File

@@ -32,14 +32,10 @@
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<div v-if="hasRecoverableState" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="hasRecoverableState" @click="$emit('recover-state', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sync" size="sm" />
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" />
{{ t('admin.accounts.clearRateLimit') }}
{{ t('admin.accounts.recoverState') }}
</button>
<button v-if="hasQuotaLimit" @click="$emit('reset-quota', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-teal-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
@@ -59,7 +55,7 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit', 'reset-quota'])
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota'])
const { t } = useI18n()
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
@@ -75,6 +71,10 @@ const isRateLimited = computed(() => {
return false
})
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
const isTempUnschedulable = computed(() => props.account?.temp_unschedulable_until && new Date(props.account.temp_unschedulable_until) > new Date())
const hasRecoverableState = computed(() => {
return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value)
})
const hasQuotaLimit = computed(() => {
return props.account?.type === 'apikey' &&
props.account?.quota_limit !== undefined &&

View File

@@ -41,8 +41,24 @@
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.cronExpression') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.cronTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleEvery30Min') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleHourly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleDaily') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleWeekly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="newPlan.cron_expression"
@@ -51,8 +67,22 @@
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.maxResults') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.maxResultsTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipBody') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipExample') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="newPlan.max_results"
@@ -66,6 +96,17 @@
{{ t('admin.scheduledTests.enabled') }}
</label>
</div>
<div class="flex items-end">
<div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="newPlan.auto_recover" />
{{ t('admin.scheduledTests.autoRecover') }}
</label>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.scheduledTests.autoRecoverHelp') }}
</p>
</div>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
@@ -135,6 +176,14 @@
{{ plan.enabled ? t('admin.scheduledTests.enabled') : '' }}
</span>
</div>
<!-- Auto Recover Badge -->
<span
v-if="plan.auto_recover"
class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400"
>
{{ t('admin.scheduledTests.autoRecover') }}
</span>
</div>
<div class="flex items-center gap-3">
@@ -202,8 +251,24 @@
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.cronExpression') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.cronTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleEvery30Min') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleHourly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleDaily') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleWeekly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="editForm.cron_expression"
@@ -212,8 +277,22 @@
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.maxResults') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.maxResultsTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipBody') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipExample') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="editForm.max_results"
@@ -227,6 +306,17 @@
{{ t('admin.scheduledTests.enabled') }}
</label>
</div>
<div class="flex items-end">
<div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="editForm.auto_recover" />
{{ t('admin.scheduledTests.autoRecover') }}
</label>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.scheduledTests.autoRecoverHelp') }}
</p>
</div>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
@@ -377,6 +467,7 @@ import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import Input from '@/components/common/Input.vue'
import Toggle from '@/components/common/Toggle.vue'
@@ -416,14 +507,16 @@ const editForm = reactive({
model_id: '' as string,
cron_expression: '' as string,
max_results: '100' as string,
enabled: true
enabled: true,
auto_recover: false
})
const newPlan = reactive({
model_id: '' as string,
cron_expression: '' as string,
max_results: '100' as string,
enabled: true
enabled: true,
auto_recover: false
})
const resetNewPlan = () => {
@@ -431,6 +524,7 @@ const resetNewPlan = () => {
newPlan.cron_expression = ''
newPlan.max_results = '100'
newPlan.enabled = true
newPlan.auto_recover = false
}
// Load plans when dialog opens
@@ -472,7 +566,8 @@ const handleCreate = async () => {
model_id: newPlan.model_id,
cron_expression: newPlan.cron_expression,
enabled: newPlan.enabled,
max_results: maxResults
max_results: maxResults,
auto_recover: newPlan.auto_recover
})
appStore.showSuccess(t('admin.scheduledTests.createSuccess'))
showAddForm.value = false
@@ -504,6 +599,7 @@ const startEdit = (plan: ScheduledTestPlan) => {
editForm.cron_expression = plan.cron_expression
editForm.max_results = String(plan.max_results)
editForm.enabled = plan.enabled
editForm.auto_recover = plan.auto_recover
}
const cancelEdit = () => {
@@ -518,7 +614,8 @@ const handleEdit = async () => {
model_id: editForm.model_id,
cron_expression: editForm.cron_expression,
max_results: Number(editForm.max_results) || 100,
enabled: editForm.enabled
enabled: editForm.enabled,
auto_recover: editForm.auto_recover
})
const index = plans.value.findIndex((p) => p.id === editingPlanId.value)
if (index !== -1) {

View File

@@ -1772,9 +1772,9 @@ export default {
remaining: 'Remaining',
matchedKeyword: 'Matched Keyword',
errorMessage: 'Error Details',
reset: 'Reset Status',
resetSuccess: 'Temp unschedulable status reset',
resetFailed: 'Failed to reset temp unschedulable status',
reset: 'Recover State',
resetSuccess: 'Account state recovered successfully',
resetFailed: 'Failed to recover account state',
failedToLoad: 'Failed to load temp unschedulable status',
notActive: 'This account is not temporarily unschedulable.',
expired: 'Expired',
@@ -1840,6 +1840,10 @@ export default {
bulkDeleteSuccess: 'Deleted {count} account(s)',
bulkDeletePartial: 'Partially deleted: {success} succeeded, {failed} failed',
bulkDeleteFailed: 'Bulk delete failed',
recoverState: 'Recover State',
recoverStateHint: 'Used to recover error, rate-limit, and temporary unschedulable runtime state.',
recoverStateSuccess: 'Account state recovered successfully',
recoverStateFailed: 'Failed to recover account state',
resetStatus: 'Reset Status',
statusReset: 'Account status reset successfully',
failedToResetStatus: 'Failed to reset account status',
@@ -2471,7 +2475,21 @@ export default {
failed: 'Failed',
running: 'Running',
schedule: 'Schedule',
cronHelp: 'Standard 5-field cron expression (e.g., */30 * * * *)'
cronHelp: 'Standard 5-field cron expression (e.g., */30 * * * *)',
cronTooltipTitle: 'Cron expression examples:',
cronTooltipMeaning: 'Defines when the test runs automatically. The 5 fields are: minute, hour, day, month, and weekday.',
cronTooltipExampleEvery30Min: '*/30 * * * *: run every 30 minutes',
cronTooltipExampleHourly: '0 * * * *: run at the start of every hour',
cronTooltipExampleDaily: '0 9 * * *: run every day at 09:00',
cronTooltipExampleWeekly: '0 9 * * 1: run every Monday at 09:00',
cronTooltipRange: 'Recommended range: use standard 5-field cron. For health checks, start with a moderate frequency such as every 30 minutes, every hour, or once a day instead of running too often.',
maxResultsTooltipTitle: 'What Max Results means:',
maxResultsTooltipMeaning: 'Sets how many historical test results are kept for a single plan so the result list does not grow without limit.',
maxResultsTooltipBody: 'Only the newest test results are kept. Once the number of saved results exceeds this value, older records are pruned automatically so the history list and storage stay under control.',
maxResultsTooltipExample: 'For example, 100 means keeping at most the latest 100 test results. When the 101st result is saved, the oldest one is removed.',
maxResultsTooltipRange: 'Recommended range: usually 20 to 200. Use 20-50 when you only care about recent health status, or 100-200 if you want a longer trend history.',
autoRecover: 'Auto Recover',
autoRecoverHelp: 'Automatically recover account from error/rate-limited state on successful test'
},
// Proxies

View File

@@ -1883,9 +1883,9 @@ export default {
remaining: '剩余时间',
matchedKeyword: '匹配关键词',
errorMessage: '错误详情',
reset: '重置状态',
resetSuccess: '临时不可调度已重置',
resetFailed: '重置临时不可调度失败',
reset: '恢复状态',
resetSuccess: '账号状态已恢复',
resetFailed: '恢复账号状态失败',
failedToLoad: '加载临时不可调度状态失败',
notActive: '当前账号未处于临时不可调度状态。',
expired: '已到期',
@@ -1986,6 +1986,10 @@ export default {
bulkDeleteSuccess: '成功删除 {count} 个账号',
bulkDeletePartial: '部分删除成功:成功 {success} 个,失败 {failed} 个',
bulkDeleteFailed: '批量删除失败',
recoverState: '恢复状态',
recoverStateHint: '用于恢复错误、限流和临时不可调度等可恢复状态。',
recoverStateSuccess: '账号状态已恢复',
recoverStateFailed: '恢复账号状态失败',
resetStatus: '重置状态',
statusReset: '账号状态已重置',
failedToResetStatus: '重置账号状态失败',
@@ -2578,7 +2582,21 @@ export default {
failed: '失败',
running: '运行中',
schedule: '定时测试',
cronHelp: '标准 5 字段 cron 表达式(例如 */30 * * * *'
cronHelp: '标准 5 字段 cron 表达式(例如 */30 * * * *',
cronTooltipTitle: 'Cron 表达式示例:',
cronTooltipMeaning: '用于定义自动执行测试的时间规则,格式依次为:分钟 小时 日 月 星期。',
cronTooltipExampleEvery30Min: '*/30 * * * *:每 30 分钟运行一次',
cronTooltipExampleHourly: '0 * * * *:每小时整点运行一次',
cronTooltipExampleDaily: '0 9 * * *:每天 09:00 运行一次',
cronTooltipExampleWeekly: '0 9 * * 1每周一 09:00 运行一次',
cronTooltipRange: '推荐填写范围:使用标准 5 字段 cron如果只是健康检查建议从每 30 分钟、每 1 小时或每天固定时间开始,不建议一开始就设置过高频率。',
maxResultsTooltipTitle: '最大结果数说明:',
maxResultsTooltipMeaning: '用于限制单个计划最多保留多少条历史测试结果,避免结果列表无限增长。',
maxResultsTooltipBody: '系统只会保留最近的测试结果;当保存数量超过这个值时,更早的历史记录会自动清理,避免列表过长和存储持续增长。',
maxResultsTooltipExample: '例如填写 100表示最多保存最近 100 次测试结果;第 101 次结果写入后,最早的一条会被清理。',
maxResultsTooltipRange: '推荐填写范围:一般可填 20 到 200。只关注近期可用性时可填 20-50需要回看较长时间的波动趋势时可填 100-200。',
autoRecover: '自动恢复',
autoRecoverHelp: '测试成功后自动恢复异常状态的账号'
},
// Proxies Management

View File

@@ -1487,6 +1487,7 @@ export interface ScheduledTestPlan {
cron_expression: string
enabled: boolean
max_results: number
auto_recover: boolean
last_run_at: string | null
next_run_at: string | null
created_at: string
@@ -1511,6 +1512,7 @@ export interface CreateScheduledTestPlanRequest {
cron_expression: string
enabled?: boolean
max_results?: number
auto_recover?: boolean
}
export interface UpdateScheduledTestPlanRequest {
@@ -1518,4 +1520,5 @@ export interface UpdateScheduledTestPlanRequest {
cron_expression?: string
enabled?: boolean
max_results?: number
auto_recover?: boolean
}

View File

@@ -261,7 +261,7 @@
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" @reset-quota="handleResetQuota" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
@@ -1105,24 +1105,15 @@ const handleRefresh = async (a: Account) => {
console.error('Failed to refresh credentials:', error)
}
}
const handleResetStatus = async (a: Account) => {
const handleRecoverState = async (a: Account) => {
try {
const updated = await adminAPI.accounts.clearError(a.id)
const updated = await adminAPI.accounts.recoverState(a.id)
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
appStore.showSuccess(t('common.success'))
} catch (error) {
console.error('Failed to reset status:', error)
}
}
const handleClearRateLimit = async (a: Account) => {
try {
const updated = await adminAPI.accounts.clearRateLimit(a.id)
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
appStore.showSuccess(t('common.success'))
} catch (error) {
console.error('Failed to clear rate limit:', error)
appStore.showSuccess(t('admin.accounts.recoverStateSuccess'))
} catch (error: any) {
console.error('Failed to recover account state:', error)
appStore.showError(error?.message || t('admin.accounts.recoverStateFailed'))
}
}
const handleResetQuota = async (a: Account) => {
@@ -1152,17 +1143,11 @@ const handleToggleSchedulable = async (a: Account) => {
}
}
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => {
if(!tempUnschedAcc.value) return
try {
const updated = await adminAPI.accounts.clearError(tempUnschedAcc.value.id)
showTempUnsched.value = false
tempUnschedAcc.value = null
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
} catch (error) {
console.error('Failed to reset temp unscheduled:', error)
}
const handleTempUnschedReset = async (updated: Account) => {
showTempUnsched.value = false
tempUnschedAcc.value = null
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
}
const formatExpiresAt = (value: number | null) => {
if (!value) return '-'