mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(web): add session overview
This commit is contained in:
1 parent
8e6cd5c6e6
commit
985ce4276d
22 files changed
+610
-289
No files matched your search
@@ -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>
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user