mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(web): add session insights
This commit is contained in:
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>
|
||||
@@ -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]))
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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"
|
||||
File renamed without changes.
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user