mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-06 05:01:19 +08:00
feat: 新增社交日历
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user