feat(web): Add Emotion Memory

This commit is contained in:
zhaoying
2025-12-19 16:54:52 +08:00
parent 7da3c5a8e8
commit bcec0ae401
17 changed files with 1620 additions and 146 deletions

View 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

View 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

View 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

View 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