feat: Echarts 替换 chart.js

This commit is contained in:
digua
2026-01-21 23:21:43 +08:00
parent 007c442067
commit 9c1b29968b
18 changed files with 620 additions and 547 deletions

View File

@@ -36,9 +36,11 @@
"@zumer/snapdom": "^2.0.1",
"ai": "^6.0.41",
"better-sqlite3": "^12.4.6",
"echarts": "^6.0.0",
"electron-updater": "^6.6.2",
"markdown-it": "^14.1.0",
"stream-json": "^1.9.1",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.2.8",
"zod": "^4.3.5"
},
@@ -57,7 +59,6 @@
"@vue/eslint-config-prettier": "^10.2.0",
"@vueuse/core": "^13.9.0",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"cross-env": "^7.0.3",
"dayjs": "^1.11.19",
"electron": "^35.0.0",
@@ -72,7 +73,6 @@
"tailwindcss": "^4.0.0",
"vite": "^6.3.5",
"vue": "^3.5.25",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3"
}
}

67
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
better-sqlite3:
specifier: ^12.4.6
version: 12.5.0
echarts:
specifier: ^6.0.0
version: 6.0.0
electron-updater:
specifier: ^6.6.2
version: 6.7.3
@@ -47,6 +50,9 @@ importers:
stream-json:
specifier: ^1.9.1
version: 1.9.1
vue-echarts:
specifier: ^8.0.1
version: 8.0.1(echarts@6.0.0)(vue@3.5.26(typescript@5.9.3))
vue-i18n:
specifier: ^11.2.8
version: 11.2.8(vue@3.5.26(typescript@5.9.3))
@@ -96,9 +102,6 @@ importers:
axios:
specifier: ^1.13.2
version: 1.13.2
chart.js:
specifier: ^4.5.1
version: 4.5.1
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -141,9 +144,6 @@ importers:
vue:
specifier: ^3.5.25
version: 3.5.26(typescript@5.9.3)
vue-chartjs:
specifier: ^5.3.3
version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.9.3))
vue-router:
specifier: ^4.6.3
version: 4.6.4(vue@3.5.26(typescript@5.9.3))
@@ -677,9 +677,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@malept/cross-spawn-promise@2.0.0':
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
@@ -1790,10 +1787,6 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -2028,6 +2021,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@@ -3735,6 +3731,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3975,12 +3974,6 @@ packages:
yaml:
optional: true
vue-chartjs@5.3.3:
resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==}
peerDependencies:
chart.js: ^4.1.1
vue: ^3.0.0-0 || ^2.7.0
vue-component-type-helpers@3.2.2:
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
@@ -3995,6 +3988,12 @@ packages:
'@vue/composition-api':
optional: true
vue-echarts@8.0.1:
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
peerDependencies:
echarts: ^6.0.0
vue: ^3.3.0
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -4117,6 +4116,9 @@ packages:
zod@4.3.5:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
7zip-bin@5.2.0: {}
@@ -4686,8 +4688,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/color@0.3.4': {}
'@malept/cross-spawn-promise@2.0.0':
dependencies:
cross-spawn: 7.0.6
@@ -6042,10 +6042,6 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -6262,6 +6258,11 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
ejs@3.1.10:
dependencies:
jake: 10.9.4
@@ -8134,6 +8135,8 @@ snapshots:
dependencies:
typescript: 5.9.3
tslib@2.3.0: {}
tslib@2.8.1: {}
tunnel-agent@0.6.0:
@@ -8323,17 +8326,17 @@ snapshots:
lightningcss: 1.30.2
yaml: 2.8.2
vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.9.3)):
dependencies:
chart.js: 4.5.1
vue: 3.5.26(typescript@5.9.3)
vue-component-type-helpers@3.2.2: {}
vue-demi@0.14.10(vue@3.5.26(typescript@5.9.3)):
dependencies:
vue: 3.5.26(typescript@5.9.3)
vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.26(typescript@5.9.3)):
dependencies:
echarts: 6.0.0
vue: 3.5.26(typescript@5.9.3)
vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 4.4.3
@@ -8451,3 +8454,7 @@ snapshots:
yocto-queue@0.1.0: {}
zod@4.3.5: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0

View File

@@ -2,8 +2,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { HourlyActivity, WeekdayActivity, MonthlyActivity } from '@/types/analysis'
import { BarChart } from '@/components/charts'
import type { BarChartData } from '@/components/charts'
import { EChartBar } from '@/components/charts'
import type { EChartBarData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
const { t, locale } = useI18n()
@@ -20,7 +20,7 @@ const props = defineProps<{
// --- 24小时分布逻辑 ---
// 24小时分布图数据
const hourlyChartData = computed<BarChartData>(() => {
const hourlyChartData = computed<EChartBarData>(() => {
return {
labels: props.hourlyActivity.map((h) => `${h.hour}:00`),
values: props.hourlyActivity.map((h) => h.messageCount),
@@ -60,7 +60,7 @@ const eveningRatio = computed(() => {
// --- 星期分布逻辑 ---
// 星期分布图数据
const weekdayChartData = computed<BarChartData>(() => {
const weekdayChartData = computed<EChartBarData>(() => {
return {
labels: props.weekdayActivity.map((w) => props.weekdayNames[w.weekday - 1]),
values: props.weekdayActivity.map((w) => w.messageCount),
@@ -69,7 +69,7 @@ const weekdayChartData = computed<BarChartData>(() => {
// --- 月份分布逻辑 ---
// 月份分布图数据
const monthlyChartData = computed<BarChartData>(() => {
const monthlyChartData = computed<EChartBarData>(() => {
return {
labels: props.monthlyActivity.map((m) => {
// 中文用 X月英文用 Jan, Feb 等
@@ -89,11 +89,7 @@ const monthlyChartData = computed<BarChartData>(() => {
<!-- 24小时分布 -->
<SectionCard :title="t('hourlyTitle')" :show-divider="false">
<div class="p-5">
<BarChart
:data="hourlyChartData"
:height="256"
:x-label-filter="(_, index) => (index % 3 === 0 ? `${index}:00` : '')"
/>
<EChartBar :data="hourlyChartData" :height="256" />
<div class="mt-6 grid grid-cols-4 gap-2">
<div class="text-center">
@@ -135,7 +131,7 @@ const monthlyChartData = computed<BarChartData>(() => {
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
</div>
<template v-else>
<BarChart :data="weekdayChartData" :height="256" />
<EChartBar :data="weekdayChartData" :height="256" />
<div class="mt-6 grid grid-cols-2 gap-4">
<div class="text-center">
@@ -173,7 +169,7 @@ const monthlyChartData = computed<BarChartData>(() => {
<div v-if="isLoadingMonthly" class="flex h-64 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
</div>
<BarChart v-else :data="monthlyChartData" :height="256" />
<EChartBar v-else :data="monthlyChartData" :height="256" />
</div>
</SectionCard>
</div>

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { SectionCard } from '@/components/UI'
import { LineChart } from '@/components/charts'
import type { LineChartData } from '@/components/charts'
import { EChartLine } from '@/components/charts'
import type { EChartLineData } from '@/components/charts'
import type { DailyActivity } from '@/types/analysis'
const { t } = useI18n()
defineProps<{
dailyActivity: DailyActivity[]
dailyChartData: LineChartData
dailyChartData: EChartLineData
}>()
</script>
<template>
<SectionCard v-if="dailyActivity.length > 0" :title="t('title')" :show-divider="false">
<div class="p-5">
<LineChart :data="dailyChartData" :height="288" />
<EChartLine :data="dailyChartData" :height="288" />
</div>
</SectionCard>
</template>

View File

@@ -1,131 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const { t } = useI18n()
export interface BarChartData {
labels: string[]
values: number[]
colors?: string[]
}
interface Props {
data: BarChartData
height?: number
showLegend?: boolean
borderRadius?: number
colorMode?: 'static' | 'gradient' // static: 使用提供的colors, gradient: 根据值自动渐变
xLabelFilter?: (label: string, index: number) => string
}
const props = withDefaults(defineProps<Props>(), {
height: 256,
showLegend: false,
borderRadius: 4,
colorMode: 'gradient',
})
// 根据数据值计算渐变颜色
const calculateColors = computed(() => {
if (props.colorMode === 'static' && props.data.colors) {
return props.data.colors
}
const maxValue = Math.max(...props.data.values)
return props.data.values.map((value) => {
const intensity = value / maxValue
if (intensity > 0.8) return '#6366f1'
if (intensity > 0.6) return '#818cf8'
if (intensity > 0.4) return '#a5b4fc'
if (intensity > 0.2) return '#c7d2fe'
return '#e0e7ff'
})
})
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
label: t('count'),
data: props.data.values,
backgroundColor: calculateColors.value,
borderRadius: props.borderRadius,
borderSkipped: false,
},
],
}
})
const chartOptions = computed(() => {
const options: any = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.showLegend,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
font: {
size: 10,
},
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
ticks: {
font: {
size: 11,
},
},
},
},
}
// 添加自定义 x 轴标签过滤器
if (props.xLabelFilter) {
options.scales.x.ticks.callback = function (this: any, _: unknown, index: number) {
const label = this.getLabelForValue(index)
return props.xLabelFilter!(label, index)
}
}
return options
})
</script>
<template>
<div :style="{ height: `${height}px` }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
<i18n>
{
"zh-CN": {
"count": "数量"
},
"en-US": {
"count": "Count"
}
}
</i18n>

View File

@@ -1,82 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend)
export interface DoughnutChartData {
labels: string[]
values: number[]
colors?: string[]
}
interface Props {
data: DoughnutChartData
cutout?: number | string
height?: number
showLegend?: boolean
legendPosition?: 'top' | 'bottom' | 'left' | 'right'
}
const props = withDefaults(defineProps<Props>(), {
cutout: '60%',
height: 256,
showLegend: true,
legendPosition: 'bottom',
})
// 默认颜色方案
const defaultColors = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#ec4899', // pink
'#f43f5e', // rose
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#94a3b8', // gray
]
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
data: props.data.values,
backgroundColor: props.data.colors || defaultColors.slice(0, props.data.values.length),
borderWidth: 0,
hoverOffset: 4,
},
],
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.showLegend,
position: props.legendPosition,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
},
},
cutout: props.cutout,
}))
</script>
<template>
<div :style="{ height: `${height}px` }">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
/**
* ECharts 基础封装组件
* 提供自动响应式、主题适配、加载状态等通用功能
*/
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import {
PieChart,
BarChart,
LineChart,
HeatmapChart,
} from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
VisualMapComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
// 注册必要的组件
echarts.use([
CanvasRenderer,
PieChart,
BarChart,
LineChart,
HeatmapChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
VisualMapComponent,
])
interface Props {
option: EChartsOption
height?: number | string
loading?: boolean
theme?: 'light' | 'dark' | 'auto'
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
loading: false,
theme: 'auto',
})
const chartRef = ref<HTMLDivElement>()
let chartInstance: echarts.ECharts | null = null
// 计算高度样式
const heightStyle = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`
}
return props.height
})
// 检测暗色模式
const isDark = computed(() => {
if (props.theme === 'auto') {
return document.documentElement.classList.contains('dark')
}
return props.theme === 'dark'
})
// 初始化图表
function initChart() {
if (!chartRef.value) return
// 销毁旧实例
if (chartInstance) {
chartInstance.dispose()
}
// 创建新实例
chartInstance = echarts.init(chartRef.value, isDark.value ? 'dark' : undefined)
chartInstance.setOption(props.option)
}
// 更新图表
function updateChart() {
if (!chartInstance) {
initChart()
return
}
chartInstance.setOption(props.option, { notMerge: true })
}
// 调整大小
function handleResize() {
chartInstance?.resize()
}
// 监听 option 变化
watch(() => props.option, updateChart, { deep: true })
// 监听主题变化
watch(isDark, () => {
initChart()
})
// 监听加载状态
watch(
() => props.loading,
(loading) => {
if (loading) {
chartInstance?.showLoading('default', {
text: '',
spinnerRadius: 12,
lineWidth: 2,
})
} else {
chartInstance?.hideLoading()
}
}
)
// 监听暗色模式变化
let observer: MutationObserver | null = null
onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
// 监听 HTML 元素的 class 变化(用于检测暗色模式切换)
observer = new MutationObserver(() => {
if (props.theme === 'auto') {
initChart()
}
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
observer?.disconnect()
chartInstance?.dispose()
chartInstance = null
})
// 暴露方法供父组件调用
defineExpose({
getInstance: () => chartInstance,
resize: handleResize,
})
</script>
<template>
<div ref="chartRef" :style="{ height: heightStyle, width: '100%' }" />
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
/**
* ECharts 柱状图组件
*/
import { computed } from 'vue'
import type { EChartsOption } from 'echarts'
import EChart from './EChart.vue'
export interface EChartBarData {
labels: string[]
values: number[]
}
interface Props {
data: EChartBarData
height?: number
/** 是否为横向柱状图 */
horizontal?: boolean
/** 是否显示渐变色 */
gradient?: boolean
/** 柱子圆角 */
borderRadius?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 200,
horizontal: false,
gradient: true,
borderRadius: 4,
})
// 渐变色
const gradientColor = {
type: 'linear' as const,
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#ec4899' }, // pink-500
{ offset: 1, color: '#f472b6' }, // pink-400
],
}
const option = computed<EChartsOption>(() => {
const isHorizontal = props.horizontal
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
color: '#fff',
},
},
grid: {
left: isHorizontal ? 60 : 40,
right: 20,
top: 20,
bottom: isHorizontal ? 20 : 30,
containLabel: false,
},
xAxis: isHorizontal
? {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
type: 'dashed',
color: '#e5e7eb',
},
},
}
: {
type: 'category',
data: props.data.labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 11,
color: '#6b7280',
},
},
yAxis: isHorizontal
? {
type: 'category',
data: props.data.labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 11,
color: '#6b7280',
},
}
: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
type: 'dashed',
color: '#e5e7eb',
},
},
},
series: [
{
type: 'bar',
data: props.data.values,
itemStyle: {
color: props.gradient ? gradientColor : '#ec4899',
borderRadius: props.borderRadius,
},
barMaxWidth: 40,
emphasis: {
itemStyle: {
color: props.gradient
? {
...gradientColor,
colorStops: [
{ offset: 0, color: '#db2777' }, // pink-600
{ offset: 1, color: '#ec4899' }, // pink-500
],
}
: '#db2777',
},
},
},
],
}
})
</script>
<template>
<EChart :option="option" :height="height" />
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
/**
* ECharts 折线图组件
*/
import { computed } from 'vue'
import type { EChartsOption } from 'echarts'
import EChart from './EChart.vue'
export interface EChartLineData {
labels: string[]
values: number[]
}
interface Props {
data: EChartLineData
height?: number
/** 是否显示面积 */
showArea?: boolean
/** 是否平滑曲线 */
smooth?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 288,
showArea: true,
smooth: true,
})
const option = computed<EChartsOption>(() => {
return {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
color: '#fff',
},
},
grid: {
left: 50,
right: 20,
top: 20,
bottom: 30,
},
xAxis: {
type: 'category',
data: props.data.labels,
boundaryGap: false,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 11,
color: '#6b7280',
// 自动间隔显示标签
interval: 'auto',
},
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
type: 'dashed',
color: '#e5e7eb',
},
},
},
series: [
{
type: 'line',
data: props.data.values,
smooth: props.smooth,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
lineStyle: {
width: 2,
color: '#ec4899', // pink-500
},
itemStyle: {
color: '#ec4899',
},
areaStyle: props.showArea
? {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(236, 72, 153, 0.3)' },
{ offset: 1, color: 'rgba(236, 72, 153, 0.05)' },
],
},
}
: undefined,
emphasis: {
focus: 'series',
itemStyle: {
color: '#ec4899',
borderColor: '#fff',
borderWidth: 2,
},
},
},
],
}
})
</script>
<template>
<EChart :option="option" :height="height" />
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* ECharts 饼图/环形图组件
*/
import { computed } from 'vue'
import type { EChartsOption } from 'echarts'
import EChart from './EChart.vue'
export interface EChartPieData {
labels: string[]
values: number[]
}
interface Props {
data: EChartPieData
height?: number
/** 是否为环形图 */
doughnut?: boolean
/** 内圈半径(环形图时生效) */
innerRadius?: string
/** 是否显示图例 */
showLegend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 280,
doughnut: true,
innerRadius: '50%',
showLegend: true,
})
// 颜色方案
const colors = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#ec4899', // pink
'#f43f5e', // rose
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#06b6d4', // cyan
'#3b82f6', // blue
]
const option = computed<EChartsOption>(() => {
const seriesData = props.data.labels.map((label, index) => ({
name: label,
value: props.data.values[index],
}))
return {
color: colors,
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
color: '#fff',
},
},
legend: props.showLegend
? {
orient: 'vertical',
right: 10,
top: 'center',
textStyle: {
fontSize: 12,
},
}
: undefined,
series: [
{
type: 'pie',
radius: props.doughnut ? [props.innerRadius, '70%'] : '70%',
center: props.showLegend ? ['35%', '50%'] : ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 4,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
data: seriesData,
},
],
}
})
</script>
<template>
<EChart :option="option" :height="height" />
</template>

View File

@@ -1,119 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const { t } = useI18n()
export interface HorizontalBarChartData {
labels: string[]
values: number[]
colors?: string[]
}
interface Props {
data: HorizontalBarChartData
height?: number
showLegend?: boolean
borderRadius?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 320,
showLegend: false,
borderRadius: 8,
})
// 默认渐变色方案
const defaultColors = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#d946ef', // fuchsia
'#ec4899', // pink
'#f43f5e', // rose
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
]
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
label: t('count'),
data: props.data.values,
backgroundColor: props.data.colors || defaultColors.slice(0, props.data.values.length),
borderRadius: props.borderRadius,
borderSkipped: false,
},
],
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
display: props.showLegend,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
font: {
size: 12,
},
},
},
y: {
grid: {
display: false,
},
ticks: {
font: {
size: 12,
},
},
},
},
}
</script>
<template>
<div :style="{ height: `${height}px` }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
<i18n>
{
"zh-CN": {
"count": "数量"
},
"en-US": {
"count": "Count"
}
}
</i18n>

View File

@@ -1,128 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
const { t } = useI18n()
export interface LineChartData {
labels: string[]
values: number[]
}
interface Props {
data: LineChartData
height?: number
fill?: boolean
lineColor?: string
fillColor?: string
tension?: number
showLegend?: boolean
xAxisRotation?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 288,
fill: true,
lineColor: '#6366f1',
fillColor: 'rgba(99, 102, 241, 0.1)',
tension: 0.4,
showLegend: false,
xAxisRotation: 45,
})
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
label: t('count'),
data: props.data.values,
fill: props.fill,
borderColor: props.lineColor,
backgroundColor: props.fillColor,
tension: props.tension,
pointBackgroundColor: props.lineColor,
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.showLegend,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
maxRotation: props.xAxisRotation,
minRotation: props.xAxisRotation,
font: {
size: 11,
},
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
ticks: {
font: {
size: 11,
},
},
},
},
interaction: {
intersect: false,
mode: 'index' as const,
},
}))
</script>
<template>
<div :style="{ height: `${height}px` }">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<i18n>
{
"zh-CN": {
"count": "数量"
},
"en-US": {
"count": "Count"
}
}
</i18n>

View File

@@ -1,17 +1,22 @@
// 图表组件统一导出
export { default as DoughnutChart } from './DoughnutChart.vue'
export { default as HorizontalBarChart } from './HorizontalBarChart.vue'
export { default as LineChart } from './LineChart.vue'
export { default as BarChart } from './BarChart.vue'
// ECharts 组件
export { default as EChart } from './EChart.vue'
export { default as EChartPie } from './EChartPie.vue'
export { default as EChartBar } from './EChartBar.vue'
export { default as EChartLine } from './EChartLine.vue'
// 其他组件
export { default as RankList } from './RankList.vue'
export { default as RankListPro } from './RankListPro.vue'
export { default as ListPro } from './ListPro.vue'
export { default as ProgressBar } from './ProgressBar.vue'
export { default as MemberNicknameHistory } from './MemberNicknameHistory.vue'
// 导出类型定义
export type { DoughnutChartData } from './DoughnutChart.vue'
export type { HorizontalBarChartData } from './HorizontalBarChart.vue'
export type { LineChartData } from './LineChart.vue'
export type { BarChartData } from './BarChart.vue'
// ECharts 类型
export type { EChartPieData } from './EChartPie.vue'
export type { EChartBarData } from './EChartBar.vue'
export type { EChartLineData } from './EChartLine.vue'
// 其他类型
export type { RankItem } from './RankList.vue'

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue'
import type { DailyActivity } from '@/types/analysis'
import type { LineChartData } from '@/components/charts'
import type { EChartLineData } from '@/components/charts'
import dayjs from 'dayjs'
export function useDailyTrend(dailyActivity: DailyActivity[]) {
@@ -12,7 +12,7 @@ export function useDailyTrend(dailyActivity: DailyActivity[]) {
})
// 每日趋势图数据(动态聚合)
const dailyChartData = computed<LineChartData>(() => {
const dailyChartData = computed<EChartLineData>(() => {
const rawData = dailyActivity
const maxPoints = 50 // 最大展示点数

View File

@@ -10,8 +10,8 @@ import type {
WeekdayActivity,
MonthlyActivity,
} from '@/types/analysis'
import { DoughnutChart } from '@/components/charts'
import type { DoughnutChartData } from '@/components/charts'
import { EChartPie } from '@/components/charts'
import type { EChartPieData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
import { useOverviewStatistics } from '@/composables/analysis/useOverviewStatistics'
import { useDailyTrend } from '@/composables/analysis/useDailyTrend'
@@ -62,7 +62,7 @@ const {
const { dailyChartData } = useDailyTrend(props.dailyActivity)
// 消息类型图表数据
const typeChartData = computed<DoughnutChartData>(() => {
const typeChartData = computed<EChartPieData>(() => {
return {
labels: props.messageTypes.map((t) => getMessageTypeName(t.type)),
values: props.messageTypes.map((t) => t.count),
@@ -70,7 +70,7 @@ const typeChartData = computed<DoughnutChartData>(() => {
})
// 成员水群分布图表数据
const memberChartData = computed<DoughnutChartData>(() => {
const memberChartData = computed<EChartPieData>(() => {
const sortedMembers = [...props.memberActivity].sort((a, b) => b.messageCount - a.messageCount)
const top10 = sortedMembers.slice(0, 10)
const othersCount = sortedMembers.slice(10).reduce((sum, m) => sum + m.messageCount, 0)
@@ -161,14 +161,14 @@ watch(
<!-- 消息类型分布 -->
<SectionCard :title="t('messageTypeDistribution')" :show-divider="false">
<div class="p-5">
<DoughnutChart :data="typeChartData" :height="256" />
<EChartPie :data="typeChartData" :height="256" />
</div>
</SectionCard>
<!-- 成员水群分布 -->
<SectionCard :title="t('memberDistribution')" :show-divider="false">
<div class="p-5">
<DoughnutChart :data="memberChartData" :height="256" />
<EChartPie :data="memberChartData" :height="256" />
</div>
</SectionCard>
</div>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { RepeatAnalysis } from '@/types/analysis'
import { RankListPro, BarChart, ListPro } from '@/components/charts'
import type { RankItem, BarChartData } from '@/components/charts'
import { RankListPro, EChartBar, ListPro } from '@/components/charts'
import type { RankItem, EChartBarData } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { getRankBadgeClass } from '@/utils'
@@ -65,7 +65,7 @@ const breakerRankData = computed<RankItem[]>(() => {
}))
})
const chainLengthChartData = computed<BarChartData>(() => {
const chainLengthChartData = computed<EChartBarData>(() => {
if (!analysis.value) return { labels: [], values: [] }
const distribution = analysis.value.chainLengthDistribution
return {
@@ -115,7 +115,7 @@ watch(
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">每次复读有多少人参与</p>
</div>
<div class="p-4">
<BarChart v-if="chainLengthChartData.labels.length > 0" :data="chainLengthChartData" :height="200" />
<EChartBar v-if="chainLengthChartData.labels.length > 0" :data="chainLengthChartData" :height="200" />
<EmptyState v-else padding="md" />
</div>
</div>

View File

@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n'
import type { AnalysisSession, MessageType } from '@/types/base'
import { getMessageTypeName } from '@/types/base'
import type { MemberActivity, HourlyActivity, DailyActivity, WeekdayActivity, MonthlyActivity } from '@/types/analysis'
import { DoughnutChart } from '@/components/charts'
import type { DoughnutChartData } from '@/components/charts'
import { EChartPie } from '@/components/charts'
import type { EChartPieData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
import { useOverviewStatistics } from '@/composables/analysis/useOverviewStatistics'
import { useDailyTrend } from '@/composables/analysis/useDailyTrend'
@@ -54,7 +54,7 @@ const {
const { dailyChartData } = useDailyTrend(props.dailyActivity)
// 消息类型图表数据
const typeChartData = computed<DoughnutChartData>(() => {
const typeChartData = computed<EChartPieData>(() => {
return {
labels: props.messageTypes.map((t) => getMessageTypeName(t.type)),
values: props.messageTypes.map((t) => t.count),
@@ -89,7 +89,7 @@ const memberComparisonData = computed(() => {
})
// 双方对比图表数据
const comparisonChartData = computed<DoughnutChartData>(() => {
const comparisonChartData = computed<EChartPieData>(() => {
if (!memberComparisonData.value) {
return { labels: [], values: [] }
}
@@ -252,14 +252,14 @@ watch(
<!-- 消息类型分布 -->
<SectionCard :title="t('messageTypeDistribution')" :show-divider="false">
<div class="p-5">
<DoughnutChart :data="typeChartData" :height="256" />
<EChartPie :data="typeChartData" :height="256" />
</div>
</SectionCard>
<!-- 双方消息占比饼图 -->
<SectionCard v-if="memberComparisonData" :title="t('memberComparison')" :show-divider="false">
<div class="p-5">
<DoughnutChart :data="comparisonChartData" :height="256" />
<EChartPie :data="comparisonChartData" :height="256" />
</div>
</SectionCard>
</div>

View File

@@ -4,8 +4,8 @@ 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 { DoughnutChart, BarChart } from '@/components/charts'
import type { DoughnutChartData, BarChartData } from '@/components/charts'
import { EChartPie, EChartBar } from '@/components/charts'
import type { EChartPieData, EChartBarData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
const { t } = useI18n()
@@ -57,7 +57,7 @@ const monthNames = computed(() => [
])
// 消息类型饼图数据
const typeChartData = computed<DoughnutChartData>(() => {
const typeChartData = computed<EChartPieData>(() => {
// 按数量排序
const sorted = [...messageTypes.value].sort((a, b) => b.count - a.count)
return {
@@ -67,7 +67,7 @@ const typeChartData = computed<DoughnutChartData>(() => {
})
// 小时分布图表数据
const hourlyChartData = computed<BarChartData>(() => {
const hourlyChartData = computed<EChartBarData>(() => {
// 补全 24 小时数据
const hourMap = new Map(hourlyActivity.value.map((h) => [h.hour, h.messageCount]))
const labels: string[] = []
@@ -82,7 +82,7 @@ const hourlyChartData = computed<BarChartData>(() => {
})
// 星期分布图表数据
const weekdayChartData = computed<BarChartData>(() => {
const weekdayChartData = computed<EChartBarData>(() => {
// 补全 7 天数据weekday: 1=周一, 2=周二, ..., 7=周日)
const dayMap = new Map(weekdayActivity.value.map((w) => [w.weekday, w.messageCount]))
const values: number[] = []
@@ -99,7 +99,7 @@ const weekdayChartData = computed<BarChartData>(() => {
})
// 月份分布图表数据
const monthlyChartData = computed<BarChartData>(() => {
const monthlyChartData = computed<EChartBarData>(() => {
// 补全 12 个月数据
const monthMap = new Map(monthlyActivity.value.map((m) => [m.month, m.messageCount]))
const values: number[] = []
@@ -115,7 +115,7 @@ const monthlyChartData = computed<BarChartData>(() => {
})
// 年份分布图表数据
const yearlyChartData = computed<BarChartData>(() => {
const yearlyChartData = computed<EChartBarData>(() => {
// 按年份排序
const sorted = [...yearlyActivity.value].sort((a, b) => a.year - b.year)
return {
@@ -216,7 +216,7 @@ watch(
<!-- 消息类型分布 -->
<SectionCard :title="t('typeDistribution')" :show-divider="false">
<div class="p-5">
<DoughnutChart v-if="typeChartData.values.length > 0" :data="typeChartData" :height="280" />
<EChartPie v-if="typeChartData.values.length > 0" :data="typeChartData" :height="280" />
<div v-else class="flex h-48 items-center justify-center text-gray-400">
{{ t('noData') }}
</div>
@@ -228,14 +228,14 @@ watch(
<!-- 小时分布 -->
<SectionCard :title="t('hourlyDistribution')" :show-divider="false">
<div class="p-5">
<BarChart :data="hourlyChartData" :height="200" />
<EChartBar :data="hourlyChartData" :height="200" />
</div>
</SectionCard>
<!-- 星期分布 -->
<SectionCard :title="t('weekdayDistribution')" :show-divider="false">
<div class="p-5">
<BarChart :data="weekdayChartData" :height="200" />
<EChartBar :data="weekdayChartData" :height="200" />
</div>
</SectionCard>
</div>
@@ -245,14 +245,14 @@ watch(
<!-- 月份分布 -->
<SectionCard :title="t('monthlyDistribution')" :show-divider="false">
<div class="p-5">
<BarChart :data="monthlyChartData" :height="200" />
<EChartBar :data="monthlyChartData" :height="200" />
</div>
</SectionCard>
<!-- 年份分布 -->
<SectionCard :title="t('yearlyDistribution')" :show-divider="false">
<div class="p-5">
<BarChart
<EChartBar
v-if="yearlyChartData.values.length > 0"
:data="yearlyChartData"
:height="200"