mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix: bind overview card to selected time range
This commit is contained in:
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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user