mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-14 10:29:15 +08:00
feat: 新增打卡榜
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
getMentionAnalysis,
|
||||
getLaughAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getCheckInAnalysis,
|
||||
// 会话管理 API(异步)
|
||||
getAllSessions,
|
||||
getSession,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {})
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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 // 群聊总天数
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user