feat(web): add session insights

This commit is contained in:
digua committed 2026-07-22 09:44:17 +08:00
1 parent 83bbc06366
commit 915aa19a20
17 files changed
+315 -84

No files matched your search

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { SectionTabs } from '@/components/navigation'
import UserSelect from '@/components/common/UserSelect.vue'
import PrivateRelationshipView from '@/components/analysis/relationships/PrivateRelationshipView.vue'
import TypeAnalysisView from '@/components/analysis/message/TypeAnalysisView.vue'
import TimeAnalysisView from '@/components/analysis/message/TimeAnalysisView.vue'
import { LanguagePreferenceTab, WordcloudTab } from '@/components/analysis/quotes'
import type { TimeFilter } from '@openchatlab/shared-types'
import GroupRelationshipInsights from './insights/GroupRelationshipInsights.vue'
const props = defineProps<{
sessionId: string
sessionName?: string
sessionType: string
timeFilter?: TimeFilter
}>()
const { t } = useI18n()
const isPrivateChat = computed(() => props.sessionType === 'private')
const activeSubTab = ref(isPrivateChat.value ? 'relationship' : 'type-analysis')
const selectedMemberId = ref<number | null>(null)
const persistKey = computed(() => (isPrivateChat.value ? 'webWasmPrivateInsightsTab' : 'webWasmGroupInsightsTab'))
const subTabs = computed(() =>
isPrivateChat.value
? [
{ id: 'relationship', label: t('analysis.subTabs.view.relationship'), icon: 'i-heroicons-heart' },
{ id: 'type-analysis', label: t('analysis.subTabs.view.typeAnalysis'), icon: 'i-heroicons-chart-pie' },
{ id: 'time-analysis', label: t('analysis.subTabs.view.timeAnalysis'), icon: 'i-heroicons-clock' },
{ id: 'topic', label: t('analysis.subTabs.view.topic'), icon: 'i-heroicons-cloud' },
{
id: 'language-preference',
label: t('analysis.subTabs.view.languagePreference'),
icon: 'i-heroicons-language',
},
]
: [
{ id: 'type-analysis', label: t('analysis.subTabs.view.typeAnalysis'), icon: 'i-heroicons-chart-pie' },
{ id: 'time-analysis', label: t('analysis.subTabs.view.timeAnalysis'), icon: 'i-heroicons-clock' },
{ id: 'topic', label: t('analysis.subTabs.view.topic'), icon: 'i-heroicons-cloud' },
{
id: 'group-relationships',
label: t('analysis.subTabs.view.groupRelationships'),
icon: 'i-heroicons-heart',
},
]
)
const viewTimeFilter = computed(() => ({ ...props.timeFilter, memberId: selectedMemberId.value }))
</script>
<template>
<div class="flex h-full flex-col">
<SectionTabs v-model="activeSubTab" :items="subTabs" :persist-key="persistKey">
<template #right>
<UserSelect
v-if="
activeSubTab === 'type-analysis' ||
activeSubTab === 'time-analysis' ||
activeSubTab === 'group-relationships'
"
v-model="selectedMemberId"
:session-id="props.sessionId"
/>
</template>
</SectionTabs>
<div class="min-h-0 flex-1 overflow-auto">
<Transition name="fade" mode="out-in">
<PrivateRelationshipView
v-if="activeSubTab === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<TypeAnalysisView
v-else-if="activeSubTab === 'type-analysis'"
:session-id="props.sessionId"
:session-name="props.sessionName"
:time-filter="viewTimeFilter"
/>
<TimeAnalysisView
v-else-if="activeSubTab === 'time-analysis'"
:session-id="props.sessionId"
:session-name="props.sessionName"
:time-filter="viewTimeFilter"
/>
<WordcloudTab
v-else-if="activeSubTab === 'topic'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:show-shared-topics="isPrivateChat"
:enable-node-nlp-features="false"
:enable-record-navigation="false"
/>
<LanguagePreferenceTab
v-else-if="activeSubTab === 'language-preference'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:enable-record-navigation="false"
/>
<GroupRelationshipInsights
v-else-if="activeSubTab === 'group-relationships'"
:session-id="props.sessionId"
:time-filter="viewTimeFilter"
/>
</Transition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { SectionTabs } from '@/components/navigation'
import ClusterView from '@/components/analysis/relationships/ClusterView.vue'
import InteractionView from '@/components/analysis/relationships/InteractionView.vue'
import MentionRankingView from '@/components/analysis/relationships/MentionRankingView.vue'
import type { TimeFilter } from '@openchatlab/shared-types'
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const { t } = useI18n()
const activeSubTab = ref('mention-graph')
const subTabs = computed(() => [
{
id: 'mention-graph',
label: t('analysis.subTabs.groupRelationships.mentionGraph'),
icon: 'i-heroicons-arrows-right-left',
},
{
id: 'mention-ranking',
label: t('analysis.subTabs.groupRelationships.mentionRanking'),
icon: 'i-heroicons-heart',
},
{
id: 'proximity',
label: t('analysis.subTabs.groupRelationships.proximity'),
icon: 'i-heroicons-user-group',
},
])
</script>
<template>
<div class="flex h-full flex-col">
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="webWasmGroupRelationshipsTab" />
<div class="min-h-0 flex-1 overflow-y-auto">
<Transition name="fade" mode="out-in">
<InteractionView
v-if="activeSubTab === 'mention-graph'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<MentionRankingView
v-else-if="activeSubTab === 'mention-ranking'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<ClusterView
v-else-if="activeSubTab === 'proximity'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
</Transition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+15 -4
View File
@@ -9,6 +9,7 @@ import { useSessionAnalysisPageBase } from '@/composables'
import { useSessionStore } from '@/stores/session'
import GroupOverview from '../components/session/GroupOverview.vue'
import PrivateOverview from '../components/session/PrivateOverview.vue'
import SessionInsights from '../components/session/SessionInsights.vue'
const { t } = useI18n()
const route = useRoute()
@@ -16,8 +17,10 @@ const router = useRouter()
const sessionStore = useSessionStore()
const { currentSessionId } = storeToRefs(sessionStore)
// 闭环一只开放总览;后续洞察能力接通后再增加可交互 Tab,避免展示不可用入口。
const tabs = [{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' }]
const tabs = [
{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' },
{ id: 'view', labelKey: 'analysis.tabs.view', icon: 'i-heroicons-presentation-chart-bar' },
]
const {
activeTab,
@@ -92,7 +95,7 @@ const loadErrorText = computed(() =>
<LoadingState v-if="isLoading" variant="overlay" :text="t('common.loading')" />
<PrivateOverview
v-if="isPrivateChat"
v-if="activeTab === 'overview' && isPrivateChat"
:key="'private-overview-' + currentSessionId"
:session="session"
:member-activity="memberActivity"
@@ -105,7 +108,7 @@ const loadErrorText = computed(() =>
:time-filter="timeFilter"
/>
<GroupOverview
v-else
v-else-if="activeTab === 'overview'"
:key="'group-overview-' + currentSessionId"
:session="session"
:member-activity="memberActivity"
@@ -117,6 +120,14 @@ const loadErrorText = computed(() =>
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<SessionInsights
v-else-if="activeTab === 'view'"
:key="'insights-' + currentSessionId"
:session-id="currentSessionId!"
:session-name="session.name"
:session-type="session.type"
:time-filter="timeFilter"
/>
</div>
</template>
@@ -296,7 +296,7 @@ watch(
<div :class="isLoading ? 'h-full' : ''">
<LoadingState v-if="isLoading" variant="page" :text="t('common.loading')" />
<div v-else class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<div v-else class="main-content mx-auto max-w-[920px] space-y-6 p-4 sm:p-6">
<!-- 时间画像卡 -->
<TimeProfileCard
v-if="dailyActivity.length > 0"
@@ -118,7 +118,7 @@ watch(
<div :class="isLoading ? 'h-full' : ''">
<LoadingState v-if="isLoading" variant="page" :text="t('common.loading')" />
<div v-else class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<div v-else class="main-content mx-auto max-w-[920px] space-y-6 p-4 sm:p-6">
<!-- 类型画像卡 -->
<TypeProfileCard
v-if="messageTypes.length > 0"
@@ -18,9 +18,13 @@ echarts.use([RadarChart, TooltipComponent, CanvasRenderer])
const { t } = useI18n()
const isDark = useDark()
const props = defineProps<{
data: LanguagePreferenceResult
}>()
const props = withDefaults(
defineProps<{
data: LanguagePreferenceResult
enableRecordNavigation?: boolean
}>(),
{ enableRecordNavigation: true }
)
const emit = defineEmits<{
wordClick: [word: string]
@@ -319,9 +323,10 @@ onUnmounted(() => {
#{{ cpIdx + 1 }}
</span>
<span
class="truncate text-sm sm:text-base font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:opacity-70 transition-opacity"
class="truncate text-sm font-bold text-gray-700 transition-opacity dark:text-gray-200 sm:text-base"
:class="props.enableRecordNavigation ? 'cursor-pointer hover:opacity-70' : ''"
:title="cp.content"
@click="emit('wordClick', cp.content)"
@click="props.enableRecordNavigation && emit('wordClick', cp.content)"
>
「{{ truncate(cp.content, 12) }}」
</span>
@@ -413,9 +418,10 @@ onUnmounted(() => {
<div class="flex flex-nowrap overflow-hidden items-center gap-x-1.5 mb-2 min-h-[24px]">
<template v-for="(tag, idx) in card.detail.tags.slice(0, 6)" :key="tag.word">
<span
class="shrink-0 cursor-pointer text-[11px] font-bold transition-opacity hover:opacity-80"
class="shrink-0 text-[11px] font-bold transition-opacity"
:class="props.enableRecordNavigation ? 'cursor-pointer hover:opacity-80' : ''"
:style="{ color: tag.color }"
@click="emit('wordClick', tag.word)"
@click="props.enableRecordNavigation && emit('wordClick', tag.word)"
>
{{ tag.word }}
</span>
@@ -15,10 +15,14 @@ import type { TimeFilter } from '@openchatlab/shared-types'
const { t, locale } = useI18n()
const layoutStore = useLayoutStore()
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const props = withDefaults(
defineProps<{
sessionId: string
timeFilter?: TimeFilter
enableRecordNavigation?: boolean
}>(),
{ enableRecordNavigation: true }
)
const data = ref<LanguagePreferenceResult | null>(null)
const isLoading = ref(false)
@@ -38,6 +42,7 @@ async function loadData() {
}
function handleWordClick(word: string) {
if (!props.enableRecordNavigation) return
layoutStore.openChatRecordDrawer({ keywords: [word] })
}
@@ -49,16 +54,24 @@ 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-[920px] space-y-6 p-4 sm:p-6">
<LoadingState v-if="isLoading" :text="t('quotes.languagePreference.loading')" />
<EmptyState v-else-if="!hasData" :text="t('quotes.languagePreference.empty')" />
<template v-else-if="data">
<!-- Hero 卡片 -->
<LanguagePreferenceCard :data="data" @word-click="handleWordClick" />
<LanguagePreferenceCard
:data="data"
:enable-record-navigation="props.enableRecordNavigation"
@word-click="handleWordClick"
/>
<!-- Section 1: 口头禅 PK -->
<CatchphrasePKSection :members="data.members" @word-click="handleWordClick" />
<CatchphrasePKSection
:members="data.members"
:enable-record-navigation="props.enableRecordNavigation"
@word-click="handleWordClick"
/>
<!-- Section 2: 词性图谱 -->
<PosPortraitSection :members="data.members" />
@@ -6,16 +6,8 @@ import { useSettingsStore } from '@/stores/settings'
import type { SharedWord } from '@/types/quotes/languagePreference'
import type { MemberWithStats } from '@/types/analysis'
import { useDataService } from '@/services'
import { analyticsPost } from '@/services/utils/http'
import type { TimeFilter } from '@openchatlab/shared-types'
interface WordFrequencyResult {
words: Array<{ word: string; count: number; percentage: number }>
totalWords: number
totalMessages: number
uniqueWords: number
}
const { t } = useI18n()
const settingsStore = useSettingsStore()
@@ -66,7 +58,6 @@ async function loadSharedWords() {
}
const baseParams = {
sessionId: props.sessionId,
locale: locale.value,
timeFilter: props.timeFilter ? { startTs: props.timeFilter.startTs, endTs: props.timeFilter.endTs } : undefined,
topN: 100,
@@ -76,10 +67,11 @@ async function loadSharedWords() {
dictType: props.dictType ?? ('default' as DictType),
excludeWords: props.excludeWords?.length ? [...props.excludeWords] : undefined,
}
const data = useDataService()
const [resultA, resultB] = await Promise.all([
analyticsPost<WordFrequencyResult>('/nlp/word-frequency', { ...baseParams, memberId: topTwo[0].id }),
analyticsPost<WordFrequencyResult>('/nlp/word-frequency', { ...baseParams, memberId: topTwo[1].id }),
data.getWordFrequency(props.sessionId, { ...baseParams, memberId: topTwo[0].id }),
data.getWordFrequency(props.sessionId, { ...baseParams, memberId: topTwo[1].id }),
])
const wordsB = new Map(resultB.words.map((w) => [w.word, w.count]))
+45 -35
View File
@@ -13,7 +13,8 @@ import { useSettingsStore } from '@/stores/settings'
import { useLayoutStore } from '@/stores/layout'
import { useWordFilterStore } from '@/stores/wordFilter'
import { useToast } from '@/composables/useToast'
import { get, post, analyticsPost } from '@/services/utils/http'
import { useDataService } from '@/services'
import { get, post } from '@/services/utils/http'
import type { TimeFilter } from '@openchatlab/shared-types'
const { t } = useI18n()
@@ -29,23 +30,23 @@ interface PosTagInfo {
meaningful: boolean
}
interface WordFreqResponse {
words: Array<{ word: string; count: number; percentage: number }>
totalWords: number
totalMessages: number
uniqueWords: number
posTagStats?: Array<{ tag: string; count: number }>
}
type PosFilterMode = 'all' | 'meaningful' | 'custom'
type DictType = 'default' | 'zh-CN' | 'zh-TW'
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
memberId?: number | null
showSharedTopics?: boolean
}>()
const props = withDefaults(
defineProps<{
sessionId: string
timeFilter?: TimeFilter
memberId?: number | null
showSharedTopics?: boolean
enableNodeNlpFeatures?: boolean
enableRecordNavigation?: boolean
}>(),
{
enableNodeNlpFeatures: true,
enableRecordNavigation: true,
}
)
const isLoading = ref(false)
// 完整词表:按最大档一次性获取并缓存,切换词数时本地切片,避免重复请求与重复分词
@@ -79,7 +80,7 @@ const wordcloudData = computed<EChartWordcloudData>(() => {
})
// 词性过滤模式
const posFilterMode = ref<PosFilterMode>('meaningful')
const posFilterMode = ref<PosFilterMode>(props.enableNodeNlpFeatures ? 'meaningful' : 'all')
// 停用词过滤开关
const enableStopwords = ref(true)
@@ -142,7 +143,7 @@ const dictList = ref<Array<{ id: string; label: string; locale: string; download
const isDictDownloading = ref(false)
const downloadingDictId = ref<string | null>(null)
const showDictPromptModal = ref(false)
const dictListInitialized = ref(false)
const dictListInitialized = ref(!props.enableNodeNlpFeatures)
const DICT_PROMPT_DISMISSED_KEY = 'chatlab_zhTW_dict_prompt_dismissed'
const locale = computed(() => settingsStore.locale as 'zh-CN' | 'en-US' | 'zh-TW' | 'ja-JP')
@@ -162,13 +163,16 @@ const hasAnyDict = computed(() => {
return dictList.value.some((d) => d.downloaded)
})
// 非中文语言无需词典即可分析;中文语言在词典列表初始化后可用内置分词器兜底
const isDictListReady = computed(() => !requiresChineseDict.value || dictListInitialized.value)
const isDictListReady = computed(
() => !props.enableNodeNlpFeatures || !requiresChineseDict.value || dictListInitialized.value
)
const undownloadedDicts = computed(() => {
return dictList.value.filter((d) => !d.downloaded)
})
async function refreshDictList() {
if (!props.enableNodeNlpFeatures) return
try {
dictList.value = await get('/nlp/dicts')
// 繁体中文用户自动切换到 zh-TW(如已下载)
@@ -263,6 +267,7 @@ const posTagOptions = computed(() =>
// 加载词性标签定义
async function loadPosTagDefinitions() {
if (!props.enableNodeNlpFeatures) return
try {
const tags = await get<PosTagInfo[]>('/nlp/pos-tags')
posTagDefinitions.value = tags
@@ -279,15 +284,14 @@ async function loadTopicMiniWords() {
if (!props.sessionId || !isDictListReady.value) return
const requestId = ++topicMiniWordsRequestId
try {
const result = await analyticsPost<WordFreqResponse>('/nlp/word-frequency', {
sessionId: props.sessionId,
const result = await useDataService().getWordFrequency(props.sessionId, {
locale: locale.value,
timeFilter: props.timeFilter ? { startTs: props.timeFilter.startTs, endTs: props.timeFilter.endTs } : undefined,
memberId: selectedMemberId.value ?? undefined,
topN: 50,
minCount: 2,
posFilterMode: 'custom',
customPosTags: [...TOPIC_POS_TAGS],
posFilterMode: props.enableNodeNlpFeatures ? 'custom' : 'all',
customPosTags: props.enableNodeNlpFeatures ? [...TOPIC_POS_TAGS] : undefined,
enableStopwords: true,
dictType: selectedDictType.value,
excludeWords: currentExcludeWords.value.length > 0 ? [...currentExcludeWords.value] : undefined,
@@ -313,15 +317,15 @@ async function loadWordFrequency() {
const requestId = ++wordFrequencyRequestId
if (allWords.value.length === 0) isLoading.value = true
try {
const result = await analyticsPost<WordFreqResponse>('/nlp/word-frequency', {
sessionId: props.sessionId,
const result = await useDataService().getWordFrequency(props.sessionId, {
locale: locale.value,
timeFilter: props.timeFilter ? { startTs: props.timeFilter.startTs, endTs: props.timeFilter.endTs } : undefined,
memberId: selectedMemberId.value ?? undefined,
topN: MAX_WORDS,
minCount: 2,
posFilterMode: posFilterMode.value,
customPosTags: posFilterMode.value === 'custom' ? [...customPosTags.value] : undefined,
posFilterMode: props.enableNodeNlpFeatures ? posFilterMode.value : 'all',
customPosTags:
props.enableNodeNlpFeatures && posFilterMode.value === 'custom' ? [...customPosTags.value] : undefined,
enableStopwords: enableStopwords.value,
dictType: selectedDictType.value,
excludeWords: currentExcludeWords.value.length > 0 ? [...currentExcludeWords.value] : undefined,
@@ -423,12 +427,14 @@ watch(locale, () => {
// 点击词语,打开聊天记录查看器
function handleWordClick(word: string) {
if (!props.enableRecordNavigation) return
layoutStore.openChatRecordDrawer({
keywords: [word],
})
}
onMounted(async () => {
if (!props.enableNodeNlpFeatures) return
loadPosTagDefinitions()
await refreshDictList()
maybeShowDictPrompt()
@@ -436,10 +442,10 @@ onMounted(async () => {
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<div class="main-content mx-auto max-w-[920px] space-y-6 p-4 sm:p-6">
<!-- 中文词库可提升分词效果,但不能阻断后端 fallback 词云结果展示 -->
<div
v-if="requiresChineseDict && !hasAnyDict"
v-if="props.enableNodeNlpFeatures && requiresChineseDict && !hasAnyDict"
class="flex flex-col gap-3 rounded-lg border border-primary-200 bg-primary-50/70 p-4 dark:border-primary-800 dark:bg-primary-950/30 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-start gap-3">
@@ -492,7 +498,7 @@ onMounted(async () => {
<!-- 3. 热门词汇分布(词云 + 配置面板) -->
<SectionCard :title="t('quotes.wordcloud.stats.wordsLabel')" :show-divider="false">
<div class="flex gap-6 p-4 sm:p-6">
<div class="flex flex-col gap-6 p-4 sm:p-6 lg:flex-row">
<!-- 左侧:词云图 -->
<div class="flex min-w-0 flex-1 flex-col">
<div class="relative w-full" style="aspect-ratio: 16 / 9">
@@ -520,13 +526,13 @@ onMounted(async () => {
@word-click="handleWordClick"
/>
</div>
<p class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
<p v-if="props.enableRecordNavigation" class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
{{ t('quotes.wordcloud.stats.clickHint') }}
</p>
</div>
<!-- 右侧:配置面板(平铺展示) -->
<div class="w-[280px] shrink-0 space-y-4">
<div class="w-full shrink-0 space-y-4 lg:w-[280px]">
<!-- 显示词数 -->
<div>
<h4 class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
@@ -552,7 +558,7 @@ onMounted(async () => {
</div>
<!-- 词库选择 -->
<div>
<div v-if="props.enableNodeNlpFeatures">
<h4 class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('quotes.wordcloud.config.dict') }}
</h4>
@@ -578,7 +584,7 @@ onMounted(async () => {
</div>
<!-- 词性过滤 -->
<div>
<div v-if="props.enableNodeNlpFeatures">
<h4 class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('quotes.wordcloud.config.posFilter') }}
</h4>
@@ -591,7 +597,7 @@ onMounted(async () => {
</div>
<!-- 自定义词性选择(仅在 custom 模式下显示) -->
<div v-if="posFilterMode === 'custom'" class="space-y-2">
<div v-if="props.enableNodeNlpFeatures && posFilterMode === 'custom'" class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('quotes.wordcloud.posFilter.customHint') }}
@@ -668,7 +674,11 @@ onMounted(async () => {
</template>
<!-- 繁体中文词库下载提示弹窗 -->
<UModal v-model:open="showDictPromptModal" :title="t('quotes.wordcloud.dict.promptTitle')">
<UModal
v-if="props.enableNodeNlpFeatures"
v-model:open="showDictPromptModal"
:title="t('quotes.wordcloud.dict.promptTitle')"
>
<template #body>
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-300">
@@ -6,9 +6,13 @@ import type { MemberLanguageProfile } from '@/types/quotes/languagePreference'
const { t } = useI18n()
const props = defineProps<{
members: MemberLanguageProfile[]
}>()
const props = withDefaults(
defineProps<{
members: MemberLanguageProfile[]
enableRecordNavigation?: boolean
}>(),
{ enableRecordNavigation: true }
)
const emit = defineEmits<{
wordClick: [word: string]
@@ -95,14 +99,15 @@ function truncateContent(content: string, maxLength = 20): string {
<div
v-for="(phrase, index) in displayA"
:key="phrase.content"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1 transition-opacity hover:opacity-75"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 transition-opacity"
:class="[
props.enableRecordNavigation ? 'cursor-pointer hover:opacity-75' : '',
sharedPhrases.has(phrase.content)
? 'bg-pink-50 text-pink-600 ring-1 ring-pink-500/20 dark:bg-pink-500/10 dark:text-pink-400 dark:ring-pink-500/30 shadow-sm'
: getRankStyle(index).bg,
]"
:title="phrase.content"
@click="emit('wordClick', phrase.content)"
@click="props.enableRecordNavigation && emit('wordClick', phrase.content)"
>
<span
v-if="index < 3"
@@ -131,14 +136,15 @@ function truncateContent(content: string, maxLength = 20): string {
<div
v-for="(phrase, index) in displayB"
:key="phrase.content"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1 transition-opacity hover:opacity-75"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 transition-opacity"
:class="[
props.enableRecordNavigation ? 'cursor-pointer hover:opacity-75' : '',
sharedPhrases.has(phrase.content)
? 'bg-pink-50 text-pink-600 ring-1 ring-pink-500/20 dark:bg-pink-500/10 dark:text-pink-400 dark:ring-pink-500/30 shadow-sm'
: getRankStyle(index).bg,
]"
:title="phrase.content"
@click="emit('wordClick', phrase.content)"
@click="props.enableRecordNavigation && emit('wordClick', phrase.content)"
>
<span
v-if="index < 3"
@@ -159,7 +165,7 @@ function truncateContent(content: string, maxLength = 20): string {
</div>
</div>
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-400">
<p v-if="props.enableRecordNavigation" class="mt-4 text-center text-xs text-gray-500 dark:text-gray-400">
{{ t('quotes.languagePreference.catchphrasePK.hint') }}
</p>
</div>
@@ -280,11 +280,11 @@ onUnmounted(() => {
</script>
<template>
<div class="mx-auto max-w-[920px] p-4 h-full">
<div class="mx-auto h-full max-w-[920px] p-2 sm:p-4">
<ThemeCard class="flex h-full flex-col">
<!-- 顶部工具栏 -->
<div
class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-page-dark/50"
class="flex flex-wrap items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-3 dark:border-gray-700 dark:bg-page-dark/50 sm:px-4"
>
<div class="flex items-center gap-3">
<UButtonGroup size="xs">
@@ -67,8 +67,8 @@ watch(
<template>
<div class="flex h-full flex-col">
<!-- 顶部工具栏(仅右上角控制) -->
<div class="flex items-center justify-end px-4 py-2">
<div class="flex items-center gap-4">
<div class="flex 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>
@@ -79,7 +79,7 @@ watch(
</script>
<template>
<div class="main-content mx-auto max-w-3xl p-6">
<div class="main-content mx-auto max-w-3xl p-4 sm:p-6">
<LoadingState v-if="isLoadingMention" :text="t('members.relationships.loading')" />
<RankListPro
@@ -282,7 +282,7 @@ function formatDuration(seconds: number): string {
<template>
<div :class="isLoading ? 'h-full' : ''">
<LoadingState v-if="isLoading" variant="page" :text="t('common.loading')" />
<div v-else class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<div v-else class="main-content mx-auto max-w-[920px] space-y-6 p-4 sm:p-6">
<!-- 无数据 -->
<EmptyState
v-if="stats && !hasData"
@@ -7,7 +7,7 @@ import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SectionTabs } from '@/components/navigation'
import InteractionView from '@/components/analysis/relationships/InteractionView.vue'
import Relationships from './Relationships.vue'
import MentionRankingView from '@/components/analysis/relationships/MentionRankingView.vue'
import ClusterView from '@/components/analysis/relationships/ClusterView.vue'
import type { TimeFilter } from '@openchatlab/shared-types'
@@ -42,7 +42,7 @@ const subTabs = computed(() => [
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<Relationships
<MentionRankingView
v-else-if="activeSubTab === 'mention-ranking'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
@@ -5,7 +5,7 @@ import { SectionTabs } from '@/components/navigation'
import UserSelect from '@/components/common/UserSelect.vue'
import TypeAnalysisView from '@/components/analysis/message/TypeAnalysisView.vue'
import TimeAnalysisView from '@/components/analysis/message/TimeAnalysisView.vue'
import RelationshipView from './view/RelationshipView.vue'
import PrivateRelationshipView from '@/components/analysis/relationships/PrivateRelationshipView.vue'
import { WordcloudTab, LanguagePreferenceTab } from '@/components/analysis/quotes'
import type { TimeFilter } from '@openchatlab/shared-types'
@@ -62,7 +62,7 @@ const viewTimeFilter = computed(() => ({
:session-name="props.sessionName"
:time-filter="viewTimeFilter"
/>
<RelationshipView
<PrivateRelationshipView
v-else-if="activeSubTab === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"