fix(web): stabilize session overview interactions

Keep filter-only route changes on the current session page, prevent superseded analysis results from overwriting the latest time range, and hide unsupported capture controls in Web WASM.
This commit is contained in:
digua committed 2026-07-20 23:23:49 +08:00
1 parent 985ce4276d
commit c42fefd227
11 files changed
+293 -49

No files matched your search

+2 -1
View File
@@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
import Sidebar from '@/components/common/Sidebar.vue'
import { initializeAppRuntime } from '@/bootstrap/app-initialization'
import { initPreferencesSync } from '@/composables/usePreferencesSync'
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
import { useBrowserRuntimeService } from '@/services/browser-runtime/service'
import { initServices } from '@/services/registry'
import { reportError } from '@/services/log-report'
@@ -29,7 +30,7 @@ const settingsStore = useSettingsStore()
const layoutStore = useLayoutStore()
const { isInitialized, sessions } = storeToRefs(sessionStore)
const initError = ref<string | null>(null)
const pageTransitionKey = computed(() => route.fullPath)
const pageTransitionKey = computed(() => resolvePageTransitionKey(route))
const shouldShowSidebar = computed(() => route.path !== '/' || sessions.value.length > 0)
const isNarrowViewport = useMediaQuery('(max-width: 767px)')
@@ -57,22 +57,23 @@ const memberChartData = computed<EChartPieData>(() => {
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
:capturable="false"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :show-divider="false">
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :capturable="false" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="typeChartData" :height="280" />
</div>
</SectionCard>
<SectionCard :title="t('analysis.overview.memberDistribution')" :show-divider="false">
<SectionCard :title="t('analysis.overview.memberDistribution')" :capturable="false" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="memberChartData" :height="280" />
</div>
</SectionCard>
</div>
<DailyTrendCard :daily-activity="dailyActivity" :daily-chart-data="dailyChartData" />
<DailyTrendCard :daily-activity="dailyActivity" :daily-chart-data="dailyChartData" :capturable="false" />
</div>
</template>
@@ -52,22 +52,28 @@ const memberComparisonData = computed(() => {
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
:capturable="false"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :show-divider="false">
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :capturable="false" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="typeChartData" :height="280" />
</div>
</SectionCard>
<SectionCard v-if="memberComparisonData" :title="t('analysis.overview.memberComparison')" :show-divider="false">
<SectionCard
v-if="memberComparisonData"
:title="t('analysis.overview.memberComparison')"
:capturable="false"
:show-divider="false"
>
<div class="p-3 sm:p-5">
<EChartPie :data="memberComparisonData" :height="280" />
</div>
</SectionCard>
</div>
<DailyTrendCard :daily-activity="dailyActivity" :daily-chart-data="dailyChartData" />
<DailyTrendCard :daily-activity="dailyActivity" :daily-chart-data="dailyChartData" :capturable="false" />
</div>
</template>
+11 -1
View File
@@ -2,12 +2,22 @@
import CardCaptureOverlay from './CardCaptureOverlay.vue'
import CardDecoration from './CardDecoration.vue'
import ThemeCard from './ThemeCard.vue'
withDefaults(
defineProps<{
/** 是否显示截图入口 */
capturable?: boolean
}>(),
{
capturable: true,
}
)
</script>
<template>
<ThemeCard data-capturable-card class="group/card relative isolate flex flex-col">
<CardDecoration />
<CardCaptureOverlay />
<CardCaptureOverlay v-if="capturable" />
<slot />
</ThemeCard>
</template>
@@ -7,14 +7,25 @@ import type { DailyActivity } from '@/types/analysis'
const { t } = useI18n()
defineProps<{
dailyActivity: DailyActivity[]
dailyChartData: EChartLineData
}>()
withDefaults(
defineProps<{
dailyActivity: DailyActivity[]
dailyChartData: EChartLineData
capturable?: boolean
}>(),
{
capturable: true,
}
)
</script>
<template>
<SectionCard v-if="dailyActivity.length > 0" :title="t('analysis.overview.dailyTrend.title')" :show-divider="false">
<SectionCard
v-if="dailyActivity.length > 0"
:title="t('analysis.overview.dailyTrend.title')"
:capturable="capturable"
:show-divider="false"
>
<div class="p-3 sm:p-5">
<EChartLine :data="dailyChartData" :height="288" />
</div>
@@ -2,18 +2,18 @@
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@vueuse/core'
import { useDataService } from '@/services'
import * as echarts from 'echarts/core'
import { HeatmapChart, CustomChart } from 'echarts/charts'
import { CalendarComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { EChartsOption } from 'echarts'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { DailyActivity, HourlyActivity, WeekdayActivity } from '@/types/analysis'
import type { DailyActivity, HourlyActivity } from '@/types/analysis'
import { formatDateRange } from '@/utils'
import { ReportCard } from '@/components/UI'
import { useOverviewStatistics } from '@/composables/analysis/useOverviewStatistics'
import { getOverviewCalendarRange, resolveOverviewTimeRange } from '@/composables/analysis/overviewTimeRange'
import { useWeekdayActivity } from '@/composables/analysis/useWeekdayActivity'
import OverviewStatCards from './OverviewStatCards.vue'
echarts.use([HeatmapChart, CustomChart, CalendarComponent, TooltipComponent, VisualMapComponent, CanvasRenderer])
@@ -21,35 +21,29 @@ echarts.use([HeatmapChart, CustomChart, CalendarComponent, TooltipComponent, Vis
const { t, locale } = useI18n()
const isDark = useDark()
const props = defineProps<{
session: AnalysisSession
dailyActivity: DailyActivity[]
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
timeRange: { start: number; end: number } | null
filteredMessageCount: number
filteredMemberCount?: number
timeFilter?: { startTs?: number; endTs?: number }
}>()
const props = withDefaults(
defineProps<{
session: AnalysisSession
dailyActivity: DailyActivity[]
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
timeRange: { start: number; end: number } | null
filteredMessageCount: number
filteredMemberCount?: number
timeFilter?: { startTs?: number; endTs?: number }
capturable?: boolean
}>(),
{
capturable: true,
}
)
// ==================== 统计数据 ====================
const weekdayActivity = ref<WeekdayActivity[]>([])
async function loadWeekdayActivity() {
if (!props.session.id) return
try {
weekdayActivity.value = await useDataService().getWeekdayActivity(props.session.id, props.timeFilter)
} catch (error) {
console.error('加载星期活跃度失败:', error)
}
}
watch(
() => [props.session.id, props.timeFilter],
() => loadWeekdayActivity(),
{ immediate: true, deep: true }
)
const { weekdayActivity } = useWeekdayActivity({
sessionId: () => props.session.id,
timeFilter: () => props.timeFilter,
})
const {
durationDays,
@@ -272,7 +266,7 @@ onUnmounted(() => {
</script>
<template>
<ReportCard>
<ReportCard :capturable="capturable">
<!-- 身份信息 + 基础统计 -->
<div class="relative z-10 px-5 pt-6 pb-4 sm:px-8 sm:pt-8">
<h2 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-gray-100">
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { setImmediate as waitForImmediate } from 'node:timers/promises'
import test from 'node:test'
import { effectScope, nextTick, ref } from 'vue'
import type { TimeFilter } from '@openchatlab/shared-types'
import { registerAdapter } from '@/services/registry'
import type { DataAdapter } from '@/services/data/types'
import type { WeekdayActivity } from '@/types/analysis'
import { useWeekdayActivity } from './useWeekdayActivity'
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
test('only the latest filter may update weekday activity', async () => {
const loads = [createDeferred<WeekdayActivity[]>(), createDeferred<WeekdayActivity[]>()]
const filters: Array<TimeFilter | undefined> = []
let loadIndex = 0
registerAdapter('data', {
getWeekdayActivity: (_sessionId: string, filter?: TimeFilter) => {
filters.push(filter)
return loads[loadIndex++]!.promise
},
} as unknown as DataAdapter)
const sessionId = ref('session-one')
const timeFilter = ref<TimeFilter>({ startTs: 1 })
const scope = effectScope()
const { weekdayActivity } = scope.run(() =>
useWeekdayActivity({
sessionId: () => sessionId.value,
timeFilter: () => timeFilter.value,
})
)!
timeFilter.value = { startTs: 2 }
await nextTick()
assert.deepEqual(filters, [{ startTs: 1 }, { startTs: 2 }])
loads[0]!.resolve([{ weekday: 1, messageCount: 1 }])
await waitForImmediate()
assert.equal(weekdayActivity.value.length, 0)
loads[1]!.resolve([{ weekday: 2, messageCount: 2 }])
await waitForImmediate()
assert.deepEqual(weekdayActivity.value, [{ weekday: 2, messageCount: 2 }])
scope.stop()
})
@@ -0,0 +1,39 @@
import { ref, watch } from 'vue'
import type { TimeFilter } from '@openchatlab/shared-types'
import { useDataService } from '@/services'
import type { WeekdayActivity } from '@/types/analysis'
interface UseWeekdayActivityOptions {
sessionId: () => string
timeFilter: () => TimeFilter | undefined
}
export function useWeekdayActivity(options: UseWeekdayActivityOptions) {
const weekdayActivity = ref<WeekdayActivity[]>([])
let loadVersion = 0
async function loadWeekdayActivity() {
const currentVersion = ++loadVersion
const sessionId = options.sessionId()
if (!sessionId) {
weekdayActivity.value = []
return
}
try {
const result = await useDataService().getWeekdayActivity(sessionId, options.timeFilter())
// Browser Runtime 查询无法被 HTTP epoch 取消,旧筛选完成后不得覆盖最新结果。
if (currentVersion === loadVersion) {
weekdayActivity.value = result
}
} catch (error) {
if (currentVersion === loadVersion) {
console.error('Failed to load weekday activity:', error)
}
}
}
watch([options.sessionId, options.timeFilter], () => loadWeekdayActivity(), { immediate: true, deep: true })
return { weekdayActivity }
}
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { effectScope, reactive, ref } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import { MessageType } from '@/types/base'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
import { registerAdapter } from '@/services/registry'
import type { DataAdapter } from '@/services/data/types'
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function createMember(name: string): MemberActivity {
return {
memberId: 1,
platformId: name,
name,
messageCount: 1,
percentage: 100,
}
}
test('only the latest analysis load may update results and loading state', async (t) => {
await t.mock.module('@/utils', {
namedExports: {
formatLocalizedDate: () => '',
},
})
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 }>>(),
]
let loadIndex = 0
registerAdapter('data', {
getSession: async () => null,
getMemberActivity: () => memberLoads[loadIndex]!.promise,
getHourlyActivity: () => hourlyLoads[loadIndex]!.promise,
getDailyActivity: () => dailyLoads[loadIndex]!.promise,
getMessageTypeDistribution: () => typeLoads[loadIndex++]!.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: 'overview',
validTabIds: ['overview'],
})
)!
const staleLoad = page.loadAnalysisData()
const latestLoad = page.loadAnalysisData()
memberLoads[0]!.resolve([createMember('stale')])
hourlyLoads[0]!.resolve([{ hour: 1, messageCount: 1 }])
dailyLoads[0]!.resolve([{ date: '2026-01-01', messageCount: 1 }])
typeLoads[0]!.resolve([{ type: MessageType.TEXT, count: 1 }])
await staleLoad
assert.equal(page.memberActivity.value.length, 0)
assert.equal(page.isLoading.value, true)
memberLoads[1]!.resolve([createMember('latest')])
hourlyLoads[1]!.resolve([{ hour: 2, messageCount: 2 }])
dailyLoads[1]!.resolve([{ date: '2026-02-02', messageCount: 2 }])
typeLoads[1]!.resolve([{ type: MessageType.IMAGE, count: 2 }])
await latestLoad
assert.equal(page.memberActivity.value[0]?.name, 'latest')
assert.deepEqual(page.hourlyActivity.value, [{ hour: 2, messageCount: 2 }])
assert.equal(page.isLoading.value, false)
})
+17 -7
View File
@@ -35,6 +35,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
let analysisLoadVersion = 0
function resolveActiveTabFromRoute(): string {
const routeTab = route.query.tab as string | undefined
@@ -73,8 +74,10 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
}
async function loadAnalysisData() {
if (!currentSessionId.value) return
const sessionId = currentSessionId.value
if (!sessionId) return
const loadVersion = ++analysisLoadVersion
isLoading.value = true
try {
@@ -82,20 +85,26 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
const adapter = useDataService()
const [members, hourly, daily, types] = await Promise.all([
adapter.getMemberActivity(currentSessionId.value, filter),
adapter.getHourlyActivity(currentSessionId.value, filter),
adapter.getDailyActivity(currentSessionId.value, filter),
adapter.getMessageTypeDistribution(currentSessionId.value, filter),
adapter.getMemberActivity(sessionId, filter),
adapter.getHourlyActivity(sessionId, filter),
adapter.getDailyActivity(sessionId, filter),
adapter.getMessageTypeDistribution(sessionId, filter),
])
// Browser Runtime 查询无法被 HTTP epoch 取消,旧批次完成时不得覆盖最新筛选结果。
if (loadVersion !== analysisLoadVersion) return
memberActivity.value = members
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
} catch (error) {
console.error('加载分析数据失败:', error)
if (loadVersion === analysisLoadVersion) {
console.error('加载分析数据失败:', error)
}
} finally {
isLoading.value = false
if (loadVersion === analysisLoadVersion) {
isLoading.value = false
}
}
}
@@ -125,6 +134,7 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
watch(
currentSessionId,
() => {
analysisLoadVersion++
// 切换会话时,上一会话的分析请求立即作废(切换后子 Tab 会按新 key 重挂并重新取数)。
abortAnalyticsRequests()
loadData()
+17
View File
@@ -29,3 +29,20 @@ test('keeps dynamic session id in page transition key', () => {
'/private-chat/:id:session-a'
)
})
test('keeps the same session page key when only time filter query changes', () => {
const baseRoute = {
matched: [{ path: '/private-chat/:id' }],
params: { id: 'session-a' },
}
const recentRoute = {
...baseRoute,
query: { timeMode: 'recent', timeDays: '365' },
}
const yearRoute = {
...baseRoute,
query: { timeMode: 'year', timeYear: '2026' },
}
assert.equal(resolvePageTransitionKey(recentRoute), resolvePageTransitionKey(yearRoute))
})