From deeb7c1e208154f4275d7ddd31423ab7842329c3 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 30 Nov 2025 13:44:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=96=97=E5=9B=BE?= =?UTF-8?q?=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipcMain.ts | 20 ++ electron/main/worker/dbWorker.ts | 2 + electron/main/worker/index.ts | 1 + electron/main/worker/queryAdvanced.ts | 188 ++++++++++++++++++ electron/main/worker/workerManager.ts | 4 + electron/preload/index.d.ts | 2 + electron/preload/index.ts | 11 + src/components/analysis/RankingTab.vue | 7 + .../analysis/ranking/MemeBattleRank.vue | 154 ++++++++++++++ src/types/chat.ts | 38 ++++ 10 files changed, 427 insertions(+) create mode 100644 src/components/analysis/ranking/MemeBattleRank.vue diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index 3d773a5..1c7d849 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -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 diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index 080e0f3..a65c9da 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -33,6 +33,7 @@ import { getMonologueAnalysis, getMentionAnalysis, getLaughAnalysis, + getMemeBattleAnalysis, } from './queryAdvanced' // 初始化数据库目录 @@ -80,6 +81,7 @@ const handlers: Record 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), } // 处理消息 diff --git a/electron/main/worker/index.ts b/electron/main/worker/index.ts index 2c286d2..37250b5 100644 --- a/electron/main/worker/index.ts +++ b/electron/main/worker/index.ts @@ -25,6 +25,7 @@ export { getMonologueAnalysis, getMentionAnalysis, getLaughAnalysis, + getMemeBattleAnalysis, // 会话管理 API(异步) getAllSessions, getSession, diff --git a/electron/main/worker/queryAdvanced.ts b/electron/main/worker/queryAdvanced.ts index b5c6307..5b9bd1b 100644 --- a/electron/main/worker/queryAdvanced.ts +++ b/electron/main/worker/queryAdvanced.ts @@ -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 + ) + ).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() + + 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, + } +} diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index be49d9e..7c0af28 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -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 { + return sendToWorker('getMemeBattleAnalysis', { sessionId, filter }) +} + export async function getAllSessions(): Promise { return sendToWorker('getAllSessions', {}) } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index a1d89f0..6c96a0d 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -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 getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise + getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise } interface Api { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 21cff8d..e68a221 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -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 => { return ipcRenderer.invoke('chat:getLaughAnalysis', sessionId, filter, keywords) }, + + /** + * 获取斗图分析数据 + */ + getMemeBattleAnalysis: ( + sessionId: string, + filter?: { startTs?: number; endTs?: number } + ): Promise => { + return ipcRenderer.invoke('chat:getMemeBattleAnalysis', sessionId, filter) + }, } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/components/analysis/RankingTab.vue b/src/components/analysis/RankingTab.vue index 0812af7..898003d 100644 --- a/src/components/analysis/RankingTab.vue +++ b/src/components/analysis/RankingTab.vue @@ -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(() => { + +
+ +
+
diff --git a/src/components/analysis/ranking/MemeBattleRank.vue b/src/components/analysis/ranking/MemeBattleRank.vue new file mode 100644 index 0000000..a1361f6 --- /dev/null +++ b/src/components/analysis/ranking/MemeBattleRank.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/types/chat.ts b/src/types/chat.ts index 2161a19..0a231e8 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -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 // 总斗图场次 +}