mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-06-14 11:49:02 +08:00
269 lines
8.3 KiB
Vue
269 lines
8.3 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* ECharts 火花榜(连续天数排名)组件
|
||
* 使用横向柱状图展示连续天数数据,当前仍在连续的成员用特殊颜色标记
|
||
*/
|
||
import { computed } from 'vue'
|
||
import type { EChartsOption, BarSeriesOption } from 'echarts'
|
||
import { EChart } from '@/components/charts'
|
||
import { SectionCard, ScrollableChart } from '@/components/UI'
|
||
|
||
interface StreakItem {
|
||
memberId: number
|
||
name: string
|
||
maxStreak: number
|
||
maxStreakStart: string
|
||
maxStreakEnd: string
|
||
currentStreak: number
|
||
}
|
||
|
||
interface Props {
|
||
/** 排名数据 */
|
||
items: StreakItem[]
|
||
/** 标题 */
|
||
title: string
|
||
/** 描述(可选) */
|
||
description?: string
|
||
/** 最大显示数量,默认 10 */
|
||
topN?: number
|
||
/** 显示模式:'max' 最长连续, 'current' 当前连续 */
|
||
mode?: 'max' | 'current'
|
||
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
|
||
maxHeightVh?: number
|
||
/** 是否为裸图表模式(不包含 SectionCard 容器) */
|
||
bare?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
topN: 10,
|
||
mode: 'max',
|
||
maxHeightVh: 60,
|
||
bare: false,
|
||
})
|
||
|
||
// 限制显示数量,根据模式过滤和排序
|
||
const displayData = computed(() => {
|
||
if (props.mode === 'current') {
|
||
// 当前连续模式:只显示 currentStreak > 0 的成员,按 currentStreak 排序
|
||
return props.items
|
||
.filter((item) => item.currentStreak > 0)
|
||
.sort((a, b) => b.currentStreak - a.currentStreak)
|
||
.slice(0, props.topN)
|
||
}
|
||
// 最长连续模式:显示全部,按 maxStreak 排序
|
||
return props.items.slice(0, props.topN)
|
||
})
|
||
|
||
// 计算图表高度
|
||
const chartHeight = computed(() => {
|
||
const dataHeight = displayData.value.length * 36
|
||
return Math.max(dataHeight + 30, 180)
|
||
})
|
||
|
||
// 正常颜色(粉色渐变)
|
||
const normalColor = {
|
||
type: 'linear' as const,
|
||
x: 0,
|
||
y: 0,
|
||
x2: 1,
|
||
y2: 0,
|
||
colorStops: [
|
||
{ offset: 0, color: '#ee4567' },
|
||
{ offset: 1, color: '#f7758c' },
|
||
],
|
||
}
|
||
|
||
// 当前仍在连续的颜色(橙色渐变 - 火焰色)
|
||
const activeColor = {
|
||
type: 'linear' as const,
|
||
x: 0,
|
||
y: 0,
|
||
x2: 1,
|
||
y2: 0,
|
||
colorStops: [
|
||
{ offset: 0, color: '#f97316' },
|
||
{ offset: 1, color: '#fb923c' },
|
||
],
|
||
}
|
||
|
||
// 截断名字
|
||
function truncateName(name: string, maxLength = 8): string {
|
||
if (name.length <= maxLength) return name
|
||
return name.slice(0, maxLength) + '…'
|
||
}
|
||
|
||
// 格式化日期区间
|
||
function formatDateRange(start: string, end: string): string {
|
||
return `${start} ~ ${end}`
|
||
}
|
||
|
||
// 获取当前模式下的数值
|
||
function getValue(item: StreakItem): number {
|
||
return props.mode === 'current' ? item.currentStreak : item.maxStreak
|
||
}
|
||
|
||
// 生成 ECharts 配置
|
||
const option = computed<EChartsOption>(() => {
|
||
const reversedData = [...displayData.value].reverse()
|
||
const names = reversedData.map((item) => truncateName(item.name))
|
||
const values = reversedData.map((item) => getValue(item))
|
||
const maxValue = Math.max(...values, 1)
|
||
|
||
// 为每个柱子配置颜色和样式
|
||
const dataWithStyle = reversedData.map((item) => ({
|
||
value: getValue(item),
|
||
itemStyle: {
|
||
// 当前连续模式全部用橙色,最长连续模式根据是否仍在连续
|
||
color: props.mode === 'current' || item.currentStreak > 0 ? activeColor : normalColor,
|
||
borderRadius: [0, 4, 4, 0],
|
||
},
|
||
}))
|
||
|
||
return {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: {
|
||
type: 'shadow',
|
||
},
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
borderColor: 'transparent',
|
||
textStyle: {
|
||
color: '#fff',
|
||
},
|
||
formatter: (params: any) => {
|
||
const data = params[0]
|
||
if (!data) return ''
|
||
const originalIndex = displayData.value.length - 1 - data.dataIndex
|
||
const item = displayData.value[originalIndex]
|
||
if (props.mode === 'current') {
|
||
// 当前连续模式
|
||
return `
|
||
<div style="padding: 4px 8px;">
|
||
<div style="font-weight: bold; margin-bottom: 6px;">${item.name}</div>
|
||
<div style="color: #fb923c;">🔥 当前连续 <b>${item.currentStreak}</b> 天</div>
|
||
<div style="margin-top: 4px; font-size: 12px; color: #9ca3af;">最长记录: ${item.maxStreak} 天</div>
|
||
</div>
|
||
`
|
||
}
|
||
// 最长连续模式
|
||
let html = `
|
||
<div style="padding: 4px 8px;">
|
||
<div style="font-weight: bold; margin-bottom: 6px;">${item.name}</div>
|
||
<div style="margin-bottom: 4px;">🔥 最长连续: <b>${item.maxStreak}</b> 天</div>
|
||
<div style="font-size: 12px; color: #9ca3af;">${formatDateRange(item.maxStreakStart, item.maxStreakEnd)}</div>
|
||
`
|
||
if (item.currentStreak > 0) {
|
||
html += `<div style="margin-top: 6px; color: #fb923c;">🔥 当前连续 ${item.currentStreak} 天</div>`
|
||
}
|
||
html += '</div>'
|
||
return html
|
||
},
|
||
},
|
||
grid: {
|
||
left: 110,
|
||
right: 70,
|
||
top: 15,
|
||
bottom: 15,
|
||
containLabel: false,
|
||
},
|
||
xAxis: {
|
||
type: 'value',
|
||
max: maxValue * 1.15,
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
axisLabel: { show: false },
|
||
splitLine: { show: false },
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: names,
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
axisLabel: {
|
||
fontSize: 12,
|
||
color: '#4b5563',
|
||
margin: 12,
|
||
formatter: (value: string, index: number) => {
|
||
const originalIndex = displayData.value.length - 1 - index
|
||
const rank = originalIndex + 1
|
||
const prefix = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `${rank}.`
|
||
return `${prefix} ${value}`
|
||
},
|
||
},
|
||
},
|
||
series: [
|
||
{
|
||
type: 'bar',
|
||
data: dataWithStyle,
|
||
barWidth: 18,
|
||
barCategoryGap: '30%',
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
distance: 8,
|
||
formatter: (params: any) => {
|
||
const originalIndex = displayData.value.length - 1 - params.dataIndex
|
||
const item = displayData.value[originalIndex]
|
||
const value = getValue(item)
|
||
// 当前连续模式或最长连续模式中仍在连续的,显示火焰图标
|
||
const suffix = props.mode === 'current' || item.currentStreak > 0 ? ' 🔥' : ''
|
||
return `${value} 天${suffix}`
|
||
},
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
color: '#6b7280',
|
||
},
|
||
emphasis: {
|
||
itemStyle: {
|
||
shadowBlur: 6,
|
||
shadowColor: 'rgba(249, 115, 22, 0.3)',
|
||
},
|
||
},
|
||
} as BarSeriesOption,
|
||
],
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 裸图表模式 -->
|
||
<div v-if="bare">
|
||
<ScrollableChart :content-height="chartHeight" :max-height-vh="maxHeightVh">
|
||
<EChart :option="option" :height="chartHeight" />
|
||
</ScrollableChart>
|
||
<!-- 图例(仅在最长连续模式显示) -->
|
||
<div
|
||
v-if="mode === 'max'"
|
||
class="flex items-center justify-center gap-6 border-t border-gray-100 px-5 py-3 dark:border-gray-800"
|
||
>
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-3 w-6 rounded bg-linear-to-r from-orange-500 to-orange-400" />
|
||
<span class="text-xs text-gray-500">当前连续中 🔥</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-3 w-6 rounded bg-linear-to-r from-pink-500 to-pink-400" />
|
||
<span class="text-xs text-gray-500">已中断</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 完整模式 -->
|
||
<SectionCard v-else :title="title" :description="description" scrollable :max-height-vh="maxHeightVh">
|
||
<div class="px-3 py-2">
|
||
<EChart :option="option" :height="chartHeight" />
|
||
</div>
|
||
<!-- 图例(仅在最长连续模式显示) -->
|
||
<template v-if="mode === 'max'" #footer>
|
||
<div class="flex items-center justify-center gap-6 border-t border-gray-100 px-5 py-3 dark:border-gray-800">
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-3 w-6 rounded bg-linear-to-r from-orange-500 to-orange-400" />
|
||
<span class="text-xs text-gray-500">当前连续中 🔥</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-3 w-6 rounded bg-linear-to-r from-pink-500 to-pink-400" />
|
||
<span class="text-xs text-gray-500">已中断</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</SectionCard>
|
||
</template>
|