feat(accounts): 自动刷新改为ETag增量同步并优化单账号更新体验
- 前端自动刷新改为 ETag/304 增量合并,减少全量重刷 - 单账号更新后增加静默窗口,避免刚更新即被自动刷新覆盖 - 列表筛选移除时改为待同步提示,不再立即触发全量补页 - 后端账号列表支持 If-None-Match,命中返回 304 - 单账号接口统一补充运行时容量字段并暴露 ETag 头
This commit is contained in:
@@ -2,8 +2,13 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -143,6 +148,44 @@ type AccountWithConcurrency struct {
|
|||||||
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
|
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
|
// List handles listing all accounts with pagination
|
||||||
// GET /api/v1/admin/accounts
|
// GET /api/v1/admin/accounts
|
||||||
func (h *AccountHandler) List(c *gin.Context) {
|
func (h *AccountHandler) List(c *gin.Context) {
|
||||||
@@ -258,9 +301,71 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
result[i] = item
|
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)
|
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
|
// GetByID handles getting an account by ID
|
||||||
// GET /api/v1/admin/accounts/:id
|
// GET /api/v1/admin/accounts/:id
|
||||||
func (h *AccountHandler) GetByID(c *gin.Context) {
|
func (h *AccountHandler) GetByID(c *gin.Context) {
|
||||||
@@ -276,7 +381,7 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create handles creating a new account
|
// Create handles creating a new account
|
||||||
@@ -334,7 +439,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update handles updating an account
|
// Update handles updating an account
|
||||||
@@ -398,7 +503,7 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete handles deleting an 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
|
// 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
|
// BatchCreate handles batch creating accounts
|
||||||
@@ -1112,7 +1217,7 @@ func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTempUnschedulable handles getting temporary unschedulable status
|
// GetTempUnschedulable handles getting temporary unschedulable status
|
||||||
@@ -1202,7 +1307,7 @@ func (h *AccountHandler) SetSchedulable(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableModels handles getting available models for an 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-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-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")
|
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,58 @@ export async function list(
|
|||||||
return data
|
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
|
* Get account by ID
|
||||||
* @param id - Account ID
|
* @param id - Account ID
|
||||||
@@ -455,6 +507,7 @@ export async function refreshOpenAIToken(
|
|||||||
|
|
||||||
export const accountsAPI = {
|
export const accountsAPI = {
|
||||||
list,
|
list,
|
||||||
|
listWithEtag,
|
||||||
getById,
|
getById,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
|
|||||||
@@ -1280,6 +1280,8 @@ export default {
|
|||||||
refreshInterval15s: '15 seconds',
|
refreshInterval15s: '15 seconds',
|
||||||
refreshInterval30s: '30 seconds',
|
refreshInterval30s: '30 seconds',
|
||||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||||
|
listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.',
|
||||||
|
listPendingSyncAction: 'Sync now',
|
||||||
syncFromCrs: 'Sync from CRS',
|
syncFromCrs: 'Sync from CRS',
|
||||||
dataExport: 'Export',
|
dataExport: 'Export',
|
||||||
dataExportSelected: 'Export Selected',
|
dataExportSelected: 'Export Selected',
|
||||||
|
|||||||
@@ -1368,6 +1368,8 @@ export default {
|
|||||||
refreshInterval15s: '15 秒',
|
refreshInterval15s: '15 秒',
|
||||||
refreshInterval30s: '30 秒',
|
refreshInterval30s: '30 秒',
|
||||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||||
|
listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。',
|
||||||
|
listPendingSyncAction: '立即同步',
|
||||||
syncFromCrs: '从 CRS 同步',
|
syncFromCrs: '从 CRS 同步',
|
||||||
dataExport: '导出',
|
dataExport: '导出',
|
||||||
dataExportSelected: '导出选中',
|
dataExportSelected: '导出选中',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
/>
|
/>
|
||||||
<AccountTableActions
|
<AccountTableActions
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@refresh="load"
|
@refresh="handleManualRefresh"
|
||||||
@sync="showSync = true"
|
@sync="showSync = true"
|
||||||
@create="showCreate = true"
|
@create="showCreate = true"
|
||||||
>
|
>
|
||||||
@@ -116,6 +116,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</AccountTableActions>
|
</AccountTableActions>
|
||||||
</div>
|
</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>
|
||||||
<template #table>
|
<template #table>
|
||||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||||
@@ -260,7 +272,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useIntervalFn } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
@@ -340,6 +352,11 @@ const autoRefreshIntervals = [5, 10, 15, 30] as const
|
|||||||
const autoRefreshEnabled = ref(false)
|
const autoRefreshEnabled = ref(false)
|
||||||
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||||
const autoRefreshCountdown = ref(0)
|
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) => {
|
const autoRefreshIntervalLabel = (sec: number) => {
|
||||||
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||||
@@ -437,11 +454,55 @@ const toggleColumn = (key: string) => {
|
|||||||
|
|
||||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
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,
|
fetchFn: adminAPI.accounts.list,
|
||||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
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(() => {
|
const isAnyModalOpen = computed(() => {
|
||||||
return (
|
return (
|
||||||
showCreate.value ||
|
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(
|
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||||
async () => {
|
async () => {
|
||||||
if (!autoRefreshEnabled.value) return
|
if (!autoRefreshEnabled.value) return
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
if (loading.value) return
|
if (loading.value || autoRefreshFetching.value) return
|
||||||
if (isAnyModalOpen.value) return
|
if (isAnyModalOpen.value) return
|
||||||
if (menu.show) return
|
if (menu.show) return
|
||||||
|
if (inAutoRefreshSilentWindow()) {
|
||||||
|
autoRefreshCountdown.value = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((autoRefreshSilentUntil.value - Date.now()) / 1000)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (autoRefreshCountdown.value <= 0) {
|
if (autoRefreshCountdown.value <= 0) {
|
||||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
try {
|
await refreshAccountsIncrementally()
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Auto refresh failed:', e)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,22 +891,12 @@ const syncPaginationAfterLocalRemoval = () => {
|
|||||||
pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0
|
pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0
|
||||||
|
|
||||||
const maxPage = Math.max(1, pagination.pages || 1)
|
const maxPage = Math.max(1, pagination.pages || 1)
|
||||||
let shouldReload = false
|
|
||||||
|
|
||||||
if (pagination.page > maxPage) {
|
if (pagination.page > maxPage) {
|
||||||
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) => {
|
const patchAccountInList = (updatedAccount: Account) => {
|
||||||
@@ -758,14 +916,11 @@ const patchAccountInList = (updatedAccount: Account) => {
|
|||||||
const nextAccounts = [...accounts.value]
|
const nextAccounts = [...accounts.value]
|
||||||
nextAccounts[index] = mergedAccount
|
nextAccounts[index] = mergedAccount
|
||||||
accounts.value = nextAccounts
|
accounts.value = nextAccounts
|
||||||
if (edAcc.value?.id === mergedAccount.id) edAcc.value = mergedAccount
|
syncAccountRefs(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
|
|
||||||
}
|
}
|
||||||
const handleAccountUpdated = (updatedAccount: Account) => {
|
const handleAccountUpdated = (updatedAccount: Account) => {
|
||||||
patchAccountInList(updatedAccount)
|
patchAccountInList(updatedAccount)
|
||||||
|
enterAutoRefreshSilentWindow()
|
||||||
}
|
}
|
||||||
const formatExportTimestamp = () => {
|
const formatExportTimestamp = () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -820,6 +975,7 @@ const handleRefresh = async (a: Account) => {
|
|||||||
try {
|
try {
|
||||||
const updated = await adminAPI.accounts.refreshCredentials(a.id)
|
const updated = await adminAPI.accounts.refreshCredentials(a.id)
|
||||||
patchAccountInList(updated)
|
patchAccountInList(updated)
|
||||||
|
enterAutoRefreshSilentWindow()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh credentials:', error)
|
console.error('Failed to refresh credentials:', error)
|
||||||
}
|
}
|
||||||
@@ -828,6 +984,7 @@ const handleResetStatus = async (a: Account) => {
|
|||||||
try {
|
try {
|
||||||
const updated = await adminAPI.accounts.clearError(a.id)
|
const updated = await adminAPI.accounts.clearError(a.id)
|
||||||
patchAccountInList(updated)
|
patchAccountInList(updated)
|
||||||
|
enterAutoRefreshSilentWindow()
|
||||||
appStore.showSuccess(t('common.success'))
|
appStore.showSuccess(t('common.success'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset status:', error)
|
console.error('Failed to reset status:', error)
|
||||||
@@ -837,6 +994,7 @@ const handleClearRateLimit = async (a: Account) => {
|
|||||||
try {
|
try {
|
||||||
const updated = await adminAPI.accounts.clearRateLimit(a.id)
|
const updated = await adminAPI.accounts.clearRateLimit(a.id)
|
||||||
patchAccountInList(updated)
|
patchAccountInList(updated)
|
||||||
|
enterAutoRefreshSilentWindow()
|
||||||
appStore.showSuccess(t('common.success'))
|
appStore.showSuccess(t('common.success'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear rate limit:', error)
|
console.error('Failed to clear rate limit:', error)
|
||||||
@@ -850,6 +1008,7 @@ const handleToggleSchedulable = async (a: Account) => {
|
|||||||
try {
|
try {
|
||||||
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
|
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
|
||||||
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
|
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
|
||||||
|
enterAutoRefreshSilentWindow()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle schedulable:', error)
|
console.error('Failed to toggle schedulable:', error)
|
||||||
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
|
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
|
||||||
@@ -865,6 +1024,7 @@ const handleTempUnschedReset = async () => {
|
|||||||
showTempUnsched.value = false
|
showTempUnsched.value = false
|
||||||
tempUnschedAcc.value = null
|
tempUnschedAcc.value = null
|
||||||
patchAccountInList(updated)
|
patchAccountInList(updated)
|
||||||
|
enterAutoRefreshSilentWindow()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset temp unscheduled:', error)
|
console.error('Failed to reset temp unscheduled:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user