fix(insights): unify loading transitions

This commit is contained in:
digua committed 2026-08-03 10:45:11 +08:00
1 parent ec10d0dded
commit aef17058d1
19 files changed
+889 -268

No files matched your search

@@ -11,6 +11,7 @@ import { LanguagePreferenceTab, WordcloudTab } from '@/components/analysis/quote
import type { TimeFilter } from '@openchatlab/shared-types'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
import { InsightViewTransition } from '@/components/UI'
import GroupRelationshipInsights from './GroupRelationshipInsights.vue'
import GroupOverview from './GroupOverview.vue'
import PrivateOverview from './PrivateOverview.vue'
@@ -80,89 +81,79 @@ const viewTimeFilter = computed(() => ({ ...props.timeFilter, memberId: selected
</SectionTabs>
<div class="min-h-0 flex-1 overflow-auto">
<Transition name="fade" mode="out-in">
<PrivateOverview
v-if="activeSubTab === 'overview' && isPrivateChat"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<GroupOverview
v-else-if="activeSubTab === 'overview'"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<PrivateRelationshipView
v-else-if="activeSubTab === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<JourneyView
v-else-if="activeSubTab === 'journey'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:time-range="props.timeRange"
/>
<TypeAnalysisView
v-else-if="activeSubTab === 'type-analysis'"
:key="selectedMemberId ?? 'all'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="activeSubTab === 'time-analysis'"
:key="selectedMemberId ?? 'all'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<WordcloudTab
v-else-if="activeSubTab === 'topic'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:show-shared-topics="isPrivateChat"
:enable-node-nlp-features="false"
:enable-record-navigation="false"
/>
<LanguagePreferenceTab
v-else-if="activeSubTab === 'language-preference'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:enable-record-navigation="false"
/>
<GroupRelationshipInsights
v-else-if="activeSubTab === 'group-relationships'"
:key="selectedMemberId ?? 'all'"
:session-id="props.sessionId"
:time-filter="viewTimeFilter"
/>
</Transition>
<InsightViewTransition :active-key="activeSubTab">
<template #default="{ viewKey }">
<PrivateOverview
v-if="viewKey === 'overview' && isPrivateChat"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<GroupOverview
v-else-if="viewKey === 'overview'"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<PrivateRelationshipView
v-else-if="viewKey === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<JourneyView
v-else-if="viewKey === 'journey'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:time-range="props.timeRange"
/>
<TypeAnalysisView
v-else-if="viewKey === 'type-analysis'"
:key="selectedMemberId ?? 'all'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="viewKey === 'time-analysis'"
:key="selectedMemberId ?? 'all'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<WordcloudTab
v-else-if="viewKey === 'topic'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:show-shared-topics="isPrivateChat"
:enable-node-nlp-features="false"
:enable-record-navigation="false"
/>
<LanguagePreferenceTab
v-else-if="viewKey === 'language-preference'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:enable-record-navigation="false"
/>
<GroupRelationshipInsights
v-else-if="viewKey === 'group-relationships'"
:key="selectedMemberId ?? 'all'"
:session-id="props.sessionId"
:time-filter="viewTimeFilter"
/>
</template>
</InsightViewTransition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+30 -12
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, watch } from 'vue'
import { computed, defineAsyncComponent, provide, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import LoadingState from '@/components/UI/LoadingState.vue'
import { LoadingState } from '@/components/UI'
import InsightLoadingDots from '@/components/UI/InsightLoadingDots.vue'
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
import { useSessionAnalysisPageBase } from '@/composables'
import { useSessionStore } from '@/stores/session'
@@ -26,6 +27,7 @@ const {
activeTab,
isLoading,
isInitialLoad,
isSessionSwitching,
session,
memberActivity,
hourlyActivity,
@@ -35,6 +37,7 @@ const {
fullTimeRange,
timeFilter,
initialTimeState,
handleTimeRangeInitialized,
} = useSessionAnalysisPageBase({
route,
router,
@@ -44,13 +47,17 @@ const {
validTabIds: tabs.map((tab) => tab.id),
})
provide('session-switch-loading', isSessionSwitching)
const isPrivateChat = computed(() => session.value?.type === 'private')
const filteredMessageCount = computed(() =>
memberActivity.value.reduce((total, member) => total + member.messageCount, 0)
)
const filteredMemberCount = computed(() => memberActivity.value.filter((member) => member.messageCount > 0).length)
const otherMemberAvatar = computed(() => {
if (!session.value || memberActivity.value.length === 0) return null
if (!session.value) return null
if (session.value.memberAvatar) return session.value.memberAvatar
if (memberActivity.value.length === 0) return null
if (session.value.ownerId) {
const otherMember = memberActivity.value.find((member) => member.platformId !== session.value?.ownerId)
@@ -79,14 +86,20 @@ watch(
</script>
<template>
<div class="flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<LoadingState
v-if="isInitialLoad"
variant="page"
:text="t(route.name === 'private-chat' ? 'analysis.privateChat.loading' : 'analysis.groupChat.loading')"
/>
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
v-if="isSessionSwitching"
data-testid="session-switch-loading"
class="absolute inset-0 z-20 flex cursor-wait items-center justify-center bg-page-bg/15 backdrop-blur-[1.5px] dark:bg-page-dark/15"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
role="status"
aria-live="polite"
:aria-label="t('common.loading')"
>
<InsightLoadingDots />
</div>
<template v-else-if="session">
<template v-if="session">
<SessionAnalysisHeader
v-model:active-tab="activeTab"
v-model:time-range-value="timeRangeValue"
@@ -103,10 +116,15 @@ watch(
:initial-time-state="initialTimeState"
:show-session-actions="false"
@update:full-range="fullTimeRange = $event"
@time-range-initialized="handleTimeRangeInitialized"
/>
<div class="relative min-h-0 flex-1" :class="activeTab === 'ai-chat' ? 'overflow-hidden' : 'overflow-y-auto'">
<LoadingState v-if="isLoading && activeTab !== 'ai-chat'" variant="overlay" :text="t('common.loading')" />
<LoadingState
v-if="isLoading && activeTab !== 'ai-chat' && !isSessionSwitching"
variant="overlay"
:text="t('common.loading')"
/>
<SessionInsights
v-if="activeTab === 'insights'"
@@ -132,7 +150,7 @@ watch(
</div>
</template>
<div v-else class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<div v-else-if="!isInitialLoad" class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ loadErrorText }}</p>
<UButton size="sm" variant="soft" to="/">{{ t('common.back') }}</UButton>
</div>
+61
View File
@@ -0,0 +1,61 @@
<template>
<svg class="insight-loading-dots" viewBox="0 0 35 14" width="35" height="14" aria-hidden="true" focusable="false">
<circle class="insight-loading-dots__dot fill-primary-500" cx="6" cy="8" r="2.5" />
<circle class="insight-loading-dots__dot fill-primary-500" cx="17.5" cy="8" r="2.5" />
<circle class="insight-loading-dots__dot fill-primary-500" cx="29" cy="8" r="2.5" />
</svg>
</template>
<style scoped>
.insight-loading-dots {
display: block;
overflow: visible;
}
.insight-loading-dots__dot {
opacity: 0.6;
transform-box: fill-box;
transform-origin: center;
animation: insight-loading-dot 760ms linear infinite;
}
.insight-loading-dots__dot:nth-child(2) {
animation-delay: 110ms;
}
.insight-loading-dots__dot:nth-child(3) {
animation-delay: 220ms;
}
@keyframes insight-loading-dot {
0%,
100% {
opacity: 0.6;
transform: translateY(0);
}
18% {
opacity: 0.6;
transform: translateY(0);
animation-timing-function: cubic-bezier(0.2, 0.75, 0.35, 1);
}
36% {
opacity: 1;
transform: translateY(-3px);
animation-timing-function: cubic-bezier(0.35, 0, 0.25, 1);
}
62% {
opacity: 0.6;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.insight-loading-dots__dot {
opacity: 0.8;
animation: none;
}
}
</style>
+221
View File
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
import type { Ref } from 'vue'
import InsightLoadingDots from './InsightLoadingDots.vue'
import { insightViewLoadingCoordinatorKey } from './insight-view-loading'
type PaneId = 'primary' | 'secondary'
type TransitionPhase = 'idle' | 'waiting' | 'revealing'
const props = defineProps<{
activeKey: string
}>()
defineSlots<{
default(props: { viewKey: string }): unknown
}>()
const primaryKey = ref<string | null>(props.activeKey)
const secondaryKey = ref<string | null>(null)
const activePane = ref<PaneId>('primary')
const phase = ref<TransitionPhase>('idle')
const pendingLoaders = ref(0)
const sessionSwitchLoading = inject<Ref<boolean> | null>('session-switch-loading', null)
const displayedKey = computed(() => (activePane.value === 'primary' ? primaryKey.value : secondaryKey.value))
const pendingPane = computed<PaneId>(() => (activePane.value === 'primary' ? 'secondary' : 'primary'))
const isTransitioning = computed(() => phase.value !== 'idle')
const showIndicator = computed(() => phase.value === 'waiting' && !sessionSwitchLoading?.value)
provide(insightViewLoadingCoordinatorKey, {
register: registerPageLoader,
suppress: isTransitioning,
})
let transitionVersion = 0
let revealTimer: ReturnType<typeof setTimeout> | null = null
function clearRevealTimer() {
if (revealTimer) clearTimeout(revealTimer)
revealTimer = null
}
function registerPageLoader() {
const version = transitionVersion
let registered = true
pendingLoaders.value++
return () => {
if (!registered) return
registered = false
// 旧面板或上一次快速切换留下的 Loading,不得阻塞当前目标视图。
if (version !== transitionVersion) return
pendingLoaders.value = Math.max(0, pendingLoaders.value - 1)
}
}
function setPaneKey(pane: PaneId, key: string | null) {
if (pane === 'primary') primaryKey.value = key
else secondaryKey.value = key
}
function paneClass(pane: PaneId) {
const isActive = pane === activePane.value
return {
'insight-view-transition__pane--active': isActive && phase.value === 'idle',
'insight-view-transition__pane--waiting': isActive && phase.value === 'waiting',
'insight-view-transition__pane--outgoing': isActive && phase.value === 'revealing',
'insight-view-transition__pane--pending': !isActive && phase.value === 'waiting',
'insight-view-transition__pane--incoming': !isActive && phase.value === 'revealing',
}
}
async function revealWhenReady(version: number) {
// LoadingState 在子组件 mounted 时登记;等待两轮渲染,避免把尚未登记的视图误判为同步完成。
await nextTick()
await nextTick()
if (version !== transitionVersion || phase.value !== 'waiting' || pendingLoaders.value > 0) return
phase.value = 'revealing'
revealTimer = setTimeout(() => {
if (version !== transitionVersion) return
const previousPane = activePane.value
activePane.value = pendingPane.value
setPaneKey(previousPane, null)
phase.value = 'idle'
revealTimer = null
}, 180)
}
function beginTransition(nextKey: string) {
const version = ++transitionVersion
clearRevealTimer()
pendingLoaders.value = 0
if (nextKey === displayedKey.value) {
setPaneKey(pendingPane.value, null)
phase.value = 'idle'
return
}
setPaneKey(pendingPane.value, nextKey)
phase.value = 'waiting'
void revealWhenReady(version)
}
watch(
() => props.activeKey,
(nextKey) => beginTransition(nextKey)
)
watch(pendingLoaders, (count) => {
if (count === 0 && phase.value === 'waiting') void revealWhenReady(transitionVersion)
})
onBeforeUnmount(() => {
transitionVersion++
clearRevealTimer()
})
</script>
<template>
<div class="insight-view-transition" :aria-busy="isTransitioning">
<div v-if="showIndicator" class="insight-view-transition__indicator" aria-hidden="true">
<InsightLoadingDots />
</div>
<div
v-if="primaryKey"
class="insight-view-transition__pane"
:class="paneClass('primary')"
:inert="activePane !== 'primary' || isTransitioning"
:aria-hidden="activePane !== 'primary'"
>
<slot :view-key="primaryKey" />
</div>
<div
v-if="secondaryKey"
class="insight-view-transition__pane"
:class="paneClass('secondary')"
:inert="activePane !== 'secondary' || isTransitioning"
:aria-hidden="activePane !== 'secondary'"
>
<slot :view-key="secondaryKey" />
</div>
</div>
</template>
<style scoped>
.insight-view-transition {
position: relative;
min-height: 100%;
}
.insight-view-transition__indicator {
position: sticky;
z-index: 3;
top: calc(50% - 0.4375rem);
display: flex;
height: 0;
align-items: center;
justify-content: center;
pointer-events: none;
}
.insight-view-transition__pane {
width: 100%;
transition:
opacity 180ms ease,
filter 180ms ease;
}
.insight-view-transition__pane--active {
position: relative;
opacity: 1;
filter: none;
}
.insight-view-transition__pane--waiting {
position: relative;
opacity: 0.52;
filter: blur(1.5px);
pointer-events: none;
user-select: none;
}
.insight-view-transition__pane--pending,
.insight-view-transition__pane--incoming {
position: absolute;
z-index: 1;
inset: 0;
pointer-events: none;
}
.insight-view-transition__pane--pending {
opacity: 0;
}
.insight-view-transition__pane--outgoing {
position: relative;
opacity: 0;
filter: blur(2px);
pointer-events: none;
}
.insight-view-transition__pane--incoming {
opacity: 1;
filter: none;
}
@media (prefers-reduced-motion: reduce) {
.insight-view-transition__pane {
transition: none;
}
.insight-view-transition__pane--waiting,
.insight-view-transition__pane--outgoing {
filter: none;
}
}
</style>
+8 -2
View File
@@ -4,7 +4,8 @@
* 支持行内加载、页面加载和蒙层覆盖
*/
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useInsightViewLoading } from './insight-view-loading'
const props = withDefaults(
defineProps<{
@@ -33,10 +34,15 @@ const containerClass = computed(() => {
return `${base} ${props.height || 'py-8'}`
}
})
const pageLoadingCoordinator = useInsightViewLoading(ref(true))
const isCoordinatedPageLoading = computed(
() => pageLoadingCoordinator !== null && (pageLoadingCoordinator.suppress?.value ?? true)
)
</script>
<template>
<div :class="containerClass" role="status" aria-live="polite">
<div v-if="!isCoordinatedPageLoading" :class="containerClass" role="status" aria-live="polite">
<div class="flex flex-col items-center justify-center text-center">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
<p v-if="text" class="mt-2 text-sm text-gray-500">{{ text }}</p>
+1
View File
@@ -9,6 +9,7 @@ export { default as SectionCard } from './SectionCard.vue'
export { default as StatCard } from './StatCard.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as LoadingState } from './LoadingState.vue'
export { default as InsightViewTransition } from './InsightViewTransition.vue'
export { default as StartupLoading } from './StartupLoading.vue'
export { default as Tabs } from './Tabs.vue'
export { default as UITabs } from './Tabs.vue'
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ref } from 'vue'
import { createInsightViewLoadingBinding } from './insight-view-loading'
test('keeps custom async views registered until their loading state finishes', async () => {
const pendingLoaders = ref(0)
const binding = createInsightViewLoadingBinding({
register: () => {
pendingLoaders.value++
let active = true
return () => {
if (!active) return
active = false
pendingLoaders.value--
}
},
})
binding.sync(true)
binding.sync(true)
await Promise.resolve()
await Promise.resolve()
assert.equal(pendingLoaders.value, 1)
binding.sync(false)
assert.equal(pendingLoaders.value, 0)
binding.sync(true)
binding.dispose()
assert.equal(pendingLoaders.value, 0)
})
+48
View File
@@ -0,0 +1,48 @@
import { inject, onBeforeUnmount, watch } from 'vue'
import type { InjectionKey, Ref } from 'vue'
export interface InsightViewLoadingCoordinator {
register: () => () => void
suppress?: Readonly<Ref<boolean>>
}
export interface InsightViewLoadingBinding {
sync: (loading: boolean) => void
dispose: () => void
}
export const insightViewLoadingCoordinatorKey: InjectionKey<InsightViewLoadingCoordinator> = Symbol(
'insight-view-loading-coordinator'
)
export function createInsightViewLoadingBinding(
coordinator: InsightViewLoadingCoordinator | null
): InsightViewLoadingBinding {
let unregister: (() => void) | null = null
function release() {
unregister?.()
unregister = null
}
return {
sync(loading) {
if (loading) {
if (!unregister && coordinator) unregister = coordinator.register()
return
}
release()
},
dispose: release,
}
}
export function useInsightViewLoading(loading: Readonly<Ref<boolean>>): InsightViewLoadingCoordinator | null {
const coordinator = inject(insightViewLoadingCoordinatorKey, null)
const binding = createInsightViewLoadingBinding(coordinator)
watch(loading, (value) => binding.sync(value), { immediate: true, flush: 'sync' })
onBeforeUnmount(binding.dispose)
return coordinator
}
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
const EChartWordcloud = defineAsyncComponent(() => import('@/components/charts/EChartWordcloud.vue'))
import type { EChartWordcloudData } from '@/components/charts'
import { LoadingState, UITabs, SectionCard } from '@/components/UI'
import { useInsightViewLoading } from '@/components/UI/insight-view-loading'
import TopicProfileCard from './TopicProfileCard.vue'
import SharedTopicsCard from './SharedTopicsCard.vue'
import type { WordFrequencyItem, PosTagStat } from './topicProfileTypes'
@@ -48,7 +49,8 @@ const props = withDefaults(
}
)
const isLoading = ref(false)
const isLoading = ref(true)
useInsightViewLoading(isLoading)
// 完整词表:按最大档一次性获取并缓存,切换词数时本地切片,避免重复请求与重复分词
const allWords = ref<WordFrequencyItem[]>([])
const stats = ref({
@@ -12,6 +12,7 @@ import { TooltipComponent, GridComponent, VisualMapComponent } from 'echarts/com
import { CanvasRenderer } from 'echarts/renderers'
import type { EChartsOption } from 'echarts'
import { ThemeCard } from '@/components/UI'
import { useInsightViewLoading } from '@/components/UI/insight-view-loading'
import { useDataService } from '@/services/data/service'
import type { ClusterGraphData, ClusterGraphOptions } from '@/types/analysis'
import type { TimeFilter } from '@openchatlab/shared-types'
@@ -29,6 +30,7 @@ const isDark = computed(() => colorMode.value === 'dark')
// 数据状态
const isLoading = ref(true)
useInsightViewLoading(isLoading)
const graphData = ref<ClusterGraphData | null>(null)
// 视图模式
@@ -7,6 +7,7 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { EChartGraph } from '@/components/charts'
import type { EChartGraphData } from '@/components/charts'
import { useInsightViewLoading } from '@/components/UI/insight-view-loading'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
@@ -19,6 +20,7 @@ const { t } = useI18n()
// 数据状态
const isLoading = ref(true)
useInsightViewLoading(isLoading)
const graphData = ref<EChartGraphData>({ nodes: [], links: [], maxLinkValue: 0 })
// 布局切换
+25 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import { formatDateRange } from '@/utils'
@@ -63,9 +63,10 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<{
(e: 'update:modelValue', value: TimeRangeValue): void
(e: 'update:modelValue', value: TimeRangeValue | null): void
(e: 'update:fullRange', value: { start: number; end: number } | null): void
(e: 'update:availableYears', value: number[]): void
(e: 'initializationComplete', hasRange: boolean): void
}>()
const { t } = useI18n()
@@ -95,6 +96,8 @@ const customEndDate = ref<string>('')
// 是否正在内部初始化(防止 watcher 重复 emit)
const isInitializing = ref(false)
// 会话切换时忽略上一数据源的迟到结果,避免旧请求通知当前页面已完成初始化。
let loadVersion = 0
// ==================== 工具函数 ====================
@@ -392,15 +395,19 @@ const customEndModel = computed({
// ==================== 数据加载 ====================
async function loadData() {
const currentLoadVersion = ++loadVersion
if (!props.rangeSource && !props.sessionId) {
availableYears.value = []
fullTimeRange.value = null
emit('update:modelValue', null)
emit('update:fullRange', null)
emit('update:availableYears', [])
isLoaded.value = true
emit('initializationComplete', false)
return
}
let hasRange = false
try {
let years: number[]
let range: { start: number; end: number } | null
@@ -414,8 +421,11 @@ async function loadData() {
adapter.getTimeRange(props.sessionId!),
])
}
if (currentLoadVersion !== loadVersion) return
availableYears.value = years
fullTimeRange.value = range
hasRange = range !== null
emit('update:fullRange', range)
emit('update:availableYears', years)
@@ -464,24 +474,33 @@ async function loadData() {
}
isInitializing.value = false
emitCurrentValue()
if (hasRange) emitCurrentValue()
else emit('update:modelValue', null)
} catch (error) {
if (currentLoadVersion !== loadVersion) return
console.error('TimeSelect 加载数据失败:', error)
availableYears.value = []
fullTimeRange.value = null
emit('update:modelValue', null)
emit('update:fullRange', null)
emit('update:availableYears', [])
} finally {
isLoaded.value = true
if (currentLoadVersion === loadVersion) {
isLoaded.value = true
emit('initializationComplete', hasRange)
}
}
}
onMounted(() => loadData())
onMounted(() => void loadData())
onBeforeUnmount(() => {
loadVersion++
})
watch(
() => buildTimeSelectSourceKey(props.sessionId, props.rangeSource),
() => {
isLoaded.value = false
loadData()
void loadData()
}
)
</script>
@@ -39,6 +39,7 @@ const emit = defineEmits<{
(e: 'openChatRecord'): void
(e: 'update:fullRange', value: { start: number; end: number } | null): void
(e: 'update:availableYears', value: number[]): void
(e: 'timeRangeInitialized', hasRange: boolean): void
}>()
const { t } = useI18n()
@@ -106,16 +107,17 @@ const navigationItems = computed(() =>
<div class="mt-3 flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<PageTabs v-model="activeTab" class="min-w-0 shrink" :items="navigationItems" />
<!-- AI 对话和实验室都不使用这里的时间范围筛选,因此在这些一级 Tab 下隐藏。 -->
<!-- 不使用时间范围的一级 Tab 不挂载筛选器,避免发起无关查询。 -->
<div class="max-w-full overflow-x-auto scrollbar-hide">
<TimeSelect
v-if="timeSelectVisible"
v-model="timeRangeValue"
:session-id="currentSessionId ?? undefined"
:visible="timeSelectVisible"
:initial-state="initialTimeState"
size="sm"
@update:full-range="emit('update:fullRange', $event)"
@update:available-years="emit('update:availableYears', $event)"
@initialization-complete="emit('timeRangeInitialized', $event)"
/>
</div>
</div>
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { effectScope, reactive, ref } from 'vue'
import { effectScope, nextTick, reactive, ref } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import { MessageType } from '@/types/base'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
@@ -20,6 +20,10 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve }
}
function createDeferredLoads<T>(count: number): Deferred<T>[] {
return Array.from({ length: count }, () => createDeferred<T>())
}
function createMember(name: string): MemberActivity {
return {
memberId: 1,
@@ -30,6 +34,10 @@ function createMember(name: string): MemberActivity {
}
}
async function flushPromises() {
await new Promise<void>((resolve) => setImmediate(resolve))
}
test('only the latest analysis load may update results and loading state', async (t) => {
await t.mock.module('@/utils', {
namedExports: {
@@ -38,17 +46,14 @@ test('only the latest analysis load may update results and loading state', async
})
const { useSessionAnalysisPageBase } = await import('./useSessionAnalysisPageBase')
const memberLoads = [createDeferred<MemberActivity[]>(), createDeferred<MemberActivity[]>()]
const hourlyLoads = [createDeferred<HourlyActivity[]>(), createDeferred<HourlyActivity[]>()]
const dailyLoads = [createDeferred<DailyActivity[]>(), createDeferred<DailyActivity[]>()]
const typeLoads = [
createDeferred<Array<{ type: MessageType; count: number }>>(),
createDeferred<Array<{ type: MessageType; count: number }>>(),
]
const memberLoads = createDeferredLoads<MemberActivity[]>(2)
const hourlyLoads = createDeferredLoads<HourlyActivity[]>(2)
const dailyLoads = createDeferredLoads<DailyActivity[]>(2)
const typeLoads = createDeferredLoads<Array<{ type: MessageType; count: number }>>(2)
let loadIndex = 0
registerAdapter('data', {
getSession: async () => null,
getSession: async () => ({ id: 'session-one' }),
getMemberActivity: () => memberLoads[loadIndex]!.promise,
getHourlyActivity: () => hourlyLoads[loadIndex]!.promise,
getDailyActivity: () => dailyLoads[loadIndex]!.promise,
@@ -72,6 +77,8 @@ test('only the latest analysis load may update results and loading state', async
})
)!
assert.equal(page.isSessionSwitching.value, true)
const staleLoad = page.loadAnalysisData()
const latestLoad = page.loadAnalysisData()
@@ -83,6 +90,7 @@ test('only the latest analysis load may update results and loading state', async
assert.equal(page.memberActivity.value.length, 0)
assert.equal(page.isLoading.value, true)
assert.equal(page.isSessionSwitching.value, true)
memberLoads[1]!.resolve([createMember('latest')])
hourlyLoads[1]!.resolve([{ hour: 2, messageCount: 2 }])
@@ -93,4 +101,142 @@ test('only the latest analysis load may update results and loading state', async
assert.equal(page.memberActivity.value[0]?.name, 'latest')
assert.deepEqual(page.hourlyActivity.value, [{ hour: 2, messageCount: 2 }])
assert.equal(page.isLoading.value, false)
assert.equal(page.isSessionSwitching.value, false)
})
test('session loading coordinates required data, missing ranges, and non-analysis tabs', async (t) => {
await t.mock.module('@/utils', {
namedExports: {
formatLocalizedDate: () => '',
},
})
const { useSessionAnalysisPageBase } = await import('./useSessionAnalysisPageBase')
const sessionLoads = createDeferredLoads<{ id: string } | null>(4)
const memberLoads = createDeferredLoads<MemberActivity[]>(4)
const hourlyLoads = createDeferredLoads<HourlyActivity[]>(4)
const dailyLoads = createDeferredLoads<DailyActivity[]>(4)
const typeLoads = createDeferredLoads<Array<{ type: MessageType; count: number }>>(4)
let sessionLoadIndex = 0
let analysisLoadIndex = 0
registerAdapter('data', {
getSession: () => sessionLoads[sessionLoadIndex++]!.promise,
getMemberActivity: () => memberLoads[analysisLoadIndex]!.promise,
getHourlyActivity: () => hourlyLoads[analysisLoadIndex]!.promise,
getDailyActivity: () => dailyLoads[analysisLoadIndex]!.promise,
getMessageTypeDistribution: () => typeLoads[analysisLoadIndex++]!.promise,
} 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: 'insights',
validTabIds: ['insights', 'ranking', 'ai-chat'],
})
)!
const firstAnalysisLoad = page.loadAnalysisData()
memberLoads[0]!.resolve([createMember('session-one')])
hourlyLoads[0]!.resolve([])
dailyLoads[0]!.resolve([])
typeLoads[0]!.resolve([])
await firstAnalysisLoad
assert.equal(page.session.value?.id ?? null, null)
assert.equal(page.isSessionSwitching.value, true)
sessionLoads[0]!.resolve({ id: 'session-one' })
await flushPromises()
assert.equal(page.session.value?.id, 'session-one')
assert.equal(page.isSessionSwitching.value, false)
currentSessionId.value = 'session-two'
await nextTick()
sessionLoads[1]!.resolve({ id: 'session-two' })
await flushPromises()
assert.equal(page.session.value?.id, 'session-two')
assert.equal(page.isSessionSwitching.value, true)
const secondAnalysisLoad = page.loadAnalysisData()
memberLoads[1]!.resolve([createMember('session-two')])
hourlyLoads[1]!.resolve([])
dailyLoads[1]!.resolve([])
typeLoads[1]!.resolve([])
await secondAnalysisLoad
assert.equal(page.memberActivity.value[0]?.name, 'session-two')
assert.equal(page.isSessionSwitching.value, false)
currentSessionId.value = 'session-three'
await nextTick()
sessionLoads[2]!.resolve({ id: 'session-three' })
await flushPromises()
assert.equal(page.isSessionSwitching.value, true)
page.handleTimeRangeInitialized(false)
memberLoads[2]!.resolve([createMember('session-three')])
hourlyLoads[2]!.resolve([])
dailyLoads[2]!.resolve([])
typeLoads[2]!.resolve([])
await flushPromises()
assert.equal(page.memberActivity.value[0]?.name, 'session-three')
assert.equal(page.isSessionSwitching.value, false)
page.activeTab.value = 'ranking'
await nextTick()
assert.equal(page.isLoading.value, false)
assert.equal(analysisLoadIndex, 3)
page.activeTab.value = 'ai-chat'
await nextTick()
currentSessionId.value = 'session-four'
await nextTick()
sessionLoads[3]!.resolve({ id: 'session-four' })
await flushPromises()
assert.equal(page.isSessionSwitching.value, false)
assert.equal(page.isLoading.value, false)
page.timeRangeValue.value = {
startTs: 1,
endTs: 2,
displayLabel: 'test',
isFullRange: false,
state: { mode: 'custom', customStart: '1970-01-01', customEnd: '1970-01-01' },
}
await nextTick()
assert.equal(analysisLoadIndex, 3)
page.timeRangeValue.value = null
page.activeTab.value = 'insights'
await nextTick()
assert.equal(page.isLoading.value, true)
assert.equal(analysisLoadIndex, 3)
page.handleTimeRangeInitialized(false)
assert.equal(analysisLoadIndex, 4)
memberLoads[3]!.resolve([createMember('session-four')])
hourlyLoads[3]!.resolve([])
dailyLoads[3]!.resolve([])
typeLoads[3]!.resolve([])
await flushPromises()
assert.equal(page.memberActivity.value[0]?.name, 'session-four')
assert.equal(page.isLoading.value, false)
})
+83 -24
View File
@@ -31,12 +31,21 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
const isLoading = ref(true)
const isInitialLoad = ref(true)
const isSessionSwitching = ref(true)
const session = ref<AnalysisSession | null>(null)
const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
let baseLoadVersion = 0
let analysisLoadVersion = 0
let baseDataReady = false
let analysisDataReady = false
function finishSessionSwitchIfReady() {
if (!isSessionSwitching.value || !baseDataReady) return
if (!session.value || analysisDataReady) isSessionSwitching.value = false
}
function resolveActiveTabFromRoute(): string {
const routeTab = route.query.tab as string | undefined
@@ -45,6 +54,8 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
}
const activeTab = ref(resolveActiveTabFromRoute())
const activeTabUsesOverviewAnalytics = computed(() => activeTab.value === 'insights')
const activeTabUsesTimeRange = computed(() => ['insights', 'ranking'].includes(activeTab.value))
watch(
activeTab,
@@ -60,12 +71,16 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
{ immediate: true }
)
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, initialTimeState } = useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, initialTimeState, resetTimeRange } = useTimeSelect(
route,
router,
{
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
}
)
function syncSession() {
const id = route.params.id as string
@@ -77,22 +92,19 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
}
}
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const sessionData = await useDataService().getSession(currentSessionId.value)
session.value = sessionData
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
async function loadAnalysisData() {
const sessionId = currentSessionId.value
if (!sessionId) return
const loadVersion = ++analysisLoadVersion
if (!activeTabUsesOverviewAnalytics.value) {
isLoading.value = false
analysisDataReady = true
finishSessionSwitchIfReady()
return
}
if (isSessionSwitching.value) analysisDataReady = false
isLoading.value = true
try {
@@ -119,18 +131,58 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
} finally {
if (loadVersion === analysisLoadVersion) {
isLoading.value = false
analysisDataReady = true
finishSessionSwitchIfReady()
}
}
}
async function loadData() {
if (!currentSessionId.value) return
isInitialLoad.value = true
await loadBaseData()
isInitialLoad.value = false
function handleTimeRangeInitialized(hasRange: boolean) {
if (hasRange) return
timeRangeValue.value = null
void loadAnalysisData()
}
async function loadData() {
const sessionId = currentSessionId.value
if (!sessionId) return
const loadVersion = ++baseLoadVersion
isInitialLoad.value = true
try {
const sessionData = await useDataService().getSession(sessionId)
if (loadVersion !== baseLoadVersion || currentSessionId.value !== sessionId) return
session.value = sessionData
} catch (error) {
if (loadVersion === baseLoadVersion && currentSessionId.value === sessionId) {
session.value = null
console.error('加载基础数据失败:', error)
}
} finally {
if (loadVersion === baseLoadVersion && currentSessionId.value === sessionId) {
isInitialLoad.value = false
baseDataReady = true
finishSessionSwitchIfReady()
}
}
}
watch(activeTab, () => {
if (!activeTabUsesOverviewAnalytics.value) {
analysisLoadVersion++
abortAnalyticsRequests()
isLoading.value = false
if (!activeTabUsesTimeRange.value) {
analysisDataReady = true
finishSessionSwitchIfReady()
}
return
}
isLoading.value = true
if (currentSessionId.value && timeRangeValue.value) void loadAnalysisData()
})
watch(
() => route.params.id,
() => {
@@ -150,9 +202,14 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
currentSessionId,
() => {
analysisLoadVersion++
resetTimeRange()
baseDataReady = false
analysisDataReady = !activeTabUsesTimeRange.value
isLoading.value = activeTabUsesOverviewAnalytics.value
isSessionSwitching.value = true
// 切换会话时,上一会话的分析请求立即作废(切换后子 Tab 会按新 key 重挂并重新取数)。
abortAnalyticsRequests()
loadData()
void loadData()
},
{ immediate: true }
)
@@ -165,6 +222,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
activeTab,
isLoading,
isInitialLoad,
isSessionSwitching,
session,
memberActivity,
hourlyActivity,
@@ -178,6 +236,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
syncSession,
loadData,
loadAnalysisData,
handleTimeRangeInitialized,
}
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { SectionTabs } from '@/components/navigation'
import UserSelect from '@/components/common/UserSelect.vue'
@@ -11,6 +11,7 @@ import type { TimeFilter } from '@openchatlab/shared-types'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
import GroupChatOverview from './GroupChatOverview.vue'
import { InsightViewTransition } from '@/components/UI'
const { t } = useI18n()
@@ -67,55 +68,41 @@ const viewTimeFilter = computed(() => ({
</template>
</SectionTabs>
<div class="flex-1 min-h-0 overflow-y-auto">
<Transition name="fade" mode="out-in">
<GroupChatOverview
v-if="activeSubTab === 'overview'"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<TypeAnalysisView
v-else-if="activeSubTab === 'type-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="activeSubTab === 'time-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<WordcloudTab
v-else-if="activeSubTab === 'topic'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<GroupRelationships
v-else-if="activeSubTab === 'group-relationships'"
:session-id="props.sessionId"
:time-filter="viewTimeFilter"
/>
</Transition>
<div class="min-h-0 flex-1 overflow-y-auto">
<InsightViewTransition :active-key="activeSubTab">
<template #default="{ viewKey }">
<GroupChatOverview
v-if="viewKey === 'overview'"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<TypeAnalysisView
v-else-if="viewKey === 'type-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="viewKey === 'time-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<WordcloudTab v-else-if="viewKey === 'topic'" :session-id="props.sessionId" :time-filter="props.timeFilter" />
<GroupRelationships
v-else-if="viewKey === 'group-relationships'"
:session-id="props.sessionId"
:time-filter="viewTimeFilter"
/>
</template>
</InsightViewTransition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+23 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, defineAsyncComponent } from 'vue'
import { ref, computed, defineAsyncComponent, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
@@ -17,7 +17,8 @@ import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
import IncrementalImportModal from '@/components/analysis/IncrementalImportModal.vue'
const MessageExportModal = defineAsyncComponent(() => import('@/components/MessageExport/MessageExportModal.vue'))
import ActionToolsPanel from '@/components/layout/ActionToolsPanel.vue'
import LoadingState from '@/components/UI/LoadingState.vue'
import { LoadingState } from '@/components/UI'
import InsightLoadingDots from '@/components/UI/InsightLoadingDots.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
@@ -74,6 +75,7 @@ const {
activeTab,
isLoading,
isInitialLoad,
isSessionSwitching,
session,
memberActivity,
hourlyActivity,
@@ -85,6 +87,7 @@ const {
timeFilter,
initialTimeState,
loadData,
handleTimeRangeInitialized,
} = useSessionAnalysisPageBase({
route,
router,
@@ -94,6 +97,8 @@ const {
validTabIds: allTabIds.value,
})
provide('session-switch-loading', isSessionSwitching)
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -106,12 +111,21 @@ const filteredMemberCount = computed(() => {
</script>
<template>
<div class="flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<!-- Loading State -->
<LoadingState v-if="isInitialLoad" variant="page" :text="t('analysis.groupChat.loading')" />
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
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/15 backdrop-blur-[1.5px] dark:bg-page-dark/15"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
role="status"
aria-live="polite"
:aria-label="t('common.loading')"
>
<InsightLoadingDots />
</div>
<!-- Content -->
<template v-else-if="session">
<template v-if="session">
<SessionAnalysisHeader
v-model:active-tab="activeTab"
v-model:time-range-value="timeRangeValue"
@@ -127,12 +141,13 @@ const filteredMemberCount = computed(() => {
@open-chat-record="openChatRecordViewer"
@update:full-range="fullTimeRange = $event"
@update:available-years="availableYears = $event"
@time-range-initialized="handleTimeRangeInitialized"
/>
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading" variant="overlay" />
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
@@ -194,7 +209,7 @@ const filteredMemberCount = computed(() => {
</template>
<!-- Empty State -->
<div v-else class="flex h-full items-center justify-center">
<div v-else-if="!isInitialLoad" class="flex h-full items-center justify-center">
<p class="text-gray-500">{{ t('analysis.groupChat.loadError') }}</p>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { SectionTabs } from '@/components/navigation'
import UserSelect from '@/components/common/UserSelect.vue'
@@ -11,6 +11,7 @@ import { WordcloudTab, LanguagePreferenceTab } from '@/components/analysis/quote
import type { TimeFilter } from '@openchatlab/shared-types'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
import { InsightViewTransition } from '@/components/UI'
import PrivateChatOverview from './PrivateChatOverview.vue'
const { t } = useI18n()
@@ -65,67 +66,57 @@ const viewTimeFilter = computed(() => ({
</template>
</SectionTabs>
<div class="flex-1 min-h-0 overflow-auto">
<Transition name="fade" mode="out-in">
<PrivateChatOverview
v-if="activeSubTab === 'overview'"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<TypeAnalysisView
v-else-if="activeSubTab === 'type-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="activeSubTab === 'time-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<PrivateRelationshipView
v-else-if="activeSubTab === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<JourneyView
v-else-if="activeSubTab === 'journey'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:time-range="props.timeRange"
/>
<WordcloudTab
v-else-if="activeSubTab === 'topic'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:show-shared-topics="true"
/>
<LanguagePreferenceTab
v-else-if="activeSubTab === 'language-preference'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
</Transition>
<div class="min-h-0 flex-1 overflow-auto">
<InsightViewTransition :active-key="activeSubTab">
<template #default="{ viewKey }">
<PrivateChatOverview
v-if="viewKey === 'overview'"
:session="props.session"
:member-activity="props.memberActivity"
:message-types="props.messageTypes"
:hourly-activity="props.hourlyActivity"
:daily-activity="props.dailyActivity"
:time-range="props.timeRange"
:filtered-message-count="props.filteredMessageCount"
:filtered-member-count="props.filteredMemberCount"
:time-filter="props.timeFilter"
/>
<TypeAnalysisView
v-else-if="viewKey === 'type-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="viewKey === 'time-analysis'"
:session-id="props.sessionId"
:session-name="props.session.name"
:time-filter="viewTimeFilter"
/>
<PrivateRelationshipView
v-else-if="viewKey === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<JourneyView
v-else-if="viewKey === 'journey'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:time-range="props.timeRange"
/>
<WordcloudTab
v-else-if="viewKey === 'topic'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:show-shared-topics="true"
/>
<LanguagePreferenceTab
v-else-if="viewKey === 'language-preference'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
</template>
</InsightViewTransition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+26 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, defineAsyncComponent } from 'vue'
import { ref, computed, defineAsyncComponent, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
@@ -15,7 +15,8 @@ import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
import IncrementalImportModal from '@/components/analysis/IncrementalImportModal.vue'
const MessageExportModal = defineAsyncComponent(() => import('@/components/MessageExport/MessageExportModal.vue'))
import ActionToolsPanel from '@/components/layout/ActionToolsPanel.vue'
import LoadingState from '@/components/UI/LoadingState.vue'
import { LoadingState } from '@/components/UI'
import InsightLoadingDots from '@/components/UI/InsightLoadingDots.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
@@ -69,6 +70,7 @@ const {
activeTab,
isLoading,
isInitialLoad,
isSessionSwitching,
session,
memberActivity,
hourlyActivity,
@@ -79,6 +81,7 @@ const {
timeFilter,
initialTimeState,
loadAnalysisData,
handleTimeRangeInitialized,
} = useSessionAnalysisPageBase({
route,
router,
@@ -88,6 +91,8 @@ const {
validTabIds: tabs.value.map((tab) => tab.id),
})
provide('session-switch-loading', isSessionSwitching)
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -100,7 +105,9 @@ const filteredMemberCount = computed(() => {
// 获取对方头像
const otherMemberAvatar = computed(() => {
if (!session.value || memberActivity.value.length === 0) return null
if (!session.value) return null
if (session.value.memberAvatar) return session.value.memberAvatar
if (memberActivity.value.length === 0) return null
// 1. 优先尝试排除 ownerId
if (session.value.ownerId) {
@@ -124,12 +131,21 @@ const otherMemberAvatar = computed(() => {
</script>
<template>
<div class="flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<!-- Loading State -->
<LoadingState v-if="isInitialLoad" variant="page" :text="t('analysis.privateChat.loading')" />
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
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/15 backdrop-blur-[1.5px] dark:bg-page-dark/15"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
role="status"
aria-live="polite"
:aria-label="t('common.loading')"
>
<InsightLoadingDots />
</div>
<!-- Content -->
<template v-else-if="session">
<template v-if="session">
<SessionAnalysisHeader
v-model:active-tab="activeTab"
v-model:time-range-value="timeRangeValue"
@@ -144,12 +160,13 @@ const otherMemberAvatar = computed(() => {
@open-member-management="showMemberManagementModal = true"
@open-chat-record="openChatRecordViewer"
@update:full-range="fullTimeRange = $event"
@time-range-initialized="handleTimeRangeInitialized"
/>
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading" 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">
@@ -206,7 +223,7 @@ const otherMemberAvatar = computed(() => {
</template>
<!-- Empty State -->
<div v-else class="flex h-full items-center justify-center">
<div v-else-if="!isInitialLoad" class="flex h-full items-center justify-center">
<p class="text-gray-500">{{ t('analysis.privateChat.loadError') }}</p>
</div>