mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-26 16:40:17 +08:00
feat: 重构榜单为图表
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -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 // 全群最高纪录
|
||||
}
|
||||
|
||||
/**
|
||||
* 龙王排名项(每天发言最多的人)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user