feat(accounts): 自动刷新改为ETag增量同步并优化单账号更新体验
- 前端自动刷新改为 ETag/304 增量合并,减少全量重刷 - 单账号更新后增加静默窗口,避免刚更新即被自动刷新覆盖 - 列表筛选移除时改为待同步提示,不再立即触发全量补页 - 后端账号列表支持 If-None-Match,命中返回 304 - 单账号接口统一补充运行时容量字段并暴露 ETag 头
This commit is contained in:
@@ -2,8 +2,13 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -143,6 +148,44 @@ type AccountWithConcurrency struct {
|
||||
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
item := AccountWithConcurrency{
|
||||
Account: dto.AccountFromService(account),
|
||||
CurrentConcurrency: 0,
|
||||
}
|
||||
if account == nil {
|
||||
return item
|
||||
}
|
||||
|
||||
if h.concurrencyService != nil {
|
||||
if counts, err := h.concurrencyService.GetAccountConcurrencyBatch(ctx, []int64{account.ID}); err == nil {
|
||||
item.CurrentConcurrency = counts[account.ID]
|
||||
}
|
||||
}
|
||||
|
||||
if account.IsAnthropicOAuthOrSetupToken() {
|
||||
if h.accountUsageService != nil && account.GetWindowCostLimit() > 0 {
|
||||
startTime := account.GetCurrentWindowStartTime()
|
||||
if stats, err := h.accountUsageService.GetAccountWindowStats(ctx, account.ID, startTime); err == nil && stats != nil {
|
||||
cost := stats.StandardCost
|
||||
item.CurrentWindowCost = &cost
|
||||
}
|
||||
}
|
||||
|
||||
if h.sessionLimitCache != nil && account.GetMaxSessions() > 0 {
|
||||
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
|
||||
idleTimeouts := map[int64]time.Duration{account.ID: idleTimeout}
|
||||
if sessions, err := h.sessionLimitCache.GetActiveSessionCountBatch(ctx, []int64{account.ID}, idleTimeouts); err == nil {
|
||||
if count, ok := sessions[account.ID]; ok {
|
||||
item.ActiveSessions = &count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// List handles listing all accounts with pagination
|
||||
// GET /api/v1/admin/accounts
|
||||
func (h *AccountHandler) List(c *gin.Context) {
|
||||
@@ -258,9 +301,71 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
result[i] = item
|
||||
}
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
func buildAccountsListETag(
|
||||
items []AccountWithConcurrency,
|
||||
total int64,
|
||||
page, pageSize int,
|
||||
platform, accountType, status, search string,
|
||||
) string {
|
||||
payload := struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Items []AccountWithConcurrency `json:"items"`
|
||||
}{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Platform: platform,
|
||||
AccountType: accountType,
|
||||
Status: status,
|
||||
Search: search,
|
||||
Items: items,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "\"" + hex.EncodeToString(sum[:]) + "\""
|
||||
}
|
||||
|
||||
func ifNoneMatchMatched(ifNoneMatch, etag string) bool {
|
||||
if etag == "" || ifNoneMatch == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range strings.Split(ifNoneMatch, ",") {
|
||||
candidate := strings.TrimSpace(token)
|
||||
if candidate == "*" {
|
||||
return true
|
||||
}
|
||||
if candidate == etag {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(candidate, "W/") && strings.TrimPrefix(candidate, "W/") == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetByID handles getting an account by ID
|
||||
// GET /api/v1/admin/accounts/:id
|
||||
func (h *AccountHandler) GetByID(c *gin.Context) {
|
||||
@@ -276,7 +381,7 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// Create handles creating a new account
|
||||
@@ -334,7 +439,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// Update handles updating an account
|
||||
@@ -398,7 +503,7 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// Delete handles deleting an account
|
||||
@@ -656,7 +761,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(updatedAccount))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount))
|
||||
}
|
||||
|
||||
// GetStats handles getting account statistics
|
||||
@@ -714,7 +819,7 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// BatchCreate handles batch creating accounts
|
||||
@@ -1112,7 +1217,7 @@ func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// GetTempUnschedulable handles getting temporary unschedulable status
|
||||
@@ -1202,7 +1307,7 @@ func (h *AccountHandler) SetSchedulable(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
}
|
||||
|
||||
// GetAvailableModels handles getting available models for an account
|
||||
|
||||
@@ -70,6 +70,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||
c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag")
|
||||
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,58 @@ export async function list(
|
||||
return data
|
||||
}
|
||||
|
||||
export interface AccountListWithEtagResult {
|
||||
notModified: boolean
|
||||
etag: string | null
|
||||
data: PaginatedResponse<Account> | null
|
||||
}
|
||||
|
||||
export async function listWithEtag(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
etag?: string | null
|
||||
}
|
||||
): Promise<AccountListWithEtagResult> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (options?.etag) {
|
||||
headers['If-None-Match'] = options.etag
|
||||
}
|
||||
|
||||
const response = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
|
||||
})
|
||||
|
||||
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
|
||||
if (response.status === 304) {
|
||||
return {
|
||||
notModified: true,
|
||||
etag: etagHeader,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
etag: etagHeader,
|
||||
data: response.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account by ID
|
||||
* @param id - Account ID
|
||||
@@ -455,6 +507,7 @@ export async function refreshOpenAIToken(
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
|
||||
@@ -1280,6 +1280,8 @@ export default {
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.',
|
||||
listPendingSyncAction: 'Sync now',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
dataExport: 'Export',
|
||||
dataExportSelected: 'Export Selected',
|
||||
|
||||
@@ -1368,6 +1368,8 @@ export default {
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。',
|
||||
listPendingSyncAction: '立即同步',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
dataExport: '导出',
|
||||
dataExportSelected: '导出选中',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
/>
|
||||
<AccountTableActions
|
||||
:loading="loading"
|
||||
@refresh="load"
|
||||
@refresh="handleManualRefresh"
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
@@ -116,6 +116,18 @@
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasPendingListSync"
|
||||
class="mt-2 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<span>{{ t('admin.accounts.listPendingSyncHint') }}</span>
|
||||
<button
|
||||
class="btn btn-secondary px-2 py-1 text-xs"
|
||||
@click="syncPendingListChanges"
|
||||
>
|
||||
{{ t('admin.accounts.listPendingSyncAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
@@ -260,7 +272,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, toRaw } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -340,6 +352,11 @@ const autoRefreshIntervals = [5, 10, 15, 30] as const
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||
const autoRefreshCountdown = ref(0)
|
||||
const autoRefreshETag = ref<string | null>(null)
|
||||
const autoRefreshFetching = ref(false)
|
||||
const AUTO_REFRESH_SILENT_WINDOW_MS = 15000
|
||||
const autoRefreshSilentUntil = ref(0)
|
||||
const hasPendingListSync = ref(false)
|
||||
|
||||
const autoRefreshIntervalLabel = (sec: number) => {
|
||||
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||
@@ -437,11 +454,55 @@ const toggleColumn = (key: string) => {
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||
const {
|
||||
items: accounts,
|
||||
loading,
|
||||
params,
|
||||
pagination,
|
||||
load: baseLoad,
|
||||
reload: baseReload,
|
||||
debouncedReload: baseDebouncedReload,
|
||||
handlePageChange: baseHandlePageChange,
|
||||
handlePageSizeChange: baseHandlePageSizeChange
|
||||
} = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||
})
|
||||
|
||||
const resetAutoRefreshCache = () => {
|
||||
autoRefreshETag.value = null
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
await baseLoad()
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
await baseReload()
|
||||
}
|
||||
|
||||
const debouncedReload = () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
baseDebouncedReload()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
baseHandlePageChange(page)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
baseHandlePageSizeChange(size)
|
||||
}
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
@@ -459,21 +520,128 @@ const isAnyModalOpen = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const enterAutoRefreshSilentWindow = () => {
|
||||
autoRefreshSilentUntil.value = Date.now() + AUTO_REFRESH_SILENT_WINDOW_MS
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
}
|
||||
|
||||
const inAutoRefreshSilentWindow = () => {
|
||||
return Date.now() < autoRefreshSilentUntil.value
|
||||
}
|
||||
|
||||
const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
|
||||
return (
|
||||
current.updated_at !== next.updated_at ||
|
||||
current.current_concurrency !== next.current_concurrency ||
|
||||
current.current_window_cost !== next.current_window_cost ||
|
||||
current.active_sessions !== next.active_sessions ||
|
||||
current.schedulable !== next.schedulable ||
|
||||
current.status !== next.status ||
|
||||
current.rate_limit_reset_at !== next.rate_limit_reset_at ||
|
||||
current.overload_until !== next.overload_until ||
|
||||
current.temp_unschedulable_until !== next.temp_unschedulable_until
|
||||
)
|
||||
}
|
||||
|
||||
const syncAccountRefs = (nextAccount: Account) => {
|
||||
if (edAcc.value?.id === nextAccount.id) edAcc.value = nextAccount
|
||||
if (reAuthAcc.value?.id === nextAccount.id) reAuthAcc.value = nextAccount
|
||||
if (tempUnschedAcc.value?.id === nextAccount.id) tempUnschedAcc.value = nextAccount
|
||||
if (deletingAcc.value?.id === nextAccount.id) deletingAcc.value = nextAccount
|
||||
if (menu.acc?.id === nextAccount.id) menu.acc = nextAccount
|
||||
}
|
||||
|
||||
const mergeAccountsIncrementally = (nextRows: Account[]) => {
|
||||
const currentRows = accounts.value
|
||||
const currentByID = new Map(currentRows.map(row => [row.id, row]))
|
||||
let changed = nextRows.length !== currentRows.length
|
||||
const mergedRows = nextRows.map((nextRow) => {
|
||||
const currentRow = currentByID.get(nextRow.id)
|
||||
if (!currentRow) {
|
||||
changed = true
|
||||
return nextRow
|
||||
}
|
||||
if (shouldReplaceAutoRefreshRow(currentRow, nextRow)) {
|
||||
changed = true
|
||||
syncAccountRefs(nextRow)
|
||||
return nextRow
|
||||
}
|
||||
return currentRow
|
||||
})
|
||||
if (!changed) {
|
||||
for (let i = 0; i < mergedRows.length; i += 1) {
|
||||
if (mergedRows[i].id !== currentRows[i]?.id) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
accounts.value = mergedRows
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAccountsIncrementally = async () => {
|
||||
if (autoRefreshFetching.value) return
|
||||
autoRefreshFetching.value = true
|
||||
try {
|
||||
const result = await adminAPI.accounts.listWithEtag(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
toRaw(params) as {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
},
|
||||
{ etag: autoRefreshETag.value }
|
||||
)
|
||||
|
||||
if (result.etag) {
|
||||
autoRefreshETag.value = result.etag
|
||||
}
|
||||
if (result.notModified || !result.data) {
|
||||
return
|
||||
}
|
||||
|
||||
pagination.total = result.data.total || 0
|
||||
pagination.pages = result.data.pages || 0
|
||||
mergeAccountsIncrementally(result.data.items || [])
|
||||
hasPendingListSync.value = false
|
||||
} catch (error) {
|
||||
console.error('Auto refresh failed:', error)
|
||||
} finally {
|
||||
autoRefreshFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const syncPendingListChanges = async () => {
|
||||
hasPendingListSync.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
async () => {
|
||||
if (!autoRefreshEnabled.value) return
|
||||
if (document.hidden) return
|
||||
if (loading.value) return
|
||||
if (loading.value || autoRefreshFetching.value) return
|
||||
if (isAnyModalOpen.value) return
|
||||
if (menu.show) return
|
||||
if (inAutoRefreshSilentWindow()) {
|
||||
autoRefreshCountdown.value = Math.max(
|
||||
0,
|
||||
Math.ceil((autoRefreshSilentUntil.value - Date.now()) / 1000)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (autoRefreshCountdown.value <= 0) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
console.error('Auto refresh failed:', e)
|
||||
}
|
||||
await refreshAccountsIncrementally()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -723,22 +891,12 @@ const syncPaginationAfterLocalRemoval = () => {
|
||||
pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0
|
||||
|
||||
const maxPage = Math.max(1, pagination.pages || 1)
|
||||
let shouldReload = false
|
||||
|
||||
if (pagination.page > maxPage) {
|
||||
pagination.page = maxPage
|
||||
shouldReload = nextTotal > 0
|
||||
} else if (nextTotal > 0) {
|
||||
const displayedEnd = (pagination.page - 1) * pagination.page_size + accounts.value.length
|
||||
// 当前页条目变少时,若后续还有数据则补齐,避免空页/少一条直到手动刷新。
|
||||
shouldReload = displayedEnd < nextTotal
|
||||
}
|
||||
|
||||
if (shouldReload) {
|
||||
load().catch((error) => {
|
||||
console.error('Failed to refresh accounts after local removal:', error)
|
||||
})
|
||||
}
|
||||
// 行被本地移除后不立刻全量补页,改为提示用户手动同步。
|
||||
hasPendingListSync.value = nextTotal > 0
|
||||
}
|
||||
|
||||
const patchAccountInList = (updatedAccount: Account) => {
|
||||
@@ -758,14 +916,11 @@ const patchAccountInList = (updatedAccount: Account) => {
|
||||
const nextAccounts = [...accounts.value]
|
||||
nextAccounts[index] = mergedAccount
|
||||
accounts.value = nextAccounts
|
||||
if (edAcc.value?.id === mergedAccount.id) edAcc.value = mergedAccount
|
||||
if (reAuthAcc.value?.id === mergedAccount.id) reAuthAcc.value = mergedAccount
|
||||
if (tempUnschedAcc.value?.id === mergedAccount.id) tempUnschedAcc.value = mergedAccount
|
||||
if (deletingAcc.value?.id === mergedAccount.id) deletingAcc.value = mergedAccount
|
||||
if (menu.acc?.id === mergedAccount.id) menu.acc = mergedAccount
|
||||
syncAccountRefs(mergedAccount)
|
||||
}
|
||||
const handleAccountUpdated = (updatedAccount: Account) => {
|
||||
patchAccountInList(updatedAccount)
|
||||
enterAutoRefreshSilentWindow()
|
||||
}
|
||||
const formatExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
@@ -820,6 +975,7 @@ const handleRefresh = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.refreshCredentials(a.id)
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh credentials:', error)
|
||||
}
|
||||
@@ -828,6 +984,7 @@ const handleResetStatus = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.clearError(a.id)
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
appStore.showSuccess(t('common.success'))
|
||||
} catch (error) {
|
||||
console.error('Failed to reset status:', error)
|
||||
@@ -837,6 +994,7 @@ 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)
|
||||
@@ -850,6 +1008,7 @@ const handleToggleSchedulable = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
|
||||
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
|
||||
enterAutoRefreshSilentWindow()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle schedulable:', error)
|
||||
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
|
||||
@@ -865,6 +1024,7 @@ const handleTempUnschedReset = async () => {
|
||||
showTempUnsched.value = false
|
||||
tempUnschedAcc.value = null
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
} catch (error) {
|
||||
console.error('Failed to reset temp unscheduled:', error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user