feat(web): Add Emotion Memory
This commit is contained in:
111
web/src/views/UserMemoryDetail/components/EmotionTags.tsx
Normal file
111
web/src/views/UserMemoryDetail/components/EmotionTags.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getWordCloud } from '@/api/memory'
|
||||
|
||||
interface TagList {
|
||||
keywords: Array<{ keyword: string; frequency: number; emotion_type: string; avg_intensity: number; }>;
|
||||
total_keywords: number;
|
||||
}
|
||||
const EmotionTags: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [tagList, setTagList] = useState<TagList | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getEmotionTagData()
|
||||
}, [id])
|
||||
|
||||
const getEmotionTagData = () => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
getWordCloud(id)
|
||||
.then((res) => {
|
||||
setTagList(res as TagList)
|
||||
})
|
||||
}
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tagList || tagList?.keywords.length === 0) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setVisibleCount(prev => {
|
||||
if (prev >= tagList?.keywords.length) {
|
||||
clearInterval(timer)
|
||||
return prev
|
||||
}
|
||||
return prev + 1
|
||||
})
|
||||
}, 200)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [tagList?.keywords.length])
|
||||
|
||||
const getEmotionColor = (emotionType: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
joy: '#52c41a',
|
||||
anger: '#ff4d4f',
|
||||
sadness: '#1890ff',
|
||||
fear: '#fa8c16',
|
||||
neutral: '#8c8c8c',
|
||||
surprise: '#722ed1'
|
||||
}
|
||||
return colors[emotionType] || '#8c8c8c'
|
||||
}
|
||||
|
||||
const emotionStats = tagList?.keywords.reduce((acc, item) => {
|
||||
acc[item.emotion_type] = (acc[item.emotion_type] || 0) + item.frequency
|
||||
return acc
|
||||
}, {} as Record<string, number>) ?? {}
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
title={t('emotionDetail.emotionTags')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
bodyClassName='rb:p-0! rb:relative'
|
||||
>
|
||||
{tagList
|
||||
? <>
|
||||
<div className="rb:flex rb:flex-wrap rb:items-center rb:gap-6 rb:text-sm rb:mt-3 rb:p-3 rb:bg-[#F0F3F8]">
|
||||
{Object.entries(emotionStats).map(([type, count]) => {
|
||||
console.log(type)
|
||||
return (
|
||||
<div key={type} className="rb:flex rb:items-center rb:gap-2">
|
||||
<div className="rb:w-3 rb:h-3 rb:rounded-full" style={{ backgroundColor: getEmotionColor(type) }}></div>
|
||||
<span className="rb:text-gray-600">{t(`emotionDetail.${type || 'neutral'}`)} ({count}个)</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="rb:mt-6 rb:flex rb:items-center rb:flex-wrap rb:gap-3 rb:mb-3 rb:px-6">
|
||||
{tagList.keywords.slice(0, visibleCount).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rb:flex rb:items-center rb:justify-center rb:animate-fadeIn rb:px-4 rb:py-2 rb:rounded-full rb:text-white rb:font-medium"
|
||||
style={{
|
||||
backgroundColor: getEmotionColor(item.emotion_type),
|
||||
fontSize: `${12 + item.avg_intensity * 8}px`,
|
||||
animationDelay: `${index * 200}ms`,
|
||||
height: `${20 + item.avg_intensity * 20}px`,
|
||||
transition: 'all 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{item.keyword}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
: <Empty />
|
||||
}
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmotionTags
|
||||
100
web/src/views/UserMemoryDetail/components/Health.tsx
Normal file
100
web/src/views/UserMemoryDetail/components/Health.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Progress } from 'antd'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getEmotionHealth } from '@/api/memory'
|
||||
interface Health {
|
||||
health_score: number;
|
||||
level: string;
|
||||
dimensions: {
|
||||
positivity_rate: {
|
||||
score: number;
|
||||
positive_count: number;
|
||||
negative_count: number;
|
||||
neutral_count: number;
|
||||
};
|
||||
stability: {
|
||||
score: number;
|
||||
std_deviation: number;
|
||||
};
|
||||
resilience: {
|
||||
score: number;
|
||||
recovery_rate: number;
|
||||
};
|
||||
};
|
||||
emotion_distribution: {
|
||||
joy: number;
|
||||
sadness: number;
|
||||
anger: number;
|
||||
fear: number;
|
||||
surprise: number;
|
||||
neutral: number;
|
||||
};
|
||||
time_range: string;
|
||||
}
|
||||
const Health: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [health, setHealth] = useState<Health | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getWordCloudData()
|
||||
}, [id])
|
||||
|
||||
const getWordCloudData = () => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
getEmotionHealth(id)
|
||||
.then((res) => {
|
||||
setHealth(res as Health)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
title={t('emotionDetail.health')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
height="100%"
|
||||
>
|
||||
{health
|
||||
? <>
|
||||
<div className="rb:flex rb:justify-center rb:items-center">
|
||||
<Progress
|
||||
size={250}
|
||||
type="circle"
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
percent={health.health_score}
|
||||
format={(percent) => `${percent}(${health.level})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{health.dimensions && <>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:mt-6">
|
||||
<div className="rb:w-40 rb:mr-3">{t('emotionDetail.positivity_rate')}</div>
|
||||
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.positivity_rate.score} />
|
||||
</div>
|
||||
<div className="rb:flex rb:items-center rb:gap-3 rb:mt-3">
|
||||
<div className="rb:w-40 rb:mr-3">{t('emotionDetail.stability')}</div>
|
||||
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.stability.score} />
|
||||
</div>
|
||||
<div className="rb:flex rb:items-center rb:gap-3 rb:mt-3">
|
||||
<div className="rb:w-40 rb:mr-3">{t('emotionDetail.resilience')}</div>
|
||||
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.resilience.score} />
|
||||
</div>
|
||||
</>}
|
||||
</>
|
||||
: <Empty />
|
||||
}
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Health
|
||||
63
web/src/views/UserMemoryDetail/components/Suggestions.tsx
Normal file
63
web/src/views/UserMemoryDetail/components/Suggestions.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getEmotionSuggestions } from '@/api/memory'
|
||||
import RbAlert from '@/components/RbAlert'
|
||||
|
||||
|
||||
interface Suggestions {
|
||||
health_summary: string;
|
||||
suggestions: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: string;
|
||||
actionable_steps: string[];
|
||||
}>;
|
||||
}
|
||||
const Suggestions: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSuggestionData()
|
||||
}, [id])
|
||||
|
||||
const getSuggestionData = () => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
getEmotionSuggestions(id)
|
||||
.then((res) => {
|
||||
setSuggestions(res as Suggestions)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
title={t('emotionDetail.suggestions')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
>
|
||||
{suggestions
|
||||
? <>
|
||||
<RbAlert className="rb:mb-3">{suggestions.health_summary}</RbAlert>
|
||||
{suggestions.suggestions.map((item, index) => (
|
||||
<div key={index} className="rb:mb-3">
|
||||
<div className="rb:font-medium">{index + 1}. {item.title}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-1 rb:mb-2">{item.content}</div>
|
||||
{item.actionable_steps.map((vo, idx) => <div key={idx} className="rb:ml-6 rb:text-[12px] rb:text-[#5B6167] rb:mt-1">- {vo}</div>)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
: <Empty />
|
||||
}
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Suggestions
|
||||
131
web/src/views/UserMemoryDetail/components/WordCloud.tsx
Normal file
131
web/src/views/UserMemoryDetail/components/WordCloud.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { type FC, useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import { Progress } from 'antd'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getEmotionTags } from '@/api/memory'
|
||||
|
||||
interface WordCloud {
|
||||
tags: Array<{
|
||||
emotion_type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
avg_intensity: number;
|
||||
}>;
|
||||
total_count: number;
|
||||
}
|
||||
const WordCloud: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
const resizeScheduledRef = useRef(false)
|
||||
const [wordCloud, setWordCloud] = useState<WordCloud | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getWordCloudData()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
resizeScheduledRef.current = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [wordCloud])
|
||||
|
||||
const getWordCloudData = () => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
getEmotionTags(id)
|
||||
.then((res) => {
|
||||
setWordCloud(res as WordCloud)
|
||||
})
|
||||
}
|
||||
const radarOption = useMemo(() => {
|
||||
if (!wordCloud?.tags.length) return {}
|
||||
|
||||
// 将avg_intensity转换为1-100范围
|
||||
const radarData = wordCloud.tags.map(item => ({
|
||||
name: item.emotion_type,
|
||||
value: Math.round(item.avg_intensity * 100),
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
}))
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
const dataIndex = params.dataIndex
|
||||
const item = radarData[dataIndex]
|
||||
return `${item.name}<br/>${item.percentage.toFixed(1)}%`
|
||||
}
|
||||
},
|
||||
radar: {
|
||||
indicator: radarData.map(item => ({
|
||||
name: t(`emotionDetail.${item.name}`),
|
||||
max: 100,
|
||||
min: 1
|
||||
}))
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
name: 'Emotion Intensity',
|
||||
data: [{
|
||||
value: radarData.map(item => item.value),
|
||||
name: 'Emotion Intensity'
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}, [wordCloud])
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
title={t('emotionDetail.wordCloud')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
height="100%"
|
||||
>
|
||||
{wordCloud
|
||||
? <div className="rb:flex rb:h-100">
|
||||
<ReactEcharts ref={chartRef} option={radarOption} style={{ width: '50%', height: '100%' }} />
|
||||
<div className="rb:w-[50%] rb:pl-4 rb:flex rb:flex-col rb:justify-center">
|
||||
<div className="rb:text-[18px] rb:font-medium rb:mb-4">样本数:{wordCloud.total_count}</div>
|
||||
<div className="rb:space-y-3">
|
||||
{wordCloud.tags.map(item => (
|
||||
<div key={item.emotion_type}>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:font-medium">
|
||||
{t(`emotionDetail.${item.emotion_type}`)}
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.count}{t('emotionDetail.pieces')}</div>
|
||||
</div>
|
||||
<Progress size="small" percent={item.percentage} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
: <Empty />
|
||||
}
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default WordCloud
|
||||
Reference in New Issue
Block a user