mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-23 01:39:37 +08:00
feat: 互动频率分析
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取含笑量分析数据
|
* 获取含笑量分析数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取含笑量分析数据
|
* 获取含笑量分析数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
3
electron/preload/index.d.ts
vendored
3
electron/preload/index.d.ts
vendored
@@ -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>
|
||||||
|
|||||||
656
src/components/view/ClusterView.vue
Normal file
656
src/components/view/ClusterView.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user