mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(insights): unify loading transitions
This commit is contained in:
1 parent
ec10d0dded
commit
aef17058d1
19 files changed
+889
-268
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
// 布局切换
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user