From d17bb6a7ee5b5f9f0b0aebcf082047909b81c138 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 8 Feb 2026 22:31:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=B6=E9=97=B4=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9B=B4=E5=A4=9A=E7=81=B5=E6=B4=BB=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/TimeSelect.vue | 588 +++++++++++++++++++++++++++ src/composables/index.ts | 1 + src/composables/useTimeSelect.ts | 140 +++++++ src/pages/group-chat/index.vue | 148 ++----- src/pages/private-chat/index.vue | 135 ++---- 5 files changed, 814 insertions(+), 198 deletions(-) create mode 100644 src/components/common/TimeSelect.vue create mode 100644 src/composables/useTimeSelect.ts diff --git a/src/components/common/TimeSelect.vue b/src/components/common/TimeSelect.vue new file mode 100644 index 0000000..7a2fb10 --- /dev/null +++ b/src/components/common/TimeSelect.vue @@ -0,0 +1,588 @@ + + + + + +{ + "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}" + } + } +} + diff --git a/src/composables/index.ts b/src/composables/index.ts index a780933..6fd7364 100644 --- a/src/composables/index.ts +++ b/src/composables/index.ts @@ -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' diff --git a/src/composables/useTimeSelect.ts b/src/composables/useTimeSelect.ts new file mode 100644 index 0000000..faf501d --- /dev/null +++ b/src/composables/useTimeSelect.ts @@ -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 + /** 是否处于初始加载(URL 同步 guard) */ + isInitialLoad: Ref + /** 当前会话 ID ref(用于 timeRangeValue watch guard) */ + currentSessionId: Ref + /** timeRangeValue 变化时的回调(通常用于重新加载分析数据) */ + onTimeRangeChange?: () => void +} + +export function useTimeSelect( + route: RouteLocationNormalizedLoaded, + router: Router, + options: UseTimeSelectOptions +) { + const { activeTab, isInitialLoad, currentSessionId, onTimeRangeChange } = options + + // ==================== 核心状态 ==================== + + /** TimeSelect v-model 绑定值 */ + const timeRangeValue = ref(null) + + /** 完整时间范围(由 TimeSelect 通过 emit 设置) */ + const fullTimeRange = ref<{ start: number; end: number } | null>(null) + + /** 可选年份列表(由 TimeSelect 通过 emit 设置,group-chat 的 ViewTab 需要) */ + const availableYears = ref([]) + + // ==================== 派生计算 ==================== + + /** 时间过滤参数(用于 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 的 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 初始状态 */ + const initialTimeState = computed>(() => { + 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 = { + 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, + } +} diff --git a/src/pages/group-chat/index.vue b/src/pages/group-chat/index.vue index f2b57aa..31ffaa8 100644 --- a/src/pages/group-chat/index.vue +++ b/src/pages/group-chat/index.vue @@ -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([]) const hourlyActivity = ref([]) const dailyActivity = ref([]) const messageTypes = ref>([]) -const timeRange = ref<{ start: number; end: number } | null>(null) - -// 年份筛选 -const availableYears = ref([]) -const selectedYear = ref(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(() => { {{ t(tab.labelKey) }} - - + @@ -340,7 +282,7 @@ 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" /> ([]) const hourlyActivity = ref([]) const dailyActivity = ref([]) const messageTypes = ref>([]) -const timeRange = ref<{ start: number; end: number } | null>(null) - -// 年份筛选 -const availableYears = ref([]) -const selectedYear = ref(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(() => { {{ t(tab.labelKey) }} - - + @@ -343,27 +288,27 @@ onMounted(() => {