feat: 新增斗图榜

This commit is contained in:
digua
2025-11-30 13:44:21 +08:00
parent bdabded14a
commit deeb7c1e20
10 changed files with 427 additions and 0 deletions
+20
View File
@@ -536,6 +536,26 @@ const mainIpcMain = (win: BrowserWindow) => {
}
}
)
/**
* 获取斗图分析数据
*/
ipcMain.handle(
'chat:getMemeBattleAnalysis',
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
try {
return await worker.getMemeBattleAnalysis(sessionId, filter)
} catch (error) {
console.error('获取斗图分析失败:', error)
return {
longestBattle: null,
rankByCount: [],
rankByImageCount: [],
totalBattles: 0,
}
}
}
)
}
export default mainIpcMain
+2
View File
@@ -33,6 +33,7 @@ import {
getMonologueAnalysis,
getMentionAnalysis,
getLaughAnalysis,
getMemeBattleAnalysis,
} from './queryAdvanced'
// 初始化数据库目录
@@ -80,6 +81,7 @@ const handlers: Record<string, (payload: any) => any> = {
getMonologueAnalysis: (p) => getMonologueAnalysis(p.sessionId, p.filter),
getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter),
}
// 处理消息
+1
View File
@@ -25,6 +25,7 @@ export {
getMonologueAnalysis,
getMentionAnalysis,
getLaughAnalysis,
getMemeBattleAnalysis,
// 会话管理 API(异步)
getAllSessions,
getSession,
+188
View File
@@ -1434,3 +1434,191 @@ export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keyword
groupLaughRate: Math.round((totalLaughs / totalMessages) * 10000) / 100,
}
}
// ==================== 斗图分析 ====================
/**
* 获取斗图分析数据
* 斗图定义:至少2人参与,总共发了3张图(图片或表情),中间无文本打断
*/
export function getMemeBattleAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabase(sessionId)
const emptyResult = {
topBattles: [],
rankByCount: [],
rankByImageCount: [],
totalBattles: 0,
}
if (!db) return emptyResult
const { clause, params } = buildTimeFilter(filter)
// 排除系统消息 (type=6)
// 斗图只看图片(1)和表情(5),其他类型(如文本0, 语音2等)视为打断
// 我们查询所有非系统消息,在内存中遍历判断
let whereClause = clause
if (whereClause.includes('WHERE')) {
whereClause += ' AND msg.type != 6'
} else {
whereClause = ' WHERE msg.type != 6'
}
const messages = db
.prepare(
`
SELECT
msg.sender_id as senderId,
msg.type,
msg.ts,
m.platform_id as platformId,
m.name
FROM message msg
JOIN member m ON msg.sender_id = m.id
${whereClause}
ORDER BY msg.ts ASC
`
)
.all(...params) as Array<{
senderId: number
type: number
ts: number
platformId: string
name: string
}>
const battles: Array<{
startTime: number
endTime: number
msgs: Array<{ senderId: number; name: string; platformId: string }>
}> = []
let currentChain: Array<{ senderId: number; name: string; platformId: string; ts: number }> = []
// 辅助函数:处理当前链
const processChain = () => {
if (currentChain.length >= 3) {
const senders = new Set(currentChain.map((m) => m.senderId))
if (senders.size >= 2) {
// 满足条件:至少3张图,至少2人
battles.push({
startTime: currentChain[0].ts,
endTime: currentChain[currentChain.length - 1].ts,
msgs: currentChain.map(({ senderId, name, platformId }) => ({ senderId, name, platformId })),
})
}
}
currentChain = []
}
for (const msg of messages) {
// 1=图片, 5=表情
if (msg.type === 1 || msg.type === 5) {
currentChain.push({
senderId: msg.senderId,
name: msg.name,
platformId: msg.platformId,
ts: msg.ts,
})
} else {
// 其他类型消息(文本、语音等)打断斗图
processChain()
}
}
// 处理最后一条链
processChain()
if (battles.length === 0) return emptyResult
// 1. 史诗级斗图榜(前30
const topBattles = battles
.map((battle) => ({
startTime: battle.startTime,
endTime: battle.endTime,
totalImages: battle.msgs.length,
participantCount: new Set(battle.msgs.map((m) => m.senderId)).size,
participants: Object.values(
battle.msgs.reduce(
(acc, curr) => {
if (!acc[curr.senderId]) {
acc[curr.senderId] = { memberId: curr.senderId, name: curr.name, imageCount: 0 }
}
acc[curr.senderId].imageCount++
return acc
},
{} as Record<number, { memberId: number; name: string; imageCount: number }>
)
).sort((a, b) => b.imageCount - a.imageCount),
}))
.sort((a, b) => b.totalImages - a.totalImages)
.slice(0, 30)
// 2. 统计达人榜
const memberStats = new Map<
number,
{
memberId: number
platformId: string
name: string
battleCount: number // 参与场次
imageCount: number // 发图总数
}
>()
for (const battle of battles) {
const participantsInBattle = new Set<number>()
for (const msg of battle.msgs) {
if (!memberStats.has(msg.senderId)) {
memberStats.set(msg.senderId, {
memberId: msg.senderId,
platformId: msg.platformId,
name: msg.name,
battleCount: 0,
imageCount: 0,
})
}
const stats = memberStats.get(msg.senderId)!
stats.imageCount++
participantsInBattle.add(msg.senderId)
}
// 参与场次+1
for (const memberId of participantsInBattle) {
const stats = memberStats.get(memberId)!
stats.battleCount++
}
}
const allStats = Array.from(memberStats.values())
// 按参与场次排名
const rankByCount = [...allStats]
.sort((a, b) => b.battleCount - a.battleCount)
.map((item) => ({
memberId: item.memberId,
platformId: item.platformId,
name: item.name,
count: item.battleCount,
percentage: battles.length > 0 ? Math.round((item.battleCount / battles.length) * 10000) / 100 : 0,
}))
// 按图片总数排名
const totalBattleImages = battles.reduce((sum, b) => sum + b.msgs.length, 0)
const rankByImageCount = [...allStats]
.sort((a, b) => b.imageCount - a.imageCount)
.map((item) => ({
memberId: item.memberId,
platformId: item.platformId,
name: item.name,
count: item.imageCount,
percentage: totalBattleImages > 0 ? Math.round((item.imageCount / totalBattleImages) * 10000) / 100 : 0,
}))
return {
topBattles,
rankByCount,
rankByImageCount,
totalBattles: battles.length,
}
}
+4
View File
@@ -239,6 +239,10 @@ export async function getLaughAnalysis(sessionId: string, filter?: any, keywords
return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords })
}
export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Promise<any> {
return sendToWorker('getMemeBattleAnalysis', { sessionId, filter })
}
export async function getAllSessions(): Promise<any[]> {
return sendToWorker('getAllSessions', {})
}
+2
View File
@@ -17,6 +17,7 @@ import type {
MonologueAnalysis,
MentionAnalysis,
LaughAnalysis,
MemeBattleAnalysis,
} from '../../src/types/chat'
interface TimeFilter {
@@ -53,6 +54,7 @@ interface ChatApi {
getMonologueAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MonologueAnalysis>
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MemeBattleAnalysis>
}
interface Api {
+11
View File
@@ -18,6 +18,7 @@ import type {
MonologueAnalysis,
MentionAnalysis,
LaughAnalysis,
MemeBattleAnalysis,
} from '../../src/types/chat'
// Custom APIs for renderer
@@ -254,6 +255,16 @@ const chatApi = {
): Promise<LaughAnalysis> => {
return ipcRenderer.invoke('chat:getLaughAnalysis', sessionId, filter, keywords)
},
/**
* 获取斗图分析数据
*/
getMemeBattleAnalysis: (
sessionId: string,
filter?: { startTs?: number; endTs?: number }
): Promise<MemeBattleAnalysis> => {
return ipcRenderer.invoke('chat:getMemeBattleAnalysis', sessionId, filter)
},
}
// Use `contextBridge` APIs to expose Electron APIs to
+7
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 MemeBattleRank from './ranking/MemeBattleRank.vue'
import MonologueRank from './ranking/MonologueRank.vue'
import RepeatSection from './ranking/RepeatSection.vue'
import DivingRank from './ranking/DivingRank.vue'
@@ -26,6 +27,7 @@ const props = defineProps<{
const anchors = [
{ id: 'member-activity', label: '📊 水群榜' },
{ id: 'dragon-king', label: '🐉 龙王榜' },
{ id: 'meme-battle', label: '⚔️ 斗图榜' },
{ id: 'monologue', label: '🎤 自言自语榜' },
{ id: 'repeat', label: '🔁 复读榜' },
{ id: 'night-owl', label: '🦉 修仙榜' },
@@ -60,6 +62,11 @@ const memberRankData = computed<RankItem[]>(() => {
<DragonKingRank :session-id="sessionId" :time-filter="timeFilter" />
</div>
<!-- 斗图榜 -->
<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" />
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { MemeBattleAnalysis } from '@/types/chat'
import { RankListPro, ListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { LoadingState, Tabs } from '@/components/UI'
import { formatDate } from '@/utils/dateFormat'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const analysis = ref<MemeBattleAnalysis | null>(null)
const isLoading = ref(false)
const activeTab = ref(0) // 0: 参与场次, 1: 图片数量
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
analysis.value = await window.chatApi.getMemeBattleAnalysis(props.sessionId, props.timeFilter)
} catch (error) {
console.error('加载斗图分析失败:', error)
} finally {
isLoading.value = false
}
}
const rankDataByCount = computed<RankItem[]>(() => {
if (!analysis.value) return []
return analysis.value.rankByCount.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.count,
percentage: m.percentage,
}))
})
const rankDataByImageCount = computed<RankItem[]>(() => {
if (!analysis.value) return []
return analysis.value.rankByImageCount.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.count,
percentage: m.percentage,
}))
})
const currentRankData = computed(() => {
return activeTab.value === 0 ? rankDataByCount.value : rankDataByImageCount.value
})
const rankTitle = computed(() => {
return activeTab.value === 0 ? '斗图达人榜 (按场次)' : '斗图达人榜 (按图量)'
})
const rankUnit = computed(() => {
return activeTab.value === 0 ? '场' : '张'
})
const rankDescription = computed(() => {
return activeTab.value === 0 ? '参与斗图次数最多的人' : '在斗图中发送图片最多的人'
})
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
{ immediate: true, deep: true }
)
</script>
<template>
<div class="space-y-6">
<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-3 right-5 z-10">
<Tabs
v-model="activeTab"
:items="[
{ label: '按场次', value: 0 },
{ label: '按图量', value: 1 },
]"
size="sm"
/>
</div>
<RankListPro :members="currentRankData" :title="rankTitle" :description="rankDescription" :unit="rankUnit" />
</div>
</template>
</div>
</template>
+38
View File
@@ -612,3 +612,41 @@ export interface KeywordTemplate {
name: string
keywords: string[]
}
// ==================== 斗图分析类型 ====================
/**
* 斗图达人榜项
*/
export interface MemeBattleRankItem {
memberId: number
platformId: string
name: string
count: number // 参与场次 或 图片总数
percentage: number // 占比
}
/**
* 斗图记录(一场)
*/
export interface MemeBattleRecord {
startTime: number // 开始时间戳
endTime: number // 结束时间戳
totalImages: number // 总图片数
participantCount: number // 参与人数
participants: Array<{
memberId: number
name: string
imageCount: number // 在该场斗图中发的图片数
}>
}
/**
* 斗图分析结果
*/
export interface MemeBattleAnalysis {
topBattles: MemeBattleRecord[] // 史诗级斗图榜(前30
rankByCount: MemeBattleRankItem[] // 按参与场次排名
rankByImageCount: MemeBattleRankItem[] // 按图片总数排名
totalBattles: number // 总斗图场次
}