mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-07 22:01:18 +08:00
feat: 新增互动分析
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取含笑量分析数据
|
||||
*/
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getDivingAnalysis,
|
||||
getMonologueAnalysis,
|
||||
getMentionAnalysis,
|
||||
getMentionGraph,
|
||||
getLaughAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getCheckInAnalysis,
|
||||
@@ -115,6 +116,7 @@ const syncHandlers: Record<string, (payload: any) => 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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<string, number>()
|
||||
const memberIdToInfo = new Map<number, { name: string; messageCount: number }>()
|
||||
|
||||
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<number, Map<number, number>>()
|
||||
const mentionRegex = /@([^\s@]+)/g
|
||||
|
||||
for (const msg of messages) {
|
||||
const matches = msg.content.matchAll(mentionRegex)
|
||||
const mentionedInThisMsg = new Set<number>()
|
||||
|
||||
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<number>()
|
||||
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 }
|
||||
}
|
||||
|
||||
// ==================== 含笑量分析 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,7 @@ export {
|
||||
getMonologueAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getMentionAnalysis,
|
||||
getMentionGraph,
|
||||
getLaughAnalysis,
|
||||
} from './advanced'
|
||||
|
||||
|
||||
@@ -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<any> {
|
||||
return sendToWorker('getMentionGraph', { sessionId, filter })
|
||||
}
|
||||
|
||||
export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise<any> {
|
||||
return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords })
|
||||
}
|
||||
|
||||
Vendored
+8
@@ -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<DivingAnalysis>
|
||||
getMonologueAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MonologueAnalysis>
|
||||
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
|
||||
getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise<MentionGraphData>
|
||||
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
|
||||
getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MemeBattleAnalysis>
|
||||
getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CheckInAnalysis>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取含笑量分析数据
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ECharts 关系图组件(支持 circular 和 force 布局)
|
||||
*/
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { GraphChart } from 'echarts/charts'
|
||||
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
type ECOption = EChartsOption
|
||||
|
||||
export interface GraphNode {
|
||||
id: number | string
|
||||
name: string
|
||||
value?: number
|
||||
symbolSize?: number
|
||||
category?: number
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string
|
||||
target: string
|
||||
value?: number
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[]
|
||||
links: GraphLink[]
|
||||
maxLinkValue?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: GraphData
|
||||
height?: number | string
|
||||
layout?: 'circular' | 'force' // 布局类型
|
||||
directed?: boolean // 是否显示箭头(有向图)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 400,
|
||||
layout: 'circular',
|
||||
directed: false,
|
||||
})
|
||||
|
||||
// 计算高度样式
|
||||
const heightStyle = computed(() => {
|
||||
if (typeof props.height === 'number') {
|
||||
return `${props.height}px`
|
||||
}
|
||||
return props.height
|
||||
})
|
||||
|
||||
const isDark = useDark()
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
|
||||
// 丰富的调色板(为每个节点分配不同颜色)
|
||||
const colorPalette = [
|
||||
'#ee4567', // 粉色(主题色)
|
||||
'#5470c6', // 蓝色
|
||||
'#91cc75', // 绿色
|
||||
'#fac858', // 黄色
|
||||
'#ee6666', // 红色
|
||||
'#73c0de', // 青色
|
||||
'#9a60b4', // 紫色
|
||||
'#fc8452', // 橙色
|
||||
'#3ba272', // 深绿
|
||||
'#ea7ccc', // 粉紫
|
||||
'#6e7074', // 灰色
|
||||
'#546570', // 深灰蓝
|
||||
]
|
||||
|
||||
// 节点名称到颜色的映射
|
||||
const nodeColorMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
props.data.nodes.forEach((node, index) => {
|
||||
map.set(node.name, colorPalette[index % colorPalette.length])
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// 计算边的宽度(根据 value 归一化)
|
||||
function getLinkWidth(value: number, maxValue: number): number {
|
||||
if (maxValue <= 0) return 1
|
||||
// 宽度范围 1-6
|
||||
return 1 + (value / maxValue) * 5
|
||||
}
|
||||
|
||||
const option = computed<ECOption>(() => {
|
||||
const maxLinkValue = props.data.maxLinkValue || Math.max(...props.data.links.map((l) => l.value || 1), 1)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: isDark.value ? 'rgba(30, 30, 30, 0.9)' : 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
textStyle: {
|
||||
color: isDark.value ? '#e5e7eb' : '#374151',
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
return `<b>${params.data.name}</b><br/>消息数: ${params.data.value || 0}`
|
||||
} else if (params.dataType === 'edge') {
|
||||
return `${params.data.source} → ${params.data.target}<br/>艾特次数: ${params.data.value || 0}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
// 动画效果
|
||||
animationDuration: 1000,
|
||||
animationDurationUpdate: 500,
|
||||
animationEasingUpdate: 'quinticInOut',
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: props.layout,
|
||||
circular: props.layout === 'circular' ? { rotateLabel: true } : undefined,
|
||||
force:
|
||||
props.layout === 'force'
|
||||
? {
|
||||
repulsion: 300,
|
||||
gravity: 0.1,
|
||||
edgeLength: [80, 200],
|
||||
friction: 0.6,
|
||||
}
|
||||
: undefined,
|
||||
roam: true,
|
||||
scaleLimit: {
|
||||
min: 0.3, // 最小缩放 30%
|
||||
max: 3, // 最大缩放 300%
|
||||
},
|
||||
draggable: true,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: '{b}',
|
||||
color: isDark.value ? '#e5e7eb' : '#374151',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
},
|
||||
edgeSymbol: props.directed ? ['none', 'arrow'] : ['none', 'none'],
|
||||
edgeSymbolSize: props.directed ? [0, 10] : [0, 0],
|
||||
lineStyle: {
|
||||
curveness: 0.3, // 始终使用曲线
|
||||
opacity: 0.5,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 4,
|
||||
opacity: 0.9,
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 15,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
// 节点数据
|
||||
data: props.data.nodes.map((node) => {
|
||||
const color = nodeColorMap.value.get(node.name) || colorPalette[0]
|
||||
return {
|
||||
name: node.name,
|
||||
value: node.value,
|
||||
symbolSize: node.symbolSize || 30,
|
||||
// circular 布局显示所有标签,force 布局只显示大节点的标签
|
||||
label: {
|
||||
show: props.layout === 'circular' ? true : (node.symbolSize || 30) > 30,
|
||||
},
|
||||
itemStyle: {
|
||||
color: color,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
shadowBlur: 5,
|
||||
shadowColor: `${color}66`, // 同色系阴影
|
||||
},
|
||||
}
|
||||
}),
|
||||
// 连接线数据(颜色跟随源节点)
|
||||
links: props.data.links.map((link) => {
|
||||
const sourceColor = nodeColorMap.value.get(link.source) || colorPalette[0]
|
||||
return {
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
value: link.value,
|
||||
lineStyle: {
|
||||
color: sourceColor,
|
||||
width: getLinkWidth(link.value || 1, maxLinkValue),
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化图表
|
||||
function initChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value, isDark.value ? 'dark' : undefined, {
|
||||
renderer: 'canvas',
|
||||
})
|
||||
chartInstance.setOption(option.value)
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
function updateChart() {
|
||||
if (!chartInstance) return
|
||||
chartInstance.setOption(option.value, { notMerge: true })
|
||||
}
|
||||
|
||||
// 响应窗口大小变化
|
||||
function handleResize() {
|
||||
chartInstance?.resize()
|
||||
}
|
||||
|
||||
// 重置视图(居中 + 重置缩放)
|
||||
function resetView() {
|
||||
if (!chartInstance) return
|
||||
chartInstance.dispatchAction({
|
||||
type: 'restore',
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
resetView,
|
||||
})
|
||||
|
||||
// 监听数据和主题变化
|
||||
watch(
|
||||
[() => props.data, () => props.layout, () => props.directed, isDark],
|
||||
() => {
|
||||
if (chartInstance) {
|
||||
updateChart()
|
||||
} else {
|
||||
initChart()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chartInstance?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ height: heightStyle, width: '100%' }" />
|
||||
</template>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 互动分析视图(群聊专属)
|
||||
* 展示成员间的 @ 互动关系图
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { EChartGraph } from '@/components/charts'
|
||||
import type { EChartGraphData } from '@/components/charts'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface TimeFilter {
|
||||
startTs?: number
|
||||
endTs?: number
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
timeFilter?: TimeFilter
|
||||
memberId?: number | null
|
||||
}>()
|
||||
|
||||
// 数据状态
|
||||
const isLoading = ref(true)
|
||||
const graphData = ref<EChartGraphData>({ nodes: [], links: [], maxLinkValue: 0 })
|
||||
|
||||
// 布局切换
|
||||
const layoutType = ref<'circular' | 'force'>('circular')
|
||||
|
||||
// 方向切换(有向图 vs 无向图)
|
||||
const showDirection = ref(false)
|
||||
|
||||
// 图表引用
|
||||
const graphRef = ref<InstanceType<typeof EChartGraph> | null>(null)
|
||||
|
||||
// 重置视图
|
||||
function handleResetView() {
|
||||
graphRef.value?.resetView()
|
||||
}
|
||||
|
||||
// 合并 timeFilter 和 memberId 的 filter
|
||||
const effectiveFilter = computed(() => ({
|
||||
...props.timeFilter,
|
||||
memberId: props.memberId,
|
||||
}))
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
if (!props.sessionId) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await window.chatApi.getMentionGraph(props.sessionId, effectiveFilter.value)
|
||||
graphData.value = {
|
||||
nodes: data.nodes,
|
||||
links: data.links,
|
||||
maxLinkValue: data.maxLinkValue,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载互动关系图数据失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 props 变化
|
||||
watch(
|
||||
() => [props.sessionId, props.timeFilter, props.memberId],
|
||||
() => {
|
||||
loadData()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- 顶部工具栏(仅右上角控制) -->
|
||||
<div class="flex items-center justify-end px-4 py-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 布局切换 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">{{ t('layout') }}:</span>
|
||||
<UButtonGroup size="xs">
|
||||
<UButton
|
||||
:color="layoutType === 'circular' ? 'primary' : 'neutral'"
|
||||
:variant="layoutType === 'circular' ? 'solid' : 'ghost'"
|
||||
@click="layoutType = 'circular'"
|
||||
>
|
||||
{{ t('circular') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:color="layoutType === 'force' ? 'primary' : 'neutral'"
|
||||
:variant="layoutType === 'force' ? 'solid' : 'ghost'"
|
||||
@click="layoutType = 'force'"
|
||||
>
|
||||
{{ t('force') }}
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
<!-- 方向切换 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">{{ t('directed') }}:</span>
|
||||
<USwitch v-model="showDirection" size="xs" />
|
||||
</div>
|
||||
<!-- 重置视图 -->
|
||||
<UButton
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-arrow-path"
|
||||
@click="handleResetView"
|
||||
>
|
||||
{{ t('reset') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域(全屏) -->
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
|
||||
</div>
|
||||
|
||||
<!-- 图表 -->
|
||||
<template v-else>
|
||||
<div v-if="graphData.nodes.length > 0" class="h-full">
|
||||
<EChartGraph
|
||||
ref="graphRef"
|
||||
:data="graphData"
|
||||
:layout="layoutType"
|
||||
:directed="showDirection"
|
||||
:height="'100%'"
|
||||
/>
|
||||
<!-- 底部统计信息 -->
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-gray-100/80 px-3 py-1 text-xs text-gray-500 backdrop-blur-sm dark:bg-gray-800/80 dark:text-gray-400">
|
||||
{{ t('graphHint', { nodes: graphData.nodes.length, links: graphData.links.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-full items-center justify-center text-gray-400">
|
||||
<div class="text-center">
|
||||
<UIcon name="i-heroicons-user-group" class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
|
||||
<p class="mt-2 text-sm">{{ t('noInteraction') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
:time-filter="props.timeFilter"
|
||||
:member-id="selectedMemberId"
|
||||
/>
|
||||
<InteractionView
|
||||
v-else-if="activeSubTab === 'interaction'"
|
||||
:session-id="props.sessionId"
|
||||
:time-filter="props.timeFilter"
|
||||
:member-id="selectedMemberId"
|
||||
/>
|
||||
<WordcloudView
|
||||
v-else-if="activeSubTab === 'wordcloud'"
|
||||
:session-id="props.sessionId"
|
||||
@@ -82,11 +89,13 @@ const selectedMemberId = ref<number | null>(null)
|
||||
{
|
||||
"zh-CN": {
|
||||
"message": "消息",
|
||||
"interaction": "互动分析",
|
||||
"wordcloud": "词云",
|
||||
"portrait": "对话画像"
|
||||
},
|
||||
"en-US": {
|
||||
"message": "Messages",
|
||||
"interaction": "Interactions",
|
||||
"wordcloud": "Word Cloud",
|
||||
"portrait": "Chat Portrait"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user