diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 41d75b7..3604fd1 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -511,6 +511,21 @@ export function registerChatHandlers(ctx: IpcContext): void { } ) + /** + * 获取 @ 互动关系图数据 + */ + ipcMain.handle( + 'chat:getMentionGraph', + async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { + try { + return await worker.getMentionGraph(sessionId, filter) + } catch (error) { + console.error('获取 @ 互动关系图失败:', error) + return { nodes: [], links: [], maxLinkValue: 0 } + } + } + ) + /** * 获取含笑量分析数据 */ diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index bfc8ae5..a468ccb 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -32,6 +32,7 @@ import { getDivingAnalysis, getMonologueAnalysis, getMentionAnalysis, + getMentionGraph, getLaughAnalysis, getMemeBattleAnalysis, getCheckInAnalysis, @@ -115,6 +116,7 @@ const syncHandlers: Record any> = { getDivingAnalysis: (p) => getDivingAnalysis(p.sessionId, p.filter), getMonologueAnalysis: (p) => getMonologueAnalysis(p.sessionId, p.filter), getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), + getMentionGraph: (p) => getMentionGraph(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), diff --git a/electron/main/worker/query/advanced/index.ts b/electron/main/worker/query/advanced/index.ts index 911e9d0..9e83968 100644 --- a/electron/main/worker/query/advanced/index.ts +++ b/electron/main/worker/query/advanced/index.ts @@ -13,5 +13,6 @@ export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, getCheck export { getMonologueAnalysis, getMemeBattleAnalysis } from './behavior' // 社交分析:@ 互动、含笑量 -export { getMentionAnalysis, getLaughAnalysis } from './social' +export { getMentionAnalysis, getMentionGraph, getLaughAnalysis } from './social' +export type { MentionGraphData, MentionGraphNode, MentionGraphLink } from './social' diff --git a/electron/main/worker/query/advanced/social.ts b/electron/main/worker/query/advanced/social.ts index f3e86de..724b048 100644 --- a/electron/main/worker/query/advanced/social.ts +++ b/electron/main/worker/query/advanced/social.ts @@ -306,6 +306,174 @@ export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any } } +// ==================== @ 互动关系图数据 ==================== + +export interface MentionGraphNode { + id: number + name: string + value: number // 消息数量(用于节点大小) + symbolSize: number // 节点大小 +} + +export interface MentionGraphLink { + source: string // 发起者名称 + target: string // 被艾特者名称 + value: number // @ 次数 +} + +export interface MentionGraphData { + nodes: MentionGraphNode[] + links: MentionGraphLink[] + maxLinkValue: number // 最大边权重(用于归一化) +} + +/** + * 获取 @ 互动关系图数据(用于 ECharts Graph) + */ +export function getMentionGraph(sessionId: string, filter?: TimeFilter): MentionGraphData { + const db = openDatabase(sessionId) + const emptyResult: MentionGraphData = { nodes: [], links: [], maxLinkValue: 0 } + + if (!db) return emptyResult + + // 1. 查询所有成员信息和消息数量(不过滤消息数为 0 的成员,因为可能被 @ 但没发消息) + const { clause, params } = buildTimeFilter(filter) + const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' + const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" + + const members = db + .prepare( + ` + SELECT + m.id, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} + WHERE COALESCE(m.account_name, '') != '系统消息' + GROUP BY m.id + ` + ) + .all(...params) as Array<{ id: number; platformId: string; name: string; messageCount: number }> + + if (members.length === 0) return emptyResult + + // 2. 构建昵称到成员ID的映射 + const nameToMemberId = new Map() + const memberIdToInfo = new Map() + + for (const member of members) { + memberIdToInfo.set(member.id, { name: member.name, messageCount: member.messageCount }) + nameToMemberId.set(member.name, member.id) + + // 查询历史昵称 + const history = db + .prepare(`SELECT name FROM member_name_history WHERE member_id = ?`) + .all(member.id) as Array<{ name: string }> + + for (const h of history) { + if (!nameToMemberId.has(h.name)) { + nameToMemberId.set(h.name, member.id) + } + } + } + + // 3. 查询包含 @ 的消息 + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += + " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" + } else { + whereClause = + " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" + } + + const messages = db + .prepare( + ` + SELECT msg.sender_id as senderId, msg.content + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${whereClause} + ` + ) + .all(...params) as Array<{ senderId: number; content: string }> + + // 4. 解析 @ 并构建关系矩阵 + const mentionMatrix = new Map>() + const mentionRegex = /@([^\s@]+)/g + + for (const msg of messages) { + const matches = msg.content.matchAll(mentionRegex) + const mentionedInThisMsg = new Set() + + for (const match of matches) { + const mentionedName = match[1] + const mentionedId = nameToMemberId.get(mentionedName) + + if (mentionedId && mentionedId !== msg.senderId && !mentionedInThisMsg.has(mentionedId)) { + mentionedInThisMsg.add(mentionedId) + + if (!mentionMatrix.has(msg.senderId)) { + mentionMatrix.set(msg.senderId, new Map()) + } + const fromMap = mentionMatrix.get(msg.senderId)! + fromMap.set(mentionedId, (fromMap.get(mentionedId) || 0) + 1) + } + } + } + + // 5. 构建 nodes(只包含有互动的成员) + const involvedMemberIds = new Set() + for (const [fromId, toMap] of mentionMatrix.entries()) { + involvedMemberIds.add(fromId) + for (const toId of toMap.keys()) { + involvedMemberIds.add(toId) + } + } + + const maxMessageCount = Math.max(...members.filter((m) => involvedMemberIds.has(m.id)).map((m) => m.messageCount), 1) + + const nodes: MentionGraphNode[] = [] + for (const memberId of involvedMemberIds) { + const info = memberIdToInfo.get(memberId) + if (info) { + // 节点大小根据消息数量计算(20-60 范围) + const symbolSize = 20 + (info.messageCount / maxMessageCount) * 40 + nodes.push({ + id: memberId, + name: info.name, + value: info.messageCount, + symbolSize: Math.round(symbolSize), + }) + } + } + + // 6. 构建 links(使用 name 而非 ID,便于前端 ECharts 匹配) + const links: MentionGraphLink[] = [] + let maxLinkValue = 0 + + for (const [fromId, toMap] of mentionMatrix.entries()) { + const fromInfo = memberIdToInfo.get(fromId) + if (!fromInfo) continue + + for (const [toId, count] of toMap.entries()) { + const toInfo = memberIdToInfo.get(toId) + if (!toInfo) continue + + links.push({ + source: fromInfo.name, + target: toInfo.name, + value: count, + }) + maxLinkValue = Math.max(maxLinkValue, count) + } + } + + return { nodes, links, maxLinkValue } +} + // ==================== 含笑量分析 ==================== /** diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index 135ba3e..d6ce9aa 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -35,6 +35,7 @@ export { getMonologueAnalysis, getMemeBattleAnalysis, getMentionAnalysis, + getMentionGraph, getLaughAnalysis, } from './advanced' diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 160e968..3261483 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -273,6 +273,10 @@ export async function getMentionAnalysis(sessionId: string, filter?: any): Promi return sendToWorker('getMentionAnalysis', { sessionId, filter }) } +export async function getMentionGraph(sessionId: string, filter?: any): Promise { + return sendToWorker('getMentionGraph', { sessionId, filter }) +} + export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise { return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords }) } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 7993a23..77569fc 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -28,6 +28,13 @@ interface TimeFilter { memberId?: number | null // 成员筛选,null 表示全部成员 } +// @ 互动关系图数据 +interface MentionGraphData { + nodes: Array<{ id: number; name: string; value: number; symbolSize: number }> + links: Array<{ source: string; target: string; value: number }> + maxLinkValue: number +} + // 迁移相关类型 interface MigrationInfo { version: number @@ -106,6 +113,7 @@ interface ChatApi { getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMonologueAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise + getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ac2261d..ed23a79 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -311,6 +311,20 @@ const chatApi = { return ipcRenderer.invoke('chat:getMentionAnalysis', sessionId, filter) }, + /** + * 获取 @ 互动关系图数据 + */ + getMentionGraph: ( + sessionId: string, + filter?: { startTs?: number; endTs?: number } + ): Promise<{ + nodes: Array<{ id: number; name: string; value: number; symbolSize: number }> + links: Array<{ source: string; target: string; value: number }> + maxLinkValue: number + }> => { + return ipcRenderer.invoke('chat:getMentionGraph', sessionId, filter) + }, + /** * 获取含笑量分析数据 */ diff --git a/src/components/charts/EChartGraph.vue b/src/components/charts/EChartGraph.vue new file mode 100644 index 0000000..249d4cb --- /dev/null +++ b/src/components/charts/EChartGraph.vue @@ -0,0 +1,268 @@ + + + + diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts index a25d601..a3cba79 100644 --- a/src/components/charts/index.ts +++ b/src/components/charts/index.ts @@ -7,6 +7,7 @@ export { default as EChartBar } from './EChartBar.vue' export { default as EChartLine } from './EChartLine.vue' export { default as EChartHeatmap } from './EChartHeatmap.vue' export { default as EChartCalendar } from './EChartCalendar.vue' +export { default as EChartGraph } from './EChartGraph.vue' // 其他组件 export { default as RankList } from './RankList.vue' @@ -21,6 +22,7 @@ export type { EChartBarData } from './EChartBar.vue' export type { EChartLineData } from './EChartLine.vue' export type { EChartHeatmapData } from './EChartHeatmap.vue' export type { CalendarData as EChartCalendarData } from './EChartCalendar.vue' +export type { GraphData as EChartGraphData, GraphNode, GraphLink } from './EChartGraph.vue' // 其他类型 export type { RankItem } from './RankList.vue' diff --git a/src/components/view/InteractionView.vue b/src/components/view/InteractionView.vue new file mode 100644 index 0000000..7684307 --- /dev/null +++ b/src/components/view/InteractionView.vue @@ -0,0 +1,178 @@ + + + + + +{ + "zh-CN": { + "mentionGraph": "艾特互动关系图", + "layout": "布局", + "circular": "环形", + "force": "力导向", + "directed": "有向", + "reset": "重置", + "graphHint": "共 {nodes} 位成员,{links} 条互动关系", + "noInteraction": "暂无艾特互动数据" + }, + "en-US": { + "mentionGraph": "Mention Interaction Graph", + "layout": "Layout", + "circular": "Circular", + "force": "Force", + "directed": "Directed", + "reset": "Reset", + "graphHint": "{nodes} members, {links} interactions", + "noInteraction": "No mention interaction data" + } +} + + diff --git a/src/components/view/index.ts b/src/components/view/index.ts index 1ad9802..564d7e6 100644 --- a/src/components/view/index.ts +++ b/src/components/view/index.ts @@ -2,4 +2,5 @@ export { default as MessageView } from './MessageView.vue' export { default as WordcloudView } from './WordcloudView.vue' export { default as PortraitView } from './PortraitView.vue' +export { default as InteractionView } from './InteractionView.vue' diff --git a/src/pages/group-chat/components/ViewTab.vue b/src/pages/group-chat/components/ViewTab.vue index 1fc5348..75ac32f 100644 --- a/src/pages/group-chat/components/ViewTab.vue +++ b/src/pages/group-chat/components/ViewTab.vue @@ -2,7 +2,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { SubTabs } from '@/components/UI' -import { MessageView, WordcloudView, PortraitView } from '@/components/view' +import { MessageView, WordcloudView, PortraitView, InteractionView } from '@/components/view' import UserSelect from '@/components/common/UserSelect.vue' const { t } = useI18n() @@ -18,9 +18,10 @@ const props = defineProps<{ timeFilter?: TimeFilter }>() -// 子 Tab 配置 +// 子 Tab 配置(群聊专属:包含互动分析) const subTabs = computed(() => [ { id: 'message', label: t('message'), icon: 'i-heroicons-chat-bubble-left-right' }, + { id: 'interaction', label: t('interaction'), icon: 'i-heroicons-arrows-right-left' }, { id: 'wordcloud', label: t('wordcloud'), icon: 'i-heroicons-cloud' }, { id: 'portrait', label: t('portrait'), icon: 'i-heroicons-user-circle' }, ]) @@ -49,6 +50,12 @@ const selectedMemberId = ref(null) :time-filter="props.timeFilter" :member-id="selectedMemberId" /> + (null) { "zh-CN": { "message": "消息", + "interaction": "互动分析", "wordcloud": "词云", "portrait": "对话画像" }, "en-US": { "message": "Messages", + "interaction": "Interactions", "wordcloud": "Word Cloud", "portrait": "Chat Portrait" }