feat(web): add session overview

This commit is contained in:
digua committed 2026-07-20 23:23:49 +08:00
1 parent 8e6cd5c6e6
commit 985ce4276d
22 files changed
+610 -289

No files matched your search

+14 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useColorMode } from '@vueuse/core'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useColorMode, useMediaQuery } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -10,6 +10,7 @@ import { initPreferencesSync } from '@/composables/usePreferencesSync'
import { useBrowserRuntimeService } from '@/services/browser-runtime/service'
import { initServices } from '@/services/registry'
import { reportError } from '@/services/log-report'
import { useLayoutStore } from '@/stores/layout'
import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings'
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
@@ -25,10 +26,21 @@ const { t } = useI18n()
const route = useRoute()
const sessionStore = useSessionStore()
const settingsStore = useSettingsStore()
const layoutStore = useLayoutStore()
const { isInitialized, sessions } = storeToRefs(sessionStore)
const initError = ref<string | null>(null)
const pageTransitionKey = computed(() => route.fullPath)
const shouldShowSidebar = computed(() => route.path !== '/' || sessions.value.length > 0)
const isNarrowViewport = useMediaQuery('(max-width: 767px)')
watch(
isNarrowViewport,
(isNarrow) => {
// Web WASM 的移动端保留会话导航入口,但默认折叠,避免侧栏挤占详情内容。
if (isNarrow) layoutStore.isSidebarCollapsed = true
},
{ immediate: true }
)
useColorMode({
emitAuto: true,
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DailyTrendCard from '@/components/analysis/Overview/DailyTrendCard.vue'
import OverviewIdentityCard from '@/components/analysis/Overview/OverviewIdentityCard.vue'
import { EChartPie, type EChartPieData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
import { useDailyTrend } from '@/composables/analysis/useDailyTrend'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
import { getMessageTypeName, type AnalysisSession, type MessageType } from '@/types/base'
const { t } = useI18n()
const props = defineProps<{
session: AnalysisSession
memberActivity: MemberActivity[]
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
dailyActivity: DailyActivity[]
timeRange: { start: number; end: number } | null
filteredMessageCount: number
filteredMemberCount: number
timeFilter?: { startTs?: number; endTs?: number }
}>()
const { dailyChartData } = useDailyTrend(() => props.dailyActivity)
const typeChartData = computed<EChartPieData>(() => ({
labels: props.messageTypes.map((item) => getMessageTypeName(item.type, t)),
values: props.messageTypes.map((item) => item.count),
}))
const memberChartData = computed<EChartPieData>(() => {
const sortedMembers = [...props.memberActivity].sort((left, right) => right.messageCount - left.messageCount)
const topMembers = sortedMembers.slice(0, 10)
const otherMessageCount = sortedMembers.slice(10).reduce((total, member) => total + member.messageCount, 0)
const labels = topMembers.map((member) => member.name)
const values = topMembers.map((member) => member.messageCount)
if (otherMessageCount > 0) {
labels.push(t('analysis.overview.others'))
values.push(otherMessageCount)
}
return { labels, values }
})
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-4 p-4 sm:space-y-6 sm:p-6">
<OverviewIdentityCard
:session="session"
:daily-activity="dailyActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="typeChartData" :height="280" />
</div>
</SectionCard>
<SectionCard :title="t('analysis.overview.memberDistribution')" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="memberChartData" :height="280" />
</div>
</SectionCard>
</div>
<DailyTrendCard :daily-activity="dailyActivity" :daily-chart-data="dailyChartData" />
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DailyTrendCard from '@/components/analysis/Overview/DailyTrendCard.vue'
import OverviewIdentityCard from '@/components/analysis/Overview/OverviewIdentityCard.vue'
import { EChartPie, type EChartPieData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
import { useDailyTrend } from '@/composables/analysis/useDailyTrend'
import type { DailyActivity, HourlyActivity, MemberActivity } from '@/types/analysis'
import { getMessageTypeName, type AnalysisSession, type MessageType } from '@/types/base'
const { t } = useI18n()
const props = defineProps<{
session: AnalysisSession
memberActivity: MemberActivity[]
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
dailyActivity: DailyActivity[]
timeRange: { start: number; end: number } | null
filteredMessageCount: number
filteredMemberCount: number
timeFilter?: { startTs?: number; endTs?: number }
}>()
const { dailyChartData } = useDailyTrend(() => props.dailyActivity)
const typeChartData = computed<EChartPieData>(() => ({
labels: props.messageTypes.map((item) => getMessageTypeName(item.type, t)),
values: props.messageTypes.map((item) => item.count),
}))
const memberComparisonData = computed(() => {
if (props.memberActivity.length < 2) return null
const topMembers = [...props.memberActivity].sort((left, right) => right.messageCount - left.messageCount).slice(0, 2)
return {
labels: topMembers.map((member) => member.name),
values: topMembers.map((member) => member.messageCount),
}
})
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-4 p-4 sm:space-y-6 sm:p-6">
<OverviewIdentityCard
:session="session"
:daily-activity="dailyActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="typeChartData" :height="280" />
</div>
</SectionCard>
<SectionCard v-if="memberComparisonData" :title="t('analysis.overview.memberComparison')" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartPie :data="memberComparisonData" :height="280" />
</div>
</SectionCard>
</div>
<DailyTrendCard :daily-activity="dailyActivity" :daily-chart-data="dailyChartData" />
</div>
</template>
+99 -139
View File
@@ -1,166 +1,126 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { EChartBar, EChartPie, RankList } from '@/components/charts'
import PageHeader from '@/components/layout/PageHeader.vue'
import { LoadingState, SectionCard } from '@/components/UI'
import { useDataService } from '@/services'
import { useRoute, useRouter } from 'vue-router'
import LoadingState from '@/components/UI/LoadingState.vue'
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
import { useSessionAnalysisPageBase } from '@/composables'
import { useSessionStore } from '@/stores/session'
import type { HourlyActivity, MemberActivity } from '@/types/analysis'
import { getMessageTypeName, type AnalysisSession } from '@/types/base'
import type { MessageTypeStats } from '@openchatlab/core'
import { buildHourlyChartData, buildMemberRankItems, buildMessageTypeChartData } from './session-detail'
import GroupOverview from '../components/session/GroupOverview.vue'
import PrivateOverview from '../components/session/PrivateOverview.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const sessionStore = useSessionStore()
const { currentSessionId } = storeToRefs(sessionStore)
const session = ref<AnalysisSession | null>(null)
const hourlyActivity = ref<HourlyActivity[]>([])
const memberActivity = ref<MemberActivity[]>([])
const messageTypes = ref<MessageTypeStats[]>([])
const isLoading = ref(true)
const loadFailed = ref(false)
let loadVersion = 0
// 闭环一只开放总览;后续洞察能力接通后再增加可交互 Tab,避免展示不可用入口。
const tabs = [{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' }]
const sessionId = computed(() => String(route.params.id ?? ''))
const isPrivateChat = computed(() => route.name === 'private-chat')
const chartData = computed(() => buildHourlyChartData(hourlyActivity.value))
const memberRankItems = computed(() => buildMemberRankItems(memberActivity.value))
const messageTypeChartData = computed(() =>
buildMessageTypeChartData(messageTypes.value, (type) => getMessageTypeName(type, t))
)
const loadErrorText = computed(() =>
t(isPrivateChat.value ? 'analysis.privateChat.loadError' : 'analysis.groupChat.loadError')
)
async function loadSessionDetail() {
const id = sessionId.value
const version = ++loadVersion
if (!id) {
session.value = null
hourlyActivity.value = []
memberActivity.value = []
messageTypes.value = []
loadFailed.value = true
isLoading.value = false
return
}
sessionStore.selectSession(id)
session.value = null
hourlyActivity.value = []
memberActivity.value = []
messageTypes.value = []
loadFailed.value = false
isLoading.value = true
try {
const data = useDataService()
const [sessionData, hourlyData, memberData, messageTypeData] = await Promise.all([
data.getSession(id),
data.getHourlyActivity(id),
data.getMemberActivity(id),
data.getMessageTypeDistribution(id),
])
if (version !== loadVersion) return
session.value = sessionData
hourlyActivity.value = hourlyData
memberActivity.value = memberData
messageTypes.value = messageTypeData
loadFailed.value = sessionData === null
} catch (error) {
if (version !== loadVersion) return
console.error('[web-wasm-session] Failed to load session detail:', error)
loadFailed.value = true
} finally {
if (version === loadVersion) isLoading.value = false
}
}
watch(sessionId, loadSessionDetail, { immediate: true })
onUnmounted(() => {
loadVersion += 1
const {
activeTab,
isLoading,
isInitialLoad,
session,
memberActivity,
hourlyActivity,
dailyActivity,
messageTypes,
timeRangeValue,
fullTimeRange,
timeFilter,
initialTimeState,
} = useSessionAnalysisPageBase({
route,
router,
currentSessionId,
selectSession: sessionStore.selectSession,
defaultTab: 'overview',
validTabIds: tabs.map((tab) => tab.id),
})
const isPrivateChat = computed(() => session.value?.type === 'private')
const filteredMessageCount = computed(() =>
memberActivity.value.reduce((total, member) => total + member.messageCount, 0)
)
const filteredMemberCount = computed(() => memberActivity.value.filter((member) => member.messageCount > 0).length)
const otherMemberAvatar = computed(() => {
if (!session.value || memberActivity.value.length === 0) return null
if (session.value.ownerId) {
const otherMember = memberActivity.value.find((member) => member.platformId !== session.value?.ownerId)
if (otherMember?.avatar) return otherMember.avatar
}
return memberActivity.value.find((member) => member.name === session.value?.name)?.avatar ?? null
})
const loadErrorText = computed(() =>
t(route.name === 'private-chat' ? 'analysis.privateChat.loadError' : 'analysis.groupChat.loadError')
)
</script>
<template>
<div class="flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<LoadingState v-if="isLoading" variant="page" :text="t('common.loading')" />
<LoadingState
v-if="isInitialLoad"
variant="page"
:text="t(route.name === 'private-chat' ? 'analysis.privateChat.loading' : 'analysis.groupChat.loading')"
/>
<template v-else-if="session">
<PageHeader
<SessionAnalysisHeader
v-model:active-tab="activeTab"
v-model:time-range-value="timeRangeValue"
:title="session.name"
:avatar="session.groupAvatar"
:avatar="isPrivateChat ? otherMemberAvatar : session.groupAvatar"
:icon="isPrivateChat ? 'i-heroicons-user' : 'i-heroicons-chat-bubble-left-right'"
:icon-class="isPrivateChat ? 'bg-pink-600 dark:bg-pink-500' : 'bg-primary-600 dark:bg-primary-500'"
size="compact"
:icon-class="
isPrivateChat
? 'bg-pink-600 text-white dark:bg-pink-500 dark:text-white'
: 'bg-primary-600 text-white dark:bg-primary-500 dark:text-white'
"
:tabs="tabs"
:current-session-id="currentSessionId"
:initial-time-state="initialTimeState"
:show-session-actions="false"
@update:full-range="fullTimeRange = $event"
/>
<div class="flex-1 overflow-y-auto">
<div class="main-content mx-auto max-w-[920px] space-y-6 p-4 sm:p-6">
<UButton
icon="i-heroicons-arrow-left"
variant="ghost"
color="gray"
size="sm"
:label="t('common.back')"
to="/"
/>
<div class="relative flex-1 overflow-y-auto">
<LoadingState v-if="isLoading" variant="overlay" :text="t('common.loading')" />
<div class="grid grid-cols-2 gap-3 sm:gap-4">
<div class="rounded-xl border border-gray-200/60 bg-white p-4 dark:border-white/5 dark:bg-card-dark">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('analysis.overview.identity.totalMessages') }}
</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-gray-900 dark:text-white">
{{ session.messageCount.toLocaleString() }}
</p>
</div>
<div class="rounded-xl border border-gray-200/60 bg-white p-4 dark:border-white/5 dark:bg-card-dark">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('views.cluster.totalMembers') }}
</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-gray-900 dark:text-white">
{{ session.memberCount.toLocaleString() }}
</p>
</div>
</div>
<SectionCard
:title="t('analysis.overview.messageTypeDistribution')"
:capturable="false"
:show-divider="false"
>
<div class="p-3 sm:p-5">
<EChartPie v-if="messageTypeChartData.values.length > 0" :data="messageTypeChartData" :height="280" />
<div v-else class="py-8 text-center text-sm text-gray-400">
{{ t('views.message.noData') }}
</div>
</div>
</SectionCard>
<SectionCard :title="t('analysis.overview.memberRanking')" :capturable="false" :show-divider="false">
<RankList v-if="memberRankItems.length > 0" :members="memberRankItems" :rank-limit="10" show-avatar />
<div v-else class="py-8 text-center text-sm text-gray-400">
{{ t('views.message.noData') }}
</div>
</SectionCard>
<SectionCard :title="t('views.message.hourlyDistribution')" :capturable="false" :show-divider="false">
<div class="p-3 sm:p-5">
<EChartBar :data="chartData" :height="260" />
</div>
</SectionCard>
</div>
<PrivateOverview
v-if="isPrivateChat"
:key="'private-overview-' + currentSessionId"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<GroupOverview
v-else
:key="'group-overview-' + currentSessionId"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:daily-activity="dailyActivity"
:time-range="fullTimeRange"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
</div>
</template>
<div v-else-if="loadFailed" class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<div v-else class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ loadErrorText }}</p>
<UButton size="sm" variant="soft" to="/">{{ t('common.back') }}</UButton>
</div>
@@ -1,53 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildHourlyChartData, buildMemberRankItems, buildMessageTypeChartData } from './session-detail'
test('builds a complete 24-hour chart and fills missing hours with zero', () => {
const result = buildHourlyChartData([
{ hour: 8, messageCount: 2 },
{ hour: 23, messageCount: 1 },
])
assert.equal(result.labels.length, 24)
assert.equal(result.values.length, 24)
assert.equal(result.labels[0], '0')
assert.equal(result.labels[23], '23')
assert.equal(result.values[8], 2)
assert.equal(result.values[23], 1)
assert.equal(result.values[7], 0)
})
test('maps member activity into the shared ranking component contract', () => {
assert.deepEqual(
buildMemberRankItems([
{
memberId: 7,
platformId: 'alice',
name: 'Alice',
avatar: null,
messageCount: 12,
percentage: 75,
},
]),
[{ id: '7', name: 'Alice', value: 12, percentage: 75 }]
)
})
test('maps message type stats into the shared pie chart contract in descending order', () => {
const source = [
{ type: 0, count: 1 },
{ type: 1, count: 3 },
]
assert.deepEqual(
buildMessageTypeChartData(source, (type) => `type-${type}`),
{
labels: ['type-1', 'type-0'],
values: [3, 1],
}
)
assert.deepEqual(source, [
{ type: 0, count: 1 },
{ type: 1, count: 3 },
])
})
-32
View File
@@ -1,32 +0,0 @@
import type { HourlyActivity, MemberActivity } from '@/types/analysis'
import type { MessageTypeStats } from '@openchatlab/core'
import type { EChartBarData, EChartPieData, RankItem } from '@/components/charts'
export function buildHourlyChartData(hourlyActivity: readonly HourlyActivity[]): EChartBarData {
const hourMap = new Map(hourlyActivity.map((item) => [item.hour, item.messageCount]))
return {
labels: Array.from({ length: 24 }, (_, hour) => String(hour)),
values: Array.from({ length: 24 }, (_, hour) => hourMap.get(hour) ?? 0),
}
}
export function buildMemberRankItems(memberActivity: readonly MemberActivity[]): RankItem[] {
return memberActivity.map((member) => ({
id: String(member.memberId),
name: member.name,
value: member.messageCount,
percentage: member.percentage,
}))
}
export function buildMessageTypeChartData(
messageTypes: readonly MessageTypeStats[],
getLabel: (type: number) => string
): EChartPieData {
const sorted = [...messageTypes].sort((left, right) => right.count - left.count)
return {
labels: sorted.map((item) => getLabel(item.type)),
values: sorted.map((item) => item.count),
}
}
@@ -787,4 +787,50 @@ describe('BrowserSessionRuntime', () => {
assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
database.dispose()
})
it('runs the core overview timeline queries with filtering and without creating missing databases', async () => {
const sqlite3 = await sqlite3InitModule()
const database = new MemoryWorkspaceDatabase(sqlite3)
const runtime = new BrowserSessionRuntime(database, {
createSessionId: () => 'overview-query-session',
now: () => 100,
})
const firstTimestamp = Math.floor(new Date(2024, 0, 2, 10, 0, 0).getTime() / 1000)
const secondTimestamp = Math.floor(new Date(2024, 0, 3, 10, 0, 0).getTime() / 1000)
const fixture = {
chatlab: { version: '1', exportedAt: firstTimestamp },
meta: { name: 'Overview Query', platform: 'wechat', type: 'private', ownerId: 'alice' },
members: [{ platformId: 'alice', accountName: 'Alice' }],
messages: [
{ sender: 'alice', accountName: 'Alice', timestamp: firstTimestamp, type: 0, content: 'one' },
{ sender: 'alice', accountName: 'Alice', timestamp: firstTimestamp + 1, type: 0, content: 'two' },
{ sender: 'alice', accountName: 'Alice', timestamp: secondTimestamp, type: 0, content: 'three' },
],
}
await runtime.importSource(source('overview-query.json', fixture), { formatId: 'chatlab' })
assert.deepEqual(await runtime.getDailyActivity('overview-query-session'), [
{ date: '2024-01-02', messageCount: 2 },
{ date: '2024-01-03', messageCount: 1 },
])
assert.deepEqual(await runtime.getDailyActivity('overview-query-session', { startTs: secondTimestamp }), [
{ date: '2024-01-03', messageCount: 1 },
])
assert.equal((await runtime.getWeekdayActivity('overview-query-session'))[1].messageCount, 2)
assert.equal((await runtime.getWeekdayActivity('overview-query-session'))[2].messageCount, 1)
assert.deepEqual(await runtime.getTimeRange('overview-query-session'), {
start: firstTimestamp,
end: secondTimestamp,
})
assert.deepEqual(await runtime.getAvailableYears('overview-query-session'), [2024])
const filenamesBeforeMissingQuery = await database.getDatabaseFilenames()
await assert.rejects(runtime.getDailyActivity('missing-session'), /Session missing-session was not found/)
await assert.rejects(runtime.getWeekdayActivity('missing-session'), /Session missing-session was not found/)
await assert.rejects(runtime.getTimeRange('missing-session'), /Session missing-session was not found/)
await assert.rejects(runtime.getAvailableYears('missing-session'), /Session missing-session was not found/)
assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery)
database.dispose()
})
})
@@ -1,13 +1,19 @@
import {
CHAT_DB_INDEXES,
CHAT_DB_TABLES,
getAvailableYears as queryAvailableYears,
getDailyActivity as queryDailyActivity,
getHourlyActivity as queryHourlyActivity,
getMemberActivity as queryMemberActivity,
getMessageTypeStats as queryMessageTypeStats,
getTimeRange as queryTimeRange,
getWeekdayActivity as queryWeekdayActivity,
writeParseResultToDb,
type DailyActivity,
type HourlyActivity,
type MemberActivity,
type MessageTypeStats,
type WeekdayActivity,
} from '@openchatlab/core'
import { WebRuntimeError } from '../runtime-error'
import type { WorkspaceDatabasePort, WorkspaceDatabaseStage } from '../storage/workspace-database'
@@ -248,6 +254,42 @@ export class BrowserSessionRuntime {
)
}
async getDailyActivity(id: string, filter?: BrowserTimeFilter): Promise<DailyActivity[]> {
validateSessionId(id)
const session = await this.catalog.get(id)
if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) =>
queryDailyActivity(db, filter)
)
}
async getWeekdayActivity(id: string, filter?: BrowserTimeFilter): Promise<WeekdayActivity[]> {
validateSessionId(id)
const session = await this.catalog.get(id)
if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) =>
queryWeekdayActivity(db, filter)
)
}
async getTimeRange(id: string): Promise<{ start: number; end: number } | null> {
validateSessionId(id)
const session = await this.catalog.get(id)
if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) => queryTimeRange(db))
}
async getAvailableYears(id: string): Promise<number[]> {
validateSessionId(id)
const session = await this.catalog.get(id)
if (!session) throw new WebRuntimeError('SESSION_NOT_FOUND', `Session ${id} was not found`)
return this.database.withDatabase(sessionDatabaseFilename(id), CHAT_DB_TABLES, (db) => queryAvailableYears(db))
}
async getMemberActivity(id: string, filter?: BrowserTimeFilter): Promise<MemberActivity[]> {
validateSessionId(id)
const session = await this.catalog.get(id)
+27 -1
View File
@@ -1,6 +1,12 @@
import type { BrowserImportFormatId, BrowserParseSource } from '../import/browser-parser'
import type { BrowserSessionCatalogItem } from '../import/session-catalog'
import type { HourlyActivity, MemberActivity, MessageTypeStats } from '@openchatlab/core'
import type {
DailyActivity,
HourlyActivity,
MemberActivity,
MessageTypeStats,
WeekdayActivity,
} from '@openchatlab/core'
import type {
BrowserImportFormatInfo,
BrowserMultiChatEntry,
@@ -77,6 +83,22 @@ export interface WebRuntimeTaskMap {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: HourlyActivity[]
}
'analysis.daily': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: DailyActivity[]
}
'analysis.weekday': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: WeekdayActivity[]
}
'analysis.timeRange': {
payload: { sessionId: string }
result: { start: number; end: number } | null
}
'analysis.availableYears': {
payload: { sessionId: string }
result: number[]
}
'analysis.members': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: MemberActivity[]
@@ -181,6 +203,10 @@ const WEB_RUNTIME_TASK_TYPES: Record<WebRuntimeTaskType, true> = {
'session.delete': true,
'session.rename': true,
'analysis.hourly': true,
'analysis.daily': true,
'analysis.weekday': true,
'analysis.timeRange': true,
'analysis.availableYears': true,
'analysis.members': true,
'analysis.messageTypes': true,
}
@@ -71,8 +71,12 @@ class FakeDatabaseRuntime implements WorkerDatabaseRuntime {
class FakeSessionRuntime implements WorkerSessionRuntime {
readonly hourlyCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly dailyCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly weekdayCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly memberCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly messageTypeCalls: Array<{ id: string; filter?: BrowserTimeFilter }> = []
readonly timeRangeCalls: string[] = []
readonly availableYearsCalls: string[] = []
readonly importCalls: Array<{ chatIndex?: number }> = []
readonly session = {
id: 'session-one',
@@ -138,6 +142,28 @@ class FakeSessionRuntime implements WorkerSessionRuntime {
: []
}
async getDailyActivity(id: string, filter?: BrowserTimeFilter) {
this.dailyCalls.push({ id, filter })
return id === this.session.id ? [{ date: '2024-01-02', messageCount: 2 }] : []
}
async getWeekdayActivity(id: string, filter?: BrowserTimeFilter) {
this.weekdayCalls.push({ id, filter })
return id === this.session.id
? Array.from({ length: 7 }, (_, index) => ({ weekday: index + 1, messageCount: index === 1 ? 2 : 0 }))
: []
}
async getTimeRange(id: string) {
this.timeRangeCalls.push(id)
return id === this.session.id ? { start: 1, end: 2 } : null
}
async getAvailableYears(id: string) {
this.availableYearsCalls.push(id)
return id === this.session.id ? [2024] : []
}
async getMemberActivity(id: string, filter?: BrowserTimeFilter) {
this.memberCalls.push({ id, filter })
return id === this.session.id
@@ -302,6 +328,26 @@ describe('WebRuntimeWorkerController', () => {
type: 'analysis.hourly',
payload: { sessionId: 'session-one', filter: { startTs: 1 } },
})
controller.handleMessage({
id: 'daily-1',
type: 'analysis.daily',
payload: { sessionId: 'session-one', filter: { startTs: 1 } },
})
controller.handleMessage({
id: 'weekday-1',
type: 'analysis.weekday',
payload: { sessionId: 'session-one', filter: { endTs: 2 } },
})
controller.handleMessage({
id: 'time-range-1',
type: 'analysis.timeRange',
payload: { sessionId: 'session-one' },
})
controller.handleMessage({
id: 'available-years-1',
type: 'analysis.availableYears',
payload: { sessionId: 'session-one' },
})
controller.handleMessage({
id: 'members-1',
type: 'analysis.members',
@@ -323,6 +369,10 @@ describe('WebRuntimeWorkerController', () => {
const imported = await waitForMessage(sink, 'import-1', 'result')
const listed = await waitForMessage(sink, 'list-1', 'result')
const hourly = await waitForMessage(sink, 'hourly-1', 'result')
const daily = await waitForMessage(sink, 'daily-1', 'result')
const weekday = await waitForMessage(sink, 'weekday-1', 'result')
const timeRange = await waitForMessage(sink, 'time-range-1', 'result')
const availableYears = await waitForMessage(sink, 'available-years-1', 'result')
const members = await waitForMessage(sink, 'members-1', 'result')
const messageTypes = await waitForMessage(sink, 'message-types-1', 'result')
const renamed = await waitForMessage(sink, 'rename-1', 'result')
@@ -344,6 +394,17 @@ describe('WebRuntimeWorkerController', () => {
Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
)
assert.deepEqual(sessions.hourlyCalls[0], { id: 'session-one', filter: { startTs: 1 } })
assert.deepEqual(daily.type === 'result' ? daily.payload.result : null, [{ date: '2024-01-02', messageCount: 2 }])
assert.deepEqual(sessions.dailyCalls[0], { id: 'session-one', filter: { startTs: 1 } })
assert.deepEqual(
weekday.type === 'result' ? weekday.payload.result : null,
Array.from({ length: 7 }, (_, index) => ({ weekday: index + 1, messageCount: index === 1 ? 2 : 0 }))
)
assert.deepEqual(sessions.weekdayCalls[0], { id: 'session-one', filter: { endTs: 2 } })
assert.deepEqual(timeRange.type === 'result' ? timeRange.payload.result : null, { start: 1, end: 2 })
assert.deepEqual(sessions.timeRangeCalls, ['session-one'])
assert.deepEqual(availableYears.type === 'result' ? availableYears.payload.result : null, [2024])
assert.deepEqual(sessions.availableYearsCalls, ['session-one'])
assert.deepEqual(members.type === 'result' ? members.payload.result : null, [
{
memberId: 1,
@@ -35,6 +35,10 @@ export type WorkerSessionRuntime = Pick<
| 'deleteSession'
| 'renameSession'
| 'getHourlyActivity'
| 'getDailyActivity'
| 'getWeekdayActivity'
| 'getTimeRange'
| 'getAvailableYears'
| 'getMemberActivity'
| 'getMessageTypeDistribution'
>
@@ -211,6 +215,54 @@ export class WebRuntimeWorkerController {
})
return result
}
case 'analysis.daily': {
this.assertSupportedBrowser()
const startedAt = performance.now()
const result = await this.sessionRuntime.getDailyActivity(request.payload.sessionId, request.payload.filter)
this.emitLog(request.id, {
level: 'debug',
scope: 'web-runtime',
message: 'Browser daily activity query completed',
data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
})
return result
}
case 'analysis.weekday': {
this.assertSupportedBrowser()
const startedAt = performance.now()
const result = await this.sessionRuntime.getWeekdayActivity(request.payload.sessionId, request.payload.filter)
this.emitLog(request.id, {
level: 'debug',
scope: 'web-runtime',
message: 'Browser weekday activity query completed',
data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
})
return result
}
case 'analysis.timeRange': {
this.assertSupportedBrowser()
const startedAt = performance.now()
const result = await this.sessionRuntime.getTimeRange(request.payload.sessionId)
this.emitLog(request.id, {
level: 'debug',
scope: 'web-runtime',
message: 'Browser time range query completed',
data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
})
return result
}
case 'analysis.availableYears': {
this.assertSupportedBrowser()
const startedAt = performance.now()
const result = await this.sessionRuntime.getAvailableYears(request.payload.sessionId)
this.emitLog(request.id, {
level: 'debug',
scope: 'web-runtime',
message: 'Browser available years query completed',
data: { sessionId: request.payload.sessionId, durationMs: Math.round(performance.now() - startedAt) },
})
return result
}
case 'analysis.members': {
this.assertSupportedBrowser()
const startedAt = performance.now()
@@ -15,7 +15,7 @@ defineProps<{
<template>
<SectionCard v-if="dailyActivity.length > 0" :title="t('analysis.overview.dailyTrend.title')" :show-divider="false">
<div class="p-5">
<div class="p-3 sm:p-5">
<EChartLine :data="dailyChartData" :height="288" />
</div>
</SectionCard>
@@ -274,12 +274,12 @@ onUnmounted(() => {
<template>
<ReportCard>
<!-- 身份信息 + 基础统计 -->
<div class="relative z-10 px-6 pt-8 pb-4 sm:px-8">
<h2 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
<div class="relative z-10 px-5 pt-6 pb-4 sm:px-8 sm:pt-8">
<h2 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-gray-100">
{{ session.name }}
</h2>
<div class="mt-4 flex items-start gap-6 sm:gap-24">
<div class="mt-4 flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-24">
<div class="min-w-0 flex flex-col gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-2">
<div class="flex h-6 w-6 shrink-0 items-center justify-center">
@@ -305,7 +305,7 @@ onUnmounted(() => {
</div>
</div>
<div class="flex shrink-0 gap-6">
<div class="grid grid-cols-2 gap-x-8 gap-y-4 sm:flex sm:shrink-0 sm:gap-6">
<div class="flex flex-col gap-1 text-center">
<span class="text-2xl font-black font-mono tracking-tight text-gray-900 dark:text-white">
{{ displayMessageCount.toLocaleString() }}
@@ -346,7 +346,7 @@ onUnmounted(() => {
</div>
<!-- 热力图区域 -->
<div class="relative z-10 px-6 pb-2 sm:px-8">
<div class="relative z-10 px-5 pb-2 sm:px-8">
<div class="mb-2 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
Activity Heatmap
@@ -99,7 +99,7 @@ const flatItems = computed<FlatStatItem[]>(() => [
<template>
<!-- flat 模式:嵌入父级 ThemeCard 内的紧凑子卡片 -->
<div v-if="flat" class="relative z-10 px-6 pb-6 pt-2 sm:px-8">
<div v-if="flat" class="relative z-10 px-5 pb-5 pt-2 sm:px-8 sm:pb-6">
<div class="mb-3 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">Key Metrics</span>
</div>
@@ -17,15 +17,21 @@ interface SessionAnalysisTab {
const activeTab = defineModel<string>('activeTab', { required: true })
const timeRangeValue = defineModel<TimeRangeValue | null>('timeRangeValue', { required: true })
const props = defineProps<{
title: string
avatar?: string | null
icon: string
iconClass: string
tabs: SessionAnalysisTab[]
currentSessionId: string | null
initialTimeState: Partial<TimeSelectState>
}>()
const props = withDefaults(
defineProps<{
title: string
avatar?: string | null
icon: string
iconClass: string
tabs: SessionAnalysisTab[]
currentSessionId: string | null
initialTimeState: Partial<TimeSelectState>
showSessionActions?: boolean
}>(),
{
showSessionActions: true,
}
)
const emit = defineEmits<{
(e: 'openIncrementalImport'): void
@@ -51,7 +57,7 @@ const navigationItems = computed(() =>
<template>
<PageHeader :title="title" :avatar="avatar" size="compact" :icon="icon" :icon-class="iconClass">
<template #actions>
<template v-if="layoutStore.toolsPanelPosition === 'header'">
<template v-if="showSessionActions && layoutStore.toolsPanelPosition === 'header'">
<UTooltip :text="t('analysis.tooltip.viewChatRecord')">
<UButton
icon="i-heroicons-chat-bubble-bottom-center-text"
@@ -95,21 +101,23 @@ const navigationItems = computed(() =>
/>
</UTooltip>
</template>
<CaptureButton v-else color="gray" />
<CaptureButton v-else-if="showSessionActions" color="gray" />
</template>
<div class="mt-3 flex items-center justify-between gap-3">
<div class="mt-3 flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<PageTabs v-model="activeTab" class="min-w-0 shrink" :items="navigationItems" />
<!-- AI 对话和实验室都不使用这里的时间范围筛选,因此在这些一级 Tab 下隐藏。 -->
<TimeSelect
v-model="timeRangeValue"
:session-id="currentSessionId ?? undefined"
:visible="timeSelectVisible"
:initial-state="initialTimeState"
size="sm"
@update:full-range="emit('update:fullRange', $event)"
@update:available-years="emit('update:availableYears', $event)"
/>
<div class="max-w-full overflow-x-auto scrollbar-hide">
<TimeSelect
v-model="timeRangeValue"
:session-id="currentSessionId ?? undefined"
:visible="timeSelectVisible"
:initial-state="initialTimeState"
size="sm"
@update:full-range="emit('update:fullRange', $event)"
@update:available-years="emit('update:availableYears', $event)"
/>
</div>
</div>
</PageHeader>
</template>
@@ -25,6 +25,13 @@ describe('overviewTimeRange', () => {
assert.equal(getOverviewDurationDays({ start: 1000, end: 1000 }), 1)
})
it('never reports fewer duration days than observed active calendar days', () => {
const start = Date.UTC(2026, 6, 18, 16, 0, 0) / 1000
const end = Date.UTC(2026, 6, 19, 16, 0, 0) / 1000
assert.equal(getOverviewDurationDays({ start, end }, 2), 2)
})
it('returns 0 for empty or invalid ranges', () => {
assert.equal(getOverviewDurationDays(null), 0)
assert.equal(getOverviewDurationDays({ start: 2, end: 1 }), 0)
@@ -34,9 +34,9 @@ export function resolveOverviewTimeRange(
return { start, end }
}
export function getOverviewDurationDays(timeRange: OverviewTimeRange | null): number {
export function getOverviewDurationDays(timeRange: OverviewTimeRange | null, minimumObservedDays = 0): number {
if (!timeRange || timeRange.end < timeRange.start) return 0
return Math.max(Math.ceil((timeRange.end - timeRange.start) / SECONDS_PER_DAY), 1)
return Math.max(Math.ceil((timeRange.end - timeRange.start) / SECONDS_PER_DAY), minimumObservedDays, 1)
}
export function getOverviewCalendarRange(timeRange: OverviewTimeRange | null): [string, string] | null {
@@ -28,8 +28,13 @@ export function useOverviewStatistics(props: UseOverviewStatisticsProps, weekday
const effectiveTimeRange = computed(() => resolveOverviewTimeRange(props.timeRange, props.timeFilter))
const hasTimeFilter = computed(() => hasOverviewTimeFilter(props.timeFilter))
// 活跃天数
const activeDays = computed(() => {
return props.dailyActivity.filter((d) => d.messageCount > 0).length
})
// 时间跨度
const durationDays = computed(() => getOverviewDurationDays(effectiveTimeRange.value))
const durationDays = computed(() => getOverviewDurationDays(effectiveTimeRange.value, activeDays.value))
// 显示的消息数
const displayMessageCount = computed(() => {
@@ -115,11 +120,6 @@ export function useOverviewStatistics(props: UseOverviewStatisticsProps, weekday
return props.dailyActivity.reduce((max, d) => (d.messageCount > max.messageCount ? d : max), props.dailyActivity[0])
})
// 活跃天数
const activeDays = computed(() => {
return props.dailyActivity.filter((d) => d.messageCount > 0).length
})
// 总天数(用于计算活跃率)
const totalDays = computed(() => {
return durationDays.value
@@ -59,7 +59,7 @@ const memberChartData = computed<EChartPieData>(() => {
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<div class="main-content mx-auto max-w-[920px] space-y-4 p-4 sm:space-y-6 sm:p-6">
<!-- 群聊身份卡 + 关键指标 -->
<OverviewIdentityCard
:session="session"
@@ -76,14 +76,14 @@ const memberChartData = computed<EChartPieData>(() => {
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 消息类型分布 -->
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :show-divider="false">
<div class="p-5">
<div class="p-3 sm:p-5">
<EChartPie :data="typeChartData" :height="280" />
</div>
</SectionCard>
<!-- 成员水群分布 -->
<SectionCard :title="t('analysis.overview.memberDistribution')" :show-divider="false">
<div class="p-5">
<div class="p-3 sm:p-5">
<EChartPie :data="memberChartData" :height="280" />
</div>
</SectionCard>
@@ -73,7 +73,7 @@ const comparisonChartData = computed<EChartPieData>(() => {
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<div class="main-content mx-auto max-w-[920px] space-y-4 p-4 sm:space-y-6 sm:p-6">
<!-- 私聊身份卡 + 关键指标 -->
<OverviewIdentityCard
:session="session"
@@ -90,14 +90,14 @@ const comparisonChartData = computed<EChartPieData>(() => {
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 消息类型分布 -->
<SectionCard :title="t('analysis.overview.messageTypeDistribution')" :show-divider="false">
<div class="p-5">
<div class="p-3 sm:p-5">
<EChartPie :data="typeChartData" :height="280" />
</div>
</SectionCard>
<!-- 双方消息占比饼图 -->
<SectionCard v-if="memberComparisonData" :title="t('analysis.overview.memberComparison')" :show-divider="false">
<div class="p-5">
<div class="p-3 sm:p-5">
<EChartPie :data="comparisonChartData" :height="280" />
</div>
</SectionCard>
+40 -19
View File
@@ -39,25 +39,36 @@ describe('BrowserDataAdapter', () => {
? item
: type === 'analysis.hourly'
? Array.from({ length: 24 }, (_, hour) => ({ hour, messageCount: hour === 8 ? 2 : 0 }))
: type === 'analysis.members'
? [
{
memberId: 1,
platformId: 'alice',
name: 'Alice',
avatar: null,
messageCount: 2,
percentage: 100,
},
]
: type === 'analysis.messageTypes'
? [
{ type: 1, count: 2 },
{ type: 0, count: 1 },
]
: type === 'session.delete'
? { deleted: true }
: { renamed: true }
: type === 'analysis.daily'
? [{ date: '2024-01-02', messageCount: 2 }]
: type === 'analysis.weekday'
? Array.from({ length: 7 }, (_, index) => ({
weekday: index + 1,
messageCount: index === 1 ? 2 : 0,
}))
: type === 'analysis.timeRange'
? { start: 1, end: 2 }
: type === 'analysis.availableYears'
? [2024]
: type === 'analysis.members'
? [
{
memberId: 1,
platformId: 'alice',
name: 'Alice',
avatar: null,
messageCount: 2,
percentage: 100,
},
]
: type === 'analysis.messageTypes'
? [
{ type: 1, count: 2 },
{ type: 0, count: 1 },
]
: type === 'session.delete'
? { deleted: true }
: { renamed: true }
return result as WebRuntimeTaskResult<T>
},
dispose: () => undefined,
@@ -86,6 +97,12 @@ describe('BrowserDataAdapter', () => {
})
assert.equal((await adapter.getSession('session-one'))?.id, 'session-one')
assert.equal((await adapter.getHourlyActivity('session-one', { startTs: 1 }))[8].messageCount, 2)
assert.deepEqual(await adapter.getDailyActivity('session-one', { startTs: 1 }), [
{ date: '2024-01-02', messageCount: 2 },
])
assert.equal((await adapter.getWeekdayActivity('session-one', { endTs: 2 }))[1].messageCount, 2)
assert.deepEqual(await adapter.getTimeRange('session-one'), { start: 1, end: 2 })
assert.deepEqual(await adapter.getAvailableYears('session-one'), [2024])
assert.equal((await adapter.getMemberActivity('session-one', { endTs: 2 }))[0].name, 'Alice')
assert.deepEqual(await adapter.getMessageTypeDistribution('session-one', { startTs: 1, endTs: 2 }), [
{ type: 1, count: 2 },
@@ -97,6 +114,10 @@ describe('BrowserDataAdapter', () => {
{ type: 'session.list', payload: undefined },
{ type: 'session.get', payload: { sessionId: 'session-one' } },
{ type: 'analysis.hourly', payload: { sessionId: 'session-one', filter: { startTs: 1 } } },
{ type: 'analysis.daily', payload: { sessionId: 'session-one', filter: { startTs: 1 } } },
{ type: 'analysis.weekday', payload: { sessionId: 'session-one', filter: { endTs: 2 } } },
{ type: 'analysis.timeRange', payload: { sessionId: 'session-one' } },
{ type: 'analysis.availableYears', payload: { sessionId: 'session-one' } },
{ type: 'analysis.members', payload: { sessionId: 'session-one', filter: { endTs: 2 } } },
{
type: 'analysis.messageTypes',
+21 -1
View File
@@ -1,6 +1,6 @@
import { sessionDatabaseFilename, type BrowserSessionCatalogItem } from '@openchatlab/web-runtime'
import type { AnalysisSession, MessageType } from '@/types/base'
import type { HourlyActivity, MemberActivity } from '@/types/analysis'
import type { DailyActivity, HourlyActivity, MemberActivity, WeekdayActivity } from '@/types/analysis'
import type { TimeFilter } from '@openchatlab/shared-types'
import type { BrowserRuntimeRpcPort } from '../browser-runtime/types'
import type { DataAdapter } from './types'
@@ -12,6 +12,10 @@ type BrowserSessionDataAdapter = Pick<
| 'deleteSession'
| 'renameSession'
| 'getHourlyActivity'
| 'getDailyActivity'
| 'getWeekdayActivity'
| 'getTimeRange'
| 'getAvailableYears'
| 'getMemberActivity'
| 'getMessageTypeDistribution'
>
@@ -40,6 +44,22 @@ export class BrowserDataAdapter implements BrowserSessionDataAdapter {
return this.rpc.request('analysis.hourly', { sessionId, filter })
}
getDailyActivity(sessionId: string, filter?: TimeFilter): Promise<DailyActivity[]> {
return this.rpc.request('analysis.daily', { sessionId, filter })
}
getWeekdayActivity(sessionId: string, filter?: TimeFilter): Promise<WeekdayActivity[]> {
return this.rpc.request('analysis.weekday', { sessionId, filter })
}
getTimeRange(sessionId: string): Promise<{ start: number; end: number } | null> {
return this.rpc.request('analysis.timeRange', { sessionId })
}
getAvailableYears(sessionId: string): Promise<number[]> {
return this.rpc.request('analysis.availableYears', { sessionId })
}
getMemberActivity(sessionId: string, filter?: TimeFilter): Promise<MemberActivity[]> {
return this.rpc.request('analysis.members', { sessionId, filter })
}