fix: bind overview card to selected time range

This commit is contained in:
Leo authored and digua committed 2026-07-04 15:51:26 +08:00
1 parent 73553c4c2c
commit 51d38bc638
10 files changed
+134 -58

No files matched your search

@@ -13,6 +13,7 @@ import type { DailyActivity, HourlyActivity, WeekdayActivity } from '@/types/ana
import { formatDateRange } from '@/utils'
import { ThemeCard } from '@/components/UI'
import { useOverviewStatistics } from '@/composables/analysis/useOverviewStatistics'
import { getOverviewCalendarRange, resolveOverviewTimeRange } from '@/composables/analysis/overviewTimeRange'
import OverviewStatCards from './OverviewStatCards.vue'
echarts.use([HeatmapChart, CustomChart, CalendarComponent, TooltipComponent, VisualMapComponent, CanvasRenderer])
@@ -26,8 +27,8 @@ const props = defineProps<{
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
timeRange: { start: number; end: number } | null
selectedYear: number | null
filteredMessageCount: number
filteredMemberCount?: number
timeFilter?: { startTs?: number; endTs?: number }
}>()
@@ -72,18 +73,21 @@ const {
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
const fullTimeRangeText = computed(() => {
if (!props.timeRange) return ''
return formatDateRange(props.timeRange.start, props.timeRange.end, 'YYYY/MM/DD')
const effectiveTimeRange = computed(() => resolveOverviewTimeRange(props.timeRange, props.timeFilter))
const timeRangeText = computed(() => {
if (!effectiveTimeRange.value) return ''
return formatDateRange(effectiveTimeRange.value.start, effectiveTimeRange.value.end, 'YYYY/MM/DD')
})
const calendarRange = computed(() => {
const calendarRange = computed<[string, string]>(() => {
const range = getOverviewCalendarRange(effectiveTimeRange.value)
if (range) return range
const today = new Date()
const yearAgo = new Date(today)
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
yearAgo.setDate(yearAgo.getDate() + 1)
const fmt = (d: Date) => d.toISOString().slice(0, 10)
return [fmt(yearAgo), fmt(today)]
const todayStr = fmt(today)
return [todayStr, todayStr]
})
const chartData = computed(() => {
@@ -249,7 +253,7 @@ function handleResize() {
chartInstance?.resize()
}
watch([() => props.dailyActivity, locale], () => updateChart())
watch([() => props.dailyActivity, locale, calendarRange], () => updateChart())
watch(isDark, () => {
chartInstance?.dispose()
@@ -293,11 +297,11 @@ onUnmounted(() => {
</span>
</div>
<div v-if="fullTimeRangeText" class="flex items-center gap-2">
<div v-if="timeRangeText" class="flex items-center gap-2">
<div class="flex h-6 w-6 shrink-0 items-center justify-center">
<UIcon name="i-heroicons-calendar" class="h-4 w-4 opacity-70" />
</div>
<span class="font-mono text-xs opacity-90 whitespace-nowrap">{{ fullTimeRangeText }}</span>
<span class="font-mono text-xs opacity-90 whitespace-nowrap">{{ timeRangeText }}</span>
</div>
</div>
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
getOverviewDurationDays,
hasOverviewTimeFilter,
resolveOverviewTimeRange,
type OverviewTimeRange,
} from './overviewTimeRange'
const SECONDS_PER_DAY = 86400
describe('overviewTimeRange', () => {
it('calculates recent 365-day ranges as 365 days', () => {
assert.equal(getOverviewDurationDays({ start: 0, end: 365 * SECONDS_PER_DAY }), 365)
})
it('calculates custom calendar ranges by selected window', () => {
const start = Date.UTC(2026, 5, 1, 0, 0, 0) / 1000
const end = Date.UTC(2026, 5, 30, 23, 59, 59) / 1000
assert.equal(getOverviewDurationDays({ start, end }), 30)
})
it('treats a single timestamp as a one-day range', () => {
assert.equal(getOverviewDurationDays({ start: 1000, end: 1000 }), 1)
})
it('returns 0 for empty or invalid ranges', () => {
assert.equal(getOverviewDurationDays(null), 0)
assert.equal(getOverviewDurationDays({ start: 2, end: 1 }), 0)
})
it('resolves the active filter before the full session range', () => {
const fullRange: OverviewTimeRange = { start: 10, end: 100 }
assert.deepEqual(resolveOverviewTimeRange(fullRange, { startTs: 40, endTs: 50 }), { start: 40, end: 50 })
assert.deepEqual(resolveOverviewTimeRange(fullRange), fullRange)
})
it('detects complete time filters', () => {
assert.equal(hasOverviewTimeFilter({ startTs: 1, endTs: 2 }), true)
assert.equal(hasOverviewTimeFilter({ startTs: 1 }), false)
assert.equal(hasOverviewTimeFilter(), false)
})
})
@@ -0,0 +1,45 @@
import dayjs from 'dayjs'
export interface OverviewTimeRange {
start: number
end: number
}
export interface OverviewTimeFilter {
startTs?: number
endTs?: number
}
const SECONDS_PER_DAY = 86400
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function hasOverviewTimeFilter(timeFilter?: OverviewTimeFilter): boolean {
return isFiniteNumber(timeFilter?.startTs) && isFiniteNumber(timeFilter?.endTs)
}
export function resolveOverviewTimeRange(
fullTimeRange: OverviewTimeRange | null,
timeFilter?: OverviewTimeFilter
): OverviewTimeRange | null {
const start = timeFilter?.startTs ?? fullTimeRange?.start
const end = timeFilter?.endTs ?? fullTimeRange?.end
if (!isFiniteNumber(start) || !isFiniteNumber(end) || end < start) {
return null
}
return { start, end }
}
export function getOverviewDurationDays(timeRange: OverviewTimeRange | null): number {
if (!timeRange || timeRange.end < timeRange.start) return 0
return Math.max(Math.ceil((timeRange.end - timeRange.start) / SECONDS_PER_DAY), 1)
}
export function getOverviewCalendarRange(timeRange: OverviewTimeRange | null): [string, string] | null {
if (!timeRange) return null
return [dayjs.unix(timeRange.start).format('YYYY-MM-DD'), dayjs.unix(timeRange.end).format('YYYY-MM-DD')]
}
@@ -3,52 +3,53 @@ import { useI18n } from 'vue-i18n'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { HourlyActivity, DailyActivity, WeekdayActivity } from '@/types/analysis'
import dayjs from 'dayjs'
import {
getOverviewDurationDays,
hasOverviewTimeFilter,
resolveOverviewTimeRange,
type OverviewTimeFilter,
type OverviewTimeRange,
} from './overviewTimeRange'
export interface UseOverviewStatisticsProps {
session: AnalysisSession
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
dailyActivity: DailyActivity[]
timeRange: { start: number; end: number } | null
selectedYear: number | null
timeRange: OverviewTimeRange | null
filteredMessageCount: number
filteredMemberCount?: number
timeFilter?: OverviewTimeFilter
}
export function useOverviewStatistics(props: UseOverviewStatisticsProps, weekdayActivity: Ref<WeekdayActivity[]>) {
const { t } = useI18n()
const effectiveTimeRange = computed(() => resolveOverviewTimeRange(props.timeRange, props.timeFilter))
const hasTimeFilter = computed(() => hasOverviewTimeFilter(props.timeFilter))
// 时间跨度
const durationDays = computed(() => {
if (props.selectedYear) {
const isLeapYear =
(props.selectedYear % 4 === 0 && props.selectedYear % 100 !== 0) || props.selectedYear % 400 === 0
return isLeapYear ? 366 : 365
}
if (!props.timeRange) return 0
return Math.ceil((props.timeRange.end - props.timeRange.start) / 86400)
})
const durationDays = computed(() => getOverviewDurationDays(effectiveTimeRange.value))
// 显示的消息数
const displayMessageCount = computed(() => {
return props.selectedYear ? props.filteredMessageCount : props.session.messageCount
return hasTimeFilter.value ? props.filteredMessageCount : props.session.messageCount
})
// 显示的成员数
const displayMemberCount = computed(() => {
return props.selectedYear ? (props.filteredMemberCount ?? props.session.memberCount) : props.session.memberCount
return hasTimeFilter.value ? (props.filteredMemberCount ?? props.session.memberCount) : props.session.memberCount
})
// 全量时间跨度 (不随筛选变化)
// 当前筛选时间跨度
const totalDurationDays = computed(() => {
if (!props.timeRange) return 0
return Math.ceil((props.timeRange.end - props.timeRange.start) / 86400)
return durationDays.value
})
// 全量日均消息
// 当前筛选日均消息
const totalDailyAvgMessages = computed(() => {
if (totalDurationDays.value === 0) return 0
return Math.round(props.session.messageCount / totalDurationDays.value)
return Math.round(displayMessageCount.value / totalDurationDays.value)
})
// 日均消息数 (随筛选变化)
@@ -121,10 +122,7 @@ export function useOverviewStatistics(props: UseOverviewStatisticsProps, weekday
// 总天数(用于计算活跃率)
const totalDays = computed(() => {
if (!props.timeRange) return 0
const start = dayjs.unix(props.timeRange.start)
const end = dayjs.unix(props.timeRange.end)
return end.diff(start, 'day') + 1
return durationDays.value
})
// 活跃率
@@ -44,13 +44,12 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
const activeTab = ref(resolveActiveTabFromRoute())
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, selectedYearForOverview, initialTimeState } =
useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, initialTimeState } = useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
function syncSession() {
const id = route.params.id as string
@@ -150,7 +149,6 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
fullTimeRange,
availableYears,
timeFilter,
selectedYearForOverview,
initialTimeState,
syncSession,
loadData,
-8
View File
@@ -57,13 +57,6 @@ export function useTimeSelect(route: RouteLocationNormalizedLoaded, router: Rout
return `${v.startTs}-${v.endTs}`
})
/** 用于 OverviewTab / ViewTab 的 selectedYear(null=全部,number=指定年份) */
const selectedYearForOverview = computed(() => {
const v = timeRangeValue.value
if (!v || v.isFullRange) return null
return new Date(v.startTs * 1000).getFullYear()
})
/**
* 从 URL query 构建 TimeSelect 初始状态。
* 优先级:URL 参数 > 缓存(上次用户设置)> 默认值(最近一年)
@@ -153,7 +146,6 @@ export function useTimeSelect(route: RouteLocationNormalizedLoaded, router: Rout
// 派生计算
timeFilter,
timeFilterKey,
selectedYearForOverview,
initialTimeState,
// 方法
resetTimeRange,
@@ -22,7 +22,6 @@ const props = defineProps<{
hourlyActivity: HourlyActivity[]
dailyActivity: DailyActivity[]
timeRange: { start: number; end: number } | null
selectedYear: number | null
filteredMessageCount: number
filteredMemberCount: number
timeFilter?: { startTs?: number; endTs?: number }
@@ -68,8 +67,8 @@ const memberChartData = computed<EChartPieData>(() => {
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
:selected-year="selectedYear"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
-2
View File
@@ -84,7 +84,6 @@ const {
fullTimeRange,
availableYears,
timeFilter,
selectedYearForOverview,
initialTimeState,
loadData,
} = useSessionAnalysisPageBase({
@@ -156,7 +155,6 @@ const filteredMemberCount = computed(() => {
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:selected-year="selectedYearForOverview"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@@ -20,7 +20,6 @@ const props = defineProps<{
hourlyActivity: HourlyActivity[]
dailyActivity: DailyActivity[]
timeRange: { start: number; end: number } | null
selectedYear: number | null
filteredMessageCount: number
filteredMemberCount: number
timeFilter?: { startTs?: number; endTs?: number }
@@ -82,8 +81,8 @@ const comparisonChartData = computed<EChartPieData>(() => {
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
:selected-year="selectedYear"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
-2
View File
@@ -80,7 +80,6 @@ const {
timeRangeValue,
fullTimeRange,
timeFilter,
selectedYearForOverview,
initialTimeState,
loadAnalysisData,
} = useSessionAnalysisPageBase({
@@ -166,7 +165,6 @@ const otherMemberAvatar = computed(() => {
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:selected-year="selectedYearForOverview"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"