refactor: 抽取会话分析页公共逻辑并统一头部文案

This commit is contained in:
digua
2026-04-03 00:08:53 +08:00
committed by digua
parent a8e404244f
commit 3a4d722645
9 changed files with 265 additions and 254 deletions
+1
View File
@@ -6,3 +6,4 @@ export { usePageAnchors, type AnchorItem } from './usePageAnchors'
export { useAIChat, type ChatMessage, type SourceMessage } from './useAIChat'
export { useScreenCapture, type ScreenCaptureOptions } from './useScreenCapture'
export { useTimeSelect } from './useTimeSelect'
export { useSessionAnalysisPageBase, useSessionHeaderDescription } from './useSessionAnalysisPageBase'
@@ -0,0 +1,181 @@
import { ref, watch, onMounted, computed } from 'vue'
import type { Ref } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { MemberActivity, HourlyActivity, DailyActivity } from '@/types/analysis'
import { useI18n } from 'vue-i18n'
import { formatLocalizedDate } from '@/utils'
import { useTimeSelect } from './useTimeSelect'
interface UseSessionAnalysisPageBaseOptions {
route: RouteLocationNormalizedLoaded
router: Router
currentSessionId: Ref<string | null>
selectSession: (id: string) => void
defaultTab: string
validTabIds: string[]
}
interface UseSessionHeaderDescriptionOptions {
session: Ref<AnalysisSession | null>
fullTimeRange: Ref<{ start: number; end: number } | null>
timeRangeValue: Ref<{ startTs: number } | null>
descriptionKey: string
}
export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOptions) {
const { route, router, currentSessionId, selectSession, defaultTab, validTabIds } = options
const isLoading = ref(true)
const isInitialLoad = ref(true)
const session = ref<AnalysisSession | null>(null)
const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
function resolveActiveTabFromRoute(): string {
const routeTab = route.query.tab as string | undefined
if (routeTab && validTabIds.includes(routeTab)) return routeTab
return defaultTab
}
const activeTab = ref(resolveActiveTabFromRoute())
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, selectedYearForOverview, initialTimeState } =
useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
function syncSession() {
const id = route.params.id as string
if (id) {
selectSession(id)
if (currentSessionId.value !== id) {
router.replace('/')
}
}
}
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const sessionData = await window.chatApi.getSession(currentSessionId.value)
session.value = sessionData
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
async function loadAnalysisData() {
if (!currentSessionId.value) return
isLoading.value = true
try {
const filter = timeFilter.value
const [members, hourly, daily, types] = await Promise.all([
window.chatApi.getMemberActivity(currentSessionId.value, filter),
window.chatApi.getHourlyActivity(currentSessionId.value, filter),
window.chatApi.getDailyActivity(currentSessionId.value, filter),
window.chatApi.getMessageTypeDistribution(currentSessionId.value, filter),
])
memberActivity.value = members
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
} catch (error) {
console.error('加载分析数据失败:', error)
} finally {
isLoading.value = false
}
}
async function loadData() {
if (!currentSessionId.value) return
isInitialLoad.value = true
await loadBaseData()
isInitialLoad.value = false
}
watch(
() => route.params.id,
() => {
activeTab.value = resolveActiveTabFromRoute()
syncSession()
}
)
watch(
() => route.query.tab,
() => {
activeTab.value = resolveActiveTabFromRoute()
}
)
watch(
currentSessionId,
() => {
loadData()
},
{ immediate: true }
)
onMounted(() => {
syncSession()
})
return {
activeTab,
isLoading,
isInitialLoad,
session,
memberActivity,
hourlyActivity,
dailyActivity,
messageTypes,
timeRangeValue,
fullTimeRange,
availableYears,
timeFilter,
selectedYearForOverview,
initialTimeState,
syncSession,
loadData,
loadAnalysisData,
}
}
export function useSessionHeaderDescription(options: UseSessionHeaderDescriptionOptions) {
const { session, fullTimeRange, timeRangeValue, descriptionKey } = options
const { t, locale } = useI18n()
const headerStartDate = computed(() => {
const startTs = fullTimeRange.value?.start ?? timeRangeValue.value?.startTs
const fallbackTs = Math.floor(Date.now() / 1000)
return formatLocalizedDate(startTs ?? fallbackTs, locale.value)
})
const headerEndDate = computed(() => formatLocalizedDate(Math.floor(Date.now() / 1000), locale.value))
const headerDescription = computed(() =>
t(descriptionKey, {
startDate: headerStartDate.value,
endDate: headerEndDate.value,
messageCount: session.value?.messageCount ?? 0,
})
)
return {
headerDescription,
headerStartDate,
headerEndDate,
}
}
+2 -2
View File
@@ -3,13 +3,13 @@
"title": "Group Chat Analysis",
"loading": "Loading analysis data...",
"loadError": "Unable to load session data",
"description": "{dateRange}, {memberCount} members chatted {messageCount} messages"
"description": "You've exchanged {messageCount} messages between {startDate} and {endDate}"
},
"privateChat": {
"title": "Private Chat Analysis",
"loading": "Loading analysis data...",
"loadError": "Unable to load session data",
"description": "{dateRange}, {messageCount} messages in total"
"description": "You've exchanged {messageCount} messages between {startDate} and {endDate}"
},
"tabs": {
"overview": "Overview",
+2 -2
View File
@@ -3,13 +3,13 @@
"title": "グループ分析",
"loading": "分析データを読み込み中...",
"loadError": "セッションデータを読み込めません",
"description": "{dateRange}、{memberCount} 人のメンバーが合計 {messageCount} 件のメッセージをやり取りしました"
"description": "{startDate} から {endDate} までの間に、{messageCount} 件のメッセージをやり取りしています"
},
"privateChat": {
"title": "個人チャット分析",
"loading": "分析データを読み込み中...",
"loadError": "セッションデータを読み込めません",
"description": "{dateRange}、合計 {messageCount} 件のメッセージ"
"description": "{startDate} から {endDate} までの間に、{messageCount} 件のメッセージをやり取りしています"
},
"tabs": {
"overview": "概要",
+2 -2
View File
@@ -3,13 +3,13 @@
"title": "群聊分析",
"loading": "加载分析数据...",
"loadError": "无法加载会话数据",
"description": "{dateRange}{memberCount} 位成员共聊了 {messageCount} 条消息"
"description": "从 {startDate} 到 {endDate},你们共聊了 {messageCount} 条消息"
},
"privateChat": {
"title": "私聊分析",
"loading": "加载分析数据...",
"loadError": "无法加载会话数据",
"description": "{dateRange},共 {messageCount} 条消息"
"description": "从 {startDate} 到 {endDate},你们共聊了 {messageCount} 条消息"
},
"tabs": {
"overview": "总览",
+2 -2
View File
@@ -3,13 +3,13 @@
"title": "群聊分析",
"loading": "載入分析資料...",
"loadError": "無法載入聊天資料",
"description": "{dateRange}{memberCount} 位成員共聊了 {messageCount} 訊息"
"description": "從 {startDate} 到 {endDate},你們一共聊了 {messageCount} 訊息"
},
"privateChat": {
"title": "私聊分析",
"loading": "載入分析資料...",
"loadError": "無法載入聊天資料",
"description": "{dateRange},共 {messageCount} 訊息"
"description": "從 {startDate} 到 {endDate},你們一共聊了 {messageCount} 訊息"
},
"tabs": {
"overview": "總覽",
+32 -123
View File
@@ -1,10 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue'
import { ref, computed, defineAsyncComponent } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 CaptureButton from '@/components/common/CaptureButton.vue'
import TimeSelect from '@/components/common/TimeSelect.vue'
import AITab from '@/components/analysis/AITab.vue'
@@ -22,7 +20,7 @@ import LoadingState from '@/components/UI/LoadingState.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
import { useTimeSelect } from '@/composables'
import { useSessionAnalysisPageBase, useSessionHeaderDescription } from '@/composables'
const { t } = useI18n()
@@ -50,15 +48,6 @@ function openChatRecordViewer() {
layoutStore.openChatRecordDrawer({})
}
// 数据状态
const isLoading = ref(true)
const session = ref<AnalysisSession | null>(null)
const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const isInitialLoad = ref(true)
// Tab 配置
const allTabs = [
{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' },
@@ -71,22 +60,30 @@ const allTabs = [
// Tab 列表
const tabs = computed(() => allTabs)
function resolveActiveTabFromRoute(): string {
const routeTab = route.query.tab as string | undefined
if (routeTab && allTabs.some((tab) => tab.id === routeTab)) return routeTab
return settingsStore.defaultSessionTab
}
const activeTab = ref(resolveActiveTabFromRoute())
// 时间范围筛选(composable 统一管理状态、派生计算、URL 同步)
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, selectedYearForOverview, initialTimeState } =
useTimeSelect(route, router, {
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
})
const {
activeTab,
isLoading,
isInitialLoad,
session,
memberActivity,
hourlyActivity,
dailyActivity,
messageTypes,
timeRangeValue,
fullTimeRange,
availableYears,
timeFilter,
selectedYearForOverview,
initialTimeState,
loadData,
} = useSessionAnalysisPageBase({
route,
router,
currentSessionId,
selectSession: sessionStore.selectSession,
defaultTab: settingsStore.defaultSessionTab,
validTabIds: allTabs.map((tab) => tab.id),
})
// 计算属性
const topMembers = computed(() => memberActivity.value.slice(0, 3))
@@ -105,93 +102,11 @@ const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// Sync route param to store
function syncSession() {
const id = route.params.id as string
if (id) {
sessionStore.selectSession(id)
// If selection failed (e.g. invalid ID), redirect to home
if (sessionStore.currentSessionId !== id) {
router.replace('/')
}
}
}
// 加载基础数据(仅会话信息,时间范围由 TimeSelect 内部拉取)
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const sessionData = await window.chatApi.getSession(currentSessionId.value)
session.value = sessionData
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
// 加载分析数据(受年份筛选影响)
async function loadAnalysisData() {
if (!currentSessionId.value) return
isLoading.value = true
try {
const filter = timeFilter.value
const [members, hourly, daily, types] = await Promise.all([
window.chatApi.getMemberActivity(currentSessionId.value, filter),
window.chatApi.getHourlyActivity(currentSessionId.value, filter),
window.chatApi.getDailyActivity(currentSessionId.value, filter),
window.chatApi.getMessageTypeDistribution(currentSessionId.value, filter),
])
memberActivity.value = members
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
} catch (error) {
console.error('加载分析数据失败:', error)
} finally {
isLoading.value = false
}
}
// 加载所有数据
async function loadData() {
if (!currentSessionId.value) return
isInitialLoad.value = true
await loadBaseData()
isInitialLoad.value = false
}
// 监听路由参数变化
watch(
() => route.params.id,
() => {
activeTab.value = resolveActiveTabFromRoute()
syncSession()
}
)
watch(
() => route.query.tab,
() => {
activeTab.value = resolveActiveTabFromRoute()
}
)
// 监听会话变化(切换会话时由 TimeSelect 自行发出新范围,避免 Tab Content 双重重建)
watch(
currentSessionId,
() => {
loadData()
},
{ immediate: true }
)
onMounted(() => {
syncSession()
const { headerDescription } = useSessionHeaderDescription({
session,
fullTimeRange,
timeRangeValue,
descriptionKey: 'analysis.groupChat.description',
})
</script>
@@ -205,13 +120,7 @@ onMounted(() => {
<!-- Header -->
<PageHeader
:title="session.name"
:description="
t('analysis.groupChat.description', {
dateRange: timeRangeValue?.displayLabel ?? '',
memberCount: timeRangeValue?.isFullRange !== false ? session.memberCount : filteredMemberCount,
messageCount: timeRangeValue?.isFullRange !== false ? session.messageCount : filteredMessageCount,
})
"
:description="headerDescription"
:avatar="session.groupAvatar"
icon="i-heroicons-chat-bubble-left-right"
icon-class="bg-primary-600 text-white dark:bg-primary-500 dark:text-white"
+30 -123
View File
@@ -1,10 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue'
import { ref, computed, defineAsyncComponent } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 CaptureButton from '@/components/common/CaptureButton.vue'
import TimeSelect from '@/components/common/TimeSelect.vue'
import AITab from '@/components/analysis/AITab.vue'
@@ -21,7 +19,7 @@ import LoadingState from '@/components/UI/LoadingState.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
import { useTimeSelect } from '@/composables'
import { useSessionAnalysisPageBase, useSessionHeaderDescription } from '@/composables'
const { t } = useI18n()
@@ -49,15 +47,6 @@ function openChatRecordViewer() {
layoutStore.openChatRecordDrawer({})
}
// 数据状态
const isLoading = ref(true)
const session = ref<AnalysisSession | null>(null)
const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const isInitialLoad = ref(true)
// Tab 配置 - 私聊包含总览、视图、语录、AI 对话和实验室
const tabs = [
{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' },
@@ -67,25 +56,29 @@ const tabs = [
{ id: 'lab', labelKey: 'analysis.tabs.lab', icon: 'i-heroicons-beaker' },
]
function resolveActiveTabFromRoute(): string {
const routeTab = route.query.tab as string | undefined
if (routeTab && tabs.some((tab) => tab.id === routeTab)) return routeTab
return settingsStore.defaultSessionTab
}
const activeTab = ref(resolveActiveTabFromRoute())
// 时间范围筛选(composable 统一管理状态、派生计算、URL 同步)
const { timeRangeValue, fullTimeRange, timeFilter, selectedYearForOverview, initialTimeState } = useTimeSelect(
const {
activeTab,
isLoading,
isInitialLoad,
session,
memberActivity,
hourlyActivity,
dailyActivity,
messageTypes,
timeRangeValue,
fullTimeRange,
timeFilter,
selectedYearForOverview,
initialTimeState,
loadAnalysisData,
} = useSessionAnalysisPageBase({
route,
router,
{
activeTab,
isInitialLoad,
currentSessionId,
onTimeRangeChange: () => loadAnalysisData(),
}
)
currentSessionId,
selectSession: sessionStore.selectSession,
defaultTab: settingsStore.defaultSessionTab,
validTabIds: tabs.map((tab) => tab.id),
})
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
@@ -97,90 +90,12 @@ const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// Sync route param to store
function syncSession() {
const id = route.params.id as string
if (id) {
sessionStore.selectSession(id)
// If selection failed (e.g. invalid ID), redirect to home
if (sessionStore.currentSessionId !== id) {
router.replace('/')
}
}
}
// 加载基础数据(仅会话信息,时间范围由 TimeSelect 内部拉取)
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const sessionData = await window.chatApi.getSession(currentSessionId.value)
session.value = sessionData
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
// 加载分析数据(受时间范围筛选影响)
async function loadAnalysisData() {
if (!currentSessionId.value) return
isLoading.value = true
try {
const filter = timeFilter.value
const [members, hourly, daily, types] = await Promise.all([
window.chatApi.getMemberActivity(currentSessionId.value, filter),
window.chatApi.getHourlyActivity(currentSessionId.value, filter),
window.chatApi.getDailyActivity(currentSessionId.value, filter),
window.chatApi.getMessageTypeDistribution(currentSessionId.value, filter),
])
memberActivity.value = members
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
} catch (error) {
console.error('加载分析数据失败:', error)
} finally {
isLoading.value = false
}
}
// 加载所有数据
async function loadData() {
if (!currentSessionId.value) return
isInitialLoad.value = true
await loadBaseData()
isInitialLoad.value = false
}
// 监听路由参数变化
watch(
() => route.params.id,
() => {
activeTab.value = resolveActiveTabFromRoute()
syncSession()
}
)
watch(
() => route.query.tab,
() => {
activeTab.value = resolveActiveTabFromRoute()
}
)
// 监听会话变化(切换会话时由 TimeSelect 自行发出新范围,避免 Tab Content 双重重建)
watch(
currentSessionId,
() => {
loadData()
},
{ immediate: true }
)
const { headerDescription } = useSessionHeaderDescription({
session,
fullTimeRange,
timeRangeValue,
descriptionKey: 'analysis.privateChat.description',
})
// 获取对方头像
const otherMemberAvatar = computed(() => {
@@ -206,9 +121,6 @@ const otherMemberAvatar = computed(() => {
return null
})
onMounted(() => {
syncSession()
})
</script>
<template>
@@ -221,12 +133,7 @@ onMounted(() => {
<!-- Header -->
<PageHeader
:title="session.name"
:description="
t('analysis.privateChat.description', {
dateRange: timeRangeValue?.displayLabel ?? '',
messageCount: timeRangeValue?.isFullRange !== false ? session.messageCount : filteredMessageCount,
})
"
:description="headerDescription"
:avatar="otherMemberAvatar"
icon="i-heroicons-user"
icon-class="bg-pink-600 text-white dark:bg-pink-500 dark:text-white"
+13
View File
@@ -97,3 +97,16 @@ export function formatDateRange(
}
return `${start}${separator}${end}`
}
/**
* 按指定语言环境格式化日期(默认长日期)
* @param ts Unix 时间戳(秒)
* @param locale 语言环境(如 zh-CN / en-US / ja-JP
*/
export function formatLocalizedDate(ts: number, locale: string): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(ts * 1000))
}