refactor(insight): remove legacy mention graph

This commit is contained in:
digua committed 2026-08-09 10:57:24 +08:00
1 parent 2f5f97f0bf
commit cd2a401c3b
25 files changed
+61 -703

No files matched your search

-3
View File
@@ -37,7 +37,6 @@ import {
getCatchphraseAnalysis,
getLanguagePreferenceAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
getLaughAnalysis,
getClusterGraph,
@@ -126,7 +125,6 @@ const CACHEABLE_QUERIES = new Set([
'getCatchphraseAnalysis',
'getLanguagePreferenceAnalysis',
'getMentionAnalysis',
'getMentionGraph',
'getLaughAnalysis',
'getClusterGraph',
'getWordFrequency',
@@ -211,7 +209,6 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getCatchphraseAnalysis: (p) => getCatchphraseAnalysis(p.sessionId, p.filter),
getLanguagePreferenceAnalysis: (p) => getLanguagePreferenceAnalysis(p),
getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter),
getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter),
getGroupRelationshipGalaxy: (p) => getGroupRelationshipGalaxy(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options),
@@ -5,22 +5,8 @@
export { getCatchphraseAnalysis } from './repeat'
export {
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
getLaughAnalysis,
getClusterGraph,
} from './social'
export type {
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
ClusterGraphData,
ClusterGraphNode,
ClusterGraphLink,
ClusterGraphOptions,
} from './social'
export { getMentionAnalysis, getGroupRelationshipGalaxy, getLaughAnalysis, getClusterGraph } from './social'
export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './social'
export { getLanguagePreferenceAnalysis } from './languagePreference'
@@ -5,32 +5,15 @@
import { openDatabaseAdapter, type TimeFilter } from '../../core'
import {
getMentionAnalysis as coreGetMentionAnalysis,
getMentionGraph as coreGetMentionGraph,
getGroupRelationshipGalaxy as coreGetGroupRelationshipGalaxy,
GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
getLaughAnalysis as coreGetLaughAnalysis,
getClusterGraph as coreGetClusterGraph,
} from '@openchatlab/core'
import type { GroupRelationshipGalaxyData } from '@openchatlab/shared-types'
import type {
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
ClusterGraphData,
ClusterGraphNode,
ClusterGraphLink,
ClusterGraphOptions,
} from '@openchatlab/core'
import type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from '@openchatlab/core'
export type {
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
ClusterGraphData,
ClusterGraphNode,
ClusterGraphLink,
ClusterGraphOptions,
}
export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions }
export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabaseAdapter(sessionId)
@@ -38,12 +21,6 @@ export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any
return coreGetMentionAnalysis(db, filter)
}
export function getMentionGraph(sessionId: string, filter?: TimeFilter): MentionGraphData {
const db = openDatabaseAdapter(sessionId)
if (!db) return { nodes: [], links: [], maxLinkValue: 0 }
return coreGetMentionGraph(db, filter)
}
export function getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter): GroupRelationshipGalaxyData {
const db = openDatabaseAdapter(sessionId)
if (!db) {
-1
View File
@@ -35,7 +35,6 @@ export {
getCatchphraseAnalysis,
getLanguagePreferenceAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
getLaughAnalysis,
getClusterGraph,
@@ -402,10 +402,6 @@ 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 getGroupRelationshipGalaxy(sessionId: string, filter?: any): Promise<any> {
return sendToWorker('getGroupRelationshipGalaxy', { sessionId, filter })
}
@@ -16,7 +16,6 @@ const RESTARTABLE_READ_ONLY_REQUEST_TYPES = new Set([
'getCatchphraseAnalysis',
'getLanguagePreferenceAnalysis',
'getMentionAnalysis',
'getMentionGraph',
'getGroupRelationshipGalaxy',
'getLaughAnalysis',
'getClusterGraph',
-4
View File
@@ -94,7 +94,6 @@ export {
executeParameterizedSql,
getCatchphraseAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
getLaughAnalysis,
@@ -190,9 +189,6 @@ export type {
CatchphraseAnalysis,
MemberCatchphrase,
CatchphraseItem,
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
ClusterGraphData,
ClusterGraphNode,
ClusterGraphLink,
@@ -176,6 +176,42 @@ describe('group relationship galaxy', () => {
assert.deepEqual(first, second)
})
it('keeps a large group within the default interactive graph limits', () => {
raw.exec(`
WITH RECURSIVE members(id) AS (
SELECT 100
UNION ALL
SELECT id + 1 FROM members WHERE id < 549
)
INSERT INTO member (id, platform_id, account_name, aliases)
SELECT id, 'large-' || id, 'LargeMember' || id, '[]' FROM members;
WITH RECURSIVE messages(id) AS (
SELECT 1
UNION ALL
SELECT id + 1 FROM messages WHERE id < 9000
)
INSERT INTO message (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id)
SELECT
id,
100 + ((id - 1) % 450),
100000 + id,
0,
'large group message',
'large-' || id,
CASE WHEN id > 1 AND id % 37 = 0 THEN 'large-' || (id - 1) ELSE NULL END
FROM messages;
`)
const first = getGroupRelationshipGalaxy(db)
const second = getGroupRelationshipGalaxy(db)
assert.equal(first.stats.activeMembers, 450)
assert.ok(first.stats.displayedMembers <= 400)
assert.ok(first.stats.displayedEdges <= 1200)
assert.deepEqual(first, second)
})
it('returns an explicit empty graph when the range has fewer than two active members', () => {
insertMessage(1, 1, 100, 'only owner', 'm1')
+2 -10
View File
@@ -7,16 +7,8 @@
export { getCatchphraseAnalysis } from './repeat'
export type { CatchphraseAnalysis, MemberCatchphrase, CatchphraseItem } from './repeat'
export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social'
export type {
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
ClusterGraphData,
ClusterGraphNode,
ClusterGraphLink,
ClusterGraphOptions,
} from './social'
export { getMentionAnalysis, getLaughAnalysis, getClusterGraph } from './social'
export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './social'
export { getGroupRelationshipGalaxy, GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION } from './group-relationship-galaxy'
export type { GroupRelationshipGalaxyOptions } from './group-relationship-galaxy'
-117
View File
@@ -112,123 +112,6 @@ export function getMentionAnalysis(db: DatabaseAdapter, filter?: TimeFilter): an
return { topMentioners, topMentioned, totalMentions }
}
// ==================== @ 互动关系图数据 ====================
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
}
export function getMentionGraph(db: DatabaseAdapter, filter?: TimeFilter): MentionGraphData {
const emptyResult: MentionGraphData = { nodes: [], links: [], maxLinkValue: 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
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)
}
}
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 }>
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 mentionedId = nameToMemberId.get(match[1])
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)
}
}
}
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) {
const symbolSize = 20 + (info.messageCount / maxMessageCount) * 40
nodes.push({ id: memberId, name: info.name, value: info.messageCount, symbolSize: Math.round(symbolSize) })
}
}
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 }
}
// ==================== 含笑量分析 ====================
function keywordToPattern(keyword: string): string {
-4
View File
@@ -216,7 +216,6 @@ export {
export {
getCatchphraseAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
getLaughAnalysis,
@@ -235,9 +234,6 @@ export type {
CatchphraseAnalysis,
MemberCatchphrase,
CatchphraseItem,
MentionGraphData,
MentionGraphNode,
MentionGraphLink,
GroupRelationshipGalaxyOptions,
ClusterGraphData,
ClusterGraphNode,
@@ -23,7 +23,6 @@ import {
getJourneyStats,
getCatchphraseAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
getLaughAnalysis,
@@ -151,15 +150,6 @@ export function registerAnalyticsRoutes(server: FastifyInstance, ctx: AnalyticsR
}
)
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
'/_web/sessions/:id/analytics/mention-graph',
async (request) => {
const id = request.params.id
const filter = parseTimeFilter(request.query)
return cached('mention-graph', id, { ...filter }, () => getMentionGraph(adapter.ensureReadonly(id), filter))
}
)
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
'/_web/sessions/:id/analytics/relationship-galaxy',
async (request) => {
@@ -1044,41 +1044,29 @@ describe('BrowserSessionRuntime', () => {
await runtime.importSource(source('insight-query.json', fixture), { formatId: 'chatlab' })
const [
monthly,
yearly,
memberTrend,
members,
mentions,
mentionGraph,
relationshipGalaxy,
clusterGraph,
language,
wordFrequency,
] = await Promise.all([
runtime.getMonthlyActivity('insight-query-session'),
runtime.getYearlyActivity('insight-query-session'),
runtime.getMemberMonthlyTrend('insight-query-session'),
runtime.getMembers('insight-query-session'),
runtime.getMentionAnalysis('insight-query-session'),
runtime.getMentionGraph('insight-query-session'),
runtime.getGroupRelationshipGalaxy('insight-query-session'),
runtime.getClusterGraph('insight-query-session'),
runtime.getLanguagePreferenceAnalysis('insight-query-session', 'en-US'),
runtime.getWordFrequency('insight-query-session', {
locale: 'en-US',
topN: 10,
minCount: 2,
posFilterMode: 'all',
enableStopwords: false,
}),
])
const [monthly, yearly, memberTrend, members, mentions, relationshipGalaxy, clusterGraph, language, wordFrequency] =
await Promise.all([
runtime.getMonthlyActivity('insight-query-session'),
runtime.getYearlyActivity('insight-query-session'),
runtime.getMemberMonthlyTrend('insight-query-session'),
runtime.getMembers('insight-query-session'),
runtime.getMentionAnalysis('insight-query-session'),
runtime.getGroupRelationshipGalaxy('insight-query-session'),
runtime.getClusterGraph('insight-query-session'),
runtime.getLanguagePreferenceAnalysis('insight-query-session', 'en-US'),
runtime.getWordFrequency('insight-query-session', {
locale: 'en-US',
topN: 10,
minCount: 2,
posFilterMode: 'all',
enableStopwords: false,
}),
])
assert.ok(monthly.length > 0)
assert.ok(yearly.length > 0)
assert.ok(memberTrend.length > 0)
assert.ok(members.length > 0)
assert.ok(mentions.totalMentions > 0)
assert.ok(mentionGraph.links.length > 0)
assert.ok(relationshipGalaxy.graph.edges.length > 0)
assert.equal(relationshipGalaxy.algorithmVersion, 'group-relationship-galaxy-v1')
assert.ok(clusterGraph.links.length > 0)
@@ -8,7 +8,6 @@ import {
getLanguagePreferenceAnalysis as queryLanguagePreferenceAnalysis,
getMembersWithAliases as queryMembersWithAliases,
getMentionAnalysis as queryMentionAnalysis,
getMentionGraph as queryMentionGraph,
getGroupRelationshipGalaxy as queryGroupRelationshipGalaxy,
getAvailableYears as queryAvailableYears,
getDailyActivity as queryDailyActivity,
@@ -37,7 +36,6 @@ import {
type MemberWithAliases,
type MessageLengthDistribution,
type MessageTypeStats,
type MentionGraphData,
type MonthlyActivity,
type RelationshipStats,
type JourneyStats,
@@ -423,10 +421,6 @@ export class BrowserSessionRuntime {
return this.withSessionDatabase(id, (db) => queryMentionAnalysis(db, filter))
}
async getMentionGraph(id: string, filter?: BrowserTimeFilter): Promise<MentionGraphData> {
return this.withSessionDatabase(id, (db) => queryMentionGraph(db, filter))
}
async getGroupRelationshipGalaxy(id: string, filter?: BrowserTimeFilter): Promise<GroupRelationshipGalaxyData> {
return this.withSessionDatabase(id, (db) => queryGroupRelationshipGalaxy(db, filter))
}
-6
View File
@@ -17,7 +17,6 @@ import type {
MemberWithAliases,
MessageLengthDistribution,
MessageTypeStats,
MentionGraphData,
MonthlyActivity,
ClusterGraphData,
ClusterGraphOptions,
@@ -228,10 +227,6 @@ export interface WebRuntimeTaskMap {
totalMentions: number
}
}
'analysis.mentionGraph': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: MentionGraphData
}
'analysis.groupRelationshipGalaxy': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: GroupRelationshipGalaxyData
@@ -385,7 +380,6 @@ const WEB_RUNTIME_TASK_TYPES: Record<WebRuntimeTaskType, true> = {
'analysis.memberMonthlyTrend': true,
'analysis.memberList': true,
'analysis.mentions': true,
'analysis.mentionGraph': true,
'analysis.groupRelationshipGalaxy': true,
'analysis.clusterGraph': true,
'analysis.relationship': true,
@@ -270,10 +270,6 @@ class FakeSessionRuntime implements WorkerSessionRuntime {
return { topMentioners: [], topMentioned: [], totalMentions: 0 }
}
async getMentionGraph(_id: string, _filter?: BrowserTimeFilter) {
return { nodes: [], links: [], maxLinkValue: 0 }
}
async getGroupRelationshipGalaxy(_id: string, _filter?: BrowserTimeFilter) {
return {
graph: { nodes: [], edges: [], communities: [] },
@@ -819,7 +815,6 @@ describe('WebRuntimeWorkerController', () => {
{ id: 'trend', type: 'analysis.memberMonthlyTrend', payload: { sessionId: 'session-one' } },
{ id: 'members', type: 'analysis.memberList', payload: { sessionId: 'session-one' } },
{ id: 'mentions', type: 'analysis.mentions', payload: { sessionId: 'session-one' } },
{ id: 'mention-graph', type: 'analysis.mentionGraph', payload: { sessionId: 'session-one' } },
{
id: 'relationship-galaxy',
type: 'analysis.groupRelationshipGalaxy',
@@ -52,7 +52,6 @@ export type WorkerSessionRuntime = Pick<
| 'getMemberMonthlyTrend'
| 'getMembers'
| 'getMentionAnalysis'
| 'getMentionGraph'
| 'getGroupRelationshipGalaxy'
| 'getClusterGraph'
| 'getRelationshipStats'
@@ -495,12 +494,6 @@ export class WebRuntimeWorkerController {
this.emitAnalysisQueryCompleted(request, startedAt)
return result
}
case 'analysis.mentionGraph': {
const startedAt = this.startAnalysisQuery()
const result = await this.sessionRuntime.getMentionGraph(request.payload.sessionId, request.payload.filter)
this.emitAnalysisQueryCompleted(request, startedAt)
return result
}
case 'analysis.groupRelationshipGalaxy': {
const startedAt = this.startAnalysisQuery()
const result = await this.sessionRuntime.getGroupRelationshipGalaxy(
@@ -1,141 +0,0 @@
<script setup lang="ts">
/**
* 互动分析视图(群聊专属)
* 展示成员间的 @ 互动关系图
*/
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { EChartGraph } from '@/components/charts'
import type { EChartGraphData } from '@/components/charts'
import { useInsightViewLoading } from '@/components/UI/insight-view-loading'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const { t } = useI18n()
// 数据状态
const isLoading = ref(true)
useInsightViewLoading(isLoading)
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()
}
// 加载数据
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
const data = await useDataService().getMentionGraph(props.sessionId, props.timeFilter)
graphData.value = {
nodes: data.nodes,
links: data.links,
maxLinkValue: data.maxLinkValue,
}
} catch (error) {
console.error('[chart-interaction] 加载互动关系图数据失败:', error)
} finally {
isLoading.value = false
}
}
// 监听 props 变化
watch(
() => [props.sessionId, props.timeFilter],
() => {
loadData()
},
{ immediate: true, deep: true }
)
</script>
<template>
<div class="flex h-full flex-col">
<!-- 顶部工具栏(仅右上角控制) -->
<div class="flex flex-wrap items-center justify-end px-3 py-2 sm:px-4">
<div class="flex flex-wrap items-center justify-end gap-2 sm:gap-4">
<!-- 布局切换 -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ t('views.interaction.layout') }}:</span>
<div class="inline-flex items-center" role="group">
<UButton
size="xs"
:color="layoutType === 'circular' ? 'primary' : 'neutral'"
:variant="layoutType === 'circular' ? 'solid' : 'ghost'"
@click="layoutType = 'circular'"
>
{{ t('views.interaction.circular') }}
</UButton>
<UButton
size="xs"
:color="layoutType === 'force' ? 'primary' : 'neutral'"
:variant="layoutType === 'force' ? 'solid' : 'ghost'"
@click="layoutType = 'force'"
>
{{ t('views.interaction.force') }}
</UButton>
</div>
</div>
<!-- 方向切换 -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ t('views.interaction.directed') }}:</span>
<USwitch v-model="showDirection" size="xs" />
</div>
<!-- 重置视图 -->
<UButton size="xs" color="neutral" variant="ghost" icon="i-heroicons-arrow-path" @click="handleResetView">
{{ t('views.interaction.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('views.interaction.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('views.interaction.noInteraction') }}</p>
</div>
</div>
</template>
</div>
</div>
</template>
-281
View File
@@ -1,281 +0,0 @@
<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', // 深灰蓝
]
// 去重后的节点(ECharts 要求节点名称唯一)
const uniqueNodes = computed(() => {
const seen = new Set<string>()
return props.data.nodes.filter((node) => {
if (seen.has(node.name)) {
return false
}
seen.add(node.name)
return true
})
})
// 节点名称到颜色的映射
const nodeColorMap = computed(() => {
const map = new Map<string, string>()
uniqueNodes.value.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: uniqueNodes.value.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
.filter((link) => nodeColorMap.value.has(link.source) && nodeColorMap.value.has(link.target))
.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({ backgroundColor: 'transparent', ...option.value })
}
// 更新图表
function updateChart() {
if (!chartInstance) return
chartInstance.setOption({ backgroundColor: 'transparent', ...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>
-2
View File
@@ -7,7 +7,6 @@ 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 EChartWordcloud } from './EChartWordcloud.vue'
export { default as EChartRank } from './EChartRank.vue'
@@ -24,7 +23,6 @@ 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 { WordcloudData as EChartWordcloudData } from './EChartWordcloud.vue'
// 其他类型
-3
View File
@@ -200,7 +200,6 @@ describe('BrowserDataAdapter', () => {
},
],
'analysis.mentions': { topMentioners: [], topMentioned: [], totalMentions: 0 },
'analysis.mentionGraph': { nodes: [], links: [], maxLinkValue: 0 },
'analysis.groupRelationshipGalaxy': {
graph: { nodes: [], edges: [], communities: [] },
members: [],
@@ -237,7 +236,6 @@ describe('BrowserDataAdapter', () => {
assert.equal((await adapter.getMemberMonthlyTrend('session-one'))[0].memberName, 'Alice')
assert.equal((await adapter.getMembers('session-one'))[0].accountName, 'Alice')
assert.equal((await adapter.getMentionAnalysis('session-one')).totalMentions, 0)
assert.equal((await adapter.getMentionGraph('session-one')).links.length, 0)
assert.equal((await adapter.getGroupRelationshipGalaxy('session-one')).stats.displayedEdges, 0)
assert.equal((await adapter.getClusterGraph('session-one')).stats.edgeCount, 0)
assert.equal((await adapter.getRelationshipStats('session-one')).hasSessionIndex, true)
@@ -253,7 +251,6 @@ describe('BrowserDataAdapter', () => {
'analysis.memberMonthlyTrend',
'analysis.memberList',
'analysis.mentions',
'analysis.mentionGraph',
'analysis.groupRelationshipGalaxy',
'analysis.clusterGraph',
'analysis.relationship',
-5
View File
@@ -47,7 +47,6 @@ type BrowserSessionDataAdapter = Pick<
| 'getMemberMonthlyTrend'
| 'getMembers'
| 'getMentionAnalysis'
| 'getMentionGraph'
| 'getGroupRelationshipGalaxy'
| 'getClusterGraph'
| 'getRelationshipStats'
@@ -153,10 +152,6 @@ export class BrowserDataAdapter implements BrowserSessionDataAdapter {
return this.requestAnalysis('analysis.mentions', { sessionId, filter })
}
getMentionGraph(sessionId: string, filter?: TimeFilter) {
return this.requestAnalysis('analysis.mentionGraph', { sessionId, filter })
}
getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter) {
return this.requestAnalysis('analysis.groupRelationshipGalaxy', { sessionId, filter })
}
-5
View File
@@ -57,7 +57,6 @@ import type {
PaginatedResult,
SQLResult,
TableSchema,
MentionGraphData,
MessageLengthDistribution,
} from './types'
import { get, post, del, put, patch, analyticsGet, analyticsPost } from '../utils/http'
@@ -393,10 +392,6 @@ export class FetchDataAdapter implements DataAdapter {
return analyticsGet(`/sessions/${sessionId}/analytics/mention${buildFilterParams(filter)}`)
}
getMentionGraph(sessionId: string, filter?: TimeFilter): Promise<MentionGraphData> {
return analyticsGet(`/sessions/${sessionId}/analytics/mention-graph${buildFilterParams(filter)}`)
}
getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter): Promise<GroupRelationshipGalaxyData> {
return analyticsGet(`/sessions/${sessionId}/analytics/relationship-galaxy${buildFilterParams(filter)}`)
}
-9
View File
@@ -134,14 +134,6 @@ export interface AnnualSummaryFetchOptions {
acceptStale?: boolean
}
// ==================== Mention Graph ====================
export interface MentionGraphData {
nodes: Array<{ id: number; name: string; value: number; symbolSize: number }>
links: Array<{ source: string; target: string; value: number }>
maxLinkValue: number
}
// ==================== 消息长度分布 ====================
export interface MessageLengthDistribution {
@@ -235,7 +227,6 @@ export interface DataAdapter {
dictType?: string
): Promise<LanguagePreferenceResult>
getMentionAnalysis(sessionId: string, filter?: TimeFilter): Promise<MentionAnalysis>
getMentionGraph(sessionId: string, filter?: TimeFilter): Promise<MentionGraphData>
getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter): Promise<GroupRelationshipGalaxyData>
getClusterGraph(sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions): Promise<ClusterGraphData>
getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): Promise<LaughAnalysis>
+1 -8
View File
@@ -12,14 +12,7 @@ export type { BrowserRuntimeServiceAdapter } from './browser-runtime/types'
export { useDataService } from './data/service'
export type { DataAdapter } from './data/types'
export type {
PaginationParams,
PaginatedResult,
SQLResult,
TableSchema,
MentionGraphData,
MessageLengthDistribution,
} from './data/types'
export type { PaginationParams, PaginatedResult, SQLResult, TableSchema, MessageLengthDistribution } from './data/types'
export { useImportService } from './import/service'
export type {