feat: 重构榜单为图表

This commit is contained in:
digua
2026-02-02 01:00:15 +08:00
parent b77db9497b
commit d9e9e0b9f3
35 changed files with 2328 additions and 1207 deletions
+21 -17
View File
@@ -30,22 +30,26 @@ function handleClick(id: string) {
<template>
<div :class="[width, 'shrink-0', hideOnMobile ? 'hidden lg:block' : '']">
<nav class="sticky top-24">
<div class="border-l border-gray-200 dark:border-gray-800">
<button
v-for="anchor in anchors"
:key="anchor.id"
class="-ml-px block border-l-2 py-1.5 pl-4 text-left text-sm transition-colors"
:class="[
activeAnchor === anchor.id
? 'border-pink-500 font-medium text-pink-600 dark:text-pink-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
]"
@click="handleClick(anchor.id)"
>
{{ anchor.label }}
</button>
</div>
</nav>
<div class="sticky top-24 space-y-6">
<nav>
<div class="border-l border-gray-200 dark:border-gray-800">
<button
v-for="anchor in anchors"
:key="anchor.id"
class="-ml-px block border-l-2 py-1.5 pl-4 text-left text-sm transition-colors"
:class="[
activeAnchor === anchor.id
? 'border-pink-500 font-medium text-pink-600 dark:text-pink-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
]"
@click="handleClick(anchor.id)"
>
{{ anchor.label }}
</button>
</div>
</nav>
<!-- 额外内容插槽 -->
<slot />
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 可滚动图表容器
* 提供最大高度限制和自动滚动功能
*/
import { computed } from 'vue'
interface Props {
/** 内容实际高度(像素) */
contentHeight: number
/** 最大高度(vh 单位),默认 60vh */
maxHeightVh?: number
/** 内边距类名 */
paddingClass?: string
}
const props = withDefaults(defineProps<Props>(), {
maxHeightVh: 60,
paddingClass: 'px-3 py-2',
})
// 计算最大高度(像素)
const maxHeightPx = computed(() => {
if (typeof window === 'undefined') return 500
return Math.round(window.innerHeight * props.maxHeightVh / 100)
})
// 是否需要滚动
const needScroll = computed(() => props.contentHeight > maxHeightPx.value)
// 容器样式
const containerStyle = computed(() => ({
maxHeight: `${props.maxHeightVh}vh`,
overflowY: (needScroll.value ? 'auto' : 'hidden') as 'auto' | 'hidden',
}))
</script>
<template>
<div :class="paddingClass" :style="containerStyle">
<slot />
</div>
</template>
+36 -11
View File
@@ -3,17 +3,36 @@
* 带标题的卡片容器组件
* 统一的分析模块卡片样式
*/
defineProps<{
/** 卡片标题 */
title: string
/** 可选的描述文字 */
description?: string
/** 是否显示边框分隔线(默认 true) */
showDivider?: boolean
}>()
import { computed } from 'vue'
// 设置默认值
const showDivider = defineModel('showDivider', { default: true })
const props = withDefaults(
defineProps<{
/** 卡片标题 */
title: string
/** 可选的描述文字 */
description?: string
/** 是否显示边框分隔线(默认 true) */
showDivider?: boolean
/** 是否启用内容滚动 */
scrollable?: boolean
/** 最大高度(vh 单位),默认 60vh */
maxHeightVh?: number
}>(),
{
showDivider: true,
scrollable: false,
maxHeightVh: 60,
}
)
// 内容区域样式
const contentStyle = computed(() => {
if (!props.scrollable) return undefined
return {
maxHeight: `${props.maxHeightVh}vh`,
overflowY: 'auto' as const,
}
})
</script>
<template>
@@ -33,6 +52,12 @@ const showDivider = defineModel('showDivider', { default: true })
</div>
<!-- 内容区域 -->
<slot />
<div v-if="scrollable" :style="contentStyle">
<slot />
</div>
<slot v-else />
<!-- 底部区域在滚动区域外 -->
<slot name="footer" />
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
/**
* TopN 数量选择器
* 用于控制排行榜显示的数量
*/
const modelValue = defineModel<number>({ default: 10 })
const options = [
{ label: 'Top 5', value: 5 },
{ label: 'Top 10', value: 10 },
{ label: 'Top 20', value: 20 },
{ label: 'Top 30', value: 30 },
{ label: 'Top 50', value: 50 },
{ label: 'Top 100', value: 100 },
]
</script>
<template>
<USelect v-model="modelValue" :items="options" value-key="value" class="w-25" />
</template>
+2
View File
@@ -11,3 +11,5 @@ export { default as SubTabs } from './SubTabs.vue'
export { default as PageAnchorsNav } from './PageAnchorsNav.vue'
export { default as FileDropZone } from './FileDropZone.vue'
export { default as DatePicker } from './DatePicker.vue'
export { default as TopNSelect } from './TopNSelect.vue'
export { default as ScrollableChart } from './ScrollableChart.vue'
+11
View File
@@ -93,6 +93,17 @@ function handleResize() {
// 监听 option 变化
watch(() => props.option, updateChart, { deep: true })
// 监听高度变化
watch(
() => props.height,
() => {
// 使用 nextTick 确保 DOM 更新后再调整大小
setTimeout(() => {
chartInstance?.resize()
}, 0)
}
)
// 监听主题变化
watch(isDark, () => {
initChart()
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
/**
* ECharts 排名图表组件
* 使用横向柱状图展示排名数据,前三名显示奖牌 emoji
*/
import { computed } from 'vue'
import type { EChartsOption, BarSeriesOption } from 'echarts'
import EChart from './EChart.vue'
import type { RankItem } from './RankList.vue'
import { SectionCard, ScrollableChart } from '@/components/UI'
interface Props {
/** 排名数据 */
members: RankItem[]
/** 标题 */
title: string
/** 描述(可选) */
description?: string
/** 最大显示数量,默认 10 */
topN?: number
/** 单位名称 */
unit?: string
/** 图表高度策略:'auto' 根据数据量计算,或固定像素值 */
height?: 'auto' | number
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
maxHeightVh?: number
/** 是否为裸图表模式(不包含 SectionCard 容器) */
bare?: boolean
}
const props = withDefaults(defineProps<Props>(), {
topN: 10,
unit: '条',
height: 'auto',
maxHeightVh: 60,
bare: false,
})
// 限制显示数量
const displayData = computed(() => {
return props.members.slice(0, props.topN)
})
// 计算图表高度
const chartHeight = computed(() => {
if (props.height !== 'auto') {
return props.height
}
// 每条数据 36px(更紧凑)
const dataHeight = displayData.value.length * 36
// 增加上下边距
return Math.max(dataHeight + 30, 180)
})
// 统一使用项目主题粉色
const barColor = {
type: 'linear' as const,
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{ offset: 0, color: '#ee4567' }, // 项目主题 pink-500
{ offset: 1, color: '#f7758c' }, // 项目主题 pink-400
],
}
// 截断名字(最多8个字符)
function truncateName(name: string, maxLength = 8): string {
if (name.length <= maxLength) return name
return name.slice(0, maxLength) + '…'
}
// 生成 ECharts 配置
const option = computed<EChartsOption>(() => {
// 数据需要反转,因为柱状图 Y 轴从下到上
const reversedData = [...displayData.value].reverse()
const names = reversedData.map((item) => truncateName(item.name))
const values = reversedData.map((item) => item.value)
const maxValue = Math.max(...values, 1)
// 柱子数据(统一颜色)
const dataWithStyle = reversedData.map((item) => ({
value: item.value,
itemStyle: {
color: barColor,
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 member = displayData.value[originalIndex]
return `
<div style="padding: 4px 8px;">
<div style="font-weight: bold; margin-bottom: 4px;">${member.name}</div>
<div>${member.value} ${props.unit} (${member.percentage}%)</div>
</div>
`
},
},
grid: {
left: 110,
right: 70,
top: 15,
bottom: 15,
containLabel: false,
},
xAxis: {
type: 'value',
max: maxValue * 1.1, // 留出标签空间
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
// 前三名添加奖牌 emoji,其他用数字
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 member = displayData.value[originalIndex]
return `${member.value} ${props.unit}`
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
emphasis: {
itemStyle: {
shadowBlur: 6,
shadowColor: 'rgba(238, 69, 103, 0.3)',
},
},
} as BarSeriesOption,
],
}
})
</script>
<template>
<!-- 裸图表模式只显示图表 -->
<ScrollableChart v-if="bare" :content-height="chartHeight" :max-height-vh="maxHeightVh">
<EChart :option="option" :height="chartHeight" />
</ScrollableChart>
<!-- 完整模式 SectionCard 容器 -->
<SectionCard v-else :title="title" :description="description" scrollable :max-height-vh="maxHeightVh">
<div class="px-3 py-2">
<EChart :option="option" :height="chartHeight" />
</div>
</SectionCard>
</template>
+1
View File
@@ -9,6 +9,7 @@ export { default as EChartHeatmap } from './EChartHeatmap.vue'
export { default as EChartCalendar } from './EChartCalendar.vue'
export { default as EChartGraph } from './EChartGraph.vue'
export { default as EChartWordcloud } from './EChartWordcloud.vue'
export { default as EChartRank } from './EChartRank.vue'
// 其他组件
export { default as RankList } from './RankList.vue'
+24 -41
View File
@@ -1,14 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { MemberActivity } from '@/types/analysis'
import { RankListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { PageAnchorsNav } from '@/components/UI'
import { PageAnchorsNav, TopNSelect } from '@/components/UI'
import { usePageAnchors } from '@/composables'
import DragonKingRank from './ranking/DragonKingRank.vue'
import ActivityRank from './ranking/ActivityRank.vue'
import CheckInRank from './ranking/CheckInRank.vue'
import MemeBattleRank from './ranking/MemeBattleRank.vue'
import MonologueRank from './ranking/MonologueRank.vue'
import RepeatSection from './ranking/RepeatSection.vue'
import DivingRank from './ranking/DivingRank.vue'
import NightOwlRank from './ranking/NightOwlRank.vue'
@@ -46,14 +43,11 @@ const seasonTitle = computed(() => {
// 锚点导航配置
const anchors = [
{ id: 'dragon-king', label: '🐉 龙王榜' },
{ id: 'member-activity', label: '📊 水群榜' },
{ id: 'activity-rank', label: '🏆 活跃榜' },
{ id: 'streak-rank', label: '🔥 火花榜' },
{ id: 'loyalty-rank', label: '💎 忠臣榜' },
{ id: 'meme-battle', label: '⚔️ 斗图榜' },
{ id: 'monologue', label: '🎤 自言自语榜' },
{ id: 'repeat', label: '🔁 复读榜' },
{ id: 'night-owl', label: '🦉 修仙榜' },
{ id: 'night-owl', label: '⏰ 出勤榜' },
{ id: 'diving', label: '🤿 潜水榜' },
]
@@ -62,15 +56,8 @@ const { contentRef, activeAnchor, scrollToAnchor } = usePageAnchors(anchors, { t
// Template ref - used via ref="contentRef" in template
void contentRef
// ==================== 成员活跃度排行 ====================
const memberRankData = computed<RankItem[]>(() => {
return props.memberActivity.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.messageCount,
percentage: m.percentage,
}))
})
// 全局 TopN 控制
const globalTopN = ref(10)
</script>
<template>
@@ -87,48 +74,44 @@ const memberRankData = computed<RankItem[]>(() => {
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">各榜单前三名请找群主领取奖励 🎁</p>
</div>
<!-- 龙王排名 -->
<div id="dragon-king" class="scroll-mt-24">
<DragonKingRank :session-id="sessionId" :time-filter="timeFilter" />
<!-- 活跃榜龙王 + 发言数量 -->
<div id="activity-rank" class="scroll-mt-24">
<ActivityRank :session-id="sessionId" :member-activity="memberActivity" :time-filter="timeFilter" :global-top-n="globalTopN" />
</div>
<!-- 成员活跃度排行 -->
<div id="member-activity" class="scroll-mt-24">
<RankListPro :members="memberRankData" title="水群榜" />
</div>
<!-- 火花榜 + 忠臣榜 -->
<CheckInRank :session-id="sessionId" :time-filter="timeFilter" />
<!-- 火花榜 -->
<CheckInRank :session-id="sessionId" :time-filter="timeFilter" :global-top-n="globalTopN" />
<!-- 斗图榜 -->
<div id="meme-battle" class="scroll-mt-24">
<MemeBattleRank :session-id="sessionId" :time-filter="timeFilter" />
</div>
<!-- 自言自语榜 -->
<div id="monologue" class="scroll-mt-24">
<MonologueRank :session-id="sessionId" :time-filter="timeFilter" />
<MemeBattleRank :session-id="sessionId" :time-filter="timeFilter" :global-top-n="globalTopN" />
</div>
<!-- 复读分析 -->
<div id="repeat" class="scroll-mt-24">
<RepeatSection :session-id="sessionId" :time-filter="timeFilter" />
<RepeatSection :session-id="sessionId" :time-filter="timeFilter" :global-top-n="globalTopN" />
</div>
<!-- 修仙排行 -->
<!-- 出勤 -->
<div id="night-owl" class="scroll-mt-24">
<NightOwlRank :session-id="sessionId" :time-filter="timeFilter" />
<NightOwlRank :session-id="sessionId" :time-filter="timeFilter" :global-top-n="globalTopN" />
</div>
<!-- 潜水排名 -->
<div id="diving" class="scroll-mt-24">
<DivingRank :session-id="sessionId" :time-filter="timeFilter" />
<DivingRank :session-id="sessionId" :time-filter="timeFilter" :global-top-n="globalTopN" />
</div>
<!-- 底部间距确保最后一个锚点可以滚动到顶部 -->
<div class="h-48 no-capture" />
</div>
<!-- 右侧锚点导航 -->
<PageAnchorsNav :anchors="anchors" :active-anchor="activeAnchor" @click="scrollToAnchor" />
<PageAnchorsNav :anchors="anchors" :active-anchor="activeAnchor" @click="scrollToAnchor">
<!-- 全局 TopN 控制 -->
<div class="border-l border-gray-200 pl-4 dark:border-gray-800">
<div class="text-xs text-gray-400 mb-2">显示数量</div>
<TopNSelect v-model="globalTopN" />
</div>
</PageAnchorsNav>
</div>
</template>
+1 -1
View File
@@ -54,7 +54,7 @@ const selectedMemberId = ref<number | null>(null)
</SubTabs>
<!-- Tab 内容 -->
<div class="flex-1 min-h-0 overflow-auto">
<div class="flex-1 min-h-0 overflow-y-auto">
<Transition name="fade" mode="out-in">
<MessageView
v-if="activeSubTab === 'message'"
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { DragonKingAnalysis, CheckInAnalysis, MemberActivity } from '@/types/analysis'
import { EChartRank } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { SectionCard, LoadingState, Tabs, TopNSelect } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = withDefaults(
defineProps<{
sessionId: string
memberActivity: MemberActivity[]
timeFilter?: TimeFilter
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}>(),
{
showTopNSelect: true,
}
)
const dragonKingAnalysis = ref<DragonKingAnalysis | null>(null)
const checkInAnalysis = ref<CheckInAnalysis | null>(null)
const isLoading = ref(false)
const activeTab = ref<'activity' | 'dragon' | 'loyalty'>('activity') // 默认发言数量
const topN = ref(props.globalTopN ?? 10)
// 监听全局 TopN 变化,强制同步
watch(
() => props.globalTopN,
(newVal) => {
if (newVal !== undefined) {
topN.value = newVal
}
}
)
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
const [dragonKing, checkIn] = await Promise.all([
window.chatApi.getDragonKingAnalysis(props.sessionId, props.timeFilter),
window.chatApi.getCheckInAnalysis(props.sessionId, props.timeFilter),
])
dragonKingAnalysis.value = dragonKing
checkInAnalysis.value = checkIn
} catch (error) {
console.error('加载活跃数据失败:', error)
} finally {
isLoading.value = false
}
}
// 发言数量数据
const memberRankData = computed<RankItem[]>(() => {
return props.memberActivity.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.messageCount,
percentage: m.percentage,
}))
})
// 龙王榜数据
const dragonKingRankData = computed<RankItem[]>(() => {
if (!dragonKingAnalysis.value) return []
return dragonKingAnalysis.value.rank.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.count,
percentage: m.percentage,
}))
})
// 忠臣榜(累计发言)数据
const loyaltyRankData = computed<RankItem[]>(() => {
if (!checkInAnalysis.value) return []
return checkInAnalysis.value.loyaltyRank.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.totalDays,
percentage: m.percentage,
}))
})
const currentRankData = computed(() => {
switch (activeTab.value) {
case 'activity':
return memberRankData.value
case 'dragon':
return dragonKingRankData.value
case 'loyalty':
return loyaltyRankData.value
default:
return memberRankData.value
}
})
const rankUnit = computed(() => {
switch (activeTab.value) {
case 'activity':
return '条'
case 'dragon':
case 'loyalty':
return '天'
default:
return '条'
}
})
const cardTitle = computed(() => {
switch (activeTab.value) {
case 'activity':
return '🏆 活跃榜 - 发言数量'
case 'dragon':
return '🏆 活跃榜 - 龙王'
case 'loyalty':
return '🏆 活跃榜 - 累计发言'
default:
return '🏆 活跃榜'
}
})
const cardDescription = computed(() => {
switch (activeTab.value) {
case 'activity':
return '按消息发送数量排名'
case 'dragon': {
const totalDays = dragonKingAnalysis.value?.totalDays ?? 0
return `每天发言最多的人+1(共 ${totalDays} 天)`
}
case 'loyalty': {
const totalDays = checkInAnalysis.value?.totalDays ?? 0
return `累计发言天数排名(共 ${totalDays} 天)`
}
default:
return ''
}
})
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
{ immediate: true, deep: true }
)
</script>
<template>
<SectionCard :title="cardTitle" :description="cardDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="topN" />
<Tabs
v-model="activeTab"
:items="[
{ label: '发言数量', value: 'activity' },
{ label: '龙王', value: 'dragon' },
{ label: '累计发言', value: 'loyalty' },
]"
size="sm"
/>
</div>
</template>
<LoadingState v-if="isLoading && (activeTab === 'dragon' || activeTab === 'loyalty')" text="正在加载数据..." />
<template v-else>
<EChartRank
v-if="currentRankData.length > 0"
:members="currentRankData"
:title="cardTitle"
:unit="rankUnit"
:top-n="topN"
bare
/>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无数据</div>
</template>
</SectionCard>
</template>
@@ -1,23 +1,54 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import type { CheckInAnalysis } from '@/types/analysis'
import { RankListPro, ListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { SectionCard, LoadingState, EmptyState } from '@/components/UI'
import { getRankBadgeClass } from '@/utils'
import { EChartStreakRank } from './charts'
import { SectionCard, LoadingState, EmptyState, Tabs, TopNSelect } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const props = withDefaults(
defineProps<{
sessionId: string
timeFilter?: TimeFilter
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}>(),
{
showTopNSelect: true,
}
)
const analysis = ref<CheckInAnalysis | null>(null)
const isLoading = ref(false)
const streakMode = ref<'max' | 'current'>('max')
const topN = ref(props.globalTopN ?? 10)
// 监听全局 TopN 变化,强制同步
watch(
() => props.globalTopN,
(newVal) => {
if (newVal !== undefined) {
topN.value = newVal
}
}
)
// 计算火花榜标题和描述
const streakTitle = computed(() => (streakMode.value === 'max' ? '🔥 火花榜 - 最长连续' : '🔥 火花榜 - 当前连续'))
const streakDescription = computed(() =>
streakMode.value === 'max' ? '历史最长连续发言天数' : '正在持续连续发言的成员'
)
// 检查是否有当前连续的成员
const hasCurrentStreak = computed(() => {
if (!analysis.value) return false
return analysis.value.streakRank.some((item) => item.currentStreak > 0)
})
async function loadAnalysis() {
if (!props.sessionId) return
@@ -31,22 +62,6 @@ async function loadAnalysis() {
}
}
// 忠臣榜数据转换
function getLoyaltyRankData(): RankItem[] {
if (!analysis.value) return []
return analysis.value.loyaltyRank.map((item) => ({
id: item.memberId.toString(),
name: item.name,
value: item.totalDays,
percentage: item.percentage,
}))
}
// 格式化日期区间
function formatDateRange(start: string, end: string): string {
return `${start} ~ ${end}`
}
watch(
() => [props.sessionId, props.timeFilter],
() => {
@@ -57,72 +72,29 @@ watch(
</script>
<template>
<div class="space-y-6">
<LoadingState v-if="isLoading" text="正在分析打卡数据..." />
<div id="streak-rank" class="scroll-mt-24">
<LoadingState v-if="isLoading" text="正在分析数据..." />
<template v-else-if="analysis">
<!-- 火花榜连续发言天数 -->
<div id="streak-rank" class="scroll-mt-24">
<ListPro
v-if="analysis.streakRank.length > 0"
:items="analysis.streakRank"
title="🔥 火花榜"
description="最长连续发言天数排名"
:topN="10"
countTemplate="共 {count} 位成员"
>
<template #item="{ item, index }">
<div class="flex items-center gap-3">
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</div>
<SectionCard v-else-if="analysis && analysis.streakRank.length > 0" :title="streakTitle" :description="streakDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="topN" />
<Tabs
v-if="hasCurrentStreak"
v-model="streakMode"
:items="[
{ label: '最长连续', value: 'max' },
{ label: '当前连续', value: 'current' },
]"
size="sm"
/>
</div>
</template>
<EChartStreakRank :items="analysis.streakRank" :title="streakTitle" :mode="streakMode" :top-n="topN" bare />
</SectionCard>
<!-- 名字 -->
<div class="w-28 shrink-0">
<p class="truncate font-medium text-gray-900 dark:text-white">
{{ item.name }}
</p>
</div>
<!-- 连续天数 -->
<div class="flex flex-1 items-center gap-3">
<div class="text-lg font-bold text-orange-600 dark:text-orange-400">{{ item.maxStreak }} </div>
<div class="text-xs text-gray-500">
{{ formatDateRange(item.maxStreakStart, item.maxStreakEnd) }}
</div>
</div>
<!-- 当前连续 -->
<div v-if="item.currentStreak > 0" class="shrink-0">
<span
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400 whitespace-nowrap"
>
当前连续 {{ item.currentStreak }} 🔥
</span>
</div>
</div>
</template>
</ListPro>
</div>
<!-- 忠臣榜累计发言天数 -->
<div id="loyalty-rank" class="scroll-mt-24">
<RankListPro
v-if="analysis.loyaltyRank.length > 0"
:members="getLoyaltyRankData()"
title="💎 忠臣榜"
:description="`累计发言天数排名(群聊共 ${analysis.totalDays} 天)`"
unit="天"
/>
</div>
</template>
<SectionCard v-else title="🎯 打卡榜">
<EmptyState text="暂无打卡数据" />
<SectionCard v-else title="🔥 火花榜">
<EmptyState text="暂无连续发言数据" />
</SectionCard>
</div>
</template>
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { DivingAnalysis } from '@/types/analysis'
import { ListPro } from '@/components/charts'
import { EChartDivingRank } from './charts'
import { LoadingState } from '@/components/UI'
import { formatFullDateTime, formatDaysSince, getRankBadgeClass } from '@/utils'
interface TimeFilter {
startTs?: number
@@ -13,6 +12,8 @@ interface TimeFilter {
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}>()
const analysis = ref<DivingAnalysis | null>(null)
@@ -39,53 +40,9 @@ watch(
<template>
<LoadingState v-if="isLoading" text="正在统计潜水数据..." />
<ListPro
<EChartDivingRank
v-else-if="analysis && analysis.rank.length > 0"
:items="analysis.rank"
title="🤿 潜水榜"
description="按最后发言时间排序,最久没发言的在前面"
countTemplate="共 {count} 位潜水员"
>
<template #item="{ item: member, index }">
<div class="flex items-center gap-3">
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</div>
<!-- 名字 -->
<div class="w-32 shrink-0">
<p class="truncate font-medium text-gray-900 dark:text-white">
{{ member.name }}
</p>
</div>
<!-- 最后发言时间 -->
<div class="flex flex-1 items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ formatFullDateTime(member.lastMessageTs) }}
</span>
</div>
<!-- 距今天数 -->
<div class="shrink-0 text-right">
<span
class="text-sm font-medium"
:class="
member.daysSinceLastMessage > 30
? 'text-red-600 dark:text-red-400'
: member.daysSinceLastMessage > 7
? 'text-orange-600 dark:text-orange-400'
: 'text-gray-600 dark:text-gray-400'
"
>
{{ formatDaysSince(member.daysSinceLastMessage) }}
</span>
</div>
</div>
</template>
</ListPro>
:global-top-n="globalTopN"
/>
</template>
@@ -1,59 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { DragonKingAnalysis } from '@/types/analysis'
import { RankListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { LoadingState } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const analysis = ref<DragonKingAnalysis | null>(null)
const isLoading = ref(false)
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
analysis.value = await window.chatApi.getDragonKingAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载龙王分析失败:', error)
} finally {
isLoading.value = false
}
}
const rankData = computed<RankItem[]>(() => {
if (!analysis.value) return []
return analysis.value.rank.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.count,
percentage: m.percentage,
}))
})
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
{ immediate: true, deep: true }
)
</script>
<template>
<LoadingState v-if="isLoading" text="正在统计龙王数据..." />
<RankListPro
v-else-if="rankData.length > 0"
:members="rankData"
title="🐉 龙王榜"
:description="`每天发言最多的人+1(共 ${analysis?.totalDays ?? 0} 天)`"
unit="天"
/>
</template>
@@ -1,24 +1,44 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { MemeBattleAnalysis } from '@/types/analysis'
import { RankListPro, ListPro } from '@/components/charts'
import { EChartRank } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { LoadingState, Tabs } from '@/components/UI'
import { formatDate } from '@/utils/dateFormat'
import { EChartBattleRank } from './charts'
import { LoadingState, Tabs, SectionCard, TopNSelect } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const props = withDefaults(
defineProps<{
sessionId: string
timeFilter?: TimeFilter
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}>(),
{
showTopNSelect: true,
}
)
const analysis = ref<MemeBattleAnalysis | null>(null)
const isLoading = ref(false)
const activeTab = ref(0) // 0: 参与场次, 1: 图片数量
const activeTab = ref<'count' | 'image' | 'battle'>('count') // 默认按场次
const topN = ref(props.globalTopN ?? 10)
// 监听全局 TopN 变化,强制同步
watch(
() => props.globalTopN,
(newVal) => {
if (newVal !== undefined) {
topN.value = newVal
}
}
)
async function loadData() {
if (!props.sessionId) return
@@ -53,19 +73,37 @@ const rankDataByImageCount = computed<RankItem[]>(() => {
})
const currentRankData = computed(() => {
return activeTab.value === 0 ? rankDataByCount.value : rankDataByImageCount.value
})
const rankTitle = computed(() => {
return activeTab.value === 0 ? '斗图达人榜 (按场次)' : '斗图达人榜 (按图量)'
return activeTab.value === 'count' ? rankDataByCount.value : rankDataByImageCount.value
})
const rankUnit = computed(() => {
return activeTab.value === 0 ? '场' : '张'
return activeTab.value === 'count' ? '场' : '张'
})
const rankDescription = computed(() => {
return activeTab.value === 0 ? '参与斗图次数最多的人' : '在斗图中发送图片最多的人'
const cardTitle = computed(() => {
switch (activeTab.value) {
case 'battle':
return '⚔️ 斗图榜 - 史诗级战役'
case 'count':
return '⚔️ 斗图榜 - 按场次'
case 'image':
return '⚔️ 斗图榜 - 按图量'
default:
return '⚔️ 斗图榜'
}
})
const cardDescription = computed(() => {
switch (activeTab.value) {
case 'battle':
return '记录最激烈的斗图大战'
case 'count':
return '参与斗图次数最多的人'
case 'image':
return '在斗图中发送图片最多的人'
default:
return ''
}
})
watch(
@@ -76,79 +114,41 @@ watch(
</script>
<template>
<div class="space-y-6">
<LoadingState v-if="isLoading" text="正在统计斗图数据..." />
<LoadingState v-if="isLoading" text="正在统计斗图数据..." />
<template v-else-if="analysis">
<!-- 史诗级斗图榜 -->
<ListPro
v-if="analysis.topBattles.length > 0"
:items="analysis.topBattles"
title="⚔️ 史诗级斗图榜"
description="记录最激烈的斗图大战(按图片数量排名)"
:top-n="10"
count-template=" {count} 场战役"
>
<template #item="{ item, index }">
<div class="flex items-start gap-4">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
:class="
index === 0
? 'bg-amber-500'
: index === 1
? 'bg-gray-400'
: index === 2
? 'bg-amber-700'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
"
>
{{ index + 1 }}
</div>
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ formatDate(item.startTime) }}
</span>
<span class="text-xs text-gray-500">{{ item.participantCount }} 人参战</span>
</div>
<span class="text-sm font-bold text-pink-500">{{ item.totalImages }} </span>
</div>
<!-- 参战人员 -->
<div class="flex flex-wrap gap-2">
<div
v-for="p in item.participants.slice(0, 5)"
:key="p.memberId"
class="flex items-center gap-1.5 rounded-full bg-gray-50 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<span class="font-medium">{{ p.name }}</span>
<span class="text-gray-400">{{ p.imageCount }}</span>
</div>
<span v-if="item.participants.length > 5" class="text-xs text-gray-400">
+{{ item.participants.length - 5 }}
</span>
</div>
</div>
</div>
</template>
</ListPro>
<!-- 斗图达人榜 -->
<div v-if="currentRankData.length > 0" class="relative">
<div class="absolute top-5 right-42">
<Tabs
v-model="activeTab"
:items="[
{ label: '按场次', value: 0 },
{ label: '按图量', value: 1 },
]"
size="sm"
/>
</div>
<RankListPro :members="currentRankData" :title="rankTitle" :description="rankDescription" :unit="rankUnit" />
<SectionCard v-else-if="analysis" :title="cardTitle" :description="cardDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="topN" />
<Tabs
v-model="activeTab"
:items="[
{ label: '按场次', value: 'count' },
{ label: '按图量', value: 'image' },
{ label: '史诗级战役', value: 'battle' },
]"
size="sm"
/>
</div>
</template>
</div>
<!-- 史诗级战役视图 -->
<template v-if="activeTab === 'battle'">
<EChartBattleRank v-if="analysis.topBattles.length > 0" :battles="analysis.topBattles" title="" :top-n="topN" bare />
<div v-else class="py-8 text-center text-sm text-gray-400">暂无史诗级战役数据</div>
</template>
<!-- 按场次/按图量视图 -->
<template v-else>
<EChartRank
v-if="currentRankData.length > 0"
:members="currentRankData"
:title="cardTitle"
:unit="rankUnit"
:top-n="topN"
bare
/>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无斗图数据</div>
</template>
</SectionCard>
</template>
@@ -1,163 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { MonologueAnalysis } from '@/types/analysis'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { formatDateTime, getRankBadgeClass } from '@/utils'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const analysis = ref<MonologueAnalysis | null>(null)
const isLoading = ref(false)
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
analysis.value = await window.chatApi.getMonologueAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载自言自语分析失败:', error)
} finally {
isLoading.value = false
}
}
function getComboLabel(maxCombo: number): { text: string; color: string } {
if (maxCombo >= 10) return { text: '无人区广播', color: 'text-red-600 dark:text-red-400' }
if (maxCombo >= 5) return { text: '小作文达人', color: 'text-yellow-600 dark:text-yellow-400' }
return { text: '加特林模式', color: 'text-green-600 dark:text-green-400' }
}
const maxTotalStreaks = computed(() => {
if (!analysis.value || analysis.value.rank.length === 0) return 1
return analysis.value.rank[0].totalStreaks
})
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
{ immediate: true, deep: true }
)
</script>
<template>
<SectionCard title="🎤 自言自语榜" description="连续发言 ≥3 条(间隔 ≤5 分钟)统计">
<LoadingState v-if="isLoading" text="正在统计自言自语数据..." />
<template v-else-if="analysis && analysis.rank.length > 0">
<!-- 最高纪录卡片 -->
<div
v-if="analysis.maxComboRecord"
class="mx-5 mt-4 rounded-lg bg-linear-to-r from-amber-50 to-orange-50 p-4 dark:from-amber-900/20 dark:to-orange-900/20"
>
<div class="flex items-center gap-2">
<span class="text-2xl">🏆</span>
<span class="font-semibold text-gray-900 dark:text-white">历史最高连击纪录</span>
</div>
<div class="mt-2 flex items-baseline gap-2 whitespace-nowrap">
<span class="text-lg font-bold text-amber-600 dark:text-amber-400">
{{ analysis.maxComboRecord.memberName }}
</span>
<span class="text-sm text-gray-500"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ formatDateTime(analysis.maxComboRecord.startTs) }}
</span>
<span class="text-sm text-gray-500">达成了</span>
<span class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ analysis.maxComboRecord.comboLength }} 连击
</span>
</div>
</div>
<!-- 排行榜 -->
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="(member, index) in analysis.rank.slice(0, 10)"
:key="member.memberId"
class="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</div>
<!-- 名字 + 标签 -->
<div class="w-32 shrink-0">
<p class="truncate font-medium text-gray-900 dark:text-white">
{{ member.name }}
</p>
<p class="text-xs" :class="getComboLabel(member.maxCombo).color">
{{ getComboLabel(member.maxCombo).text }}
</p>
</div>
<!-- 三色能量条 -->
<div class="flex flex-1 items-center">
<div class="h-4 w-full rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="flex h-full overflow-hidden rounded-full"
:style="{ width: `${(member.totalStreaks / maxTotalStreaks) * 100}%` }"
>
<div
v-if="member.lowStreak > 0"
class="h-full bg-green-500"
:style="{ width: `${(member.lowStreak / member.totalStreaks) * 100}%` }"
:title="`3-4句: ${member.lowStreak}次`"
/>
<div
v-if="member.midStreak > 0"
class="h-full bg-yellow-500"
:style="{ width: `${(member.midStreak / member.totalStreaks) * 100}%` }"
:title="`5-9句: ${member.midStreak}次`"
/>
<div
v-if="member.highStreak > 0"
class="h-full bg-red-500"
:style="{ width: `${(member.highStreak / member.totalStreaks) * 100}%` }"
:title="`10+句: ${member.highStreak}次`"
/>
</div>
</div>
</div>
<!-- 统计数据 -->
<div class="shrink-0 text-right">
<div class="text-lg font-bold text-gray-900 dark:text-white">{{ member.totalStreaks }} </div>
<div class="flex items-center justify-end gap-1 text-xs text-gray-500">
<span>Max</span>
<span class="font-semibold text-pink-600 dark:text-pink-400">{{ member.maxCombo }}</span>
</div>
</div>
</div>
</div>
<!-- 图例 -->
<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-3 rounded-full bg-green-500" />
<span class="text-xs text-gray-500">3-4</span>
</div>
<div class="flex items-center gap-1.5">
<div class="h-3 w-3 rounded-full bg-yellow-500" />
<span class="text-xs text-gray-500">5-9</span>
</div>
<div class="flex items-center gap-1.5">
<div class="h-3 w-3 rounded-full bg-red-500" />
<span class="text-xs text-gray-500">10+</span>
</div>
</div>
</template>
<EmptyState v-else text="暂无自言自语数据" />
</SectionCard>
</template>
@@ -1,32 +1,46 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { NightOwlAnalysis } from '@/types/analysis'
import { RankListPro } from '@/components/charts'
import { SectionCard } from '@/components/UI'
import { EChartRank } from '@/components/charts'
import { EChartConsecutiveRank, EChartNightOwlRank } from './charts'
import { SectionCard, Tabs, TopNSelect } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const props = withDefaults(
defineProps<{
sessionId: string
timeFilter?: TimeFilter
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}>(),
{
showTopNSelect: true,
}
)
const analysis = ref<NightOwlAnalysis | null>(null)
const isLoading = ref(false)
const timeRankTab = ref<'first' | 'last'>('first') // 默认最早上班
const nightStatsTab = ref<'distribution' | 'consecutive'>('distribution') // 修仙统计 Tab
const nightStatsTopN = ref(props.globalTopN ?? 10) // 修仙统计 TopN
const timeRankTopN = ref(props.globalTopN ?? 10) // 出勤排行 TopN
// 称号颜色映射
const titleColors: Record<string, string> = {
养生达人: 'text-green-600 dark:text-green-400',
偶尔失眠: 'text-blue-600 dark:text-blue-400',
经常失眠: 'text-yellow-600 dark:text-yellow-400',
夜猫子: 'text-orange-600 dark:text-orange-400',
秃头预备役: 'text-pink-600 dark:text-pink-400',
修仙练习生: 'text-purple-600 dark:text-purple-400',
守夜冠军: 'text-red-600 dark:text-red-400',
}
// 监听全局 TopN 变化,强制同步所有内部 TopN
watch(
() => props.globalTopN,
(newVal) => {
if (newVal !== undefined) {
nightStatsTopN.value = newVal
timeRankTopN.value = newVal
}
}
)
async function loadData() {
if (!props.sessionId) return
@@ -34,7 +48,7 @@ async function loadData() {
try {
analysis.value = await window.chatApi.getNightOwlAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载修仙分析失败:', error)
console.error('加载出勤分析失败:', error)
} finally {
isLoading.value = false
}
@@ -62,6 +76,36 @@ const firstSpeakerMembers = computed(() => {
}))
})
// 当前时间排行数据
const currentTimeRankData = computed(() => {
return timeRankTab.value === 'last' ? lastSpeakerMembers.value : firstSpeakerMembers.value
})
// 时间排行标题
const timeRankTitle = computed(() => {
return timeRankTab.value === 'last' ? '⏰ 出勤排行 - 最晚下班' : '⏰ 出勤排行 - 最早上班'
})
// 时间排行描述
const timeRankDescription = computed(() => {
const totalDays = analysis.value?.totalDays ?? 0
return timeRankTab.value === 'last'
? `每天最后一个发言的人(共 ${totalDays} 天)`
: `每天第一个发言的人(共 ${totalDays} 天)`
})
// 修仙统计标题
const nightStatsTitle = computed(() => {
return nightStatsTab.value === 'distribution' ? '🦉 修仙统计 - 发言分布' : '🦉 修仙统计 - 连续记录'
})
// 修仙统计描述
const nightStatsDescription = computed(() => {
return nightStatsTab.value === 'distribution'
? '深夜时段(23:00-05:00)各时段发言分布'
: '连续在深夜时段发言的天数'
})
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
@@ -70,126 +114,79 @@ watch(
</script>
<template>
<SectionCard title="🦉 修仙榜" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">深夜时段 23:00 - 05:00</span>
</template>
<div class="p-5">
<div v-if="isLoading" class="flex h-32 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
</div>
<template v-else-if="analysis">
<!-- 修仙王者 TOP 3 -->
<div v-if="analysis.champions.length > 0" class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">🏆 修仙王者</h4>
<div class="grid gap-3 sm:grid-cols-3">
<div
v-for="(champion, index) in analysis.champions.slice(0, 3)"
:key="champion.memberId"
class="relative overflow-hidden rounded-lg p-4"
:class="[
index === 0
? 'bg-gradient-to-br from-amber-50 to-orange-100 dark:from-amber-900/20 dark:to-orange-900/20'
: index === 1
? 'bg-gradient-to-br from-gray-50 to-slate-100 dark:from-gray-800/50 dark:to-slate-800/50'
: 'bg-gradient-to-br from-orange-50 to-amber-100 dark:from-orange-900/10 dark:to-amber-900/10',
]"
>
<div class="absolute right-2 top-2 text-3xl opacity-20">
{{ index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉' }}
</div>
<div class="text-lg font-bold text-gray-900 dark:text-white">{{ champion.name }}</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">综合得分 {{ champion.score }}</div>
<div class="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-300">
<div>🌙 深夜发言 {{ champion.nightMessages }} </div>
<div>🔚 最晚下班 {{ champion.lastSpeakerCount }} </div>
<div>🔥 连续修仙 {{ champion.consecutiveDays }} </div>
</div>
</div>
</div>
</div>
<!-- 修仙排行榜 -->
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">🌙 深夜发言排行</h4>
<div v-if="analysis.nightOwlRank.length > 0" class="space-y-2">
<div
v-for="(item, index) in analysis.nightOwlRank.slice(0, 10)"
:key="item.memberId"
class="flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
>
<span class="w-6 text-center text-sm font-bold text-gray-400">{{ index + 1 }}</span>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">{{ item.name }}</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="titleColors[item.title]">
{{ item.title }}
</span>
</div>
<div class="mt-1 flex gap-3 text-xs text-gray-500 dark:text-gray-400">
<span> {{ item.totalNightMessages }} </span>
<span>23:{{ item.hourlyBreakdown.h23 }}</span>
<span>0:{{ item.hourlyBreakdown.h0 }}</span>
<span>1:{{ item.hourlyBreakdown.h1 }}</span>
<span>2:{{ item.hourlyBreakdown.h2 }}</span>
<span>3-4:{{ item.hourlyBreakdown.h3to4 }}</span>
</div>
</div>
<span class="text-sm font-semibold text-pink-600 dark:text-pink-400">{{ item.percentage }}%</span>
</div>
</div>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无深夜发言数据</div>
</div>
<!-- 最晚下班 & 最早上班 -->
<div class="grid gap-6">
<!-- 最晚下班排名 -->
<div>
<RankListPro
v-if="lastSpeakerMembers.length > 0"
:members="lastSpeakerMembers"
title="🔚 最晚下班排名"
:description="`每天最后一个发言的人(共 ${analysis.totalDays} 天)`"
unit="次"
/>
<div v-else class="py-4 text-center text-sm text-gray-400">暂无数据</div>
</div>
<!-- 最早上班排名 -->
<div>
<RankListPro
v-if="firstSpeakerMembers.length > 0"
:members="firstSpeakerMembers"
title="🌅 最早上班排名"
:description="`每天第一个发言的人(共 ${analysis.totalDays} 天)`"
unit="次"
/>
<div v-else class="py-4 text-center text-sm text-gray-400">暂无数据</div>
</div>
</div>
<!-- 连续修仙记录 -->
<div v-if="analysis.consecutiveRecords.length > 0" class="mt-6">
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">🔥 连续修仙记录</h4>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="record in analysis.consecutiveRecords.slice(0, 6)"
:key="record.memberId"
class="flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
>
<span class="font-medium text-gray-900 dark:text-white whitespace-nowrap">{{ record.name }}</span>
<div class="text-right">
<div class="text-lg font-bold text-pink-600 dark:text-pink-400">{{ record.maxConsecutiveDays }} </div>
<div v-if="record.currentStreak > 0" class="text-xs text-green-600 dark:text-green-400">
当前连续 {{ record.currentStreak }}
</div>
</div>
</div>
</div>
</div>
</template>
<div class="space-y-6">
<div v-if="isLoading" class="flex h-32 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
</div>
</SectionCard>
<template v-else-if="analysis">
<!-- 修仙统计发言分布 + 连续记录 -->
<SectionCard :title="nightStatsTitle" :description="nightStatsDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="nightStatsTopN" />
<Tabs
v-model="nightStatsTab"
:items="[
{ label: '发言分布', value: 'distribution' },
{ label: '连续记录', value: 'consecutive' },
]"
size="sm"
/>
</div>
</template>
<!-- 发言分布 -->
<template v-if="nightStatsTab === 'distribution'">
<EChartNightOwlRank
v-if="analysis.nightOwlRank.length > 0"
:items="analysis.nightOwlRank"
:top-n="nightStatsTopN"
title=""
bare
/>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无深夜发言数据</div>
</template>
<!-- 连续记录 -->
<template v-else>
<EChartConsecutiveRank
v-if="analysis.consecutiveRecords.length > 0"
:items="analysis.consecutiveRecords"
:top-n="nightStatsTopN"
title=""
bare
/>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无连续记录</div>
</template>
</SectionCard>
<!-- 出勤排行最早上班 + 最晚下班 -->
<SectionCard :title="timeRankTitle" :description="timeRankDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="timeRankTopN" />
<Tabs
v-model="timeRankTab"
:items="[
{ label: '最早上班', value: 'first' },
{ label: '最晚下班', value: 'last' },
]"
size="sm"
/>
</div>
</template>
<EChartRank
v-if="currentTimeRankData.length > 0"
:members="currentTimeRankData"
:title="timeRankTitle"
:top-n="timeRankTopN"
unit="次"
bare
/>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无数据</div>
</SectionCard>
</template>
</div>
</template>
@@ -1,24 +1,47 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { RepeatAnalysis } from '@/types/analysis'
import { RankListPro, EChartBar, ListPro } from '@/components/charts'
import { EChartRank, EChartBar } from '@/components/charts'
import type { RankItem, EChartBarData } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { getRankBadgeClass } from '@/utils'
import { EChartTimeRank } from './charts'
import { SectionCard, EmptyState, LoadingState, Tabs, TopNSelect } from '@/components/UI'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const props = withDefaults(
defineProps<{
sessionId: string
timeFilter?: TimeFilter
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}>(),
{
showTopNSelect: true,
}
)
const analysis = ref<RepeatAnalysis | null>(null)
const isLoading = ref(false)
const rankMode = ref<'count' | 'rate'>('count')
const roleTab = ref<'originator' | 'initiator' | 'breaker'>('originator') // 角色 Tab
const statsTab = ref<'fastest' | 'distribution'>('fastest') // 统计 Tab
const roleTopN = ref(props.globalTopN ?? 10) // 复读榜 TopN
const statsTopN = ref(props.globalTopN ?? 10) // 复读统计 TopN
// 监听全局 TopN 变化,强制同步所有内部 TopN
watch(
() => props.globalTopN,
(newVal) => {
if (newVal !== undefined) {
roleTopN.value = newVal
statsTopN.value = newVal
}
}
)
async function loadData() {
if (!props.sessionId) return
@@ -34,37 +57,76 @@ async function loadData() {
const originatorRankData = computed<RankItem[]>(() => {
if (!analysis.value) return []
const data = rankMode.value === 'count' ? analysis.value.originators : analysis.value.originatorRates
return data.map((m) => ({
return analysis.value.originators.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: (m as any).count,
percentage: rankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
value: m.count,
percentage: m.percentage,
}))
})
const initiatorRankData = computed<RankItem[]>(() => {
if (!analysis.value) return []
const data = rankMode.value === 'count' ? analysis.value.initiators : analysis.value.initiatorRates
return data.map((m) => ({
return analysis.value.initiators.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: (m as any).count,
percentage: rankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
value: m.count,
percentage: m.percentage,
}))
})
const breakerRankData = computed<RankItem[]>(() => {
if (!analysis.value) return []
const data = rankMode.value === 'count' ? analysis.value.breakers : analysis.value.breakerRates
return data.map((m) => ({
return analysis.value.breakers.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: (m as any).count,
percentage: rankMode.value === 'count' ? (m as any).percentage : (m as any).rate,
value: m.count,
percentage: m.percentage,
}))
})
// 根据当前 Tab 获取数据
const currentRankData = computed<RankItem[]>(() => {
switch (roleTab.value) {
case 'originator':
return originatorRankData.value
case 'initiator':
return initiatorRankData.value
case 'breaker':
return breakerRankData.value
default:
return originatorRankData.value
}
})
// 卡片标题
const cardTitle = computed(() => {
switch (roleTab.value) {
case 'originator':
return '🔁 复读榜 - 被复读'
case 'initiator':
return '🔁 复读榜 - 挑起'
case 'breaker':
return '🔁 复读榜 - 打断'
default:
return '🔁 复读榜'
}
})
// 卡片描述
const cardDescription = computed(() => {
switch (roleTab.value) {
case 'originator':
return '发出的消息被别人复读的次数'
case 'initiator':
return '第二个发送相同消息、带起节奏的人'
case 'breaker':
return '终结复读链的人'
default:
return ''
}
})
const chainLengthChartData = computed<EChartBarData>(() => {
if (!analysis.value) return { labels: [], values: [] }
const distribution = analysis.value.chainLengthDistribution
@@ -74,6 +136,21 @@ const chainLengthChartData = computed<EChartBarData>(() => {
}
})
// 统计卡片标题
const statsTitle = computed(() => {
return statsTab.value === 'fastest' ? '📊 复读统计 - 最快反应' : '📊 复读统计 - 链长分布'
})
// 统计卡片描述
const statsDescription = computed(() => {
if (statsTab.value === 'fastest') {
return '平均复读反应时间(至少参与5次复读)'
}
const total = analysis.value?.totalRepeatChains ?? 0
const avg = analysis.value?.avgChainLength ?? 0
return `${total} 次复读,平均 ${avg} 人参与`
})
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
@@ -82,121 +159,78 @@ watch(
</script>
<template>
<SectionCard
title="复读榜"
:description="
isLoading
? '加载中...'
: analysis
? `共检测到 ${analysis.totalRepeatChains} 次复读,平均复读链长度 ${analysis.avgChainLength} 人`
: '暂无复读数据'
"
>
<template #headerRight>
<UTabs
v-if="analysis && analysis.totalRepeatChains > 0"
v-model="rankMode"
:items="[
{ label: '按次数', value: 'count' },
{ label: '按复读率', value: 'rate' },
]"
size="xs"
/>
</template>
<div class="space-y-6">
<LoadingState v-if="isLoading" text="正在分析复读数据..." />
<div v-else-if="analysis && analysis.totalRepeatChains > 0" class="space-y-6 p-5">
<!-- 复读链长度分布 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="rounded-lg border border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/50">
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">📊 复读链长度分布</h4>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">每次复读有多少人参与</p>
<template v-else-if="analysis && analysis.totalRepeatChains > 0">
<!-- 复读榜主卡片 -->
<SectionCard :title="cardTitle" :description="cardDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="roleTopN" />
<Tabs
v-model="roleTab"
:items="[
{ label: '被复读', value: 'originator' },
{ label: '挑起', value: 'initiator' },
{ label: '打断', value: 'breaker' },
]"
size="sm"
/>
</div>
<div class="p-4">
</template>
<EChartRank
v-if="currentRankData.length > 0"
:members="currentRankData"
:title="cardTitle"
unit="次"
:top-n="roleTopN"
bare
/>
<EmptyState v-else text="暂无数据" />
</SectionCard>
<!-- 复读统计最快反应 + 链长分布 -->
<SectionCard :title="statsTitle" :description="statsDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect && statsTab === 'fastest'" v-model="statsTopN" />
<Tabs
v-model="statsTab"
:items="[
{ label: '最快反应', value: 'fastest' },
{ label: '链长分布', value: 'distribution' },
]"
size="sm"
/>
</div>
</template>
<!-- 最快反应 -->
<template v-if="statsTab === 'fastest'">
<EChartTimeRank
v-if="analysis.fastestRepeaters && analysis.fastestRepeaters.length > 0"
:items="analysis.fastestRepeaters"
:top-n="statsTopN"
title=""
bare
/>
<EmptyState v-else text="暂无最快复读数据" />
</template>
<!-- 链长分布 -->
<template v-else>
<div class="px-3 py-2">
<EChartBar v-if="chainLengthChartData.labels.length > 0" :data="chainLengthChartData" :height="200" />
<EmptyState v-else padding="md" />
<EmptyState v-else text="暂无分布数据" />
</div>
</div>
</div>
</template>
</SectionCard>
</template>
<!-- 复读排行榜 Grid -->
<div class="grid grid-cols-1 gap-6">
<RankListPro
v-if="originatorRankData.length > 0"
:members="originatorRankData"
title="🎯 谁的聊天最容易产生复读"
:description="rankMode === 'rate' ? '被复读次数 / 总发言数' : '发出的消息被别人复读的次数'"
unit="次"
/>
<RankListPro
v-if="initiatorRankData.length > 0"
:members="initiatorRankData"
title="🔥 谁最喜欢挑起复读"
:description="rankMode === 'rate' ? '挑起复读次数 / 总发言数' : '第二个发送相同消息、带起节奏的人'"
unit="次"
/>
<RankListPro
v-if="breakerRankData.length > 0"
:members="breakerRankData"
title="✂️ 谁喜欢打断复读"
:description="rankMode === 'rate' ? '打断复读次数 / 总发言数' : '终结复读链的人'"
unit="次"
/>
<!-- 最快复读选手 -->
<ListPro
v-if="analysis.fastestRepeaters && analysis.fastestRepeaters.length > 0"
:items="analysis.fastestRepeaters"
title="⚡️ 最快复读选手"
description="平均复读反应时间(至少参与5次复读)"
countTemplate="共 {count} 位选手"
>
<template #item="{ item: member, index }">
<div class="flex items-center gap-3">
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</div>
<!-- 名字 -->
<div class="w-32 shrink-0">
<p class="truncate font-medium text-gray-900 dark:text-white">
{{ member.name }}
</p>
</div>
<!-- 反应时间条第一名100%越慢越短 -->
<div class="flex flex-1 items-center">
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-linear-to-r from-yellow-400 to-orange-500"
:style="{
width: `${Math.round((analysis!.fastestRepeaters[0].avgTimeDiff / member.avgTimeDiff) * 100)}%`,
}"
/>
</div>
</div>
<!-- 统计数据 -->
<div class="flex shrink-0 items-baseline gap-2">
<span class="text-lg font-bold text-gray-900 dark:text-white">
{{ (member.avgTimeDiff / 1000).toFixed(2) }}s
</span>
<span class="text-xs text-gray-500">· {{ member.count }} </span>
</div>
</div>
</template>
</ListPro>
</div>
</div>
<EmptyState v-else text="该群组暂无复读记录" />
</SectionCard>
<SectionCard v-else title="🔁 复读榜">
<EmptyState text="该群组暂无复读记录" />
</SectionCard>
</div>
</template>
@@ -0,0 +1,210 @@
<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'
import { formatDate } from '@/utils/dateFormat'
interface BattleParticipant {
memberId: number
name: string
imageCount: number
}
interface BattleRecord {
startTime: number
endTime: number
totalImages: number
participantCount: number
participants: BattleParticipant[]
}
interface Props {
/** 战役数据 */
battles: BattleRecord[]
/** 标题 */
title: string
/** 描述(可选) */
description?: string
/** 最大显示数量,默认 10 */
topN?: number
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
maxHeightVh?: number
/** 是否为裸图表模式(不包含 SectionCard 容器) */
bare?: boolean
}
const props = withDefaults(defineProps<Props>(), {
topN: 10,
maxHeightVh: 60,
bare: false,
})
// 限制显示数量
const displayData = computed(() => {
return props.battles.slice(0, props.topN)
})
// 计算图表高度(与 EChartRank 保持一致)
const chartHeight = computed(() => {
const dataHeight = displayData.value.length * 36
return Math.max(dataHeight + 30, 180)
})
// 柱状图颜色
const barColor = {
type: 'linear' as const,
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{ offset: 0, color: '#ee4567' },
{ offset: 1, color: '#f7758c' },
],
}
// 生成 Y 轴标签(仅人数)
function formatLabel(battle: BattleRecord): string {
return `${battle.participantCount} 人参战`
}
// 生成 ECharts 配置
const option = computed<EChartsOption>(() => {
const reversedData = [...displayData.value].reverse()
const labels = reversedData.map((item) => formatLabel(item))
const values = reversedData.map((item) => item.totalImages)
const maxValue = Math.max(...values, 1)
const dataWithStyle = reversedData.map((item) => ({
value: item.totalImages,
itemStyle: {
color: barColor,
borderRadius: [0, 4, 4, 0],
},
}))
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'rgba(0, 0, 0, 0.85)',
borderColor: 'transparent',
textStyle: {
color: '#fff',
fontSize: 12,
},
extraCssText: 'max-width: 300px;',
formatter: (params: any) => {
const data = params[0]
if (!data) return ''
const originalIndex = displayData.value.length - 1 - data.dataIndex
const battle = displayData.value[originalIndex]
// 构建参战人员列表(最多显示5人)
const participantList = battle.participants
.slice(0, 5)
.map((p) => `<span style="color: #d1d5db;">${p.name}</span> <b>${p.imageCount}</b>张`)
.join('、')
const moreCount = battle.participants.length > 5 ? `、+${battle.participants.length - 5}` : ''
return `
<div style="padding: 6px 8px;">
<div style="font-weight: bold; margin-bottom: 8px; font-size: 13px;">
⚔️ ${formatDate(battle.startTime)}
</div>
<div style="margin-bottom: 6px;">
<span style="color: #9ca3af;">参战人数:</span> <b>${battle.participantCount}</b> 人
</div>
<div style="margin-bottom: 8px;">
<span style="color: #9ca3af;">总图片数:</span> <b style="color: #f472b6;">${battle.totalImages}</b> 张
</div>
<div style="border-top: 1px solid #374151; padding-top: 6px; font-size: 11px;">
${participantList}${moreCount}
</div>
</div>
`
},
},
grid: {
left: 95,
right: 125,
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: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 11,
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 battle = displayData.value[originalIndex]
const date = formatDate(battle.startTime)
return `${battle.totalImages} 张 (${date})`
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
emphasis: {
itemStyle: {
shadowBlur: 6,
shadowColor: 'rgba(238, 69, 103, 0.3)',
},
},
} as BarSeriesOption,
],
}
})
</script>
<template>
<!-- 裸图表模式 -->
<ScrollableChart v-if="bare" :content-height="chartHeight" :max-height-vh="maxHeightVh">
<EChart :option="option" :height="chartHeight" />
</ScrollableChart>
<!-- 完整模式 -->
<SectionCard v-else :title="title" :description="description" scrollable :max-height-vh="maxHeightVh">
<div class="px-3 py-2">
<EChart :option="option" :height="chartHeight" />
</div>
</SectionCard>
</template>
@@ -0,0 +1,212 @@
<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 ConsecutiveItem {
memberId: number
name: string
maxConsecutiveDays: number
currentStreak: number
}
interface Props {
/** 排名数据 */
items: ConsecutiveItem[]
/** 标题 */
title: string
/** 描述(可选) */
description?: string
/** 最大显示数量,默认 10 */
topN?: number
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
maxHeightVh?: number
/** 是否为裸图表模式 */
bare?: boolean
}
const props = withDefaults(defineProps<Props>(), {
topN: 10,
maxHeightVh: 60,
bare: false,
})
// 限制显示数量
const displayData = computed(() => {
return [...props.items]
.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays)
.slice(0, props.topN)
})
// 计算图表高度
const chartHeight = computed(() => {
const dataHeight = displayData.value.length * 36
return Math.max(dataHeight + 30, 180)
})
// 柱状图颜色
const barColorActive = {
type: 'linear' as const,
x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: '#f97316' }, // orange-500
{ offset: 1, color: '#fb923c' }, // orange-400
],
}
const barColorInactive = {
type: 'linear' as const,
x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: '#ec4899' }, // pink-500
{ offset: 1, color: '#f472b6' }, // pink-400
],
}
// 截断名字
function truncateName(name: string, maxLength = 8): string {
if (name.length <= maxLength) return name
return name.slice(0, maxLength) + '…'
}
// 生成 ECharts 配置
const option = computed<EChartsOption>(() => {
if (displayData.value.length === 0) return {}
const reversedData = [...displayData.value].reverse()
const names = reversedData.map((item) => truncateName(item.name))
const maxValue = Math.max(...displayData.value.map((item) => item.maxConsecutiveDays), 1)
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(0, 0, 0, 0.85)',
borderColor: 'transparent',
textStyle: { color: '#fff', fontSize: 12 },
formatter: (params: any) => {
if (!params || params.length === 0) return ''
const dataIndex = params[0].dataIndex
const originalIndex = displayData.value.length - 1 - dataIndex
const item = displayData.value[originalIndex]
let html = `
<div style="padding: 6px 8px;">
<div style="font-weight: bold; margin-bottom: 6px;">${item.name}</div>
<div>最长连续: <b style="color: #f472b6;">${item.maxConsecutiveDays}</b> 天</div>
`
if (item.currentStreak > 0) {
html += `<div style="color: #f97316;">🔥 当前连续 ${item.currentStreak} 天</div>`
}
html += '</div>'
return html
},
},
legend: {
show: false,
},
grid: {
left: 110,
right: 75,
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: [
{
name: '当前连续',
type: 'bar',
data: reversedData.map((item) => ({
value: item.currentStreak > 0 ? item.maxConsecutiveDays : 0,
itemStyle: { color: barColorActive, borderRadius: [0, 4, 4, 0] },
})),
barWidth: 18,
barGap: '-100%',
label: {
show: true,
position: 'right',
distance: 8,
formatter: (params: any) => {
const originalIndex = displayData.value.length - 1 - params.dataIndex
const item = displayData.value[originalIndex]
if (item.currentStreak > 0) {
return `${item.maxConsecutiveDays}`
}
return ''
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
} as BarSeriesOption,
{
name: '已中断',
type: 'bar',
data: reversedData.map((item) => ({
value: item.currentStreak === 0 ? item.maxConsecutiveDays : 0,
itemStyle: { color: barColorInactive, borderRadius: [0, 4, 4, 0] },
})),
barWidth: 18,
label: {
show: true,
position: 'right',
distance: 8,
formatter: (params: any) => {
const originalIndex = displayData.value.length - 1 - params.dataIndex
const item = displayData.value[originalIndex]
if (item.currentStreak === 0) {
return `${item.maxConsecutiveDays}`
}
return ''
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
} as BarSeriesOption,
],
}
})
</script>
<template>
<!-- 裸图表模式 -->
<ScrollableChart v-if="bare" :content-height="chartHeight" :max-height-vh="maxHeightVh">
<EChart :option="option" :height="chartHeight" />
</ScrollableChart>
<!-- 完整模式 -->
<SectionCard v-else :title="title" :description="description" scrollable :max-height-vh="maxHeightVh">
<div class="px-3 py-2">
<EChart :option="option" :height="chartHeight" />
</div>
</SectionCard>
</template>
@@ -0,0 +1,223 @@
<script setup lang="ts">
/**
* ECharts 潜水榜组件
* 使用横向柱状图展示距今天数
*/
import { computed, ref, watch } from 'vue'
import type { EChartsOption, BarSeriesOption } from 'echarts'
import { EChart } from '@/components/charts'
import { SectionCard, Tabs, TopNSelect } from '@/components/UI'
import { formatFullDateTime } from '@/utils'
interface DivingItem {
memberId: number
name: string
lastMessageTs: number // 时间戳(秒)
daysSinceLastMessage: number
}
interface Props {
/** 排名数据 */
items: DivingItem[]
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
maxHeightVh?: number
/** 全局 TopN 控制(变化时强制同步) */
globalTopN?: number
}
const props = withDefaults(defineProps<Props>(), {
showTopNSelect: true,
maxHeightVh: 60,
})
const sortOrder = ref<'desc' | 'asc'>('desc') // 默认倒序(潜水最久的在前)
const topN = ref(props.globalTopN ?? 10) // 内部控制的 topN
// 监听全局 TopN 变化,强制同步
watch(
() => props.globalTopN,
(newVal) => {
if (newVal !== undefined) {
topN.value = newVal
}
}
)
// 排序并限制显示数量
const displayData = computed(() => {
const sorted = [...props.items].sort((a, b) => {
return sortOrder.value === 'desc'
? b.daysSinceLastMessage - a.daysSinceLastMessage
: a.daysSinceLastMessage - b.daysSinceLastMessage
})
return sorted.slice(0, topN.value)
})
// 动态标题
const dynamicTitle = computed(() => {
return sortOrder.value === 'desc' ? '🤿 潜水榜 - 潜水最久' : '🤿 潜水榜 - 最近冒泡'
})
// 动态描述
const dynamicDescription = computed(() => {
return sortOrder.value === 'desc' ? '距离上次发言时间最久的成员' : '最近发言过的成员'
})
// 计算图表高度
const chartHeight = computed(() => {
const dataHeight = displayData.value.length * 36
return Math.max(dataHeight + 30, 180)
})
// 统一的柱状图颜色
const barColor = {
type: 'linear' as const,
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{ offset: 0, color: '#06b6d4' }, // cyan-500
{ offset: 1, color: '#22d3ee' }, // cyan-400
],
}
// 截断名字
function truncateName(name: string, maxLength = 8): string {
if (name.length <= maxLength) return name
return name.slice(0, maxLength) + '…'
}
// 格式化天数显示
function formatDays(days: number): string {
if (days === 0) return '今天'
if (days === 1) return '昨天'
return `${days} 天前`
}
// 生成 ECharts 配置
const option = computed<EChartsOption>(() => {
if (displayData.value.length === 0) return {}
const reversedData = [...displayData.value].reverse()
const names = reversedData.map((item) => truncateName(item.name))
const maxDays = Math.max(...displayData.value.map((item) => item.daysSinceLastMessage), 1)
const dataWithStyle = reversedData.map((item) => ({
value: item.daysSinceLastMessage,
itemStyle: {
color: barColor,
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]
return `
<div style="padding: 4px 8px;">
<div style="font-weight: bold; margin-bottom: 6px;">${item.name}</div>
<div style="margin-bottom: 4px;">🤿 ${formatDays(item.daysSinceLastMessage)}</div>
<div style="font-size: 12px; color: #9ca3af;">最后发言: ${formatFullDateTime(item.lastMessageTs)}</div>
</div>
`
},
},
grid: {
left: 110,
right: 100,
top: 15,
bottom: 15,
containLabel: false,
},
xAxis: {
type: 'value',
max: maxDays * 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]
return formatDays(item.daysSinceLastMessage)
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
emphasis: {
itemStyle: {
shadowBlur: 6,
shadowColor: 'rgba(0, 0, 0, 0.2)',
},
},
} as BarSeriesOption,
],
}
})
</script>
<template>
<SectionCard :title="dynamicTitle" :description="dynamicDescription" scrollable :max-height-vh="maxHeightVh">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="topN" />
<Tabs
v-model="sortOrder"
:items="[
{ label: '潜水最久', value: 'desc' },
{ label: '最近冒泡', value: 'asc' },
]"
size="sm"
/>
</div>
</template>
<div class="px-3 py-2">
<EChart :option="option" :height="chartHeight" />
</div>
</SectionCard>
</template>
@@ -0,0 +1,221 @@
<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 NightOwlItem {
memberId: number
name: string
totalNightMessages: number
title: string
hourlyBreakdown: {
h23: number
h0: number
h1: number
h2: number
h3to4: number
}
percentage: number
}
interface Props {
/** 排名数据 */
items: NightOwlItem[]
/** 标题 */
title: string
/** 描述(可选) */
description?: string
/** 最大显示数量,默认 10 */
topN?: number
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
maxHeightVh?: number
/** 是否为裸图表模式 */
bare?: boolean
}
const props = withDefaults(defineProps<Props>(), {
topN: 10,
maxHeightVh: 60,
bare: false,
})
// 限制显示数量
const displayData = computed(() => {
return props.items.slice(0, props.topN)
})
// 计算图表高度
const chartHeight = computed(() => {
const dataHeight = displayData.value.length * 36
return Math.max(dataHeight + 50, 200)
})
// 各时段颜色
const colors = {
h23: '#8b5cf6', // violet-500 (23点)
h0: '#3b82f6', // blue-500 (0点)
h1: '#06b6d4', // cyan-500 (1点)
h2: '#f59e0b', // amber-500 (2点)
h3to4: '#ef4444', // red-500 (3-4点)
}
// 截断名字
function truncateName(name: string, maxLength = 8): string {
if (name.length <= maxLength) return name
return name.slice(0, maxLength) + '…'
}
// 生成 ECharts 配置
const option = computed<EChartsOption>(() => {
if (displayData.value.length === 0) return {}
const reversedData = [...displayData.value].reverse()
const names = reversedData.map((item) => truncateName(item.name))
const maxValue = Math.max(...displayData.value.map((item) => item.totalNightMessages), 1)
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(0, 0, 0, 0.85)',
borderColor: 'transparent',
textStyle: { color: '#fff', fontSize: 12 },
formatter: (params: any) => {
if (!params || params.length === 0) return ''
const dataIndex = params[0].dataIndex
const originalIndex = displayData.value.length - 1 - dataIndex
const item = displayData.value[originalIndex]
const b = item.hourlyBreakdown
return `
<div style="padding: 6px 8px;">
<div style="font-weight: bold; margin-bottom: 6px;">${item.name}</div>
<div style="margin-bottom: 4px;">
称号: <b style="color: #f472b6;">${item.title}</b>
</div>
<div style="margin-bottom: 6px;">总发言: <b>${item.totalNightMessages}</b> 条</div>
<div style="border-top: 1px solid #374151; padding-top: 6px;">
<div><span style="color: ${colors.h23};">●</span> 23点: ${b.h23} 条</div>
<div><span style="color: ${colors.h0};">●</span> 0点: ${b.h0} 条</div>
<div><span style="color: ${colors.h1};">●</span> 1点: ${b.h1} 条</div>
<div><span style="color: ${colors.h2};">●</span> 2点: ${b.h2} 条</div>
<div><span style="color: ${colors.h3to4};">●</span> 3-4点: ${b.h3to4} 条</div>
</div>
</div>
`
},
},
legend: {
show: true,
bottom: 0,
itemWidth: 12,
itemHeight: 12,
textStyle: { color: '#6b7280', fontSize: 10 },
},
grid: {
left: 110,
right: 70,
top: 15,
bottom: 35,
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: [
{
name: '23点',
type: 'bar',
stack: 'total',
data: reversedData.map((item) => item.hourlyBreakdown.h23),
itemStyle: { color: colors.h23 },
barWidth: 18,
} as BarSeriesOption,
{
name: '0点',
type: 'bar',
stack: 'total',
data: reversedData.map((item) => item.hourlyBreakdown.h0),
itemStyle: { color: colors.h0 },
barWidth: 18,
} as BarSeriesOption,
{
name: '1点',
type: 'bar',
stack: 'total',
data: reversedData.map((item) => item.hourlyBreakdown.h1),
itemStyle: { color: colors.h1 },
barWidth: 18,
} as BarSeriesOption,
{
name: '2点',
type: 'bar',
stack: 'total',
data: reversedData.map((item) => item.hourlyBreakdown.h2),
itemStyle: { color: colors.h2 },
barWidth: 18,
} as BarSeriesOption,
{
name: '3-4点',
type: 'bar',
stack: 'total',
data: reversedData.map((item) => item.hourlyBreakdown.h3to4),
itemStyle: { color: colors.h3to4, borderRadius: [0, 4, 4, 0] },
barWidth: 18,
label: {
show: true,
position: 'right',
distance: 8,
formatter: (params: any) => {
const originalIndex = displayData.value.length - 1 - params.dataIndex
const item = displayData.value[originalIndex]
return `${item.totalNightMessages}`
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
} as BarSeriesOption,
],
}
})
</script>
<template>
<!-- 裸图表模式 -->
<ScrollableChart v-if="bare" :content-height="chartHeight" :max-height-vh="maxHeightVh">
<EChart :option="option" :height="chartHeight" />
</ScrollableChart>
<!-- 完整模式 -->
<SectionCard v-else :title="title" :description="description" scrollable :max-height-vh="maxHeightVh">
<div class="px-3 py-2">
<EChart :option="option" :height="chartHeight" />
</div>
</SectionCard>
</template>
@@ -0,0 +1,268 @@
<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>
@@ -0,0 +1,195 @@
<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 TimeRankItem {
memberId: number
name: string
count: number
avgTimeDiff: number // 毫秒
}
interface Props {
/** 排名数据 */
items: TimeRankItem[]
/** 标题 */
title: string
/** 描述(可选) */
description?: string
/** 最大显示数量,默认 10 */
topN?: number
/** 容器最大高度(vh 单位),默认 60vh,超出则滚动 */
maxHeightVh?: number
/** 是否为裸图表模式(不包含 SectionCard 容器) */
bare?: boolean
}
const props = withDefaults(defineProps<Props>(), {
topN: 10,
maxHeightVh: 60,
bare: false,
})
// 限制显示数量
const displayData = computed(() => {
return props.items.slice(0, props.topN)
})
// 计算图表高度
const chartHeight = computed(() => {
const dataHeight = displayData.value.length * 36
return Math.max(dataHeight + 30, 180)
})
// 柱状图颜色(黄橙色 - 闪电快)
const barColor = {
type: 'linear' as const,
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{ offset: 0, color: '#f59e0b' },
{ offset: 1, color: '#fbbf24' },
],
}
// 截断名字
function truncateName(name: string, maxLength = 8): string {
if (name.length <= maxLength) return name
return name.slice(0, maxLength) + '…'
}
// 格式化时间
function formatTime(ms: number): string {
return (ms / 1000).toFixed(2) + 's'
}
// 生成 ECharts 配置
const option = computed<EChartsOption>(() => {
if (displayData.value.length === 0) return {}
const reversedData = [...displayData.value].reverse()
const names = reversedData.map((item) => truncateName(item.name))
// 计算相对值:第一名时间最短,进度条最长(100%)
// 使用反比例:第一名时间 / 当前时间 * 100
const fastestTime = displayData.value[0].avgTimeDiff
const relativeValues = reversedData.map((item) => {
return Math.round((fastestTime / item.avgTimeDiff) * 100)
})
const dataWithStyle = reversedData.map((item) => ({
value: Math.round((fastestTime / item.avgTimeDiff) * 100),
itemStyle: {
color: barColor,
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]
return `
<div style="padding: 4px 8px;">
<div style="font-weight: bold; margin-bottom: 6px;">${item.name}</div>
<div style="margin-bottom: 4px;">⚡️ 平均反应时间: <b>${formatTime(item.avgTimeDiff)}</b></div>
<div style="font-size: 12px; color: #9ca3af;">参与复读 ${item.count} 次</div>
</div>
`
},
},
grid: {
left: 110,
right: 100,
top: 15,
bottom: 15,
containLabel: false,
},
xAxis: {
type: 'value',
max: 105,
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]
return `${formatTime(item.avgTimeDiff)} · ${item.count}`
},
fontSize: 11,
fontWeight: 500,
color: '#6b7280',
},
emphasis: {
itemStyle: {
shadowBlur: 6,
shadowColor: 'rgba(245, 158, 11, 0.3)',
},
},
} as BarSeriesOption,
],
}
})
</script>
<template>
<!-- 裸图表模式 -->
<ScrollableChart v-if="bare" :content-height="chartHeight" :max-height-vh="maxHeightVh">
<EChart :option="option" :height="chartHeight" />
</ScrollableChart>
<!-- 完整模式 -->
<SectionCard v-else :title="title" :description="description" scrollable :max-height-vh="maxHeightVh">
<div class="px-3 py-2">
<EChart :option="option" :height="chartHeight" />
</div>
</SectionCard>
</template>
@@ -0,0 +1,7 @@
// 排行榜专用图表组件
export { default as EChartStreakRank } from './EChartStreakRank.vue'
export { default as EChartBattleRank } from './EChartBattleRank.vue'
export { default as EChartTimeRank } from './EChartTimeRank.vue'
export { default as EChartDivingRank } from './EChartDivingRank.vue'
export { default as EChartConsecutiveRank } from './EChartConsecutiveRank.vue'
export { default as EChartNightOwlRank } from './EChartNightOwlRank.vue'
-35
View File
@@ -154,41 +154,6 @@ export interface NightOwlAnalysis {
totalDays: number
}
// ==================== 自言自语分析类型 ====================
/**
* 自言自语排名项
*/
export interface MonologueRankItem {
memberId: number
platformId: string
name: string
totalStreaks: number // 总连击次数(>=2的段落数)
maxCombo: number // 个人最高连击数
lowStreak: number // 2-4句(加特林模式)
midStreak: number // 5-9句(小作文)
highStreak: number // 10+句(无人区广播)
}
/**
* 最高连击纪录
*/
export interface MaxComboRecord {
memberId: number
platformId: string
memberName: string
comboLength: number // 连击长度
startTs: number // 开始时间
}
/**
* 自言自语分析结果
*/
export interface MonologueAnalysis {
rank: MonologueRankItem[]
maxComboRecord: MaxComboRecord | null // 全群最高纪录
}
/**
* 龙王排名项(每天发言最多的人)
*/