feat: 互动频率分析

This commit is contained in:
digua
2026-02-10 23:36:03 +08:00
parent 2d6c4d085a
commit 448f28da14
12 changed files with 1151 additions and 3 deletions

View File

@@ -513,6 +513,43 @@ export function registerChatHandlers(ctx: IpcContext): void {
} }
) )
/**
* 获取小团体关系图数据(基于时间相邻共现)
*/
ipcMain.handle(
'chat:getClusterGraph',
async (
_,
sessionId: string,
filter?: { startTs?: number; endTs?: number },
options?: {
lookAhead?: number
decaySeconds?: number
minScore?: number
topEdges?: number
}
) => {
try {
return await worker.getClusterGraph(sessionId, filter, options)
} catch (error) {
console.error('获取小团体关系图失败:', error)
return {
nodes: [],
links: [],
maxLinkValue: 0,
communities: [],
stats: {
totalMembers: 0,
totalMessages: 0,
involvedMembers: 0,
edgeCount: 0,
communityCount: 0,
},
}
}
}
)
/** /**
* 获取含笑量分析数据 * 获取含笑量分析数据
*/ */

View File

@@ -33,6 +33,7 @@ import {
getMentionAnalysis, getMentionAnalysis,
getMentionGraph, getMentionGraph,
getLaughAnalysis, getLaughAnalysis,
getClusterGraph,
getMemeBattleAnalysis, getMemeBattleAnalysis,
getCheckInAnalysis, getCheckInAnalysis,
searchMessages, searchMessages,
@@ -123,6 +124,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter),
getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter), getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options),
getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter), getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter),
getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter), getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter),

View File

@@ -12,6 +12,14 @@ export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, getCheck
// 行为分析:斗图 // 行为分析:斗图
export { getMemeBattleAnalysis } from './behavior' export { getMemeBattleAnalysis } from './behavior'
// 社交分析:@ 互动、含笑量 // 社交分析:@ 互动、含笑量、小团体
export { getMentionAnalysis, getMentionGraph, getLaughAnalysis } from './social' export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social'
export type { MentionGraphData, MentionGraphNode, MentionGraphLink } from './social' export type {
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
ClusterGraphData,
ClusterGraphNode,
ClusterGraphLink,
ClusterGraphOptions,
} from './social'

View File

@@ -670,3 +670,343 @@ export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keyword
groupLaughRate: Math.round((totalLaughs / totalMessages) * 10000) / 100, groupLaughRate: Math.round((totalLaughs / totalMessages) * 10000) / 100,
} }
} }
// ==================== 小团体关系图(时间相邻共现) ====================
/**
* 小团体关系图参数
*/
export interface ClusterGraphOptions {
/** 向后看几个不同发言者默认3 */
lookAhead?: number
/** 时间衰减常数默认120 */
decaySeconds?: number
/** 最多保留边数默认100 */
topEdges?: number
}
/**
* 小团体图节点
*/
export interface ClusterGraphNode {
id: number
name: string
messageCount: number
symbolSize: number
degree: number
normalizedDegree: number
}
/**
* 小团体图边
*/
export interface ClusterGraphLink {
source: string
target: string
value: number
rawScore: number
expectedScore: number
coOccurrenceCount: number
}
/**
* 小团体图结果
*/
export interface ClusterGraphData {
nodes: ClusterGraphNode[]
links: ClusterGraphLink[]
maxLinkValue: number
communities: Array<{ id: number; name: string; size: number }>
stats: {
totalMembers: number
totalMessages: number
involvedMembers: number
edgeCount: number
communityCount: number
}
}
const DEFAULT_CLUSTER_OPTIONS = {
lookAhead: 3,
decaySeconds: 120,
topEdges: 100,
}
function roundNum(value: number, digits = 4): number {
const factor = 10 ** digits
return Math.round(value * factor) / factor
}
function clusterPairKey(aId: number, bId: number): string {
return aId < bId ? `${aId}-${bId}` : `${bId}-${aId}`
}
/**
* 获取小团体关系图(基于时间相邻共现)
*
* 算法原理:
* 1. 相邻定义消息A发出后后续N个不同发言者视为与A的发言者"相邻"
* 2. 时间衰减:越快出现的相邻者权重越高 (exp(-delta/decay))
* 3. 归一化raw_score / expected_score去除"话唠偏差"
* 4. 社区检测:加权标签传播
*/
export function getClusterGraph(
sessionId: string,
filter?: TimeFilter,
options?: ClusterGraphOptions
): ClusterGraphData {
const db = openDatabase(sessionId)
const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options }
const emptyResult: ClusterGraphData = {
nodes: [],
links: [],
maxLinkValue: 0,
communities: [],
stats: {
totalMembers: 0,
totalMessages: 0,
involvedMembers: 0,
edgeCount: 0,
communityCount: 0,
},
}
if (!db) return emptyResult
// 1. 查询所有成员
const members = db
.prepare(
`
SELECT
id,
platform_id as platformId,
COALESCE(group_nickname, account_name, platform_id) as name,
(SELECT COUNT(*) FROM message WHERE sender_id = member.id) as messageCount
FROM member
WHERE COALESCE(account_name, '') != '系统消息'
`
)
.all() as Array<{ id: number; platformId: string; name: string; messageCount: number }>
if (members.length < 2) return { ...emptyResult, stats: { ...emptyResult.stats, totalMembers: members.length } }
const memberInfo = new Map<number, { name: string; platformId: string; messageCount: number }>()
for (const m of members) {
memberInfo.set(m.id, { name: m.name, platformId: m.platformId, messageCount: m.messageCount })
}
// 2. 查询消息(按时间排序)
const { clause, params } = buildTimeFilter(filter)
let whereClause = clause
if (whereClause.includes('WHERE')) {
whereClause += " AND COALESCE(m.account_name, '') != '系统消息'"
} else {
whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息'"
}
const messages = db
.prepare(
`
SELECT msg.sender_id as senderId, msg.ts as ts
FROM message msg
JOIN member m ON msg.sender_id = m.id
${whereClause}
ORDER BY msg.ts ASC, msg.id ASC
`
)
.all(...params) as Array<{ senderId: number; ts: number }>
if (messages.length < 2) {
return { ...emptyResult, stats: { ...emptyResult.stats, totalMembers: members.length, totalMessages: messages.length } }
}
// 3. 统计每个成员的消息数(用于归一化)
const memberMsgCount = new Map<number, number>()
for (const msg of messages) {
memberMsgCount.set(msg.senderId, (memberMsgCount.get(msg.senderId) || 0) + 1)
}
const totalMessages = messages.length
// 4. 计算成员对的原始相邻分数
const pairRawScore = new Map<string, number>()
const pairCoOccurrence = new Map<string, number>()
for (let i = 0; i < messages.length - 1; i++) {
const anchor = messages[i]
const seenPartners = new Set<number>()
let partnersFound = 0
// 向后看 lookAhead 个不同发言者
for (let j = i + 1; j < messages.length && partnersFound < opts.lookAhead; j++) {
const candidate = messages[j]
// 跳过同一发言者
if (candidate.senderId === anchor.senderId) continue
// 跳过已计入的发言者
if (seenPartners.has(candidate.senderId)) continue
seenPartners.add(candidate.senderId)
partnersFound++
// 计算时间衰减权重
const deltaSeconds = (candidate.ts - anchor.ts) / 1000
const decayWeight = Math.exp(-deltaSeconds / opts.decaySeconds)
// 位置衰减第1个邻居权重1第2个0.8第3个0.6
const positionWeight = 1 - (partnersFound - 1) * 0.2
const weight = decayWeight * positionWeight
const key = clusterPairKey(anchor.senderId, candidate.senderId)
pairRawScore.set(key, (pairRawScore.get(key) || 0) + weight)
pairCoOccurrence.set(key, (pairCoOccurrence.get(key) || 0) + 1)
}
}
// 5. 归一化:计算期望分数并除以期望
// 期望公式expected = (A消息数/总数) × (B消息数/总数) × 总消息数 × 平均窗口覆盖率
// 简化expected ≈ (A消息数 × B消息数) / 总消息数 × lookAhead因子
const lookAheadFactor = opts.lookAhead * 0.8 // 平均每条消息能覆盖的邻居数
// 收集所有边和分数
const rawEdges: Array<{
sourceId: number
targetId: number
rawScore: number
expectedScore: number
normalizedScore: number
coOccurrenceCount: number
}> = []
for (const [key, rawScore] of pairRawScore) {
const [aIdStr, bIdStr] = key.split('-')
const aId = parseInt(aIdStr)
const bId = parseInt(bIdStr)
const aMsgCount = memberMsgCount.get(aId) || 0
const bMsgCount = memberMsgCount.get(bId) || 0
// 期望分数(保留用于参考)
const expectedScore = ((aMsgCount * bMsgCount) / totalMessages) * lookAheadFactor
const normalizedScore = expectedScore > 0 ? rawScore / expectedScore : 0
rawEdges.push({
sourceId: aId,
targetId: bId,
rawScore,
expectedScore,
normalizedScore,
coOccurrenceCount: pairCoOccurrence.get(key) || 0,
})
}
// 计算最大分数,用于归一化到 [0, 1]
const maxRawScore = Math.max(...rawEdges.map((e) => e.rawScore), 1)
const maxNormalizedScore = Math.max(...rawEdges.map((e) => e.normalizedScore), 1)
// 混合分数50% 原始分数 + 50% 归一化分数
const edges = rawEdges.map((e) => {
const hybridScore = 0.5 * (e.rawScore / maxRawScore) + 0.5 * (e.normalizedScore / maxNormalizedScore)
return {
...e,
rawScore: roundNum(e.rawScore),
expectedScore: roundNum(e.expectedScore),
normalizedScore: roundNum(e.normalizedScore),
hybridScore: roundNum(hybridScore),
}
})
// 6. 按原始分数排序,取 Top N
edges.sort((a, b) => b.hybridScore - a.hybridScore)
const keptEdges = edges.slice(0, opts.topEdges)
if (keptEdges.length === 0) {
return {
...emptyResult,
stats: { ...emptyResult.stats, totalMembers: members.length, totalMessages: messages.length },
}
}
// 7. 找出参与的成员
const involvedIds = new Set<number>()
for (const edge of keptEdges) {
involvedIds.add(edge.sourceId)
involvedIds.add(edge.targetId)
}
// 8. 计算节点度数(使用混合分数)
const nodeDegree = new Map<number, number>()
for (const edge of keptEdges) {
nodeDegree.set(edge.sourceId, (nodeDegree.get(edge.sourceId) || 0) + edge.hybridScore)
nodeDegree.set(edge.targetId, (nodeDegree.get(edge.targetId) || 0) + edge.hybridScore)
}
const maxDegree = Math.max(...nodeDegree.values(), 1)
// 10. 构建唯一显示名称(处理同名)
const nameCount = new Map<string, number>()
for (const id of involvedIds) {
const name = memberInfo.get(id)?.name || String(id)
nameCount.set(name, (nameCount.get(name) || 0) + 1)
}
const displayNames = new Map<number, string>()
for (const id of involvedIds) {
const info = memberInfo.get(id)
const baseName = info?.name || String(id)
if ((nameCount.get(baseName) || 0) > 1) {
displayNames.set(id, `${baseName}#${(info?.platformId || String(id)).slice(-4)}`)
} else {
displayNames.set(id, baseName)
}
}
// 11. 构建输出
const maxMsgCount = Math.max(...[...involvedIds].map((id) => memberInfo.get(id)?.messageCount || 0), 1)
const nodes: ClusterGraphNode[] = [...involvedIds].map((id) => {
const info = memberInfo.get(id)!
const degree = nodeDegree.get(id) || 0
const normalizedDegree = degree / maxDegree
const msgNorm = info.messageCount / maxMsgCount
// 节点大小70% 基于度数30% 基于消息数
const symbolSize = 20 + (0.7 * normalizedDegree + 0.3 * msgNorm) * 35
return {
id,
name: displayNames.get(id)!,
messageCount: info.messageCount,
symbolSize: Math.round(symbolSize),
degree: roundNum(degree),
normalizedDegree: roundNum(normalizedDegree),
}
})
nodes.sort((a, b) => b.degree - a.degree)
const maxLinkValue = keptEdges.length > 0 ? Math.max(...keptEdges.map((e) => e.hybridScore)) : 0
const links: ClusterGraphLink[] = keptEdges.map((e) => ({
source: displayNames.get(e.sourceId)!,
target: displayNames.get(e.targetId)!,
value: e.hybridScore, // 使用混合分数作为主要输出
rawScore: e.rawScore,
expectedScore: e.expectedScore,
coOccurrenceCount: e.coOccurrenceCount,
}))
return {
nodes,
links,
maxLinkValue: roundNum(maxLinkValue),
communities: [], // 保留字段兼容性,但不再计算
stats: {
totalMembers: members.length,
totalMessages: messages.length,
involvedMembers: involvedIds.size,
edgeCount: keptEdges.length,
communityCount: 0,
},
}
}

View File

@@ -41,8 +41,12 @@ export {
getMentionAnalysis, getMentionAnalysis,
getMentionGraph, getMentionGraph,
getLaughAnalysis, getLaughAnalysis,
getClusterGraph,
} from './advanced' } from './advanced'
// 小团体图类型
export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './advanced'
// 聊天记录查询 // 聊天记录查询
export { export {
searchMessages, searchMessages,

View File

@@ -306,6 +306,10 @@ export async function getLaughAnalysis(sessionId: string, filter?: any, keywords
return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords }) return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords })
} }
export async function getClusterGraph(sessionId: string, filter?: any, options?: any): Promise<any> {
return sendToWorker('getClusterGraph', { sessionId, filter, options })
}
export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Promise<any> { export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Promise<any> {
return sendToWorker('getMemeBattleAnalysis', { sessionId, filter }) return sendToWorker('getMemeBattleAnalysis', { sessionId, filter })
} }

View File

@@ -20,6 +20,8 @@ import type {
CheckInAnalysis, CheckInAnalysis,
MemeBattleAnalysis, MemeBattleAnalysis,
MemberWithStats, MemberWithStats,
ClusterGraphData,
ClusterGraphOptions,
} from '../../../src/types/analysis' } from '../../../src/types/analysis'
import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../src/types/format' import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../src/types/format'
@@ -277,6 +279,17 @@ export const chatApi = {
return ipcRenderer.invoke('chat:getMentionGraph', sessionId, filter) return ipcRenderer.invoke('chat:getMentionGraph', sessionId, filter)
}, },
/**
* 获取小团体关系图数据(基于时间相邻共现)
*/
getClusterGraph: (
sessionId: string,
filter?: { startTs?: number; endTs?: number },
options?: ClusterGraphOptions
): Promise<ClusterGraphData> => {
return ipcRenderer.invoke('chat:getClusterGraph', sessionId, filter, options)
},
/** /**
* 获取含笑量分析数据 * 获取含笑量分析数据
*/ */

View File

@@ -17,6 +17,8 @@ import type {
MemeBattleAnalysis, MemeBattleAnalysis,
CheckInAnalysis, CheckInAnalysis,
MemberWithStats, MemberWithStats,
ClusterGraphData,
ClusterGraphOptions,
} from '../../src/types/analysis' } from '../../src/types/analysis'
import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format' import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format'
import type { TableSchema, SQLResult } from '../../src/components/analysis/SQLLab/types' import type { TableSchema, SQLResult } from '../../src/components/analysis/SQLLab/types'
@@ -131,6 +133,7 @@ interface ChatApi {
getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<DivingAnalysis> getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<DivingAnalysis>
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis> getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise<MentionGraphData> getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise<MentionGraphData>
getClusterGraph: (sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions) => Promise<ClusterGraphData>
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis> getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MemeBattleAnalysis> getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MemeBattleAnalysis>
getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CheckInAnalysis> getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CheckInAnalysis>

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
/**
* 小团体关系视图
* 支持两种展示模式:矩阵热力图 和 圈子视图
*/
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import * as echarts from 'echarts/core'
import { HeatmapChart } from 'echarts/charts'
import { TooltipComponent, GridComponent, VisualMapComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useDark } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
import type { ClusterGraphData, ClusterGraphOptions, ClusterGraphNode } from '@/types/analysis'
echarts.use([HeatmapChart, TooltipComponent, GridComponent, VisualMapComponent, CanvasRenderer])
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
memberId?: number | null
}>()
// 数据状态
const isLoading = ref(true)
const graphData = ref<ClusterGraphData | null>(null)
// 视图模式
const viewMode = ref<'matrix' | 'member' | 'circle'>('matrix')
// 成员视图状态
const selectedMemberId = ref<number | null>(null)
// 模型参数
const modelOptions = ref<ClusterGraphOptions>({
lookAhead: 3,
decaySeconds: 120,
topEdges: 150,
})
// 图表引用
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
const isDark = useDark()
// 加载数据
async function loadData() {
console.log('[ClusterView] loadData called', {
sessionId: props.sessionId,
viewMode: viewMode.value,
hasChartInstance: !!chartInstance,
})
if (!props.sessionId) return
isLoading.value = true
try {
const filter = props.timeFilter ? { ...props.timeFilter } : undefined
const options = {
lookAhead: modelOptions.value.lookAhead,
decaySeconds: modelOptions.value.decaySeconds,
topEdges: modelOptions.value.topEdges,
}
console.log('[ClusterView] calling getClusterGraph with options', options)
graphData.value = await window.chatApi.getClusterGraph(props.sessionId, filter, options)
console.log('[ClusterView] getClusterGraph returned', {
nodeCount: graphData.value?.nodes?.length,
linkCount: graphData.value?.links?.length,
})
} catch (error) {
console.error('加载小团体关系数据失败:', error)
graphData.value = null
} finally {
isLoading.value = false
console.log('[ClusterView] loadData finished, isLoading =', isLoading.value)
}
}
// ==================== 矩阵热力图 ====================
// 构建矩阵数据
const matrixData = computed(() => {
if (!graphData.value || graphData.value.nodes.length === 0) return null
const { nodes, links, maxLinkValue } = graphData.value
// 按消息数排序的成员名(限制数量避免过于拥挤)
const sortedNodes = [...nodes].sort((a, b) => b.messageCount - a.messageCount).slice(0, 20)
const names = sortedNodes.map((n) => n.name)
// 构建关系矩阵
const linkMap = new Map<string, number>()
for (const link of links) {
linkMap.set(`${link.source}-${link.target}`, link.value)
linkMap.set(`${link.target}-${link.source}`, link.value)
}
// 生成热力图数据
const data: Array<[number, number, number]> = []
for (let i = 0; i < names.length; i++) {
for (let j = 0; j < names.length; j++) {
if (i === j) {
data.push([i, j, -1]) // 对角线标记为 -1
} else {
const value = linkMap.get(`${names[i]}-${names[j]}`) || 0
data.push([i, j, value])
}
}
}
return { names, data, maxValue: maxLinkValue }
})
// 构建热力图选项
function buildHeatmapOptions(): EChartsOption {
if (!matrixData.value) {
return { graphic: { type: 'text', style: { text: t('noData'), fill: '#999' } } }
}
const { names, data, maxValue } = matrixData.value
return {
tooltip: {
position: 'top',
formatter: (params: any) => {
const [x, y, value] = params.data
if (value < 0) return `${names[x]}`
if (value === 0) return `${names[x]}${names[y]}<br/>暂无关系数据`
return `${names[x]}${names[y]}<br/>临近度: ${value.toFixed(2)}`
},
},
grid: {
left: 120,
right: 40,
top: 40,
bottom: 120,
},
xAxis: {
type: 'category',
data: names,
splitArea: { show: true },
axisLabel: {
rotate: 45,
fontSize: 10,
color: isDark.value ? '#ccc' : '#333',
},
},
yAxis: {
type: 'category',
data: names,
splitArea: { show: true },
axisLabel: {
fontSize: 10,
color: isDark.value ? '#ccc' : '#333',
},
},
visualMap: {
min: 0,
max: maxValue || 1,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 10,
inRange: {
color: isDark.value
? ['#1a1a2e', '#16213e', '#0f3460', '#e94560']
: ['#f7f7f7', '#fce4ec', '#f8bbd9', '#ee4567'],
},
textStyle: { color: isDark.value ? '#ccc' : '#333' },
},
series: [
{
type: 'heatmap',
data: data.filter((d) => d[2] >= 0), // 排除对角线
label: { show: false },
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
backgroundColor: 'transparent',
}
}
// ==================== 成员视图(选择一个人,查看所有关系) ====================
// 所有参与的成员列表(按消息数排序)
const memberList = computed(() => {
if (!graphData.value) return []
return [...graphData.value.nodes].sort((a, b) => b.messageCount - a.messageCount)
})
// 选中的成员信息
const selectedMember = computed(() => {
if (!graphData.value || selectedMemberId.value === null) return null
return graphData.value.nodes.find((n) => n.id === selectedMemberId.value) || null
})
// 选中成员的所有关系(按分数排序)
const selectedMemberRelations = computed(() => {
if (!graphData.value || !selectedMember.value) return []
const memberName = selectedMember.value.name
const relations: Array<{
otherName: string
value: number
coOccurrenceCount: number
}> = []
for (const link of graphData.value.links) {
if (link.source === memberName) {
relations.push({
otherName: link.target,
value: link.value,
coOccurrenceCount: link.coOccurrenceCount,
})
} else if (link.target === memberName) {
relations.push({
otherName: link.source,
value: link.value,
coOccurrenceCount: link.coOccurrenceCount,
})
}
}
// 按分数从高到低排序
relations.sort((a, b) => b.value - a.value)
return relations
})
// ==================== 排行视图 ====================
// Top 关系排行(显示更多条目)
const topRelations = computed(() => {
if (!graphData.value) return []
return graphData.value.links.slice(0, 50)
})
// ==================== 图表管理 ====================
function updateChart() {
console.log('[ClusterView] updateChart called', {
hasChartInstance: !!chartInstance,
viewMode: viewMode.value,
isLoading: isLoading.value,
})
if (!chartInstance) {
console.log('[ClusterView] updateChart: no chartInstance, skipping')
return
}
if (viewMode.value === 'matrix') {
console.log('[ClusterView] updateChart: setting heatmap options')
chartInstance.setOption(buildHeatmapOptions(), { notMerge: true })
}
}
function initChart() {
console.log('[ClusterView] initChart called', {
hasChartRef: !!chartRef.value,
chartRefSize: chartRef.value ? { width: chartRef.value.clientWidth, height: chartRef.value.clientHeight } : null,
})
if (!chartRef.value) {
console.log('[ClusterView] initChart: no chartRef, skipping')
return
}
chartInstance = echarts.init(chartRef.value, isDark.value ? 'dark' : undefined)
console.log('[ClusterView] initChart: echarts instance created')
updateChart()
}
function handleResize() {
chartInstance?.resize()
}
// 切换视图时重新初始化图表
watch(viewMode, async (newMode, oldMode) => {
console.log('[ClusterView] viewMode changed', { oldMode, newMode })
// 切换到非矩阵视图时,销毁图表实例
if (oldMode === 'matrix' && chartInstance) {
console.log('[ClusterView] disposing chart instance')
chartInstance.dispose()
chartInstance = null
}
// 切换到矩阵视图时,重新初始化
if (newMode === 'matrix') {
await nextTick()
console.log('[ClusterView] reinitializing chart after view change')
initChart()
}
})
watch([graphData, isDark], () => {
console.log('[ClusterView] graphData/isDark changed', {
hasGraphData: !!graphData.value,
nodeCount: graphData.value?.nodes?.length,
viewMode: viewMode.value,
isLoading: isLoading.value,
})
if (viewMode.value === 'matrix') {
updateChart()
}
}, { deep: true })
// 加载完成后重新初始化图表(因为 v-if 会重新创建 DOM
watch(isLoading, async (loading, wasLoading) => {
console.log('[ClusterView] isLoading changed', { wasLoading, loading, viewMode: viewMode.value })
if (wasLoading && !loading && viewMode.value === 'matrix') {
// 销毁旧的图表实例(如果存在)
if (chartInstance) {
console.log('[ClusterView] disposing old chart instance after loading')
chartInstance.dispose()
chartInstance = null
}
// 等待 DOM 更新
await nextTick()
console.log('[ClusterView] reinitializing chart after loading complete')
initChart()
}
})
watch(
() => [props.sessionId, props.timeFilter, props.memberId],
() => loadData(),
{ immediate: true, deep: true }
)
onMounted(() => {
window.addEventListener('resize', handleResize)
if (viewMode.value === 'matrix') {
nextTick(() => initChart())
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance = null
})
</script>
<template>
<div class="p-4 h-full">
<div class="flex h-full flex-col rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm overflow-hidden">
<!-- 顶部工具栏 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<!-- 视图切换 -->
<UButtonGroup size="xs">
<UButton
:color="viewMode === 'matrix' ? 'primary' : 'neutral'"
:variant="viewMode === 'matrix' ? 'solid' : 'ghost'"
@click="viewMode = 'matrix'"
>
{{ t('matrixView') }}
</UButton>
<UButton
:color="viewMode === 'member' ? 'primary' : 'neutral'"
:variant="viewMode === 'member' ? 'solid' : 'ghost'"
@click="viewMode = 'member'"
>
{{ t('memberView') }}
</UButton>
<UButton
:color="viewMode === 'circle' ? 'primary' : 'neutral'"
:variant="viewMode === 'circle' ? 'solid' : 'ghost'"
@click="viewMode = 'circle'"
>
{{ t('rankingView') }}
</UButton>
</UButtonGroup>
</div>
<div class="flex items-center gap-3">
<!-- 参数设置 -->
<UPopover>
<UButton variant="ghost" size="xs" icon="i-heroicons-adjustments-horizontal" />
<template #content>
<div class="p-3 w-64">
<h4 class="text-sm font-medium mb-3">{{ t('modelSettings') }}</h4>
<div class="space-y-3">
<div>
<label class="text-xs text-gray-500 mb-1 block">{{ t('lookAhead') }}</label>
<UInput
v-model.number="modelOptions.lookAhead"
type="number"
:min="1"
:max="10"
size="xs"
placeholder="1-10"
/>
<p class="text-xs text-gray-400 mt-1">{{ t('lookAheadDesc') }}</p>
</div>
<div>
<label class="text-xs text-gray-500 mb-1 block">{{ t('decaySeconds') }}</label>
<UInput
v-model.number="modelOptions.decaySeconds"
type="number"
:min="30"
:max="3600"
size="xs"
placeholder="30-3600"
/>
<p class="text-xs text-gray-400 mt-1">{{ t('decaySecondsDesc') }}</p>
</div>
<UButton size="xs" color="primary" class="w-full mt-2" @click="loadData">
{{ t('applySettings') }}
</UButton>
</div>
</div>
</template>
</UPopover>
<!-- 刷新 -->
<UButton variant="ghost" size="xs" icon="i-heroicons-arrow-path" @click="loadData" />
</div>
</div>
<!-- 内容区域 -->
<div class="flex-1 min-h-0 overflow-hidden">
<!-- 加载中 -->
<div v-if="isLoading" class="h-full flex items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="w-6 h-6 animate-spin text-gray-400" />
</div>
<!-- 无数据 -->
<div v-else-if="!graphData || graphData.nodes.length === 0" class="h-full flex items-center justify-center">
<div class="text-center text-gray-400">
<UIcon name="i-heroicons-user-group" class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>{{ t('noData') }}</p>
</div>
</div>
<!-- 矩阵热力图 -->
<div v-else-if="viewMode === 'matrix'" class="h-full">
<div ref="chartRef" class="w-full h-full" />
</div>
<!-- 成员视图 -->
<div v-else-if="viewMode === 'member'" class="h-full flex overflow-hidden">
<!-- 左侧成员列表 -->
<div class="w-64 border-r border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium flex items-center gap-2">
<UIcon name="i-heroicons-users" class="w-4 h-4" />
{{ t('selectMember') }}
</h3>
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="member in memberList"
:key="member.id"
class="px-4 py-2 cursor-pointer border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50"
:class="{ 'bg-primary-50 dark:bg-primary-900/30': selectedMemberId === member.id }"
@click="selectedMemberId = member.id"
>
<div class="flex items-center justify-between">
<span class="text-sm truncate">{{ member.name }}</span>
<span class="text-xs text-gray-400">{{ member.messageCount }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧选中成员的关系 -->
<div class="flex-1 overflow-hidden flex flex-col">
<div v-if="!selectedMember" class="flex-1 flex items-center justify-center">
<div class="text-center text-gray-400">
<UIcon name="i-heroicons-cursor-arrow-rays" class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>{{ t('selectMemberHint') }}</p>
</div>
</div>
<template v-else>
<!-- 选中成员信息 -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold bg-primary-500"
>
{{ selectedMember.name.charAt(0) }}
</div>
<div>
<div class="font-medium">{{ selectedMember.name }}</div>
<div class="text-xs text-gray-400">
{{ t('msgCount') }}: {{ selectedMember.messageCount }} |
{{ t('relationCount') }}: {{ selectedMemberRelations.length }}
</div>
</div>
</div>
<!-- 关系排行 -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium flex items-center gap-2">
<UIcon name="i-heroicons-heart" class="w-4 h-4 text-pink-500" />
{{ t('relationsByIntimacy') }}
</h3>
</div>
<div class="flex-1 overflow-y-auto">
<div v-if="selectedMemberRelations.length === 0" class="p-4 text-center text-gray-400">
{{ t('noRelations') }}
</div>
<div
v-for="(relation, index) in selectedMemberRelations"
:key="relation.otherName"
class="px-4 py-3 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<div class="flex items-center gap-3">
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
:class="index < 3 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
>
{{ index + 1 }}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ relation.otherName }}</div>
<div class="flex items-center gap-3 mt-0.5 text-xs text-gray-400">
<span>{{ t('intimacy') }}: {{ (relation.value * 100).toFixed(0) }}%</span>
<span>{{ t('coOccurrence') }}: {{ relation.coOccurrenceCount }}{{ t('times') }}</span>
</div>
</div>
<!-- 亲密度条以最高分为100%基准 -->
<div class="w-20 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-linear-to-r from-pink-400 to-pink-600"
:style="{ width: `${selectedMemberRelations[0]?.value ? (relation.value / selectedMemberRelations[0].value * 100) : 0}%` }"
/>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 排行视图 -->
<div v-else class="h-full flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium flex items-center gap-2">
<UIcon name="i-heroicons-trophy" class="w-4 h-4 text-yellow-500" />
{{ t('interactionRanking') }}
</h3>
</div>
<div class="flex-1 overflow-y-auto">
<div v-for="(link, index) in topRelations" :key="index" class="px-4 py-3 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<div class="flex items-center gap-3">
<span
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="index < 3 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
>
{{ index + 1 }}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-sm">
<span class="font-medium truncate">{{ link.source }}</span>
<span class="text-gray-400"></span>
<span class="font-medium truncate">{{ link.target }}</span>
</div>
<div class="flex items-center gap-3 mt-1 text-xs text-gray-400">
<span>{{ t('score') }}: {{ link.value.toFixed(2) }}</span>
<span>{{ t('coOccurrence') }}: {{ link.coOccurrenceCount }}{{ t('times') }}</span>
</div>
</div>
<!-- 临近度条以最高分为100%基准 -->
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-linear-to-r from-yellow-400 to-orange-500"
:style="{ width: `${topRelations[0]?.value ? (link.value / topRelations[0].value * 100) : 0}%` }"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部统计 -->
<div v-if="graphData" class="px-4 py-2 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-400 flex items-center gap-4 bg-gray-50 dark:bg-gray-800/50">
<span>{{ t('totalMembers') }}: {{ graphData.stats.totalMembers }}</span>
<span>{{ t('totalMessages') }}: {{ graphData.stats.totalMessages.toLocaleString() }}</span>
<span>{{ t('involvedMembers') }}: {{ graphData.stats.involvedMembers }}</span>
<span>{{ t('edgeCount') }}: {{ graphData.stats.edgeCount }}</span>
</div>
</div>
</div>
</template>
<i18n>
{
"zh-CN": {
"title": "互动频率",
"rankingView": "排行视图",
"memberView": "成员视图",
"matrixView": "矩阵视图",
"modelSettings": "模型参数",
"lookAhead": "邻居数量",
"lookAheadDesc": "每条消息向后看几个发言者",
"decaySeconds": "时间衰减(秒)",
"decaySecondsDesc": "值越大,远距离消息权重越高",
"applySettings": "应用设置",
"noData": "暂无数据",
"selectMember": "选择成员",
"selectMemberHint": "请从左侧选择一个成员",
"msgCount": "发言数",
"relationCount": "关系数",
"relationsByIntimacy": "发言临近度排行",
"noRelations": "暂无关系数据",
"intimacy": "临近度",
"interactionRanking": "互动排行",
"score": "临近度",
"coOccurrence": "共现",
"times": "次",
"totalMembers": "群成员",
"totalMessages": "消息总数",
"involvedMembers": "参与成员",
"edgeCount": "关系数"
},
"en-US": {
"title": "Interaction Frequency",
"rankingView": "Ranking",
"memberView": "Member View",
"matrixView": "Matrix View",
"modelSettings": "Model Settings",
"lookAhead": "Look Ahead",
"lookAheadDesc": "Messages to look ahead per sender",
"decaySeconds": "Time Decay (sec)",
"decaySecondsDesc": "Higher = distant messages matter more",
"applySettings": "Apply",
"noData": "No data",
"selectMember": "Select Member",
"selectMemberHint": "Select a member from the left",
"msgCount": "Messages",
"relationCount": "Relations",
"relationsByIntimacy": "Proximity Ranking",
"noRelations": "No relations",
"intimacy": "Proximity",
"interactionRanking": "Interaction Ranking",
"score": "Proximity",
"coOccurrence": "Co-occur",
"times": " times",
"totalMembers": "Total Members",
"totalMessages": "Total Messages",
"involvedMembers": "Involved",
"edgeCount": "Relations"
}
}
</i18n>

View File

@@ -1,3 +1,4 @@
// 视图组件统一导出 // 视图组件统一导出
export { default as MessageView } from './MessageView.vue' export { default as MessageView } from './MessageView.vue'
export { default as InteractionView } from './InteractionView.vue' export { default as InteractionView } from './InteractionView.vue'
export { default as ClusterView } from './ClusterView.vue'

View File

@@ -2,6 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { SubTabs } from '@/components/UI' import { SubTabs } from '@/components/UI'
import { ClusterView } from '@/components/view'
import MemberList from './member/MemberList.vue' import MemberList from './member/MemberList.vue'
import NicknameHistory from './member/NicknameHistory.vue' import NicknameHistory from './member/NicknameHistory.vue'
import Relationships from './member/Relationships.vue' import Relationships from './member/Relationships.vue'
@@ -28,6 +29,7 @@ const emit = defineEmits<{
const subTabs = computed(() => [ const subTabs = computed(() => [
{ id: 'list', label: t('memberList'), icon: 'i-heroicons-users' }, { id: 'list', label: t('memberList'), icon: 'i-heroicons-users' },
{ id: 'relationships', label: t('relationships'), icon: 'i-heroicons-heart' }, { id: 'relationships', label: t('relationships'), icon: 'i-heroicons-heart' },
{ id: 'cluster', label: t('cluster'), icon: 'i-heroicons-user-group' },
{ id: 'history', label: t('nicknameHistory'), icon: 'i-heroicons-clock' }, { id: 'history', label: t('nicknameHistory'), icon: 'i-heroicons-clock' },
]) ])
@@ -56,6 +58,13 @@ function handleDataChanged() {
:time-filter="props.timeFilter" :time-filter="props.timeFilter"
/> />
<!-- 小团体 -->
<ClusterView
v-else-if="activeSubTab === 'cluster'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<!-- 昵称变更记录 --> <!-- 昵称变更记录 -->
<NicknameHistory v-else-if="activeSubTab === 'history'" :session-id="props.sessionId" /> <NicknameHistory v-else-if="activeSubTab === 'history'" :session-id="props.sessionId" />
</Transition> </Transition>
@@ -80,11 +89,13 @@ function handleDataChanged() {
"zh-CN": { "zh-CN": {
"memberList": "成员列表", "memberList": "成员列表",
"relationships": "群关系", "relationships": "群关系",
"cluster": "互动频率",
"nicknameHistory": "昵称变更" "nicknameHistory": "昵称变更"
}, },
"en-US": { "en-US": {
"memberList": "Member List", "memberList": "Member List",
"relationships": "Relationships", "relationships": "Relationships",
"cluster": "Interaction",
"nicknameHistory": "Nickname History" "nicknameHistory": "Nickname History"
} }
} }

View File

@@ -507,3 +507,72 @@ export interface KeywordTemplate {
name: string name: string
keywords: string[] keywords: string[]
} }
// ==================== 小团体关系图类型 ====================
/**
* 小团体关系图参数
*/
export interface ClusterGraphOptions {
/** 向后看几个不同发言者默认3 */
lookAhead?: number
/** 时间衰减常数默认120 */
decaySeconds?: number
/** 最多保留边数默认100 */
topEdges?: number
}
/**
* 小团体图节点
*/
export interface ClusterGraphNode {
id: number
name: string
messageCount: number
symbolSize: number
degree: number
normalizedDegree: number
}
/**
* 小团体图边
*/
export interface ClusterGraphLink {
source: string
target: string
value: number
rawScore: number
expectedScore: number
coOccurrenceCount: number
}
/**
* 小团体图社区
*/
export interface ClusterGraphCommunity {
id: number
name: string
size: number
}
/**
* 小团体图统计
*/
export interface ClusterGraphStats {
totalMembers: number
totalMessages: number
involvedMembers: number
edgeCount: number
communityCount: number
}
/**
* 小团体关系图结果
*/
export interface ClusterGraphData {
nodes: ClusterGraphNode[]
links: ClusterGraphLink[]
maxLinkValue: number
communities: ClusterGraphCommunity[]
stats: ClusterGraphStats
}