fix(ui): smooth session tab transitions

This commit is contained in:
digua committed 2026-08-28 00:20:02 +08:00
1 parent dcee433174
commit 37628302c8
7 files changed
+110 -38

No files matched your search

+19 -8
View File
@@ -10,6 +10,7 @@ import AIChatInput from './input/AIChatInput.vue'
import AIThinkingIndicator from './chat/AIThinkingIndicator.vue'
import ChatStatusBar from './chat/ChatStatusBar.vue'
import ChatHeaderActions from './chat/ChatHeaderActions.vue'
import LoadingState from '@/components/UI/LoadingState.vue'
import { useAIChat } from '@/composables/useAIChat'
import { useAIService, useLLMService } from '@/services'
import AssistantInlineBar from './assistant/AssistantInlineBar.vue'
@@ -46,12 +47,7 @@ const props = defineProps<{
chatType?: 'group' | 'private'
}>()
const emit = defineEmits<{
'restore-loading-change': [loading: boolean]
}>()
const initialAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null
emit('restore-loading-change', true)
// 使用 AI 对话 Composable
const {
@@ -85,6 +81,19 @@ const {
let isAIChatInitialized = false
let isUnmounted = false
const showRestoreLoading = ref(false)
// 快速缓存恢复不显示遮罩,避免切换 Tab 时出现单帧闪屏;较慢恢复只覆盖 AI 内容区。
let restoreLoadingTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
showRestoreLoading.value = true
}, 120)
function finishRestoreLoading(): void {
if (restoreLoadingTimer) {
clearTimeout(restoreLoadingTimer)
restoreLoadingTimer = null
}
showRestoreLoading.value = false
}
function isCurrentSessionRoute(): boolean {
const routeName = (props.chatType ?? 'group') === 'private' ? 'private-chat' : 'group-chat'
@@ -106,16 +115,16 @@ async function syncAIChatIdToRoute(aiChatId: string | null): Promise<void> {
}
void initialization.finally(() => {
finishRestoreLoading()
if (!isCurrentSessionRoute()) return
emit('restore-loading-change', false)
isAIChatInitialized = true
void syncAIChatIdToRoute(currentAIChatId.value)
})
onUnmounted(() => {
isUnmounted = true
emit('restore-loading-change', false)
finishRestoreLoading()
})
watch(currentAIChatId, (aiChatId) => {
@@ -472,7 +481,9 @@ watch(
</script>
<template>
<div class="main-content flex h-full overflow-hidden">
<div class="main-content relative flex h-full overflow-hidden">
<LoadingState v-if="showRestoreLoading" variant="overlay" />
<!-- 左侧:对话记录列表(始终显示) -->
<ConversationList
ref="conversationListRef"
@@ -105,12 +105,13 @@ const navigationItems = computed(() =>
<CaptureButton v-else-if="showSessionActions" color="gray" />
</template>
<div class="mt-3 flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div
class="mt-3 flex flex-col items-stretch gap-2 sm:min-h-8 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<PageTabs v-model="activeTab" class="min-w-0 shrink" :items="navigationItems" />
<!-- 不使用时间范围的一级 Tab 不挂载筛选器,避免发起无关查询。 -->
<div class="max-w-full overflow-x-auto scrollbar-hide">
<div v-if="timeSelectVisible" class="max-w-full overflow-x-auto scrollbar-hide">
<TimeSelect
v-if="timeSelectVisible"
v-model="timeRangeValue"
:session-id="currentSessionId ?? undefined"
:initial-state="initialTimeState"
@@ -240,3 +240,64 @@ test('session loading coordinates required data, missing ranges, and non-analysi
assert.equal(page.memberActivity.value[0]?.name, 'session-four')
assert.equal(page.isLoading.value, false)
})
test('returning to insights reuses analytics for the unchanged session and time range', async (t) => {
await t.mock.module('@/utils', {
namedExports: {
formatLocalizedDate: () => '',
},
})
const { useSessionAnalysisPageBase } = await import('./useSessionAnalysisPageBase')
let analysisLoadCount = 0
registerAdapter('data', {
getSession: async () => ({ id: 'session-one' }),
getMemberActivity: async () => [createMember('session-one')],
getHourlyActivity: async () => [],
getDailyActivity: async () => [],
getMessageTypeDistribution: async () => {
analysisLoadCount++
return []
},
} as unknown as DataAdapter)
t.mock.method(console, 'warn', () => undefined)
const scope = effectScope()
t.after(() => scope.stop())
const route = reactive({ params: { id: 'session-one' }, query: {} }) as unknown as RouteLocationNormalizedLoaded
const router = { replace: async () => undefined } as unknown as Router
const currentSessionId = ref<string | null>('session-one')
const page = scope.run(() =>
useSessionAnalysisPageBase({
route,
router,
currentSessionId,
selectSession: () => undefined,
defaultTab: 'ai-chat',
validTabIds: ['insights', 'ai-chat'],
})
)!
page.timeRangeValue.value = {
startTs: 1,
endTs: 2,
displayLabel: 'test',
isFullRange: false,
state: { mode: 'custom', customStart: '1970-01-01', customEnd: '1970-01-01' },
}
await nextTick()
page.activeTab.value = 'insights'
await flushPromises()
assert.equal(analysisLoadCount, 1)
assert.equal(page.isLoading.value, false)
page.activeTab.value = 'ai-chat'
await nextTick()
page.activeTab.value = 'insights'
await flushPromises()
assert.equal(analysisLoadCount, 1)
assert.equal(page.isLoading.value, false)
})
@@ -41,6 +41,14 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
let analysisLoadVersion = 0
let baseDataReady = false
let analysisDataReady = false
let loadedAnalysisScopeKey: string | null = null
function getAnalysisScopeKey(): string | null {
const sessionId = currentSessionId.value
if (!sessionId) return null
const filter = timeFilter.value
return filter ? `${sessionId}:${filter.startTs}:${filter.endTs}` : `${sessionId}:all`
}
function finishSessionSwitchIfReady() {
if (!isSessionSwitching.value || !baseDataReady) return
@@ -96,6 +104,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
if (!sessionId) return
const loadVersion = ++analysisLoadVersion
const analysisScopeKey = getAnalysisScopeKey()
if (!activeTabUsesOverviewAnalytics.value) {
isLoading.value = false
analysisDataReady = true
@@ -123,6 +132,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
loadedAnalysisScopeKey = analysisScopeKey
} catch (error) {
if (loadVersion === analysisLoadVersion) {
console.error('加载分析数据失败:', error)
@@ -178,6 +188,14 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
return
}
const analysisScopeKey = getAnalysisScopeKey()
if (analysisScopeKey && analysisScopeKey === loadedAnalysisScopeKey) {
isLoading.value = false
analysisDataReady = true
finishSessionSwitchIfReady()
return
}
isLoading.value = true
if (currentSessionId.value && timeRangeValue.value) void loadAnalysisData()
})
@@ -201,6 +219,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
currentSessionId,
() => {
analysisLoadVersion++
loadedAnalysisScopeKey = null
resetTimeRange()
baseDataReady = false
analysisDataReady = !activeTabUsesTimeRange.value
+3 -13
View File
@@ -87,9 +87,6 @@ const {
provide('session-switch-loading', isSessionSwitching)
const isAIChatRestoring = ref(activeTab.value === 'ai-chat' && typeof route.query.aiChatId === 'string')
const showSessionLoading = computed(() => isSessionSwitching.value || isAIChatRestoring.value)
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -104,7 +101,7 @@ const filteredMemberCount = computed(() => {
<template>
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
v-if="showSessionLoading"
v-if="isSessionSwitching"
data-testid="group-chat-switch-loading"
class="absolute inset-0 z-20 flex cursor-wait items-center justify-center bg-page-bg dark:bg-page-dark"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
@@ -138,7 +135,7 @@ const filteredMemberCount = computed(() => {
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !showSessionLoading" variant="overlay" :text="t('common.loading')" />
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
@@ -168,7 +165,6 @@ const filteredMemberCount = computed(() => {
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="group"
@restore-loading-change="isAIChatRestoring = $event"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
@@ -269,8 +265,7 @@ const filteredMemberCount = computed(() => {
</template>
<style scoped>
.tab-slide-enter-active,
.tab-slide-leave-active {
.tab-slide-enter-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
@@ -280,9 +275,4 @@ const filteredMemberCount = computed(() => {
opacity: 0;
transform: translateY(10px);
}
.tab-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
+1 -1
View File
@@ -101,7 +101,7 @@ onBeforeUnmount(() => {
>
<PageTabs
v-if="navigationItems.length > 0"
class="mt-3 pb-1.5"
class="mt-3 min-h-[38px] pb-1.5"
:model-value="activeSubpage"
:items="navigationItems"
:aria-label="t('insight.tabs.nav')"
+3 -13
View File
@@ -83,9 +83,6 @@ const {
provide('session-switch-loading', isSessionSwitching)
const isAIChatRestoring = ref(activeTab.value === 'ai-chat' && typeof route.query.aiChatId === 'string')
const showSessionLoading = computed(() => isSessionSwitching.value || isAIChatRestoring.value)
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -126,7 +123,7 @@ const otherMemberAvatar = computed(() => {
<template>
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
v-if="showSessionLoading"
v-if="isSessionSwitching"
data-testid="private-chat-switch-loading"
class="absolute inset-0 z-20 flex cursor-wait items-center justify-center bg-page-bg dark:bg-page-dark"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
@@ -159,7 +156,7 @@ const otherMemberAvatar = computed(() => {
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !showSessionLoading" variant="overlay" :text="t('common.loading')" />
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
@@ -183,7 +180,6 @@ const otherMemberAvatar = computed(() => {
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="private"
@restore-loading-change="isAIChatRestoring = $event"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
@@ -281,8 +277,7 @@ const otherMemberAvatar = computed(() => {
</template>
<style scoped>
.tab-slide-enter-active,
.tab-slide-leave-active {
.tab-slide-enter-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
@@ -292,9 +287,4 @@ const otherMemberAvatar = computed(() => {
opacity: 0;
transform: translateY(10px);
}
.tab-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>