From 598d56baed581b592f4f65bf4ce15e647ed54a8b Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 18 Oct 2026 13:32:46 +0800 Subject: [PATCH] fix(frontend): stabilize async state and session lifecycles Preserve plugin pages during session renewal, restore pagination after failed requests, and cancel plugin resources on reload or disposal. Reuse request state for quota forecasts and disable logout while authentication changes. Validated with frontend lint and build, 23 temporary behavioral checks, and browser checks using simulated pagination and authentication responses. Co-authored-by: Codex --- frontend/src/composables/usePagedQuery.ts | 50 +- frontend/src/layout/components/AppSidebar.vue | 1 + frontend/src/stores/modules/auth.ts | 9 +- frontend/src/stores/modules/plugin-views.ts | 5 +- .../composables/useAccountQuotaForecast.ts | 48 +- .../accounts/composables/useAccountsQuery.ts | 16 +- .../groups/composables/useAccountGroups.ts | 13 +- .../components/PluginManagementFrame.vue | 679 +----------------- .../composables/usePluginFrameSession.ts | 668 +++++++++++++++++ .../views/proxies/composables/useProxies.ts | 11 +- .../proxies/composables/useProxyAccounts.ts | 30 +- .../settings/composables/useBackupRecords.ts | 7 +- 12 files changed, 780 insertions(+), 757 deletions(-) create mode 100644 frontend/src/views/plugins/composables/usePluginFrameSession.ts diff --git a/frontend/src/composables/usePagedQuery.ts b/frontend/src/composables/usePagedQuery.ts index 9deaf0af8..e1d51ca63 100644 --- a/frontend/src/composables/usePagedQuery.ts +++ b/frontend/src/composables/usePagedQuery.ts @@ -14,6 +14,13 @@ interface PageResult { } } +export interface PagedQueryExecution { + page?: number + pageSize?: number + silent?: boolean + background?: boolean +} + export function usePagedQuery(options: { initialPageSize: number load: (pagination: { page: number, pageSize: number }, options: RequestOptions) => Promise @@ -25,34 +32,52 @@ export function usePagedQuery(options: { const total = shallowRef(0) const items = shallowRef([]) const request = useRequestState(options.onError) - const { loading, error, invalidate } = request + const { loading, error } = request + let confirmedPagination = { page: page.value, pageSize: pageSize.value } - async function execute(execution: { silent?: boolean, background?: boolean } = {}) { + function restorePagination() { + page.value = confirmedPagination.page + pageSize.value = confirmedPagination.pageSize + } + + function invalidate(options?: Parameters[0]) { + request.invalidate(options) + restorePagination() + } + + async function execute(execution: PagedQueryExecution = {}): Promise { // 保留当前列表的刷新不一定静默;手动刷新仍需由请求层提示失败。 const background = execution.background || execution.silent const requestId = request.start(background) + const pagination = { + page: execution.page ?? page.value, + pageSize: execution.pageSize ?? pageSize.value, + } + // 同步控件的目标值,失败时再回退;只在成功时写入会让控件内部模型无法恢复。 + // 轮询或操作后的回读也沿用当前目标页,避免把尚未完成的翻页拉回旧页。 + page.value = pagination.page + pageSize.value = pagination.pageSize try { - const result = await options.load({ - page: page.value, - pageSize: pageSize.value, - }, { silent: execution.silent, signal: request.signal }) + const result = await options.load(pagination, { silent: execution.silent, signal: request.signal }) if (!request.isCurrent(requestId)) return false if (result.items.length === 0 && result.page.total > 0 && result.page.page > 1) { - page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY) - return execute(execution) + return execute({ ...execution, page: clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY) }) } items.value = result.items page.value = result.page.page pageSize.value = result.page.pageSize total.value = result.page.total + confirmedPagination = { page: page.value, pageSize: pageSize.value } options.onSuccess?.(result) return true } catch (cause: unknown) { + if (request.isCurrent(requestId)) + restorePagination() request.fail(requestId, cause, background) return false } @@ -61,6 +86,14 @@ export function usePagedQuery(options: { } } + function reloadFromStart() { + // 筛选条件已改变,旧结果不能继续对应当前查询,也不能在失败时恢复旧页。 + items.value = [] + total.value = 0 + confirmedPagination = { page: 1, pageSize: confirmedPagination.pageSize } + return execute(confirmedPagination) + } + return { page, pageSize, @@ -69,6 +102,7 @@ export function usePagedQuery(options: { loading, error, execute, + reloadFromStart, invalidate, } } diff --git a/frontend/src/layout/components/AppSidebar.vue b/frontend/src/layout/components/AppSidebar.vue index 409b57b9f..d47a365b6 100644 --- a/frontend/src/layout/components/AppSidebar.vue +++ b/frontend/src/layout/components/AppSidebar.vue @@ -577,6 +577,7 @@ onBeforeUnmount(() => { :size="isCollapsed ? 'default' : 'small'" aria-label="退出登录" type="danger" + :disabled="authStore.loading || !authStore.isAuthenticated" @click="handleLogout" > diff --git a/frontend/src/stores/modules/auth.ts b/frontend/src/stores/modules/auth.ts index d3898485f..5280a650c 100644 --- a/frontend/src/stores/modules/auth.ts +++ b/frontend/src/stores/modules/auth.ts @@ -8,6 +8,8 @@ import { resetUnauthorizedHandling } from '@/api/request' export const useAuthStore = defineStore('auth', () => { const session = shallowRef(null) + // 登录身份变化使关联数据失效,正常续期只更新会话有效期。 + const sessionGeneration = shallowRef(0) const isAuthenticated = computed(() => session.value !== null) const isAdmin = computed(() => session.value?.role === 'admin') const sessionChecked = shallowRef(false) @@ -41,7 +43,10 @@ export const useAuthStore = defineStore('auth', () => { // 登录或退出之后到达的旧状态响应,不覆盖新会话。 if (currentRevision === revision) { const wasAuthenticated = isAuthenticated.value + const previousRole = session.value?.role session.value = status.session + if (previousRole !== status.session?.role) + sessionGeneration.value += 1 sessionChecked.value = true if (status.authenticated && !wasAuthenticated) resetUnauthorizedHandling() @@ -61,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => { revision += 1 pendingCheck.value = undefined session.value = result + sessionGeneration.value += 1 sessionChecked.value = true resetUnauthorizedHandling() return result @@ -97,9 +103,10 @@ export const useAuthStore = defineStore('auth', () => { revision += 1 pendingCheck.value = undefined session.value = null + sessionGeneration.value += 1 sessionChecked.value = true resetUnauthorizedHandling() } - return { session, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession } + return { session, sessionGeneration, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession } }) diff --git a/frontend/src/stores/modules/plugin-views.ts b/frontend/src/stores/modules/plugin-views.ts index d23efd2fa..c47d9bf08 100644 --- a/frontend/src/stores/modules/plugin-views.ts +++ b/frontend/src/stores/modules/plugin-views.ts @@ -70,10 +70,7 @@ export const usePluginViewsStore = defineStore('plugin-views', () => { return pending ?? refresh() } - watch(() => authStore.session, (session, previous) => { - if (session !== previous) - reset() - }) + watch(() => authStore.sessionGeneration, reset, { flush: 'sync' }) return { views, diff --git a/frontend/src/views/accounts/composables/useAccountQuotaForecast.ts b/frontend/src/views/accounts/composables/useAccountQuotaForecast.ts index 0494e1885..2529ea986 100644 --- a/frontend/src/views/accounts/composables/useAccountQuotaForecast.ts +++ b/frontend/src/views/accounts/composables/useAccountQuotaForecast.ts @@ -3,6 +3,8 @@ import type { Account, AccountQuotaForecastResponse } from '@/api' import { ZNotification } from '@codex-proxy/ui' import { onScopeDispose, shallowRef, watch } from 'vue' import { getAccountQuotaForecast, refreshAccountQuota } from '@/api' +import { useRequestState } from '@/composables/useRequestState' +import { errorMessage } from '@/utils/operation' export function useAccountQuotaForecast( accountId: Ref, @@ -10,44 +12,27 @@ export function useAccountQuotaForecast( onAccountUpdated: (account: Account) => void, ) { const report = shallowRef(null) - const loading = shallowRef(false) + const request = useRequestState() + const { loading, error } = request const refreshing = shallowRef(false) - const error = shallowRef(false) - let requestVersion = 0 - let controller: AbortController | undefined let disposed = false - function cancelLoad(resetLoading = true) { - requestVersion += 1 - controller?.abort() - controller = undefined - if (resetLoading) - loading.value = false - } - async function load() { if (!open.value || !accountId.value || disposed) return false - cancelLoad() - const version = requestVersion - controller = new AbortController() - loading.value = true - error.value = false + const requestId = request.start() try { - const result = await getAccountQuotaForecast({ accountId: accountId.value }, { signal: controller.signal }) - if (version === requestVersion) { + const result = await getAccountQuotaForecast({ accountId: accountId.value }, { signal: request.signal }) + if (request.isCurrent(requestId)) { report.value = result return true } } - catch { - if (version === requestVersion) { - error.value = true - } + catch (cause) { + request.fail(requestId, cause) } finally { - if (version === requestVersion) - loading.value = false + request.finish(requestId) } return false } @@ -56,9 +41,9 @@ export function useAccountQuotaForecast( if (refreshing.value || !open.value) return const targetAccountId = accountId.value - cancelLoad() + request.invalidate() refreshing.value = true - error.value = false + error.value = '' try { // 刷新属于现有额度动作;预测查询本身始终只读。 const result = await refreshAccountQuota({ accountId: targetAccountId }) @@ -68,10 +53,10 @@ export function useAccountQuotaForecast( if (accountId.value === targetAccountId && await load()) ZNotification.success({ message: '额度已刷新' }) } - catch { + catch (cause) { if (!disposed && accountId.value === targetAccountId) { if (!report.value) - error.value = true + error.value = errorMessage(cause) } } finally { @@ -80,17 +65,16 @@ export function useAccountQuotaForecast( } watch([open, accountId], ([isOpen]) => { - cancelLoad(isOpen) + request.invalidate({ resetLoading: isOpen }) if (!isOpen) return report.value = null - error.value = false + error.value = '' void load() }, { immediate: true }) onScopeDispose(() => { disposed = true - cancelLoad() }) return { report, loading, refreshing, error, load, refresh } diff --git a/frontend/src/views/accounts/composables/useAccountsQuery.ts b/frontend/src/views/accounts/composables/useAccountsQuery.ts index 52b486158..6d32895ea 100644 --- a/frontend/src/views/accounts/composables/useAccountsQuery.ts +++ b/frontend/src/views/accounts/composables/useAccountsQuery.ts @@ -45,20 +45,16 @@ export function useAccountsQuery() { })) function handlePageChange(page: number) { - query.page.value = page - void query.execute() + void query.execute({ page }) } function handlePageSizeChange(pageSize: number) { - query.pageSize.value = pageSize - query.page.value = 1 - void query.execute() + void query.execute({ page: 1, pageSize }) } function handleSortChange(nextSort: TableSort | undefined) { sort.value = nextSort - query.page.value = 1 - void query.execute() + void query.reloadFromStart() } async function replaceAccount(updated: Account) { @@ -74,15 +70,13 @@ export function useAccountsQuery() { watch(searchQuery, (_value, _previous, onCleanup) => { const timer = setTimeout(() => { - query.page.value = 1 - void query.execute() + void query.reloadFromStart() }, 250) onCleanup(() => clearTimeout(timer)) }) watch([providerQuery, statusQuery, groupQuery], () => { - query.page.value = 1 - void query.execute() + void query.reloadFromStart() }) onMounted(() => { diff --git a/frontend/src/views/groups/composables/useAccountGroups.ts b/frontend/src/views/groups/composables/useAccountGroups.ts index 6eaca8aa9..0261b6f14 100644 --- a/frontend/src/views/groups/composables/useAccountGroups.ts +++ b/frontend/src/views/groups/composables/useAccountGroups.ts @@ -256,26 +256,21 @@ export function useAccountGroups() { } function handlePageChange(page: number) { - query.page.value = page - void query.execute() + void query.execute({ page }) } function handlePageSizeChange(pageSize: number) { - query.pageSize.value = pageSize - query.page.value = 1 - void query.execute() + void query.execute({ page: 1, pageSize }) } watch(searchQuery, (_value, _previous, onCleanup) => { const timer = setTimeout(() => { - query.page.value = 1 - void query.execute() + void query.reloadFromStart() }, 250) onCleanup(() => clearTimeout(timer)) }) watch(statusQuery, () => { - query.page.value = 1 - void query.execute() + void query.reloadFromStart() }) onMounted(() => { void Promise.all([query.execute(), loadReferenceKeys()]) diff --git a/frontend/src/views/plugins/components/PluginManagementFrame.vue b/frontend/src/views/plugins/components/PluginManagementFrame.vue index 193de88d0..18d2ce9d5 100644 --- a/frontend/src/views/plugins/components/PluginManagementFrame.vue +++ b/frontend/src/views/plugins/components/PluginManagementFrame.vue @@ -1,74 +1,13 @@