feat: 时间筛选支持更多灵活选择

This commit is contained in:
digua
2026-02-08 22:31:23 +08:00
parent 26d4164098
commit d17bb6a7ee
5 changed files with 814 additions and 198 deletions
+588
View File
@@ -0,0 +1,588 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import { formatDateRange } from '@/utils'
import UITabs from '@/components/UI/Tabs.vue'
import DatePicker from '@/components/UI/DatePicker.vue'
// ==================== 类型定义(导出供父组件使用) ====================
export type TimeSelectMode = 'recent' | 'quarter' | 'year' | 'custom'
/** 组件内部状态快照,用于父组件 URL 序列化 */
export interface TimeSelectState {
mode: TimeSelectMode
recentDays?: number // 最近模式:天数 (180/365/730/1825/0=全部)
year?: number // 按年模式:年份
quarterYear?: number // 按季模式:年份
quarter?: number // 按季模式:季度 (1-4)
customStart?: string // 自定义模式:开始日期 YYYY-MM-DD
customEnd?: string // 自定义模式:结束日期 YYYY-MM-DD
}
/** v-model 绑定值 */
export interface TimeRangeValue {
startTs: number
endTs: number
/** 显示标签,用于父组件展示(如 "最近一年" / "2024年 第3季度" */
displayLabel: string
/** 是否选中「全部」范围(父组件据此决定使用 session 总数还是筛选数) */
isFullRange: boolean
/** 内部状态快照,便于父组件 URL 序列化 */
state: TimeSelectState
}
// ==================== Props & Emits ====================
interface Props {
sessionId: string | undefined
modelValue: TimeRangeValue | null
visible?: boolean
/** 初始状态(通常从 URL query 构建) */
initialState?: Partial<TimeSelectState>
}
const props = withDefaults(defineProps<Props>(), {
visible: true,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: TimeRangeValue): void
(e: 'update:fullRange', value: { start: number; end: number } | null): void
(e: 'update:availableYears', value: number[]): void
}>()
const { t } = useI18n()
// ==================== 内部数据 ====================
const isLoaded = ref(false)
const availableYears = ref<number[]>([])
const fullTimeRange = ref<{ start: number; end: number } | null>(null)
// 模式
const mode = ref<TimeSelectMode>('recent')
// 最近
const recentPeriod = ref<number>(365)
// 按年
const selectedYear = ref<number>(0)
// 按季
const selectedQuarterYear = ref<number>(0)
const selectedQuarter = ref<number>(1)
// 自定义
const customStartDate = ref<string>('')
const customEndDate = ref<string>('')
// 是否正在内部初始化(防止 watcher 重复 emit
const isInitializing = ref(false)
// ==================== 工具函数 ====================
function getQuarterFromTs(ts: number): { year: number; quarter: number } {
const date = new Date(ts * 1000)
return {
year: date.getFullYear(),
quarter: Math.floor(date.getMonth() / 3) + 1,
}
}
function getQuarterRange(year: number, quarter: number): { startTs: number; endTs: number } {
const startMonth = (quarter - 1) * 3
const startDate = new Date(year, startMonth, 1, 0, 0, 0)
const endDate = new Date(year, startMonth + 3, 0, 23, 59, 59) // 季度最后一天
return {
startTs: Math.floor(startDate.getTime() / 1000),
endTs: Math.floor(endDate.getTime() / 1000),
}
}
function getYearRange(year: number): { startTs: number; endTs: number } {
const startDate = new Date(year, 0, 1, 0, 0, 0)
const endDate = new Date(year, 11, 31, 23, 59, 59)
return {
startTs: Math.floor(startDate.getTime() / 1000),
endTs: Math.floor(endDate.getTime() / 1000),
}
}
// ==================== 选项配置 ====================
const modeOptions = computed(() => [
{ label: t('mode.recent'), value: 'recent' as const },
{ label: t('mode.quarter'), value: 'quarter' as const },
{ label: t('mode.year'), value: 'year' as const },
{ label: t('mode.custom'), value: 'custom' as const },
])
const recentOptions = computed(() => [
{ label: t('recent.halfYear'), value: 180 },
{ label: t('recent.oneYear'), value: 365 },
{ label: t('recent.twoYears'), value: 730 },
{ label: t('recent.fiveYears'), value: 1825 },
{ label: t('recent.all'), value: 0 },
])
// ==================== 导航边界 ====================
const minQuarter = computed(() => {
if (!fullTimeRange.value) return { year: 0, quarter: 1 }
return getQuarterFromTs(fullTimeRange.value.start)
})
const maxQuarter = computed(() => {
if (!fullTimeRange.value) return { year: 0, quarter: 1 }
return getQuarterFromTs(fullTimeRange.value.end)
})
const canPrevQuarter = computed(() => {
const min = minQuarter.value
return (
selectedQuarterYear.value > min.year ||
(selectedQuarterYear.value === min.year && selectedQuarter.value > min.quarter)
)
})
const canNextQuarter = computed(() => {
const max = maxQuarter.value
return (
selectedQuarterYear.value < max.year ||
(selectedQuarterYear.value === max.year && selectedQuarter.value < max.quarter)
)
})
const canPrevYear = computed(() => {
if (availableYears.value.length === 0) return false
const currentIdx = availableYears.value.indexOf(selectedYear.value)
return currentIdx < availableYears.value.length - 1 // years 降序
})
const canNextYear = computed(() => {
if (availableYears.value.length === 0) return false
const currentIdx = availableYears.value.indexOf(selectedYear.value)
return currentIdx > 0 // years 降序
})
// ==================== 显示标签 ====================
const quarterDisplayLabel = computed(() => {
return t('quarter.label', {
year: selectedQuarterYear.value,
quarter: selectedQuarter.value,
})
})
const yearDisplayLabel = computed(() => {
return t('year.label', { year: selectedYear.value })
})
// ==================== 核心:构建输出值 ====================
/** 最近模式 displayLabel 映射 */
function getRecentDisplayLabel(days: number): string {
const map: Record<number, string> = {
180: t('display.recent180'),
365: t('display.recent365'),
730: t('display.recent730'),
1825: t('display.recent1825'),
}
return map[days] || ''
}
function buildValue(): TimeRangeValue | null {
if (!fullTimeRange.value) return null
const stateBase: TimeSelectState = { mode: mode.value }
switch (mode.value) {
case 'recent': {
stateBase.recentDays = recentPeriod.value
if (recentPeriod.value === 0) {
// 全部
return {
startTs: fullTimeRange.value.start,
endTs: fullTimeRange.value.end,
displayLabel: formatDateRange(fullTimeRange.value.start, fullTimeRange.value.end),
isFullRange: true,
state: stateBase,
}
}
const endTs = fullTimeRange.value.end
const startTs = Math.max(endTs - recentPeriod.value * 86400, fullTimeRange.value.start)
return {
startTs,
endTs,
displayLabel: getRecentDisplayLabel(recentPeriod.value),
isFullRange: false,
state: stateBase,
}
}
case 'quarter': {
stateBase.quarterYear = selectedQuarterYear.value
stateBase.quarter = selectedQuarter.value
const range = getQuarterRange(selectedQuarterYear.value, selectedQuarter.value)
return {
...range,
displayLabel: quarterDisplayLabel.value,
isFullRange: false,
state: stateBase,
}
}
case 'year': {
stateBase.year = selectedYear.value
const range = getYearRange(selectedYear.value)
return {
...range,
displayLabel: yearDisplayLabel.value,
isFullRange: false,
state: stateBase,
}
}
case 'custom': {
stateBase.customStart = customStartDate.value
stateBase.customEnd = customEndDate.value
if (!customStartDate.value || !customEndDate.value) return null
let startTs = dayjs(customStartDate.value).startOf('day').unix()
let endTs = dayjs(customEndDate.value).endOf('day').unix()
// 如果开始 > 结束,交换
if (startTs > endTs) [startTs, endTs] = [endTs, startTs]
return {
startTs,
endTs,
displayLabel: formatDateRange(startTs, endTs),
isFullRange: false,
state: stateBase,
}
}
}
}
function emitCurrentValue() {
const value = buildValue()
if (value) emit('update:modelValue', value)
}
// ==================== 导航方法 ====================
function navigateQuarter(direction: number) {
let y = selectedQuarterYear.value
let q = selectedQuarter.value + direction
if (q > 4) {
y++
q = 1
}
if (q < 1) {
y--
q = 4
}
const min = minQuarter.value
const max = maxQuarter.value
if (y < min.year || (y === min.year && q < min.quarter)) return
if (y > max.year || (y === max.year && q > max.quarter)) return
selectedQuarterYear.value = y
selectedQuarter.value = q
emitCurrentValue()
}
function navigateYear(direction: number) {
const years = availableYears.value // 降序
const currentIdx = years.indexOf(selectedYear.value)
// direction 1 = 向后(更新年份,idx 更小),-1 = 向前(更旧年份,idx 更大)
const newIdx = currentIdx - direction
if (newIdx >= 0 && newIdx < years.length) {
selectedYear.value = years[newIdx]
emitCurrentValue()
}
}
// ==================== 模式切换默认值 ====================
function initModeDefaults(newMode: TimeSelectMode) {
if (!fullTimeRange.value) return
switch (newMode) {
case 'recent':
if (![180, 365, 730, 1825, 0].includes(recentPeriod.value)) {
recentPeriod.value = 365
}
break
case 'quarter': {
const { year, quarter } = getQuarterFromTs(fullTimeRange.value.end)
selectedQuarterYear.value = year
selectedQuarter.value = quarter
break
}
case 'year': {
selectedYear.value =
availableYears.value[0] || new Date(fullTimeRange.value.end * 1000).getFullYear()
break
}
case 'custom': {
const endTs = fullTimeRange.value.end
const startTs = Math.max(endTs - 30 * 86400, fullTimeRange.value.start)
customStartDate.value = dayjs.unix(startTs).format('YYYY-MM-DD')
customEndDate.value = dayjs.unix(endTs).format('YYYY-MM-DD')
break
}
}
}
// ==================== 双向绑定模型 ====================
/** 模式选择器(USelect v-model */
const modeModel = computed({
get: () => mode.value,
set: (val: TimeSelectMode) => {
mode.value = val
isInitializing.value = true
initModeDefaults(val)
isInitializing.value = false
emitCurrentValue()
},
})
/** 最近选项(UITabs v-model */
const recentPeriodModel = computed({
get: () => recentPeriod.value,
set: (val: number) => {
recentPeriod.value = val
emitCurrentValue()
},
})
/** 自定义开始日期 */
const customStartModel = computed({
get: () => customStartDate.value,
set: (val: string) => {
customStartDate.value = val
if (customEndDate.value) emitCurrentValue()
},
})
/** 自定义结束日期 */
const customEndModel = computed({
get: () => customEndDate.value,
set: (val: string) => {
customEndDate.value = val
if (customStartDate.value) emitCurrentValue()
},
})
// ==================== 数据加载 ====================
async function loadData() {
if (!props.sessionId) {
availableYears.value = []
fullTimeRange.value = null
emit('update:fullRange', null)
emit('update:availableYears', [])
isLoaded.value = true
return
}
try {
const [years, range] = await Promise.all([
window.chatApi.getAvailableYears(props.sessionId),
window.chatApi.getTimeRange(props.sessionId),
])
availableYears.value = years
fullTimeRange.value = range
emit('update:fullRange', range)
emit('update:availableYears', years)
// 从 initialState 或默认值初始化
const init = props.initialState
const initMode = init?.mode ?? 'recent'
mode.value = initMode
isInitializing.value = true
switch (initMode) {
case 'recent':
recentPeriod.value = init?.recentDays ?? 365
break
case 'quarter': {
if (init?.quarterYear && init?.quarter) {
selectedQuarterYear.value = init.quarterYear
selectedQuarter.value = init.quarter
} else if (range) {
const { year, quarter } = getQuarterFromTs(range.end)
selectedQuarterYear.value = year
selectedQuarter.value = quarter
}
break
}
case 'year': {
if (init?.year && years.includes(init.year)) {
selectedYear.value = init.year
} else {
selectedYear.value = years[0] || 0
}
break
}
case 'custom': {
if (init?.customStart && init?.customEnd) {
customStartDate.value = init.customStart
customEndDate.value = init.customEnd
} else if (range) {
const endTs = range.end
const startTs = Math.max(endTs - 30 * 86400, range.start)
customStartDate.value = dayjs.unix(startTs).format('YYYY-MM-DD')
customEndDate.value = dayjs.unix(endTs).format('YYYY-MM-DD')
}
break
}
}
isInitializing.value = false
emitCurrentValue()
} catch (error) {
console.error('TimeSelect 加载数据失败:', error)
availableYears.value = []
fullTimeRange.value = null
emit('update:fullRange', null)
emit('update:availableYears', [])
} finally {
isLoaded.value = true
}
}
onMounted(() => loadData())
watch(
() => props.sessionId,
() => {
isLoaded.value = false
loadData()
}
)
</script>
<template>
<div v-if="visible && isLoaded" class="flex items-center gap-2">
<!-- 模式选择器 -->
<USelect
v-model="modeModel"
:items="modeOptions"
size="md"
class="w-28 shrink-0"
/>
<!-- 最近模式UITabs 选择时间段 -->
<UITabs
v-if="mode === 'recent'"
v-model="recentPeriodModel"
:items="recentOptions"
size="sm"
class="min-w-0 shrink"
/>
<!-- 按季模式箭头导航 -->
<div v-else-if="mode === 'quarter'" class="flex items-center">
<UButton
icon="i-heroicons-chevron-left"
size="sm"
variant="ghost"
color="neutral"
:disabled="!canPrevQuarter"
@click="navigateQuarter(-1)"
/>
<span class="whitespace-nowrap px-0.5 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ quarterDisplayLabel }}
</span>
<UButton
icon="i-heroicons-chevron-right"
size="sm"
variant="ghost"
color="neutral"
:disabled="!canNextQuarter"
@click="navigateQuarter(1)"
/>
</div>
<!-- 按年模式箭头导航 -->
<div v-else-if="mode === 'year'" class="flex items-center">
<UButton
icon="i-heroicons-chevron-left"
size="sm"
variant="ghost"
color="neutral"
:disabled="!canPrevYear"
@click="navigateYear(-1)"
/>
<span class="whitespace-nowrap px-0.5 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ yearDisplayLabel }}
</span>
<UButton
icon="i-heroicons-chevron-right"
size="sm"
variant="ghost"
color="neutral"
:disabled="!canNextYear"
@click="navigateYear(1)"
/>
</div>
<!-- 自定义模式双日期选择器 -->
<div v-else-if="mode === 'custom'" class="flex items-center gap-1">
<DatePicker v-model="customStartModel" width-class="w-28" :clearable="false" />
<span class="text-xs text-gray-400">-</span>
<DatePicker v-model="customEndModel" width-class="w-28" :clearable="false" />
</div>
</div>
</template>
<i18n>
{
"zh-CN": {
"mode": {
"recent": "最近",
"quarter": "按季",
"year": "按年",
"custom": "自定义"
},
"recent": {
"halfYear": "近半年",
"oneYear": "近一年",
"twoYears": "近两年",
"fiveYears": "近五年",
"all": "全部"
},
"display": {
"recent180": "最近半年",
"recent365": "最近一年",
"recent730": "最近两年",
"recent1825": "最近五年"
},
"quarter": {
"label": "{year}年 第{quarter}季度"
},
"year": {
"label": "{year}年"
}
},
"en-US": {
"mode": {
"recent": "Recent",
"quarter": "Quarter",
"year": "Year",
"custom": "Custom"
},
"recent": {
"halfYear": "Last 6M",
"oneYear": "Last 1Y",
"twoYears": "Last 2Y",
"fiveYears": "Last 5Y",
"all": "All"
},
"display": {
"recent180": "Last 6 months",
"recent365": "Last 1 year",
"recent730": "Last 2 years",
"recent1825": "Last 5 years"
},
"quarter": {
"label": "{year} Q{quarter}"
},
"year": {
"label": "{year}"
}
}
}
</i18n>
+1
View File
@@ -5,3 +5,4 @@ export { useAsyncData, useMultipleAsyncData } from './useAsyncData'
export { usePageAnchors, type AnchorItem } from './usePageAnchors'
export { useAIChat, type ChatMessage, type SourceMessage } from './useAIChat'
export { useScreenCapture, type ScreenCaptureOptions } from './useScreenCapture'
export { useTimeSelect } from './useTimeSelect'
+140
View File
@@ -0,0 +1,140 @@
/**
* useTimeSelect — 管理 TimeSelect 组件的状态、派生计算与 URL 同步
*
* 从 private-chat/index.vue 和 group-chat/index.vue 中提取的共通逻辑,
* 消除两个页面间 ~50 行重复代码。
*/
import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import type {
TimeRangeValue,
TimeSelectState,
TimeSelectMode,
} from '@/components/common/TimeSelect.vue'
interface UseTimeSelectOptions {
/** 当前激活的 Tab ref(用于 URL 同步) */
activeTab: Ref<string>
/** 是否处于初始加载(URL 同步 guard) */
isInitialLoad: Ref<boolean>
/** 当前会话 ID ref(用于 timeRangeValue watch guard */
currentSessionId: Ref<string | null>
/** timeRangeValue 变化时的回调(通常用于重新加载分析数据) */
onTimeRangeChange?: () => void
}
export function useTimeSelect(
route: RouteLocationNormalizedLoaded,
router: Router,
options: UseTimeSelectOptions
) {
const { activeTab, isInitialLoad, currentSessionId, onTimeRangeChange } = options
// ==================== 核心状态 ====================
/** TimeSelect v-model 绑定值 */
const timeRangeValue = ref<TimeRangeValue | null>(null)
/** 完整时间范围(由 TimeSelect 通过 emit 设置) */
const fullTimeRange = ref<{ start: number; end: number } | null>(null)
/** 可选年份列表(由 TimeSelect 通过 emit 设置,group-chat 的 ViewTab 需要) */
const availableYears = ref<number[]>([])
// ==================== 派生计算 ====================
/** 时间过滤参数(用于 API 调用) */
const timeFilter = computed(() => {
const v = timeRangeValue.value
if (!v) return undefined
return { startTs: v.startTs, endTs: v.endTs }
})
/** Tab 内容 key(确保时间筛选切换时组件能正确刷新) */
const timeFilterKey = computed(() => {
const v = timeRangeValue.value
if (!v) return 'init'
return `${v.startTs}-${v.endTs}`
})
/** 用于 OverviewTab / ViewTab 的 selectedYearnull=全部,number=指定年份) */
const selectedYearForOverview = computed(() => {
const v = timeRangeValue.value
if (!v || v.isFullRange) return null
return new Date(v.startTs * 1000).getFullYear()
})
/** 从 URL query 构建 TimeSelect 初始状态 */
const initialTimeState = computed<Partial<TimeSelectState>>(() => {
const q = route.query
const m = q.timeMode as TimeSelectMode | undefined
return {
mode: m ?? undefined,
recentDays: q.timeDays ? Number(q.timeDays) : undefined,
year: q.timeYear ? Number(q.timeYear) : undefined,
quarterYear: q.timeYear ? Number(q.timeYear) : undefined,
quarter: q.timeQuarter ? Number(q.timeQuarter) : undefined,
customStart: (q.timeStart as string) || undefined,
customEnd: (q.timeEnd as string) || undefined,
}
})
// ==================== URL 同步 ====================
watch([activeTab, timeRangeValue], ([newTab, newTimeRange]) => {
if (isInitialLoad.value || !newTimeRange) return
const state = (newTimeRange as TimeRangeValue).state
const query: Record<string, string | number | undefined> = {
tab: newTab as string,
timeMode: state.mode,
}
if (state.mode === 'recent') query.timeDays = state.recentDays
if (state.mode === 'year') query.timeYear = state.year
if (state.mode === 'quarter') {
query.timeYear = state.quarterYear
query.timeQuarter = state.quarter
}
if (state.mode === 'custom') {
query.timeStart = state.customStart
query.timeEnd = state.customEnd
}
router.replace({ query })
})
// ==================== timeRangeValue 变化监听 ====================
watch(
timeRangeValue,
(val) => {
if (!val || !currentSessionId.value) return
onTimeRangeChange?.()
},
{ immediate: true }
)
// ==================== 重置方法 ====================
/** 切换会话时调用,清空时间范围状态 */
function resetTimeRange() {
timeRangeValue.value = null
fullTimeRange.value = null
availableYears.value = []
}
return {
// 状态
timeRangeValue,
fullTimeRange,
availableYears,
// 派生计算
timeFilter,
timeFilterKey,
selectedYearForOverview,
initialTimeState,
// 方法
resetTimeRange,
}
}
+45 -103
View File
@@ -5,9 +5,8 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { MemberActivity, HourlyActivity, DailyActivity } from '@/types/analysis'
import { formatDateRange } from '@/utils'
import CaptureButton from '@/components/common/CaptureButton.vue'
import UITabs from '@/components/UI/Tabs.vue'
import TimeSelect from '@/components/common/TimeSelect.vue'
import AITab from '@/components/analysis/AITab.vue'
import OverviewTab from './components/OverviewTab.vue'
import ViewTab from './components/ViewTab.vue'
@@ -19,6 +18,7 @@ import IncrementalImportModal from '@/components/analysis/IncrementalImportModal
import LoadingState from '@/components/UI/LoadingState.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useTimeSelect } from '@/composables'
const { t } = useI18n()
@@ -46,12 +46,7 @@ const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const timeRange = ref<{ start: number; end: number } | null>(null)
// 年份筛选
const availableYears = ref<number[]>([])
const selectedYear = ref<number>(0) // 0 表示全部
const isInitialLoad = ref(true) // 用于跳过初始加载时的 watch 触发,并控制首屏加载状态
const isInitialLoad = ref(true)
// Tab 配置
const allTabs = [
@@ -67,27 +62,21 @@ const tabs = computed(() => allTabs)
const activeTab = ref((route.query.tab as string) || 'overview')
// 计算时间过滤参数
const timeFilter = computed(() => {
if (selectedYear.value === 0) {
return undefined
}
// 计算年份的开始和结束时间戳
const startDate = new Date(selectedYear.value, 0, 1, 0, 0, 0)
const endDate = new Date(selectedYear.value, 11, 31, 23, 59, 59)
return {
startTs: Math.floor(startDate.getTime() / 1000),
endTs: Math.floor(endDate.getTime() / 1000),
}
})
// 年份选项
const yearOptions = computed(() => {
const options = [{ label: t('analysis.yearFilter.allTime'), value: 0 }]
for (const year of availableYears.value) {
options.push({ label: t('analysis.yearFilter.year', { year }), value: year })
}
return options
// 时间范围筛选(composable 统一管理状态、派生计算、URL 同步)
const {
timeRangeValue,
fullTimeRange,
availableYears,
timeFilter,
timeFilterKey,
selectedYearForOverview,
initialTimeState,
resetTimeRange,
} = useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
// 计算属性
@@ -107,15 +96,6 @@ const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// 格式化时间范围显示
const dateRangeText = computed(() => {
if (selectedYear.value) {
return t('analysis.yearFilter.year', { year: selectedYear.value })
}
if (!timeRange.value) return ''
return formatDateRange(timeRange.value.start, timeRange.value.end)
})
// Sync route param to store
function syncSession() {
const id = route.params.id as string
@@ -128,33 +108,13 @@ function syncSession() {
}
}
// 加载基础数据(不受年份筛选影响
// 加载基础数据(仅会话信息,时间范围由 TimeSelect 内部拉取
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const [sessionData, years, range] = await Promise.all([
window.chatApi.getSession(currentSessionId.value),
window.chatApi.getAvailableYears(currentSessionId.value),
window.chatApi.getTimeRange(currentSessionId.value),
])
const sessionData = await window.chatApi.getSession(currentSessionId.value)
session.value = sessionData
availableYears.value = years
timeRange.value = range
// 初始化年份选择
// 1. 优先使用 URL 参数中的年份
// 2. 否则默认选择最近的年份(years 已按降序排列)
// 3. 如果没有年份数据,选 0 (全部)
const queryYear = Number(route.query.year)
if (queryYear === 0 || (queryYear && years.includes(queryYear))) {
selectedYear.value = queryYear
} else if (years.length > 0) {
selectedYear.value = years[0]
} else {
selectedYear.value = 0
}
} catch (error) {
console.error('加载基础数据失败:', error)
}
@@ -189,12 +149,10 @@ async function loadAnalysisData() {
// 加载所有数据
async function loadData() {
// 如果没有会话 ID,保持 loading 状态,等待 syncSession 设置后再触发
if (!currentSessionId.value) return
isInitialLoad.value = true
await loadBaseData()
await loadAnalysisData()
isInitialLoad.value = false
}
@@ -215,37 +173,18 @@ watch(
}
)
// 监听会话变化 (syncSession 会触发 currentSessionId 变化)
// 监听会话变化(切换会话时清空时间范围,等待 TimeSelect 重新拉取)
watch(
currentSessionId,
() => {
// 年份筛选会在 loadBaseData 中自动设置为最近年份
(newId, oldId) => {
if (oldId !== undefined && newId !== oldId) {
resetTimeRange()
}
loadData()
},
{ immediate: true }
)
// 监听年份筛选变化(仅用户手动切换年份时触发)
watch(selectedYear, () => {
// 跳过初始加载时的触发,避免重复加载
if (isInitialLoad.value) return
loadAnalysisData()
})
// 同步状态到 URL
watch([activeTab, selectedYear], ([newTab, newYear]) => {
// 避免在初始化过程中频繁更新 URL
if (isInitialLoad.value) return
router.replace({
query: {
...route.query,
tab: newTab,
year: newYear,
},
})
})
onMounted(() => {
syncSession()
})
@@ -263,9 +202,11 @@ onMounted(() => {
:title="session.name"
:description="
t('analysis.groupChat.description', {
dateRange: dateRangeText,
memberCount: selectedYear ? filteredMemberCount : session.memberCount,
messageCount: selectedYear ? filteredMessageCount : session.messageCount,
dateRange: timeRangeValue?.displayLabel ?? '',
memberCount:
timeRangeValue?.isFullRange !== false ? session.memberCount : filteredMemberCount,
messageCount:
timeRangeValue?.isFullRange !== false ? session.messageCount : filteredMessageCount,
})
"
:avatar="session.groupAvatar"
@@ -320,13 +261,14 @@ onMounted(() => {
<span class="whitespace-nowrap">{{ t(tab.labelKey) }}</span>
</button>
</div>
<!-- 年份选择器靠右允许收缩AI实验室时隐藏 -->
<UITabs
v-if="activeTab !== 'ai'"
v-model="selectedYear"
:items="yearOptions"
size="sm"
class="min-w-0 shrink"
<!-- 时间范围选择器靠右AI实验室时隐藏 -->
<TimeSelect
v-model="timeRangeValue"
:session-id="currentSessionId ?? undefined"
:visible="activeTab !== 'ai'"
:initial-state="initialTimeState"
@update:full-range="fullTimeRange = $event"
@update:available-years="availableYears = $event"
/>
</div>
</PageHeader>
@@ -340,7 +282,7 @@ onMounted(() => {
<Transition name="tab-slide" mode="out-in">
<OverviewTab
v-if="activeTab === 'overview'"
:key="'overview-' + selectedYear"
:key="'overview-' + timeFilterKey"
:session="session"
:member-activity="memberActivity"
:top-members="topMembers"
@@ -348,30 +290,30 @@ onMounted(() => {
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="timeRange"
:selected-year="selectedYear"
:time-range="fullTimeRange"
:selected-year="selectedYearForOverview"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<ViewTab
v-else-if="activeTab === 'view'"
:key="'view-' + selectedYear"
:key="'view-' + timeFilterKey"
:session-id="currentSessionId!"
:time-filter="timeFilter"
:member-activity="memberActivity"
:selected-year="selectedYear"
:selected-year="selectedYearForOverview ?? undefined"
:available-years="availableYears"
/>
<QuotesTab
v-else-if="activeTab === 'quotes'"
:key="'quotes-' + selectedYear"
:key="'quotes-' + timeFilterKey"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<MemberTab
v-else-if="activeTab === 'members'"
:key="'members-' + selectedYear"
:key="'members-' + timeFilterKey"
:session-id="currentSessionId!"
:time-filter="timeFilter"
@data-changed="loadData"
+40 -95
View File
@@ -5,9 +5,8 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { MemberActivity, HourlyActivity, DailyActivity } from '@/types/analysis'
import { formatDateRange } from '@/utils'
import CaptureButton from '@/components/common/CaptureButton.vue'
import UITabs from '@/components/UI/Tabs.vue'
import TimeSelect from '@/components/common/TimeSelect.vue'
import AITab from '@/components/analysis/AITab.vue'
import OverviewTab from './components/OverviewTab.vue'
import ViewTab from './components/ViewTab.vue'
@@ -19,6 +18,7 @@ import IncrementalImportModal from '@/components/analysis/IncrementalImportModal
import LoadingState from '@/components/UI/LoadingState.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useTimeSelect } from '@/composables'
const { t } = useI18n()
@@ -46,12 +46,7 @@ const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const timeRange = ref<{ start: number; end: number } | null>(null)
// 年份筛选
const availableYears = ref<number[]>([])
const selectedYear = ref<number>(0) // 0 表示全部
const isInitialLoad = ref(true) // 用于跳过初始加载时的 watch 触发,并控制首屏加载状态
const isInitialLoad = ref(true)
// Tab 配置 - 私聊有总览、视图、语录、成员、AI实验室
const tabs = [
@@ -64,27 +59,20 @@ const tabs = [
const activeTab = ref((route.query.tab as string) || 'overview')
// 计算时间过滤参数
const timeFilter = computed(() => {
if (selectedYear.value === 0) {
return undefined
}
// 计算年份的开始和结束时间戳
const startDate = new Date(selectedYear.value, 0, 1, 0, 0, 0)
const endDate = new Date(selectedYear.value, 11, 31, 23, 59, 59)
return {
startTs: Math.floor(startDate.getTime() / 1000),
endTs: Math.floor(endDate.getTime() / 1000),
}
})
// 年份选项
const yearOptions = computed(() => {
const options = [{ label: t('analysis.yearFilter.allTime'), value: 0 }]
for (const year of availableYears.value) {
options.push({ label: t('analysis.yearFilter.year', { year }), value: year })
}
return options
// 时间范围筛选(composable 统一管理状态、派生计算、URL 同步)
const {
timeRangeValue,
fullTimeRange,
timeFilter,
timeFilterKey,
selectedYearForOverview,
initialTimeState,
resetTimeRange,
} = useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
// 当前筛选后的消息总数
@@ -97,15 +85,6 @@ const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// 格式化时间范围显示
const dateRangeText = computed(() => {
if (selectedYear.value) {
return t('analysis.yearFilter.year', { year: selectedYear.value })
}
if (!timeRange.value) return ''
return formatDateRange(timeRange.value.start, timeRange.value.end)
})
// Sync route param to store
function syncSession() {
const id = route.params.id as string
@@ -118,36 +97,19 @@ function syncSession() {
}
}
// 加载基础数据(不受年份筛选影响
// 加载基础数据(仅会话信息,时间范围由 TimeSelect 内部拉取
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const [sessionData, years, range] = await Promise.all([
window.chatApi.getSession(currentSessionId.value),
window.chatApi.getAvailableYears(currentSessionId.value),
window.chatApi.getTimeRange(currentSessionId.value),
])
const sessionData = await window.chatApi.getSession(currentSessionId.value)
session.value = sessionData
availableYears.value = years
timeRange.value = range
// 初始化年份选择
const queryYear = Number(route.query.year)
if (queryYear === 0 || (queryYear && years.includes(queryYear))) {
selectedYear.value = queryYear
} else if (years.length > 0) {
selectedYear.value = years[0]
} else {
selectedYear.value = 0
}
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
// 加载分析数据(受年份筛选影响)
// 加载分析数据(受时间范围筛选影响)
async function loadAnalysisData() {
if (!currentSessionId.value) return
@@ -176,12 +138,10 @@ async function loadAnalysisData() {
// 加载所有数据
async function loadData() {
// 如果没有会话 ID,保持 loading 状态,等待 syncSession 设置后再触发
if (!currentSessionId.value) return
isInitialLoad.value = true
await loadBaseData()
await loadAnalysisData()
isInitialLoad.value = false
}
@@ -198,34 +158,18 @@ watch(
}
)
// 监听会话变化
// 监听会话变化(切换会话时清空时间范围,等待 TimeSelect 重新拉取)
watch(
currentSessionId,
() => {
(newId, oldId) => {
if (oldId !== undefined && newId !== oldId) {
resetTimeRange()
}
loadData()
},
{ immediate: true }
)
// 监听年份筛选变化
watch(selectedYear, () => {
if (isInitialLoad.value) return
loadAnalysisData()
})
// 同步状态到 URL
watch([activeTab, selectedYear], ([newTab, newYear]) => {
if (isInitialLoad.value) return
router.replace({
query: {
...route.query,
tab: newTab,
year: newYear,
},
})
})
// 获取对方头像
const otherMemberAvatar = computed(() => {
if (!session.value || memberActivity.value.length === 0) return null
@@ -267,8 +211,9 @@ onMounted(() => {
:title="session.name"
:description="
t('analysis.privateChat.description', {
dateRange: dateRangeText,
messageCount: selectedYear ? filteredMessageCount : session.messageCount,
dateRange: timeRangeValue?.displayLabel ?? '',
messageCount:
timeRangeValue?.isFullRange !== false ? session.messageCount : filteredMessageCount,
})
"
:avatar="otherMemberAvatar"
@@ -323,13 +268,13 @@ onMounted(() => {
<span class="whitespace-nowrap">{{ t(tab.labelKey) }}</span>
</button>
</div>
<!-- 年份选择器靠右AI实验室时隐藏 -->
<UITabs
v-if="activeTab !== 'ai'"
v-model="selectedYear"
:items="yearOptions"
size="sm"
class="min-w-0 shrink"
<!-- 时间范围选择器靠右AI实验室时隐藏 -->
<TimeSelect
v-model="timeRangeValue"
:session-id="currentSessionId ?? undefined"
:visible="activeTab !== 'ai'"
:initial-state="initialTimeState"
@update:full-range="fullTimeRange = $event"
/>
</div>
</PageHeader>
@@ -343,27 +288,27 @@ onMounted(() => {
<Transition name="tab-slide" mode="out-in">
<OverviewTab
v-if="activeTab === 'overview'"
:key="'overview-' + selectedYear"
:key="'overview-' + timeFilterKey"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="timeRange"
:selected-year="selectedYear"
:time-range="fullTimeRange"
:selected-year="selectedYearForOverview"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<ViewTab
v-else-if="activeTab === 'view'"
:key="'view-' + selectedYear"
:key="'view-' + timeFilterKey"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<QuotesTab
v-else-if="activeTab === 'quotes'"
:key="'quotes-' + selectedYear"
:key="'quotes-' + timeFilterKey"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>