mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
985ce4276d
commit
c42fefd227
11 files changed
+293
-49
No files matched your search
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
Reference in new issue
Block a user