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 @@