mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(analysis): simplify ranking cards
This commit is contained in:
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "会話メンバー",
|
||||
|
||||
@@ -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": "リピートデータがありません"
|
||||
},
|
||||
|
||||
@@ -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": "对话成员",
|
||||
|
||||
@@ -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": "暂无复读数据"
|
||||
},
|
||||
|
||||
@@ -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": "對話成員",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
Reference in new issue
Block a user