refactor(analysis): simplify ranking cards

This commit is contained in:
digua committed 2026-07-16 21:12:21 +08:00
1 parent 041d2191da
commit 6967779650
21 files changed
+338 -647

No files matched your search

@@ -31,7 +31,7 @@ export type {
export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabaseAdapter(sessionId)
if (!db) return { topMentioners: [], topMentioned: [], oneWay: [], twoWay: [], totalMentions: 0, memberDetails: [] }
if (!db) return { topMentioners: [], topMentioned: [], totalMentions: 0 }
return coreGetMentionAnalysis(db, filter)
}
@@ -1,14 +1,93 @@
/**
* Tests for shared social graph helpers.
* Tests for shared social analysis queries and graph helpers.
*
* Run: pnpm test -- packages/core/src/query/advanced/__tests__/social.test.ts
*/
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { accumulateCoOccurrencePairs } from '../social'
import Database from 'better-sqlite3'
import { accumulateCoOccurrencePairs, getMentionAnalysis } from '../social'
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../../interfaces'
describe('social graph helpers', () => {
class Statement implements PreparedStatement {
readonly?: boolean
constructor(private readonly statement: Database.Statement) {
this.readonly = statement.readonly
}
get(...params: unknown[]) {
return this.statement.get(...params) as Record<string, unknown> | undefined
}
all(...params: unknown[]) {
return this.statement.all(...params) as Record<string, unknown>[]
}
run(...params: unknown[]): RunResult {
const result = this.statement.run(...params)
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid }
}
}
class Adapter implements DatabaseAdapter {
constructor(private readonly database: Database.Database) {}
exec(sql: string) {
this.database.exec(sql)
}
prepare(sql: string) {
return new Statement(this.database.prepare(sql))
}
transaction<T>(fn: () => T): T {
return this.database.transaction(fn)()
}
pragma(pragma: string) {
return this.database.pragma(pragma)
}
close() {
this.database.close()
}
}
function createMentionDatabase(): Adapter {
const database = new Database(':memory:')
database.exec(`
CREATE TABLE member (
id INTEGER PRIMARY KEY,
platform_id TEXT,
account_name TEXT,
group_nickname TEXT
);
CREATE TABLE member_name_history (member_id INTEGER, name TEXT);
CREATE TABLE message (
id INTEGER PRIMARY KEY,
sender_id INTEGER,
ts INTEGER,
type INTEGER,
content TEXT
);
INSERT INTO member (id, platform_id, account_name) VALUES
(1, 'u1', 'Alice'),
(2, 'u2', 'Bob'),
(3, 'u3', 'Carol');
INSERT INTO message (id, sender_id, ts, type, content) VALUES
(1, 1, 1, 0, '@Bob'),
(2, 1, 2, 0, '@Bob'),
(3, 1, 3, 0, '@Bob'),
(4, 1, 4, 0, '@Carol'),
(5, 2, 5, 0, '@Alice'),
(6, 2, 6, 0, '@Alice');
`)
return new Adapter(database)
}
describe('social analysis', () => {
it('tracks the latest timestamp for each co-occurrence pair', () => {
const pairs = accumulateCoOccurrencePairs([
{ senderId: 1, ts: 1704103200 },
@@ -45,4 +124,25 @@ describe('social graph helpers', () => {
assert.ok(closePair.rawScore > 0.6)
assert.ok(distantPair.rawScore < 0.001)
})
it('returns only the two member rankings and total mention count', () => {
const database = createMentionDatabase()
try {
assert.deepEqual(getMentionAnalysis(database), {
topMentioners: [
{ memberId: 1, platformId: 'u1', name: 'Alice', count: 4, percentage: 66.67 },
{ memberId: 2, platformId: 'u2', name: 'Bob', count: 2, percentage: 33.33 },
],
topMentioned: [
{ memberId: 2, platformId: 'u2', name: 'Bob', count: 3, percentage: 50 },
{ memberId: 1, platformId: 'u1', name: 'Alice', count: 2, percentage: 33.33 },
{ memberId: 3, platformId: 'u3', name: 'Carol', count: 1, percentage: 16.67 },
],
totalMentions: 6,
})
} finally {
database.close()
}
})
})
+1 -121
View File
@@ -13,10 +13,7 @@ export function getMentionAnalysis(db: DatabaseAdapter, filter?: TimeFilter): an
const emptyResult = {
topMentioners: [],
topMentioned: [],
oneWay: [],
twoWay: [],
totalMentions: 0,
memberDetails: [],
}
const members = db
@@ -63,7 +60,6 @@ export function getMentionAnalysis(db: DatabaseAdapter, filter?: TimeFilter): an
)
.all(...params) as Array<{ senderId: number; content: string }>
const mentionMatrix = new Map<number, Map<number, number>>()
const mentionedCount = new Map<number, number>()
const mentionerCount = new Map<number, number>()
let totalMentions = 0
@@ -79,10 +75,6 @@ export function getMentionAnalysis(db: DatabaseAdapter, filter?: TimeFilter): an
mentionedInThisMsg.add(mentionedId)
totalMentions++
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)
mentionerCount.set(msg.senderId, (mentionerCount.get(msg.senderId) || 0) + 1)
mentionedCount.set(mentionedId, (mentionedCount.get(mentionedId) || 0) + 1)
}
@@ -117,119 +109,7 @@ export function getMentionAnalysis(db: DatabaseAdapter, filter?: TimeFilter): an
}
topMentioned.sort((a, b) => b.count - a.count)
const oneWay: any[] = []
const processedPairs = new Set<string>()
for (const [fromId, toMap] of mentionMatrix.entries()) {
for (const [toId, fromToCount] of toMap.entries()) {
const pairKey = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}`
if (processedPairs.has(pairKey)) continue
processedPairs.add(pairKey)
const toFromCount = mentionMatrix.get(toId)?.get(fromId) || 0
const total = fromToCount + toFromCount
if (total < 3) continue
const ratio = fromToCount / total
if (ratio >= 0.8) {
const fromInfo = memberIdToInfo.get(fromId)!
const toInfo = memberIdToInfo.get(toId)!
oneWay.push({
fromMemberId: fromId,
fromName: fromInfo.name,
toMemberId: toId,
toName: toInfo.name,
fromToCount,
toFromCount,
ratio: Math.round(ratio * 100) / 100,
})
} else if (ratio <= 0.2) {
const fromInfo = memberIdToInfo.get(fromId)!
const toInfo = memberIdToInfo.get(toId)!
oneWay.push({
fromMemberId: toId,
fromName: toInfo.name,
toMemberId: fromId,
toName: fromInfo.name,
fromToCount: toFromCount,
toFromCount: fromToCount,
ratio: Math.round((1 - ratio) * 100) / 100,
})
}
}
}
oneWay.sort((a, b) => b.fromToCount - a.fromToCount)
const twoWay: any[] = []
processedPairs.clear()
for (const [fromId, toMap] of mentionMatrix.entries()) {
for (const [toId, fromToCount] of toMap.entries()) {
const pairKey = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}`
if (processedPairs.has(pairKey)) continue
processedPairs.add(pairKey)
const toFromCount = mentionMatrix.get(toId)?.get(fromId) || 0
const total = fromToCount + toFromCount
if (total < 5 || toFromCount === 0 || fromToCount === 0) continue
const ratio = Math.min(fromToCount, toFromCount) / Math.max(fromToCount, toFromCount)
if (ratio >= 0.3) {
const m1Info = memberIdToInfo.get(fromId)!
const m2Info = memberIdToInfo.get(toId)!
twoWay.push({
member1Id: fromId,
member1Name: m1Info.name,
member2Id: toId,
member2Name: m2Info.name,
member1To2: fromToCount,
member2To1: toFromCount,
total,
balance: Math.round(ratio * 100) / 100,
})
}
}
}
twoWay.sort((a, b) => b.total - a.total)
const memberDetails: any[] = []
for (const member of members) {
const info = memberIdToInfo.get(member.id)!
const topMentionedByThis: any[] = []
const toMap = mentionMatrix.get(member.id)
if (toMap) {
for (const [toId, count] of toMap.entries()) {
const toInfo = memberIdToInfo.get(toId)!
topMentionedByThis.push({
fromMemberId: member.id,
fromName: info.name,
toMemberId: toId,
toName: toInfo.name,
count,
})
}
topMentionedByThis.sort((a, b) => b.count - a.count)
}
const topMentionersOfThis: any[] = []
for (const [fromId, fToMap] of mentionMatrix.entries()) {
const count = fToMap.get(member.id)
if (count) {
const fromInfo = memberIdToInfo.get(fromId)!
topMentionersOfThis.push({
fromMemberId: fromId,
fromName: fromInfo.name,
toMemberId: member.id,
toName: info.name,
count,
})
}
}
topMentionersOfThis.sort((a, b) => b.count - a.count)
if (topMentionedByThis.length > 0 || topMentionersOfThis.length > 0) {
memberDetails.push({
memberId: member.id,
name: info.name,
topMentioned: topMentionedByThis.slice(0, 5),
topMentioners: topMentionersOfThis.slice(0, 5),
})
}
}
return { topMentioners, topMentioned, oneWay, twoWay, totalMentions, memberDetails }
return { topMentioners, topMentioned, totalMentions }
}
// ==================== @ 互动关系图数据 ====================
@@ -77,7 +77,7 @@ async function disposeFixture(cleanup: ServiceFixtureCleanup): Promise<void> {
cleanup.service?.close()
} finally {
try {
if (cleanup.db?.open) cleanup.db.close()
cleanup.db?.close()
} finally {
removeOwnedServiceTempDir(cleanup.dir)
}
+33 -33
View File
@@ -5,7 +5,7 @@ import type { RepeatAnalysis } from '@openchatlab/core'
import { useDataService } from '@/services/data/service'
import { ListPro } from '@/components/charts'
import { LoadingState, EmptyState, SectionCard } from '@/components/UI'
import { formatDate, getRankBadgeClass } from '@/utils'
import { formatDate, formatRankNumber, getRankNumberClass } from '@/utils'
import { useLayoutStore } from '@/stores/layout'
import type { TimeFilter } from '@openchatlab/shared-types'
@@ -34,11 +34,6 @@ async function loadRepeatAnalysis() {
}
}
function truncateContent(content: string, maxLength = 30): string {
if (content.length <= maxLength) return content
return content.slice(0, maxLength) + '...'
}
/**
* 查看复读内容的聊天记录上下文
*/
@@ -70,42 +65,47 @@ watch(
:items="repeatAnalysis.hotContents"
:title="t('quotes.hotRepeat.title')"
:description="t('quotes.hotRepeat.description')"
:top-n="50"
:top-n="10"
:count-template="t('quotes.hotRepeat.countTemplate')"
>
<template #item="{ item, index }">
<div class="flex items-center gap-3">
<button
type="button"
class="group/item grid w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 rounded-lg text-left outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:aria-label="`${t('quotes.hotRepeat.viewChat')}: ${item.content}`"
@click="viewRepeatContext(item)"
>
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="getRankBadgeClass(index)"
class="w-8 shrink-0 text-center font-mono text-sm font-black tabular-nums"
:class="getRankNumberClass(index)"
>
{{ index + 1 }}
{{ formatRankNumber(index) }}
</span>
<span class="shrink-0 text-lg font-bold text-pink-600">
{{ t('quotes.hotRepeat.people', { count: item.maxChainLength }) }}
</span>
<div class="flex flex-1 items-center gap-1 overflow-hidden text-sm">
<span class="shrink-0 font-medium text-gray-900 dark:text-white whitespace-nowrap">
{{ item.originatorName }}{{ t('quotes.hotRepeat.colon') }}
</span>
<span class="truncate text-gray-600 dark:text-gray-400" :title="item.content">
{{ truncateContent(item.content) }}
</span>
<div class="min-w-0">
<p class="line-clamp-2 text-sm font-medium leading-5 text-gray-900 dark:text-white" :title="item.content">
{{ item.content }}
</p>
<div class="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
<span class="truncate">{{ item.originatorName }}</span>
<span aria-hidden="true">·</span>
<span class="shrink-0">{{ formatDate(item.lastTs) }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-gray-500">
<span>{{ t('quotes.hotRepeat.times', { count: item.count }) }}</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>{{ formatDate(item.lastTs) }}</span>
<UButton
icon="i-heroicons-chat-bubble-left-right"
color="neutral"
variant="ghost"
size="xs"
:title="t('quotes.hotRepeat.viewChat')"
@click.stop="viewRepeatContext(item)"
<div class="flex shrink-0 items-center gap-3 pl-2">
<div class="text-right">
<p class="font-mono text-sm font-black tabular-nums text-primary-600 dark:text-primary-400">
{{ t('quotes.hotRepeat.people', { count: item.maxChainLength }) }}
</p>
<p class="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">
{{ t('quotes.hotRepeat.times', { count: item.count }) }}
</p>
</div>
<UIcon
name="i-heroicons-chevron-right"
class="h-4 w-4 text-gray-300 transition-colors group-hover/item:text-gray-500 dark:text-gray-600 dark:group-hover/item:text-gray-400"
/>
</div>
</div>
</button>
</template>
</ListPro>
@@ -6,7 +6,7 @@ import { useDataService } from '@/services'
import { ListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { LoadingState } from '@/components/UI'
import { getRankBadgeClass } from '@/utils'
import { formatRankNumber, getRankNumberClass } from '@/utils'
import { usePromptStore } from '@/stores/prompt'
import type { TimeFilter } from '@openchatlab/shared-types'
@@ -541,12 +541,12 @@ watch(
<template #item="{ item: member, index }">
<div class="flex items-center gap-3">
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankBadgeClass(index)"
<span
class="w-8 shrink-0 text-center font-mono text-sm font-black tabular-nums"
:class="getRankNumberClass(index)"
>
{{ index + 1 }}
</div>
{{ formatRankNumber(index) }}
</span>
<!-- 名字 -->
<div class="w-32 shrink-0">
@@ -557,7 +557,7 @@ watch(
<!-- 堆叠进度条 -->
<div class="flex flex-1 items-center">
<div class="flex h-2.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="flex h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-white/5">
<div
v-for="segment in getStackedWidths(member, index)"
:key="segment.keyword"
@@ -571,8 +571,10 @@ watch(
<!-- 数值和百分比 -->
<div class="flex shrink-0 items-baseline gap-2">
<span class="text-lg font-bold text-gray-900 dark:text-white">{{ member.value }}</span>
<span class="text-sm text-gray-500">
<span class="font-mono text-base font-black tabular-nums text-gray-900 dark:text-white">
{{ member.value }}
</span>
<span class="text-xs text-gray-500">
{{ t('quotes.keywords.timesWithPercent', { count: member.value, percent: member.percentage }) }}
</span>
</div>
+44 -26
View File
@@ -16,35 +16,53 @@ const modalBodyRef = ref<HTMLElement | null>(null)
</script>
<template>
<SectionCard :title="title" :description="description">
<SectionCard :title="title" :description="description" :show-divider="false">
<template #headerRight>
<div class="no-capture flex items-center gap-2">
<slot name="headerRight" />
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-3xl' }">
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">
{{ viewAllLabel }}
</UButton>
<template #content>
<div ref="modalBodyRef" class="section-content flex flex-col">
<div
class="flex w-full items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-gray-900 whitespace-nowrap dark:text-white">{{ title }}</h3>
<span class="text-sm text-gray-500">({{ countLabel }})</span>
</div>
<CaptureButton size="xs" type="element" :target-element="modalBodyRef" />
</div>
<div class="max-h-[60vh] overflow-y-auto p-4">
<slot name="full" />
</div>
</div>
</template>
</UModal>
<div class="flex items-center gap-2">
<span
class="shrink-0 whitespace-nowrap rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
>
{{ countLabel }}
</span>
<div class="no-capture">
<slot name="headerRight" />
</div>
</div>
</template>
<slot />
<div>
<slot />
</div>
<div v-if="showViewAll" class="no-capture px-3 pb-3 pt-1">
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-4xl' }">
<UButton
color="neutral"
variant="ghost"
class="w-full justify-center rounded-lg py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400"
>
<span class="flex items-center gap-1.5">
{{ viewAllLabel }}
<UIcon name="i-heroicons-arrow-right" class="h-3.5 w-3.5" />
</span>
</UButton>
<template #content>
<div ref="modalBodyRef" class="section-content flex flex-col overflow-hidden">
<div
class="flex w-full items-center justify-between border-b border-gray-100 px-6 py-5 dark:border-white/5"
>
<div class="min-w-0">
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ countLabel }}</p>
</div>
<CaptureButton size="xs" type="element" :target-element="modalBodyRef" />
</div>
<div class="max-h-[70vh] overflow-y-auto">
<slot name="full" />
</div>
</div>
</template>
</UModal>
</div>
</SectionCard>
</template>
+12 -4
View File
@@ -49,8 +49,12 @@ const formattedCount = computed(() => {
</template>
<template #full>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div v-for="(item, index) in items" :key="index" class="px-5 py-3">
<div>
<div
v-for="(item, index) in items"
:key="index"
class="group/list-row px-5 py-3.5 transition-colors hover:bg-gray-50/70 dark:hover:bg-white/5"
>
<slot name="item" :item="item" :index="index" />
</div>
</div>
@@ -60,8 +64,12 @@ const formattedCount = computed(() => {
<slot name="config" />
<!-- 默认显示 Top N -->
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div v-for="(item, index) in topNData" :key="index" class="px-5 py-3">
<div>
<div
v-for="(item, index) in topNData"
:key="index"
class="group/list-row px-5 py-3.5 transition-colors hover:bg-gray-50/70 dark:hover:bg-white/5"
>
<slot name="item" :item="item" :index="index" />
</div>
</div>
+31 -28
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getRankBadgeClass, getRankBarColor } from '@/utils'
import { formatRankNumber, getRankBarColor, getRankNumberClass } from '@/utils'
const { t } = useI18n()
@@ -41,19 +41,19 @@ function getRelativePercentage(index: number): number {
</script>
<template>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div>
<div
v-for="(member, index) in displayMembers"
:key="member.id"
class="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex items-start gap-3 px-5 py-3.5 transition-colors hover:bg-gray-50/70 dark:hover:bg-white/5"
>
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankBadgeClass(index)"
<span
class="w-8 shrink-0 pt-0.5 text-center font-mono text-sm font-black tabular-nums"
:class="getRankNumberClass(index)"
>
{{ index + 1 }}
</div>
{{ formatRankNumber(index) }}
</span>
<!-- 头像占位 -->
<div
@@ -63,28 +63,31 @@ function getRelativePercentage(index: number): number {
{{ member.name.slice(0, 1) }}
</div>
<!-- 名字 -->
<div class="w-32 shrink-0">
<p class="wrap-break-word font-medium text-gray-900 dark:text-white">
{{ member.name }}
</p>
</div>
<!-- 进度条 -->
<div class="flex flex-1 items-center">
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-linear-to-r transition-all"
:class="getRankBarColor(index)"
:style="{ width: `${getRelativePercentage(index)}%` }"
/>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-3">
<p class="truncate text-sm font-medium text-gray-900 dark:text-white" :title="member.name">
{{ member.name }}
</p>
<div class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span class="font-mono text-base font-black tabular-nums text-gray-900 dark:text-white">
{{ member.value }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ displayUnit }}</span>
</div>
</div>
</div>
<!-- 数值和百分比 -->
<div class="flex shrink-0 items-baseline gap-2 whitespace-nowrap">
<span class="text-lg font-bold text-gray-900 dark:text-white">{{ member.value }}</span>
<span class="text-sm text-gray-500">{{ displayUnit }} ({{ member.percentage }}%)</span>
<div class="mt-2 flex items-center gap-2.5">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-white/5">
<div
class="h-full rounded-full bg-linear-to-r transition-all"
:class="getRankBarColor(index)"
:style="{ width: `${getRelativePercentage(index)}%` }"
/>
</div>
<span class="w-10 shrink-0 text-right font-mono text-[11px] tabular-nums text-gray-400 dark:text-gray-500">
{{ member.percentage }}%
</span>
</div>
</div>
</div>
</div>
+4
View File
@@ -43,6 +43,10 @@ const showViewAll = computed(() => {
:view-all-label="t('views.charts.rankListPro.fullRanking')"
:count-label="t('views.charts.rankListPro.memberCount', { count: members.length })"
>
<template #headerRight>
<slot name="headerRight" />
</template>
<template #full>
<RankList :members="members" :unit="unit" />
</template>
+8 -25
View File
@@ -70,33 +70,16 @@
},
"relationships": {
"loading": "Analyzing {'@'} interaction data...",
"topMentioners": "📢 Top {'@'} Initiators",
"totalMentions": "{count} total {'@'}s",
"topMentioned": "🎯 Most {'@'} Mentioned",
"popularMember": "Most popular in the group",
"rankingTitle": "{'@'} Interaction Ranking",
"rankModes": {
"mentioners": "Sent {'@'}",
"mentioned": "Received {'@'}"
},
"topMentionersDescription": "Ranked by {'@'} mentions sent",
"topMentionedDescription": "Ranked by {'@'} mentions received",
"times": "times",
"oneWay": {
"title": "🐕 One-way Detection",
"description": "Found {count} one-way relationships (one party {'@'} another ≥80%)",
"neverRespond": "Never responds",
"reverse": "{count}x reverse",
"ratio": "{value}% one-way"
},
"twoWay": {
"title": "💕 Mutual Detection",
"description": "Found {count} frequent CP pairs (mutual {'@'} with ≥30% balance)",
"interactions": "interactions",
"balance": "{value}% balanced"
},
"emptyTitle": "📱 {'@'} Interaction Analysis",
"empty": "No {'@'} interaction data available",
"modal": {
"title": "{name}'s {'@'} Relationships",
"topMentioned": "Most frequently {'@'}s",
"topMentioners": "Most frequently {'@'}d by",
"timesCount": "{count}x",
"close": "Close"
}
"empty": "No {'@'} interaction data available"
},
"private": {
"title": "Chat Members",
+2 -3
View File
@@ -49,7 +49,7 @@
}
},
"keywords": {
"title": "🔍 Keyword Ranking",
"title": "Keyword Ranking",
"description": "Analyze keyword usage rankings in chats, customize your own lists",
"countTemplate": "{count} members",
"templateLabel": "Templates:",
@@ -116,13 +116,12 @@
}
},
"hotRepeat": {
"title": "🔥 Hot Repeats",
"title": "Hot Repeats",
"loading": "Loading repeat data...",
"description": "Content with most participants in single repeat chain",
"countTemplate": "{count} hot repeats",
"people": "{count} ppl",
"times": "{count}x",
"colon": ": ",
"viewChat": "View chat history",
"empty": "No repeat data available"
},
+8 -25
View File
@@ -70,33 +70,16 @@
},
"relationships": {
"loading": "{'@'} インタラクションデータを分析中...",
"topMentioners": "📢 最も {'@'} する人",
"totalMentions": "全 {count} 回の {'@'}",
"topMentioned": "🎯 最も {'@'} される人",
"popularMember": "グループの人気者",
"rankingTitle": "{'@'} インタラクションランキング",
"rankModes": {
"mentioners": "送った {'@'}",
"mentioned": "受け取った {'@'}"
},
"topMentionersDescription": "送った {'@'} の回数順",
"topMentionedDescription": "受け取った {'@'} の回数順",
"times": "回",
"oneWay": {
"title": "🐕 片思い検出",
"description": "{count} 組の片思い関係を発見(一方の {'@'} が80%以上を占める)",
"neverRespond": "返信なし",
"reverse": "逆方向 {count} 回",
"ratio": "{value}% 片方向"
},
"twoWay": {
"title": "💕 両思い検出",
"description": "{count} 組の頻繁な相互やり取りを発見(互いに {'@'} かつバランス度 ≥30%)",
"interactions": "回のやり取り",
"balance": "{value}% バランス"
},
"emptyTitle": "📱 {'@'} インタラクション分析",
"empty": "{'@'} インタラクションデータがありません",
"modal": {
"title": "{name} の {'@'} 関係",
"topMentioned": "最も {'@'} する相手",
"topMentioners": "最も {'@'} してくる相手",
"timesCount": "{count} 回",
"close": "閉じる"
}
"empty": "{'@'} インタラクションデータがありません"
},
"private": {
"title": "会話メンバー",
+2 -3
View File
@@ -49,7 +49,7 @@
}
},
"keywords": {
"title": "🔍 キーワードランキング",
"title": "キーワードランキング",
"description": "グループチャットのキーワード使用ランキングを分析します。カスタムランキングも作成できます",
"countTemplate": "全 {count} 名のメンバー",
"templateLabel": "テンプレート:",
@@ -116,13 +116,12 @@
}
},
"hotRepeat": {
"title": "🔥 盛り上がった復唱",
"title": "盛り上がった復唱",
"loading": "リピートデータを読み込み中...",
"description": "1 回の復唱で最も多くの人が参加したフレーズ",
"countTemplate": "全 {count} 件の復唱",
"people": "{count}人",
"times": "{count} 回",
"colon": ":",
"viewChat": "チャット履歴を見る",
"empty": "リピートデータがありません"
},
+8 -25
View File
@@ -70,33 +70,16 @@
},
"relationships": {
"loading": "正在分析 {'@'} 互动数据...",
"topMentioners": "📢 谁最喜欢 {'@'} 别人",
"totalMentions": "共 {count} 次 {'@'}",
"topMentioned": "🎯 谁最常被 {'@'}",
"popularMember": "群里的人气王",
"rankingTitle": "{'@'} 互动排行",
"rankModes": {
"mentioners": "主动 {'@'}",
"mentioned": "被 {'@'}"
},
"topMentionersDescription": "按主动 {'@'} 次数排序",
"topMentionedDescription": "按被 {'@'} 次数排序",
"times": "次",
"oneWay": {
"title": "🐕 单向关注检测",
"description": "发现 {count} 对单向关注关系(一方 {'@'} 另一方占比 ≥80%)",
"neverRespond": "从不回应",
"reverse": "反向 {count} 次",
"ratio": "{value}% 单向"
},
"twoWay": {
"title": "💕 双向奔赴检测",
"description": "发现 {count} 对互动频繁的 CP(互相 {'@'} 且平衡度 ≥30%)",
"interactions": "次互动",
"balance": "{value}% 平衡"
},
"emptyTitle": "📱 {'@'} 互动分析",
"empty": "暂无 {'@'} 互动数据",
"modal": {
"title": "{name} 的 {'@'} 关系",
"topMentioned": "最常 {'@'} 的人",
"topMentioners": "最常 {'@'} TA 的人",
"timesCount": "{count} 次",
"close": "关闭"
}
"empty": "暂无 {'@'} 互动数据"
},
"private": {
"title": "对话成员",
+2 -3
View File
@@ -49,7 +49,7 @@
}
},
"keywords": {
"title": "🔍 关键词排行",
"title": "关键词排行",
"description": "分析群聊关键词使用排行,这里可以自定义多种榜单",
"countTemplate": "共 {count} 位成员",
"templateLabel": "模板:",
@@ -116,13 +116,12 @@
}
},
"hotRepeat": {
"title": "🔥 最火复读内容",
"title": "最火复读内容",
"loading": "正在加载复读数据...",
"description": "单次复读参与人数最多的内容",
"countTemplate": "共 {count} 条热门复读",
"people": "{count}人",
"times": "{count} 次",
"colon": ":",
"viewChat": "查看聊天记录",
"empty": "暂无复读数据"
},
+8 -25
View File
@@ -70,33 +70,16 @@
},
"relationships": {
"loading": "正在分析 {'@'} 互動資料...",
"topMentioners": "📢 誰最喜歡 {'@'} 別人",
"totalMentions": "共 {count} 次 {'@'}",
"topMentioned": "🎯 誰最常被 {'@'}",
"popularMember": "群裡的人氣王",
"rankingTitle": "{'@'} 互動排行",
"rankModes": {
"mentioners": "主動 {'@'}",
"mentioned": "被 {'@'}"
},
"topMentionersDescription": "依主動 {'@'} 次數排序",
"topMentionedDescription": "依被 {'@'} 次數排序",
"times": "次",
"oneWay": {
"title": "🐕 單向關注偵測",
"description": "發現 {count} 對單向關注關係(一方 {'@'} 另一方佔比 ≥80%)",
"neverRespond": "從不回應",
"reverse": "反向 {count} 次",
"ratio": "{value}% 單向"
},
"twoWay": {
"title": "💕 雙向奔赴偵測",
"description": "發現 {count} 對互動頻繁的 CP(互相 {'@'} 且平衡度 ≥30%)",
"interactions": "次互動",
"balance": "{value}% 平衡"
},
"emptyTitle": "📱 {'@'} 互動分析",
"empty": "暫無 {'@'} 互動資料",
"modal": {
"title": "{name} 的 {'@'} 關係",
"topMentioned": "最常 {'@'} 的人",
"topMentioners": "最常 {'@'} TA 的人",
"timesCount": "{count} 次",
"close": "關閉"
}
"empty": "暫無 {'@'} 互動資料"
},
"private": {
"title": "對話成員",
+2 -3
View File
@@ -49,7 +49,7 @@
}
},
"keywords": {
"title": "🔍 關鍵字排行榜",
"title": "關鍵字排行榜",
"description": "分析群聊中的關鍵字使用情況,也能自訂不同榜單",
"countTemplate": "共 {count} 位成員",
"templateLabel": "範本:",
@@ -116,13 +116,12 @@
}
},
"hotRepeat": {
"title": "🔥 熱門複讀",
"title": "熱門複讀",
"loading": "正在載入複讀資料...",
"description": "單次複讀參與人數最多的內容",
"countTemplate": "共 {count} 筆熱門複讀",
"people": "{count}人",
"times": "{count} 次",
"colon": ":",
"viewChat": "檢視聊天紀錄",
"empty": "暫無複讀資料"
},
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MentionAnalysis, MemberMentionDetail } from '@/types/analysis'
import type { MentionAnalysis } from '@/types/analysis'
import { useDataService } from '@/services'
import { RankListPro } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { SectionCard, EmptyState, LoadingState, Tabs } from '@/components/UI'
import type { TimeFilter } from '@openchatlab/shared-types'
const { t } = useI18n()
@@ -15,11 +15,8 @@ const props = defineProps<{
timeFilter?: TimeFilter
}>()
// ==================== @ 互动分析 ====================
const mentionAnalysis = ref<MentionAnalysis | null>(null)
const isLoadingMention = ref(false)
const selectedMemberDetail = ref<MemberMentionDetail | null>(null)
const showMemberDetailModal = ref(false)
async function loadMentionAnalysis() {
if (!props.sessionId) return
@@ -35,32 +32,42 @@ async function loadMentionAnalysis() {
const mentionerRankData = computed<RankItem[]>(() => {
if (!mentionAnalysis.value) return []
return mentionAnalysis.value.topMentioners.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.count,
percentage: m.percentage,
return mentionAnalysis.value.topMentioners.map((member) => ({
id: member.memberId.toString(),
name: member.name,
value: member.count,
percentage: member.percentage,
}))
})
const mentionedRankData = computed<RankItem[]>(() => {
if (!mentionAnalysis.value) return []
return mentionAnalysis.value.topMentioned.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.count,
percentage: m.percentage,
return mentionAnalysis.value.topMentioned.map((member) => ({
id: member.memberId.toString(),
name: member.name,
value: member.count,
percentage: member.percentage,
}))
})
function openMemberDetail(memberId: number) {
if (!mentionAnalysis.value) return
const detail = mentionAnalysis.value.memberDetails.find((d) => d.memberId === memberId)
if (detail) {
selectedMemberDetail.value = detail
showMemberDetailModal.value = true
}
}
type MentionRankMode = 'mentioners' | 'mentioned'
const activeMentionRank = ref<MentionRankMode>('mentioners')
const mentionRankTabs = computed(() => [
{ label: t('members.relationships.rankModes.mentioners'), value: 'mentioners' },
{ label: t('members.relationships.rankModes.mentioned'), value: 'mentioned' },
])
const activeMentionRankData = computed(() =>
activeMentionRank.value === 'mentioners' ? mentionerRankData.value : mentionedRankData.value
)
const activeMentionRankDescription = computed(() =>
activeMentionRank.value === 'mentioners'
? t('members.relationships.topMentionersDescription')
: t('members.relationships.topMentionedDescription')
)
watch(
() => [props.sessionId, props.timeFilter],
@@ -72,223 +79,23 @@ watch(
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<!-- @ 互动分析模块 -->
<div class="main-content mx-auto max-w-3xl p-6">
<LoadingState v-if="isLoadingMention" :text="t('members.relationships.loading')" />
<template v-else-if="mentionAnalysis && mentionAnalysis.totalMentions > 0">
<!-- @ 排行榜 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<RankListPro
v-if="mentionerRankData.length > 0"
:members="mentionerRankData"
:title="t('members.relationships.topMentioners')"
:description="t('members.relationships.totalMentions', { count: mentionAnalysis.totalMentions })"
:unit="t('members.relationships.times')"
/>
<RankListPro
v-else-if="mentionAnalysis && mentionAnalysis.totalMentions > 0 && activeMentionRankData.length > 0"
:members="activeMentionRankData"
:title="t('members.relationships.rankingTitle')"
:description="activeMentionRankDescription"
:unit="t('members.relationships.times')"
>
<template #headerRight>
<Tabs v-model="activeMentionRank" :items="mentionRankTabs" size="sm" />
</template>
</RankListPro>
<RankListPro
v-if="mentionedRankData.length > 0"
:members="mentionedRankData"
:title="t('members.relationships.topMentioned')"
:description="t('members.relationships.popularMember')"
:unit="t('members.relationships.times')"
/>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 单向关注 -->
<!-- 有严重BUG,很不准,先隐藏 -->
<SectionCard
v-if="mentionAnalysis.oneWay.length > 0"
class="hidden"
:title="t('members.relationships.oneWay.title')"
:description="t('members.relationships.oneWay.description', { count: mentionAnalysis.oneWay.length })"
>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="(pair, index) in mentionAnalysis.oneWay.slice(0, 10)"
:key="index"
class="flex items-center gap-4 px-5 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<!-- 排名 -->
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="
index === 0
? 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
"
>
{{ index + 1 }}
</span>
<!-- 关系展示 -->
<div class="flex flex-1 items-center gap-2">
<button
class="font-medium text-gray-900 hover:text-pink-600 dark:text-white dark:hover:text-pink-600"
@click="openMemberDetail(pair.fromMemberId)"
>
{{ pair.fromName }}
</button>
<div class="flex items-center gap-1 text-pink-500">
<span class="text-lg">→</span>
<span class="text-xs">{{ pair.fromToCount }}次</span>
</div>
<button
class="font-medium text-gray-900 hover:text-pink-600 dark:text-white dark:hover:text-pink-600"
@click="openMemberDetail(pair.toMemberId)"
>
{{ pair.toName }}
</button>
</div>
<!-- 反向数据 -->
<div class="shrink-0 text-right text-sm text-gray-500">
<span v-if="pair.toFromCount === 0" class="text-red-500">
{{ t('members.relationships.oneWay.neverRespond') }}
</span>
<span v-else>{{ t('members.relationships.oneWay.reverse', { count: pair.toFromCount }) }}</span>
</div>
<!-- 单向比例标签 -->
<UBadge color="pink" variant="soft" size="xs">
{{ t('members.relationships.oneWay.ratio', { value: Math.round(pair.ratio * 100) }) }}
</UBadge>
</div>
</div>
</SectionCard>
<!-- 双向奔赴(CP检测) -->
<SectionCard
v-if="mentionAnalysis.twoWay.length > 0"
:title="t('members.relationships.twoWay.title')"
:description="t('members.relationships.twoWay.description', { count: mentionAnalysis.twoWay.length })"
>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="(pair, index) in mentionAnalysis.twoWay.slice(0, 10)"
:key="index"
class="flex items-center gap-4 px-5 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<!-- 排名 -->
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="
index === 0
? 'bg-linear-to-r from-pink-400 to-red-400 text-white'
: index === 1
? 'bg-linear-to-r from-pink-300 to-red-300 text-white'
: index === 2
? 'bg-linear-to-r from-pink-200 to-red-200 text-pink-700'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
"
>
{{ index + 1 }}
</span>
<!-- CP 关系展示 -->
<div class="flex flex-1 items-center gap-2">
<button
class="font-medium text-gray-900 hover:text-pink-600 dark:text-white dark:hover:text-pink-600"
@click="openMemberDetail(pair.member1Id)"
>
{{ pair.member1Name }}
</button>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">{{ pair.member1To2 }}</span>
<span class="text-lg text-pink-500">⇄</span>
<span class="text-xs text-gray-400">{{ pair.member2To1 }}</span>
</div>
<button
class="font-medium text-gray-900 hover:text-pink-600 dark:text-white dark:hover:text-pink-600"
@click="openMemberDetail(pair.member2Id)"
>
{{ pair.member2Name }}
</button>
</div>
<!-- 总互动次数 -->
<div class="shrink-0 text-right">
<span class="text-lg font-bold text-pink-600">{{ pair.total }}</span>
<span class="ml-1 text-sm text-gray-500">{{ t('members.relationships.twoWay.interactions') }}</span>
</div>
<!-- 平衡度标签 -->
<UBadge
:color="pair.balance >= 0.7 ? 'green' : pair.balance >= 0.5 ? 'blue' : 'gray'"
variant="soft"
size="xs"
>
{{ t('members.relationships.twoWay.balance', { value: Math.round(pair.balance * 100) }) }}
</UBadge>
</div>
</div>
</SectionCard>
</div>
</template>
<SectionCard v-else-if="!isLoadingMention" :title="t('members.relationships.emptyTitle')">
<SectionCard v-else :title="t('members.relationships.emptyTitle')">
<EmptyState :text="t('members.relationships.empty')" />
</SectionCard>
<!-- 成员 @ 详情弹窗 -->
<UModal v-model:open="showMemberDetailModal">
<template #content>
<div v-if="selectedMemberDetail" class="p-5">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('members.relationships.modal.title', { name: selectedMemberDetail.name }) }}
</h3>
<!-- 该成员最常 @ 的人 -->
<div v-if="selectedMemberDetail.topMentioned.length > 0" class="mb-4">
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('members.relationships.modal.topMentioned') }}
</h4>
<div class="space-y-2">
<div
v-for="(item, index) in selectedMemberDetail.topMentioned"
:key="index"
class="flex items-center gap-3 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ item.toName }}
</span>
<span class="text-xs text-gray-500">
{{ t('members.relationships.modal.timesCount', { count: item.count }) }}
</span>
</div>
</div>
</div>
<!-- 最常 @ 该成员的人 -->
<div v-if="selectedMemberDetail.topMentioners.length > 0">
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('members.relationships.modal.topMentioners') }}
</h4>
<div class="space-y-2">
<div
v-for="(item, index) in selectedMemberDetail.topMentioners"
:key="index"
class="flex items-center gap-3 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ item.fromName }}
</span>
<span class="text-xs text-gray-500">
{{ t('members.relationships.modal.timesCount', { count: item.count }) }}
</span>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<UButton variant="soft" @click="showMemberDetailModal = false">
{{ t('members.relationships.modal.close') }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
-56
View File
@@ -106,56 +106,6 @@ export interface MentionRankItem {
percentage: number // 占比
}
/**
* @ 关系对(谁 @ 谁)
*/
export interface MentionPair {
fromMemberId: number
fromName: string
toMemberId: number
toName: string
count: number // @ 次数
}
/**
* 单向关注
*/
export interface OneWayMention {
fromMemberId: number
fromName: string
toMemberId: number
toName: string
fromToCount: number // A @ B 的次数
toFromCount: number // B @ A 的次数
ratio: number // 单向比例 (fromToCount / (fromToCount + toFromCount))
}
/**
* 双向奔赴(CP检测)
*/
export interface TwoWayMention {
member1Id: number
member1Name: string
member2Id: number
member2Name: string
member1To2: number // A @ B
member2To1: number // B @ A
total: number // 总互动次数
balance: number // 平衡度 (较小值 / 较大值),越接近 1 越平衡
}
/**
* 成员的 @ 详情(点击成员查看其 @ 关系)
*/
export interface MemberMentionDetail {
memberId: number
name: string
/** 该成员最常 @ 的人 TOP N */
topMentioned: MentionPair[]
/** 最常 @ 该成员的人 TOP N */
topMentioners: MentionPair[]
}
/**
* @ 互动分析结果
*/
@@ -164,14 +114,8 @@ export interface MentionAnalysis {
topMentioners: MentionRankItem[]
/** 被 @ 最多的人排行 */
topMentioned: MentionRankItem[]
/** 单向关注列表 */
oneWay: OneWayMention[]
/** 双向奔赴列表(CP检测) */
twoWay: TwoWayMention[]
/** @ 总次数 */
totalMentions: number
/** 所有成员的 @ 详情(用于点击查看详细关系) */
memberDetails: MemberMentionDetail[]
}
// ==================== 含笑量分析类型 ====================
+14 -17
View File
@@ -3,25 +3,22 @@
* 统一管理排行榜中的金银铜等排名样式
*/
/** 排名徽章样式(圆形背景) */
export function getRankBadgeClass(index: number): string {
if (index === 0) return 'bg-linear-to-r from-amber-400 to-orange-500 text-white'
if (index === 1) return 'bg-linear-to-r from-gray-300 to-gray-400 text-white'
if (index === 2) return 'bg-linear-to-r from-amber-600 to-amber-700 text-white'
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
/** 排名数字样式 */
export function getRankNumberClass(index: number): string {
if (index === 0) return 'text-amber-500 dark:text-amber-400'
if (index === 1) return 'text-gray-500 dark:text-gray-300'
if (index === 2) return 'text-orange-600 dark:text-orange-400'
return 'text-gray-400 dark:text-gray-500'
}
export function formatRankNumber(index: number): string {
return String(index + 1).padStart(2, '0')
}
/** 进度条渐变颜色 */
export function getRankBarColor(index: number): string {
const colors = [
'from-amber-400 to-orange-500',
'from-gray-300 to-gray-400',
'from-amber-600 to-amber-700',
'from-pink-400 to-pink-600',
'from-pink-300 to-rose-500',
'from-cyan-400 to-blue-500',
'from-green-400 to-emerald-500',
'from-rose-400 to-pink-500',
]
return colors[index % colors.length]
if (index === 0) return 'from-amber-400 to-orange-500'
if (index === 1) return 'from-gray-300 to-gray-400'
if (index === 2) return 'from-orange-400 to-amber-600'
return 'from-primary-300 to-primary-500'
}