feat: 新增社交日历

This commit is contained in:
digua
2026-01-22 00:37:11 +08:00
parent af202e8180
commit 7afe5a0152
3 changed files with 260 additions and 24 deletions
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
/**
* ECharts 日历热力图组件(GitHub 贡献图风格)
*/
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts/core'
import { HeatmapChart } from 'echarts/charts'
import { CalendarComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useDark } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
// 注册必要的组件
echarts.use([HeatmapChart, CalendarComponent, TooltipComponent, VisualMapComponent, CanvasRenderer])
type ECOption = EChartsOption
export interface CalendarData {
date: string // YYYY-MM-DD
value: number
}
interface Props {
data: CalendarData[]
height?: number
year?: number // 指定年份,不指定则自动计算
}
const props = withDefaults(defineProps<Props>(), {
height: 180,
})
const isDark = useDark()
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 计算年份范围
const yearRange = computed(() => {
if (props.year) return props.year
if (props.data.length === 0) {
return new Date().getFullYear()
}
// 找到数据中最大的年份
const years = props.data.map((d) => parseInt(d.date.split('-')[0]))
return Math.max(...years)
})
// 计算最大值(用于颜色映射)
const maxValue = computed(() => {
if (props.data.length === 0) return 10
return Math.max(...props.data.map((d) => d.value), 1)
})
// 转换数据格式为 ECharts 需要的格式
const chartData = computed(() => {
return props.data.map((d) => [d.date, d.value])
})
// 项目主题粉色
const themeColors = {
light: ['#fee5e8', '#fbb5c2', '#f7758c', '#ee4567'],
dark: ['#3d1f24', '#6b2f3a', '#a34557', '#ee4567'],
}
const option = computed<ECOption>(() => ({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const date = params.data[0]
const value = params.data[1]
return `${date}<br/>消息: ${value}`
},
},
visualMap: {
min: 0,
max: maxValue.value,
calculable: false,
orient: 'horizontal',
left: 'center',
bottom: 0,
itemWidth: 10,
itemHeight: 100,
text: [`${maxValue.value}`, '0'], // 显示实际的最大值
inRange: {
color: isDark.value ? themeColors.dark : themeColors.light,
},
textStyle: {
color: isDark.value ? '#9ca3af' : '#6b7280',
fontSize: 10,
},
show: true,
},
calendar: {
top: 30,
left: 40,
cellSize: [13, 13], // 显式设置正方形格子
range: String(yearRange.value),
itemStyle: {
borderWidth: 2,
borderColor: isDark.value ? '#1f2937' : '#ffffff',
},
yearLabel: {
show: true,
position: 'top',
color: isDark.value ? '#9ca3af' : '#6b7280',
fontSize: 12,
},
monthLabel: {
show: true,
color: isDark.value ? '#9ca3af' : '#6b7280',
fontSize: 10,
},
dayLabel: {
show: true,
firstDay: 1, // 从周一开始
color: isDark.value ? '#6b7280' : '#9ca3af',
fontSize: 10,
nameMap: ['日', '一', '二', '三', '四', '五', '六'],
},
splitLine: {
show: false,
},
},
series: [
{
type: 'heatmap',
coordinateSystem: 'calendar',
data: chartData.value,
},
],
}))
// 初始化图表
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value, isDark.value ? 'dark' : undefined, {
renderer: 'canvas',
})
chartInstance.setOption(option.value)
}
// 更新图表
function updateChart() {
if (!chartInstance) return
chartInstance.setOption(option.value, { notMerge: true })
}
// 响应窗口大小变化
function handleResize() {
chartInstance?.resize()
}
// 监听数据和主题变化
watch([() => props.data, isDark], () => {
updateChart()
})
watch(
() => props.year,
() => {
updateChart()
}
)
onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div ref="chartRef" :style="{ height: `${height}px`, width: '100%' }" />
</template>
+2
View File
@@ -6,6 +6,7 @@ export { default as EChartPie } from './EChartPie.vue'
export { default as EChartBar } from './EChartBar.vue'
export { default as EChartLine } from './EChartLine.vue'
export { default as EChartHeatmap } from './EChartHeatmap.vue'
export { default as EChartCalendar } from './EChartCalendar.vue'
// 其他组件
export { default as RankList } from './RankList.vue'
@@ -19,6 +20,7 @@ export type { EChartPieData } from './EChartPie.vue'
export type { EChartBarData } from './EChartBar.vue'
export type { EChartLineData } from './EChartLine.vue'
export type { EChartHeatmapData } from './EChartHeatmap.vue'
export type { CalendarData as EChartCalendarData } from './EChartCalendar.vue'
// 其他类型
export type { RankItem } from './RankList.vue'
+76 -24
View File
@@ -3,9 +3,9 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MessageType } from '@/types/base'
import { getMessageTypeName } from '@/types/base'
import type { HourlyActivity, WeekdayActivity, MonthlyActivity } from '@/types/analysis'
import { EChartPie, EChartBar, EChartHeatmap } from '@/components/charts'
import type { EChartPieData, EChartBarData, EChartHeatmapData } from '@/components/charts'
import type { HourlyActivity, WeekdayActivity, MonthlyActivity, DailyActivity } from '@/types/analysis'
import { EChartPie, EChartBar, EChartHeatmap, EChartCalendar } from '@/components/charts'
import type { EChartPieData, EChartBarData, EChartHeatmapData, EChartCalendarData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
const { t } = useI18n()
@@ -33,6 +33,7 @@ const hourlyActivity = ref<HourlyActivity[]>([])
const weekdayActivity = ref<WeekdayActivity[]>([])
const monthlyActivity = ref<MonthlyActivity[]>([])
const yearlyActivity = ref<Array<{ year: number; messageCount: number }>>([])
const dailyActivity = ref<DailyActivity[]>([])
const lengthDetail = ref<Array<{ len: number; count: number }>>([])
const lengthGrouped = ref<Array<{ range: string; count: number }>>([])
@@ -204,6 +205,33 @@ const heatmapChartData = computed<EChartHeatmapData>(() => {
return { xLabels, yLabels, data }
})
// 日历热力图数据(GitHub 贡献图风格)
const calendarChartData = computed<EChartCalendarData[]>(() => {
return dailyActivity.value.map((d) => ({
date: d.date,
value: d.messageCount,
}))
})
// 日历热力图可用年份
const calendarYears = computed(() => {
const years = new Set<number>()
dailyActivity.value.forEach((d) => {
const year = parseInt(d.date.split('-')[0])
if (!isNaN(year)) years.add(year)
})
return Array.from(years).sort((a, b) => b - a) // 降序排列
})
// 当前选中的日历年份
const selectedCalendarYear = ref<number>(new Date().getFullYear())
// 过滤当前年份的日历数据
const filteredCalendarData = computed(() => {
const year = selectedCalendarYear.value
return calendarChartData.value.filter((d) => d.date.startsWith(`${year}-`))
})
// 合并 timeFilter 和 memberId 的 filter
const effectiveFilter = computed(() => ({
...props.timeFilter,
@@ -217,12 +245,13 @@ async function loadData() {
isLoading.value = true
try {
const filter = effectiveFilter.value
const [types, hourly, weekday, monthly, yearly, lengthData] = await Promise.all([
const [types, hourly, weekday, monthly, yearly, daily, lengthData] = await Promise.all([
window.chatApi.getMessageTypeDistribution(props.sessionId, filter),
window.chatApi.getHourlyActivity(props.sessionId, filter),
window.chatApi.getWeekdayActivity(props.sessionId, filter),
window.chatApi.getMonthlyActivity(props.sessionId, filter),
window.chatApi.getYearlyActivity(props.sessionId, filter),
window.chatApi.getDailyActivity(props.sessionId, filter),
window.chatApi.getMessageLengthDistribution(props.sessionId, filter),
])
@@ -231,8 +260,14 @@ async function loadData() {
weekdayActivity.value = weekday
monthlyActivity.value = monthly
yearlyActivity.value = yearly
dailyActivity.value = daily
lengthDetail.value = lengthData.detail
lengthGrouped.value = lengthData.grouped
// 自动选择最新的年份
if (calendarYears.value.length > 0) {
selectedCalendarYear.value = calendarYears.value[0]
}
} catch (error) {
console.error('加载消息视图数据失败:', error)
} finally {
@@ -355,16 +390,6 @@ watch(
</SectionCard>
</div>
<!-- 时间热力图 -->
<SectionCard :title="t('timeHeatmap')" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">{{ t('heatmapHint') }}</span>
</template>
<div class="p-5">
<EChartHeatmap :data="heatmapChartData" :height="320" />
</div>
</SectionCard>
<!-- 消息长度分布左右两图 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 左侧1-25字逐字分布 -->
@@ -402,12 +427,39 @@ watch(
</SectionCard>
</div>
<!-- 双方类型对比 (占位) -->
<SectionCard :title="t('memberTypeComparison')" :show-divider="false">
<div class="flex h-48 items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-user-group" class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm text-gray-400">{{ t('comingSoon') }}</p>
<!-- 时间热力图 -->
<SectionCard :title="t('timeHeatmap')" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">{{ t('heatmapHint') }}</span>
</template>
<div class="p-5">
<EChartHeatmap :data="heatmapChartData" :height="320" />
</div>
</SectionCard>
<!-- 日历热力图GitHub 贡献图风格 -->
<SectionCard :title="t('calendarHeatmap')" :show-divider="false">
<template #headerRight>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ t('calendarHint') }}</span>
<USelect
v-if="calendarYears.length > 1"
v-model="selectedCalendarYear"
:items="calendarYears.map((y) => ({ value: y, label: String(y) }))"
size="xs"
class="w-20"
/>
</div>
</template>
<div class="p-5">
<EChartCalendar
v-if="filteredCalendarData.length > 0"
:data="filteredCalendarData"
:year="selectedCalendarYear"
:height="180"
/>
<div v-else class="flex h-32 items-center justify-center text-gray-400">
{{ t('noData') }}
</div>
</div>
</SectionCard>
@@ -425,14 +477,14 @@ watch(
"yearlyDistribution": "年份分布",
"timeHeatmap": "时间热力图",
"heatmapHint": "展示聊天时间规律",
"calendarHeatmap": "消息日历",
"calendarHint": "每日消息分布",
"lengthDetailTitle": "短消息分布 (1-25字)",
"lengthDetailHint": "逐字统计",
"lengthGroupedTitle": "长度区间分布",
"lengthGroupedHint": "每5字一组",
"noTextMessages": "暂无文字消息",
"memberTypeComparison": "双方类型对比",
"noData": "暂无数据",
"comingSoon": "功能开发中...",
"weekdays": {
"sun": "周日",
"mon": "周一",
@@ -465,14 +517,14 @@ watch(
"yearlyDistribution": "Yearly Distribution",
"timeHeatmap": "Time Heatmap",
"heatmapHint": "Shows chat time patterns",
"calendarHeatmap": "Message Calendar",
"calendarHint": "Daily message distribution",
"lengthDetailTitle": "Short Messages (1-25 chars)",
"lengthDetailHint": "Per character",
"lengthGroupedTitle": "Length Range Distribution",
"lengthGroupedHint": "Grouped by 5",
"noTextMessages": "No text messages",
"memberTypeComparison": "Member Type Comparison",
"noData": "No data",
"comingSoon": "Coming soon...",
"weekdays": {
"sun": "Sun",
"mon": "Mon",