mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(ui): smooth session tab transitions
This commit is contained in:
1 parent
dcee433174
commit
37628302c8
7 files changed
+110
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user