mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-25 08:00:16 +08:00
545 lines
19 KiB
Vue
545 lines
19 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* 小团体关系视图(群聊专属)
|
|
* 支持三种展示模式:矩阵热力图、成员视图、排行视图
|
|
*/
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useColorMode } from '@vueuse/core'
|
|
import * as echarts from 'echarts/core'
|
|
import { HeatmapChart } from 'echarts/charts'
|
|
import { TooltipComponent, GridComponent, VisualMapComponent } from 'echarts/components'
|
|
import { CanvasRenderer } from 'echarts/renderers'
|
|
import type { EChartsOption } from 'echarts'
|
|
import { loadClusterGraph } from './queries'
|
|
import type { ClusterGraphData, ClusterGraphOptions } from './types'
|
|
|
|
echarts.use([HeatmapChart, TooltipComponent, GridComponent, VisualMapComponent, CanvasRenderer])
|
|
|
|
interface TimeFilter {
|
|
startTs?: number
|
|
endTs?: number
|
|
memberId?: number | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
sessionId: string
|
|
timeFilter?: TimeFilter
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const colorMode = useColorMode()
|
|
const isDark = computed(() => colorMode.value === 'dark')
|
|
|
|
// 数据状态
|
|
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
|
|
|
|
// 加载数据
|
|
async function loadData() {
|
|
if (!props.sessionId) return
|
|
|
|
isLoading.value = true
|
|
try {
|
|
graphData.value = await loadClusterGraph(props.sessionId, props.timeFilter, {
|
|
lookAhead: modelOptions.value.lookAhead,
|
|
decaySeconds: modelOptions.value.decaySeconds,
|
|
topEdges: modelOptions.value.topEdges,
|
|
})
|
|
} catch (error) {
|
|
console.error('[chart-cluster] 加载小团体关系数据失败:', error)
|
|
graphData.value = null
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ==================== 矩阵热力图 ====================
|
|
|
|
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])
|
|
} 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('views.cluster.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
|
|
})
|
|
|
|
// ==================== 排行视图 ====================
|
|
|
|
const topRelations = computed(() => {
|
|
if (!graphData.value) return []
|
|
return graphData.value.links.slice(0, 50)
|
|
})
|
|
|
|
// ==================== 图表管理 ====================
|
|
|
|
function updateChart() {
|
|
if (!chartInstance) return
|
|
if (viewMode.value === 'matrix') {
|
|
chartInstance.setOption(buildHeatmapOptions(), { notMerge: true })
|
|
}
|
|
}
|
|
|
|
function initChart() {
|
|
if (!chartRef.value) return
|
|
chartInstance = echarts.init(chartRef.value, isDark.value ? 'dark' : undefined)
|
|
updateChart()
|
|
}
|
|
|
|
function handleResize() {
|
|
chartInstance?.resize()
|
|
}
|
|
|
|
watch(viewMode, async (newMode, oldMode) => {
|
|
if (oldMode === 'matrix' && chartInstance) {
|
|
chartInstance.dispose()
|
|
chartInstance = null
|
|
}
|
|
if (newMode === 'matrix') {
|
|
await nextTick()
|
|
initChart()
|
|
}
|
|
})
|
|
|
|
watch(
|
|
[graphData, () => isDark.value],
|
|
() => {
|
|
if (viewMode.value === 'matrix') {
|
|
updateChart()
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(isLoading, async (loading, wasLoading) => {
|
|
if (wasLoading && !loading && viewMode.value === 'matrix') {
|
|
if (chartInstance) {
|
|
chartInstance.dispose()
|
|
chartInstance = null
|
|
}
|
|
await nextTick()
|
|
initChart()
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => [props.sessionId, props.timeFilter],
|
|
() => 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('views.cluster.matrixView') }}
|
|
</UButton>
|
|
<UButton
|
|
:color="viewMode === 'member' ? 'primary' : 'neutral'"
|
|
:variant="viewMode === 'member' ? 'solid' : 'ghost'"
|
|
@click="viewMode = 'member'"
|
|
>
|
|
{{ t('views.cluster.memberView') }}
|
|
</UButton>
|
|
<UButton
|
|
:color="viewMode === 'circle' ? 'primary' : 'neutral'"
|
|
:variant="viewMode === 'circle' ? 'solid' : 'ghost'"
|
|
@click="viewMode = 'circle'"
|
|
>
|
|
{{ t('views.cluster.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('views.cluster.modelSettings') }}</h4>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-xs text-gray-500 mb-1 block">{{ t('views.cluster.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('views.cluster.lookAheadDesc') }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-gray-500 mb-1 block">{{ t('views.cluster.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('views.cluster.decaySecondsDesc') }}</p>
|
|
</div>
|
|
<UButton size="xs" color="primary" class="w-full mt-2" @click="loadData">
|
|
{{ t('views.cluster.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('views.cluster.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('views.cluster.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('views.cluster.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('views.cluster.msgCount') }}: {{ selectedMember.messageCount }} |
|
|
{{ t('views.cluster.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('views.cluster.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('views.cluster.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('views.cluster.intimacy') }}: {{ (relation.value * 100).toFixed(0) }}%</span>
|
|
<span>
|
|
{{ t('views.cluster.coOccurrence') }}: {{ relation.coOccurrenceCount
|
|
}}{{ t('views.cluster.times') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<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('views.cluster.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('views.cluster.score') }}: {{ link.value.toFixed(2) }}</span>
|
|
<span>
|
|
{{ t('views.cluster.coOccurrence') }}: {{ link.coOccurrenceCount }}{{ t('views.cluster.times') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<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('views.cluster.totalMembers') }}: {{ graphData.stats.totalMembers }}</span>
|
|
<span>{{ t('views.cluster.totalMessages') }}: {{ graphData.stats.totalMessages.toLocaleString() }}</span>
|
|
<span>{{ t('views.cluster.involvedMembers') }}: {{ graphData.stats.involvedMembers }}</span>
|
|
<span>{{ t('views.cluster.edgeCount') }}: {{ graphData.stats.edgeCount }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|