mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-06-16 14:02:53 +08:00
feat: 新增斗图榜
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
getMonologueAnalysis,
|
||||
getMentionAnalysis,
|
||||
getLaughAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
// 会话管理 API(异步)
|
||||
getAllSessions,
|
||||
getSession,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {})
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 // 总斗图场次
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user