feat: 新增打卡榜

This commit is contained in:
digua
2025-11-30 14:27:54 +08:00
parent deeb7c1e20
commit 479d81960e
13 changed files with 474 additions and 55 deletions
+19
View File
@@ -556,6 +556,25 @@ const mainIpcMain = (win: BrowserWindow) => {
}
}
)
/**
* 获取打卡分析数据(火花榜 + 忠臣榜)
*/
ipcMain.handle(
'chat:getCheckInAnalysis',
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
try {
return await worker.getCheckInAnalysis(sessionId, filter)
} catch (error) {
console.error('获取打卡分析失败:', error)
return {
streakRank: [],
loyaltyRank: [],
totalDays: 0,
}
}
}
)
}
export default mainIpcMain
+2
View File
@@ -34,6 +34,7 @@ import {
getMentionAnalysis,
getLaughAnalysis,
getMemeBattleAnalysis,
getCheckInAnalysis,
} from './queryAdvanced'
// 初始化数据库目录
@@ -82,6 +83,7 @@ const handlers: Record<string, (payload: any) => any> = {
getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter),
getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter),
}
// 处理消息
+1
View File
@@ -26,6 +26,7 @@ export {
getMentionAnalysis,
getLaughAnalysis,
getMemeBattleAnalysis,
getCheckInAnalysis,
// 会话管理 API(异步)
getAllSessions,
getSession,
+168
View File
@@ -1622,3 +1622,171 @@ export function getMemeBattleAnalysis(sessionId: string, filter?: TimeFilter): a
totalBattles: battles.length,
}
}
// ==================== 打卡分析 ====================
/**
* 获取打卡分析数据(火花榜 + 忠臣榜)
*/
export function getCheckInAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabase(sessionId)
const emptyResult = {
streakRank: [],
loyaltyRank: [],
totalDays: 0,
}
if (!db) return emptyResult
const { clause, params } = buildTimeFilter(filter)
const whereClause = buildSystemMessageFilter(clause)
// 1. 获取每个成员每天是否发言的数据
// 检查时间戳格式:如果 ts > 1e12 则是毫秒,否则是秒
const sampleTs = db.prepare(`SELECT ts FROM message LIMIT 1`).get() as { ts: number } | undefined
const tsIsMillis = sampleTs?.ts && sampleTs.ts > 1e12
const tsExpr = tsIsMillis ? 'msg.ts / 1000' : 'msg.ts'
const dailyActivity = db
.prepare(
`
SELECT
msg.sender_id as senderId,
m.name,
DATE(${tsExpr}, 'unixepoch', 'localtime') as day
FROM message msg
JOIN member m ON msg.sender_id = m.id
${whereClause}
GROUP BY msg.sender_id, day
ORDER BY msg.sender_id, day
`
)
.all(...params) as Array<{
senderId: number
name: string
day: string
}>
if (dailyActivity.length === 0) return emptyResult
// 2. 获取群聊总天数
const allDays = new Set(dailyActivity.map((r) => r.day))
const totalDays = allDays.size
// 获取最后一天(用于判断当前连续)
const sortedDays = Array.from(allDays).sort()
const lastDay = sortedDays[sortedDays.length - 1]
// 3. 按成员分组
const memberDays = new Map<number, { name: string; days: Set<string> }>()
for (const record of dailyActivity) {
if (!memberDays.has(record.senderId)) {
memberDays.set(record.senderId, { name: record.name, days: new Set() })
}
memberDays.get(record.senderId)!.days.add(record.day)
}
// 4. 计算每个成员的连续发言和累计发言
const streakData: Array<{
memberId: number
name: string
maxStreak: number
maxStreakStart: string
maxStreakEnd: string
currentStreak: number
}> = []
const loyaltyData: Array<{
memberId: number
name: string
totalDays: number
}> = []
for (const [memberId, data] of memberDays) {
const sortedMemberDays = Array.from(data.days).sort()
const totalMemberDays = sortedMemberDays.length
// 计算最长连续
let maxStreak = 1
let maxStreakStart = sortedMemberDays[0]
let maxStreakEnd = sortedMemberDays[0]
let currentStreakCount = 1
let currentStreakStart = sortedMemberDays[0]
for (let i = 1; i < sortedMemberDays.length; i++) {
const prevDate = new Date(sortedMemberDays[i - 1])
const currDate = new Date(sortedMemberDays[i])
const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
// 连续
currentStreakCount++
} else {
// 中断,检查是否更新最大值
if (currentStreakCount > maxStreak) {
maxStreak = currentStreakCount
maxStreakStart = currentStreakStart
maxStreakEnd = sortedMemberDays[i - 1]
}
currentStreakCount = 1
currentStreakStart = sortedMemberDays[i]
}
}
// 检查最后一段连续
if (currentStreakCount > maxStreak) {
maxStreak = currentStreakCount
maxStreakStart = currentStreakStart
maxStreakEnd = sortedMemberDays[sortedMemberDays.length - 1]
}
// 计算当前连续(是否以最后一天结束)
let finalCurrentStreak = 0
if (sortedMemberDays[sortedMemberDays.length - 1] === lastDay) {
// 从最后一天往前数
finalCurrentStreak = 1
for (let i = sortedMemberDays.length - 2; i >= 0; i--) {
const currDate = new Date(sortedMemberDays[i + 1])
const prevDate = new Date(sortedMemberDays[i])
const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
finalCurrentStreak++
} else {
break
}
}
}
streakData.push({
memberId,
name: data.name,
maxStreak,
maxStreakStart,
maxStreakEnd,
currentStreak: finalCurrentStreak,
})
loyaltyData.push({
memberId,
name: data.name,
totalDays: totalMemberDays,
})
}
// 5. 排序
const streakRank = streakData.sort((a, b) => b.maxStreak - a.maxStreak)
const sortedLoyalty = loyaltyData.sort((a, b) => b.totalDays - a.totalDays)
const maxLoyaltyDays = sortedLoyalty.length > 0 ? sortedLoyalty[0].totalDays : 1
const loyaltyRank = sortedLoyalty.map((item) => ({
...item,
percentage: Math.round((item.totalDays / maxLoyaltyDays) * 100),
}))
return {
streakRank,
loyaltyRank,
totalDays,
}
}
+4
View File
@@ -243,6 +243,10 @@ export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Pr
return sendToWorker('getMemeBattleAnalysis', { sessionId, filter })
}
export async function getCheckInAnalysis(sessionId: string, filter?: any): Promise<any> {
return sendToWorker('getCheckInAnalysis', { sessionId, filter })
}
export async function getAllSessions(): Promise<any[]> {
return sendToWorker('getAllSessions', {})
}
+2
View File
@@ -18,6 +18,7 @@ import type {
MentionAnalysis,
LaughAnalysis,
MemeBattleAnalysis,
CheckInAnalysis,
} from '../../src/types/chat'
interface TimeFilter {
@@ -55,6 +56,7 @@ interface ChatApi {
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MemeBattleAnalysis>
getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CheckInAnalysis>
}
interface Api {
+8
View File
@@ -18,6 +18,7 @@ import type {
MonologueAnalysis,
MentionAnalysis,
LaughAnalysis,
CheckInAnalysis,
MemeBattleAnalysis,
} from '../../src/types/chat'
@@ -265,6 +266,13 @@ const chatApi = {
): Promise<MemeBattleAnalysis> => {
return ipcRenderer.invoke('chat:getMemeBattleAnalysis', sessionId, filter)
},
/**
* 获取打卡分析数据(火花榜 + 忠臣榜)
*/
getCheckInAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise<CheckInAnalysis> => {
return ipcRenderer.invoke('chat:getCheckInAnalysis', sessionId, filter)
},
}
// Use `contextBridge` APIs to expose Electron APIs to
+54 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { CatchphraseAnalysis } from '@/types/chat'
import type { CatchphraseAnalysis, RepeatAnalysis } from '@/types/chat'
import { ListPro } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { KeywordAnalysis } from './quotes'
import { formatDate, getRankBadgeClass } from '@/utils'
interface TimeFilter {
startTs?: number
@@ -31,6 +32,22 @@ async function loadCatchphraseAnalysis() {
}
}
// ==================== 最火复读内容 ====================
const repeatAnalysis = ref<RepeatAnalysis | null>(null)
const isLoadingRepeat = ref(false)
async function loadRepeatAnalysis() {
if (!props.sessionId) return
isLoadingRepeat.value = true
try {
repeatAnalysis.value = await window.chatApi.getRepeatAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载复读分析失败:', error)
} finally {
isLoadingRepeat.value = false
}
}
function truncateContent(content: string, maxLength = 30): string {
if (content.length <= maxLength) return content
return content.slice(0, maxLength) + '...'
@@ -41,6 +58,7 @@ watch(
() => [props.sessionId, props.timeFilter],
() => {
loadCatchphraseAnalysis()
loadRepeatAnalysis()
},
{ immediate: true, deep: true }
)
@@ -97,6 +115,41 @@ watch(
<EmptyState text="暂无口头禅数据" />
</SectionCard>
<!-- 最火复读内容 -->
<LoadingState v-if="isLoadingRepeat" text="正在加载复读数据..." />
<ListPro
v-else-if="repeatAnalysis && repeatAnalysis.hotContents.length > 0"
:items="repeatAnalysis.hotContents"
title="🔥 最火复读内容"
description="单次复读参与人数最多的内容"
:topN="10"
countTemplate="共 {count} 条热门复读"
>
<template #item="{ item, index }">
<div class="flex items-center gap-3">
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</span>
<span class="shrink-0 text-lg font-bold text-pink-600">{{ item.maxChainLength }}</span>
<div class="flex flex-1 items-center gap-1 overflow-hidden text-sm">
<span class="shrink-0 font-medium text-gray-900 dark:text-white">{{ item.originatorName }}</span>
<span class="truncate text-gray-600 dark:text-gray-400" :title="item.content">
{{ truncateContent(item.content) }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-gray-500">
<span>{{ item.count }} </span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>{{ formatDate(item.lastTs) }}</span>
</div>
</div>
</template>
</ListPro>
<!-- 关键词分析 -->
<KeywordAnalysis :session-id="sessionId" :time-filter="timeFilter" />
</div>
+44 -5
View File
@@ -6,6 +6,7 @@ import type { RankItem } from '@/components/charts'
import { PageAnchorsNav } from '@/components/UI'
import { usePageAnchors } from '@/composables'
import DragonKingRank from './ranking/DragonKingRank.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'
@@ -21,12 +22,34 @@ const props = defineProps<{
sessionId: string
memberActivity: MemberActivity[]
timeFilter?: TimeFilter
selectedYear?: number // 0 或 undefined 表示全部时间
availableYears?: number[] // 可用年份列表(用于生成全部时间的标题)
}>()
// 计算赛季标题
const seasonTitle = computed(() => {
if (props.selectedYear && props.selectedYear > 0) {
return `${props.selectedYear} 赛季`
}
// 全部时间:显示年份范围
if (props.availableYears && props.availableYears.length > 0) {
const sorted = [...props.availableYears].sort((a, b) => a - b)
const minYear = sorted[0]
const maxYear = sorted[sorted.length - 1]
if (minYear === maxYear) {
return `${minYear} 赛季`
}
return `${minYear}-${maxYear} 赛季`
}
return '全部赛季'
})
// 锚点导航配置
const anchors = [
{ id: 'member-activity', label: '📊 水群榜' },
{ id: 'dragon-king', label: '🐉 龙王榜' },
{ id: 'member-activity', label: '📊 水群榜' },
{ id: 'streak-rank', label: '🔥 火花榜' },
{ id: 'loyalty-rank', label: '💎 忠臣榜' },
{ id: 'meme-battle', label: '⚔️ 斗图榜' },
{ id: 'monologue', label: '🎤 自言自语榜' },
{ id: 'repeat', label: '🔁 复读榜' },
@@ -35,7 +58,7 @@ const anchors = [
]
// 使用锚点导航 composable
const { contentRef, activeAnchor, scrollToAnchor } = usePageAnchors(anchors)
const { contentRef, activeAnchor, scrollToAnchor } = usePageAnchors(anchors, { threshold: 350 })
// ==================== 成员活跃度排行 ====================
const memberRankData = computed<RankItem[]>(() => {
@@ -52,9 +75,14 @@ const memberRankData = computed<RankItem[]>(() => {
<div ref="contentRef" class="flex gap-6">
<!-- 主内容区 -->
<div class="min-w-0 flex-1 space-y-6">
<!-- 成员活跃度排行 -->
<div id="member-activity" class="scroll-mt-24">
<RankListPro :members="memberRankData" title="水群榜" />
<!-- 赛季大标题 -->
<div class="mb-8 mt-4">
<h1
class="bg-gradient-to-r from-amber-500 via-pink-500 to-purple-600 bg-clip-text text-5xl font-extrabold tracking-wider text-transparent"
>
🏆 {{ seasonTitle }}
</h1>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">各榜单第一名请@群主领取奖品 🎁</p>
</div>
<!-- 龙王排名 -->
@@ -62,6 +90,14 @@ const memberRankData = computed<RankItem[]>(() => {
<DragonKingRank :session-id="sessionId" :time-filter="timeFilter" />
</div>
<!-- 成员活跃度排行 -->
<div id="member-activity" class="scroll-mt-24">
<RankListPro :members="memberRankData" title="水群榜" />
</div>
<!-- 火花榜 + 忠臣榜 -->
<CheckInRank :session-id="sessionId" :time-filter="timeFilter" />
<!-- 斗图榜 -->
<div id="meme-battle" class="scroll-mt-24">
<MemeBattleRank :session-id="sessionId" :time-filter="timeFilter" />
@@ -86,6 +122,9 @@ const memberRankData = computed<RankItem[]>(() => {
<div id="diving" class="scroll-mt-24">
<DivingRank :session-id="sessionId" :time-filter="timeFilter" />
</div>
<!-- 底部间距确保最后一个锚点可以滚动到顶部 -->
<div class="h-48" />
</div>
<!-- 右侧锚点导航 -->
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { CheckInAnalysis } from '@/types/chat'
import { RankListPro, ListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { SectionCard, LoadingState, EmptyState } from '@/components/UI'
import { getRankBadgeClass } from '@/utils'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const analysis = ref<CheckInAnalysis | null>(null)
const isLoading = ref(false)
async function loadAnalysis() {
if (!props.sessionId) return
isLoading.value = true
try {
analysis.value = await window.chatApi.getCheckInAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载打卡分析失败:', error)
} finally {
isLoading.value = false
}
}
// 忠臣榜数据转换
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],
() => {
loadAnalysis()
},
{ immediate: true, deep: true }
)
</script>
<template>
<div class="space-y-6">
<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>
<!-- 名字 -->
<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"
>
当前连续 {{ 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>
</div>
</template>
@@ -4,7 +4,7 @@ import type { RepeatAnalysis } from '@/types/chat'
import { RankListPro, BarChart, ListPro } from '@/components/charts'
import type { RankItem, BarChartData } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { formatDate, getRankBadgeClass } from '@/utils'
import { getRankBadgeClass } from '@/utils'
interface TimeFilter {
startTs?: number
@@ -18,7 +18,7 @@ const props = defineProps<{
const analysis = ref<RepeatAnalysis | null>(null)
const isLoading = ref(false)
const rankMode = ref<'count' | 'rate'>('rate')
const rankMode = ref<'count' | 'rate'>('count')
async function loadData() {
if (!props.sessionId) return
@@ -32,11 +32,6 @@ async function loadData() {
}
}
function truncateContent(content: string, maxLength = 30): string {
if (content.length <= maxLength) return content
return content.slice(0, maxLength) + '...'
}
const originatorRankData = computed<RankItem[]>(() => {
if (!analysis.value) return []
const data = rankMode.value === 'count' ? analysis.value.originators : analysis.value.originatorRates
@@ -102,8 +97,8 @@ watch(
v-if="analysis && analysis.totalRepeatChains > 0"
v-model="rankMode"
:items="[
{ label: '按复读率', value: 'rate' },
{ label: '按次数', value: 'count' },
{ label: '按复读率', value: 'rate' },
]"
size="xs"
/>
@@ -125,38 +120,6 @@ watch(
<EmptyState v-else padding="md" />
</div>
</div>
<!-- 最火复读内容榜 -->
<ListPro
v-if="analysis.hotContents.length > 0"
:items="analysis.hotContents"
title="🏆 最火复读内容榜"
description="单次复读参与人数最多的内容"
countTemplate="共 {count} 条热门复读"
>
<template #item="{ item, index }">
<div class="flex items-center gap-3">
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="getRankBadgeClass(index)"
>
{{ index + 1 }}
</span>
<span class="shrink-0 text-lg font-bold text-pink-600">{{ item.maxChainLength }}</span>
<div class="flex flex-1 items-center gap-1 overflow-hidden text-sm">
<span class="shrink-0 font-medium text-gray-900 dark:text-white">{{ item.originatorName }}</span>
<span class="truncate text-gray-600 dark:text-gray-400" :title="item.content">
{{ truncateContent(item.content) }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-gray-500">
<span>{{ item.count }} </span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>{{ formatDate(item.lastTs) }}</span>
</div>
</div>
</template>
</ListPro>
</div>
<!-- 复读排行榜 Grid -->
@@ -210,27 +173,24 @@ watch(
</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.max(
5,
100 - (member.avgTimeDiff / analysis!.fastestRepeaters[0].avgTimeDiff - 1) * 20
)}%`,
width: `${Math.round((analysis!.fastestRepeaters[0].avgTimeDiff / member.avgTimeDiff) * 100)}%`,
}"
/>
</div>
</div>
<!-- 统计数据 -->
<div class="shrink-0 text-right">
<div class="text-lg font-bold text-gray-900 dark:text-white">
<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
</div>
<div class="text-xs text-gray-500">参与 {{ member.count }} </div>
</span>
<span class="text-xs text-gray-500">· {{ member.count }} </span>
</div>
</div>
</template>
+2
View File
@@ -321,6 +321,8 @@ onMounted(() => {
:session-id="currentSessionId!"
:member-activity="memberActivity"
:time-filter="timeFilter"
:selected-year="selectedYear"
:available-years="availableYears"
/>
<RelationshipsTab
v-else-if="activeTab === 'relationships'"
+33
View File
@@ -650,3 +650,36 @@ export interface MemeBattleAnalysis {
rankByImageCount: MemeBattleRankItem[] // 按图片总数排名
totalBattles: number // 总斗图场次
}
// ==================== 打卡分析类型 ====================
/**
* 火花榜项(连续发言天数)
*/
export interface StreakRankItem {
memberId: number
name: string
maxStreak: number // 最长连续天数
maxStreakStart: string // 最长连续开始日期 (YYYY-MM-DD)
maxStreakEnd: string // 最长连续结束日期 (YYYY-MM-DD)
currentStreak: number // 当前连续天数(0表示已中断)
}
/**
* 忠臣榜项(累计发言天数)
*/
export interface LoyaltyRankItem {
memberId: number
name: string
totalDays: number // 累计发言天数
percentage: number // 相对于第一名的百分比
}
/**
* 打卡分析结果
*/
export interface CheckInAnalysis {
streakRank: StreakRankItem[] // 火花榜 - 连续发言天数排名
loyaltyRank: LoyaltyRankItem[] // 忠臣榜 - 累计发言天数排名
totalDays: number // 群聊总天数
}