feat: Add base project structure with API and web components

This commit is contained in:
Ke Sun
2025-12-02 20:28:01 +08:00
parent f3de6d6cc9
commit c1adc62ec6
817 changed files with 111226 additions and 106 deletions

View File

@@ -0,0 +1,23 @@
import { type FC, type ReactNode } from 'react'
import RbCard from '@/components/RbCard/Card'
interface CardProps {
children: ReactNode;
title: string;
headerOperate?: ReactNode;
className?: string;
}
const Card: FC<CardProps> = ({ children, title, headerOperate, className }) => {
return (
<RbCard
headerType="borderless"
title={title}
extra={headerOperate}
className={`rb:h-full! ${className}`}
>
{children}
</RbCard>
)
}
export default Card;

View File

@@ -0,0 +1,176 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Select } from 'antd'
import ReactEcharts from 'echarts-for-react';
import * as echarts from 'echarts';
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
import Card from './Card'
interface LineCardProps {
chartData: Array<Record<string, string | number>>;
limit: number;
onChange: (value: string, type: string) => void;
type: string;
className?: string;
seriesList: string[];
}
const SeriesConfig = {
type: 'line',
stack: 'Total',
smooth: true,
lineStyle: {
width: 3
},
showSymbol: false,
label: {
show: true,
position: 'top'
},
emphasis: {
focus: 'series'
},
data: [220, 302, 181, 234, 210, 290, 150]
}
const Colors = ['#FFB048', '#4DA8FF', '#155EEF']
const LineCard: FC<LineCardProps> = ({ chartData, limit, onChange, type, className, seriesList }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
const options = [
{ label: t('dashboard.lastDays', { days: 7 }), value: 7 },
{ label: t('dashboard.lastDays', { days: 30 }), value: 30 },
{ label: t('dashboard.lastDays', { days: 90 }), value: 90 },
{ label: t('dashboard.lastHalfYear'), value: 180 },
{ label: t('dashboard.lastYear'), value: 365 },
]
const getSeries = () => {
const list = seriesList.map((key, index) => {
return {
...SeriesConfig,
name: t(`dashboard.${key}`),
data: chartData.map(vo => vo[key]),
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: Colors[index]
},
{
offset: 1,
color: '#FFFFFF'
}
])
},
}
})
return list
}
const formatSeriesList = () => {
return seriesList.map(key => ({
...SeriesConfig,
name: t(`dashboard.${key}`),
}))
}
return (
<Card
title={t(`dashboard.${type}`)}
headerOperate={
<Select
value={limit}
options={options}
onChange={(value) => onChange(value, type)}
style={{ width: '150px' }}
/>
}
className={`rb:pb-[24px] ${className}`}
>
{chartData && chartData.length > 0 ? (
<ReactEcharts
ref={chartRef}
option={{
color: Colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
},
label: {
show: false
}
},
},
legend: {
data: formatSeriesList(),
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
},
itemGap: 32,
padding: 0,
itemWidth: 26,
itemHeight: 10,
left: 'center'
},
grid: {
left: 4,
right: '3%',
bottom: 0,
containLabel: true
},
xAxis: {
type: 'category',
data: chartData.map(item => formatDateTime(item.created_at, 'DD/MM')),
boundaryGap: false,
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC',
align: 'right',
lineHeight: 17,
},
axisLine: {
lineStyle: {
color: '#EBEBEB',
}
},
},
series: getSeries()
}}
style={{ height: '265px', width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
opts={{ renderer: 'canvas' }}
notMerge={true}
lazyUpdate={true}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
}
}}
/>
) : <Empty size={120} className="rb:mt-[48px] rb:mb-[81px]" />}
</Card>
)
}
export default LineCard

View File

@@ -0,0 +1,112 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react';
import Card from './Card'
import Loading from '@/components/Empty/Loading'
import Empty from '@/components/Empty'
interface PieCardProps {
chartData: Array<Record<string, string | number>>;
loading: boolean;
}
const Colors = ['#155EEF', '#31E8FF', '#AD88FF', '#FFB048', '#4DA8FF', '#03BDFF']
const PieCard: FC<PieCardProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
return (
<Card
title={t('dashboard.knowledgeBaseTypeDistribution')}
>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-[48px] rb:mb-[81px]" />
: <ReactEcharts
option={{
color: Colors,
tooltip: {
trigger: 'item',
textStyle: {
color: '#5B6167',
fontSize: 12,
width: 27,
height: 16,
},
formatter: '{d}%',
padding: [8, 5],
backgroundColor: '#FFFFFF',
borderColor: '#DFE4ED',
extraCssText: 'width: 36px; height: 36px; box-shadow: 0px 2px 4px 0px rgba(33,35,50,0.12);border-radius: 36px;'
},
legend: {
right: 20 ,
top: 'middle',
padding: 0,
itemWidth: 12,
itemHeight: 12,
borderRadius: 2,
orient: 'vertical',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['60%', '100%'],
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 4,
width: 200,
height: 200,
left: '10%',
top: 'middle',
itemStyle: {
borderRadius: 0
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 24,
fontWeight: 'bold',
color: '#212332',
formatter: '{d}%\n{b}',
}
},
labelLine: {
show: false
},
data: chartData
}
]
}}
style={{ height: '265px', width: '100%', minWidth: '400px' }}
notMerge={true}
lazyUpdate={true}
onEvents={{
// 图表渲染完成后再次调整大小,确保宽度正确
// 使用 setTimeout 避免在主渲染过程中调用 resize
rendered: () => {
if (chartRef.current) {
setTimeout(() => {
chartRef.current?.getEchartsInstance().resize();
}, 0);
}
}
}}
/>
}
</Card>
)
}
export default PieCard

View File

@@ -0,0 +1,49 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom';
import Card from './Card';
import applicationIcon from '@/assets/images/menu/application_active.svg';
import knowledgeIcon from '@/assets/images/menu/knowledge_active.svg';
import memoryConversationIcon from '@/assets/images/menu/memoryConversation_active.svg';
import arrowTopRight from '@/assets/images/home/arrow_top_right.svg';
const quickOperations = [
{ key: 'createNewApplication', url: '/application' },
{ key: 'createNewKnowledge', url: '/knowledge-base' },
{ key: 'memoryConversation', url: '/memory-conversation' },
]
const quickOperationIcons: {[key: string]: string | undefined} = {
createNewApplication: applicationIcon,
createNewKnowledge: knowledgeIcon,
memoryConversation: memoryConversationIcon,
}
const QuickOperation:FC = () => {
const { t } = useTranslation()
const navigate = useNavigate();
const handleJump = (url: string | null) => {
if (url) {
navigate(url)
}
}
return (
<Card
title={t('dashboard.quickOperation')}
>
<div className="rb:grid rb:grid-cols-3 rb:gap-[16px]">
{quickOperations.map(item => (
<div key={item.key} className="rb:rounded-[8px] rb:p-[20px_16px] rb:border-1 rb:border-[#DFE4ED] rb:cursor-pointer rb:hover:border-[#155EEF]" onClick={() => handleJump(item.url)}>
<div className="rb:flex rb:justify-between">
<img className="rb:w-[32px] rb:h-[32px]" src={quickOperationIcons[item.key]} />
<img className="rb:w-[16px] rb:h-[16px]" src={arrowTopRight} />
</div>
<div className="rb:mt-[24px] rb:text-[#212332] rb:text-[16px] rb:leading-[20px] rb:font-medium">{t(`dashboard.${item.key}`)}</div>
<div className="rb:mt-[8px] rb:text-[#5B6167] rb:text-[12px] rb:font-regular">{t(`dashboard.${item.key}Desc`)}</div>
</div>
))}
</div>
</Card>
)
}
export default QuickOperation

View File

@@ -0,0 +1,85 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { Skeleton } from 'antd';
import chunkCountIcon from '@/assets/images/home/chunk_count.svg';
import statementsCountIcon from '@/assets/images/home/statements_count.svg';
import tripletCountIcon from '@/assets/images/home/triplet_count.svg';
import temporalCountIcon from '@/assets/images/home/temporal_count.svg';
import activityEmpty from '@/assets/images/home/ActivityEmpty.svg'
import Empty from '@/components/Empty';
import Card from './Card';
import { getRecentActivityStats } from '@/api/memory';
interface Data {
latest_relative: string;
stats: RecentActivities;
}
interface RecentActivities {
"chunk_count": number; // 数据分块
"statements_count": number; // 语句提取
"triplet_entities_count": number; // 实体关系萃取-实体节点
"triplet_relations_count": number; // 实体关系萃取 - 关系连接
"temporal_count": number; // 时间萃取
}
const activityList = [
{ key: 'chunk_count', icon: chunkCountIcon },
{ key: 'statements_count', icon: statementsCountIcon },
{ key: 'triplet_count', icon: tripletCountIcon },
{ key: 'temporal_count', icon: temporalCountIcon },
]
const RecentActivity:FC = () => {
const { t } = useTranslation()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<Data | null>(null);
const [recentActivities, setRecentActivities] = useState<RecentActivities | null>(null);
useEffect(() => {
getRecentActivityList()
}, [])
// 最近活动统计
const getRecentActivityList = () => {
setLoading(true)
getRecentActivityStats().then(res => {
const response = res as Data || {}
setData(response)
setRecentActivities(response.stats as RecentActivities || {})
})
.finally(() => setLoading(false))
}
return (
<Card
title={t('dashboard.recentMemoryActivities')}
>
{loading
? <Skeleton />
: !recentActivities || Object.keys(recentActivities).length === 0
? <Empty url={activityEmpty} subTitle={t('dashboard.activityEmpty')} size={120} className="rb:mt-[45px] rb:mb-[81px]" />
: activityList.map((item, index) => (
<div key={item.key} className={clsx("rb:flex rb:justify-between rb:items-center rb:not-italic", {
'rb:mt-[24px]': index !== 0
})}>
<div className="rb:flex rb:items-center rb:text-[#060419] rb:text-[16px] rb:font-medium">
<img className="rb:w-[40px] rb:h-[40px] rb:mr-[16px]" src={item.icon} />
<div>
{t(`dashboard.${item.key}`)}
<div className="rb:text-[#5B6167] rb:text-[14px] rb:font-normal">
{item.key === 'triplet_count'
? t(`dashboard.${item.key}_desc`, { entities_count: recentActivities.triplet_entities_count, relations_count: recentActivities.triplet_relations_count })
: t(`dashboard.${item.key}_desc`, { count: recentActivities[item.key as keyof RecentActivities] })
}
</div>
</div>
</div>
<div className="rb:text-[#5F6266] rb:text-right rb:whitespace-nowrap">{data?.latest_relative || ''}</div>
</div>
))
}
</Card>
)
}
export default RecentActivity

View File

@@ -0,0 +1,50 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { Skeleton } from 'antd';
import tagEmpty from '@/assets/images/home/tagEmpty.svg'
import Empty from '@/components/Empty';
import Card from './Card';
import { getHotMemoryTags } from '@/api/memory';
const TagList:FC = () => {
const { t } = useTranslation()
const [loading, setLoading] = useState<boolean>(false)
const [tagList, setTagList] = useState<Array<{ name: string; frequency: number }>>([]);
useEffect(() => {
getTagList()
}, [])
// 热门记忆标签
const getTagList = () => {
setLoading(true)
getHotMemoryTags().then(res => {
setTagList(Array.isArray(res) ? res : [])
}).finally(() => setLoading(false))
}
return (
<Card
title={t('dashboard.popularMemoryTags')}
>
{loading
? <Skeleton />
: !tagList || tagList.length === 0
? <Empty url={tagEmpty} title={t('dashboard.activityEmpty')} size={120} className="rb:mt-[36px] rb:mb-[81px]" />
: <div className="rb:gap-[12px] rb:flex rb:flex-wrap">
{tagList.map((item, index) => (
<div
key={item.name}
className={clsx("rb:pt-[6px] rb:pb-[6px] rb:pr-[23px] rb:pl-[20px] rb:border-1 rb:leading-[20px] rb:bg-white rb:rounded-[17px]", {
'rb:border-[rgba(21,94,239,0.4)] rb:text-[#155EEF]': index % 3 === 0,
'rb:border-[rgba(255,138,76,0.4)] rb:text-[#FF5D34]': index % 3 === 1,
'rb:border-[rgba(54,159,33,0.4)] rb:text-[#369F21]': index % 3 === 2,
})}
>{item.name} {item.frequency}</div>
))}
</div>
}
</Card>
)
}
export default TagList

View File

@@ -0,0 +1,97 @@
.card {
border-radius: 12px;
border: 1px solid #DFE4ED;
padding: 0;
}
.header {
padding: 20px;
line-height: 44px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 16px;
color: #5B6167;
font-style: normal;
display: flex;
align-items: center;
border-bottom: 1px solid #DFE4ED;
}
.avatar {
width: 44px;
height: 44px;
background: #FFFFFF;
box-shadow: 0px 2px 6px 0px rgba(33, 35, 50, 0.1);
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.avatar img {
width: 24px;
height: 24px;
}
.content {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-family: Gilroy, Gilroy;
font-weight: 800;
font-size: 28px;
color: #212332;
text-align: left;
font-style: normal;
}
.content-right {
text-align: right;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 12px;
color: #5F6266;
line-height: 16px;
font-style: normal;
row-gap: 4px;
}
.trend {
font-family: PingFangSC, PingFang SC;
font-weight: 500;
font-size: 14px;
line-height: 16px;
font-style: normal;
padding-left: 15px;
position: relative;
margin-bottom: 4px;
display: inline-block;
}
.trend::before {
width: 14px;
height: 14px;
content: '';
position: absolute;
left: 0;
top: 1px;
background-repeat: no-repeat;
background-size: contain;
}
.trend.up {
color: #369F21;
}
.trend.up::before {
background-image: url('@/assets/images/home/arrow_up_success.svg');
}
.trend.down {
color: #FF5D34;
}
.trend.down::before {
background-image: url('@/assets/images/home/arrow_down.png');
}
.trend-desc {
font-family: PingFangSC, PingFang SC;
font-weight: 500;
font-size: 14px;
color: #155EEF;
line-height: 16px;
text-align: left;
font-style: normal;
}

View File

@@ -0,0 +1,81 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import totalMemoryCapacity from '@/assets/images/home/totalMemoryCapacity.svg';
import userMemory from '@/assets/images/home/userMemory.svg';
import knowledgeBaseCount from '@/assets/images/home/knowledgeBaseCount.svg';
import apiCallCount from '@/assets/images/home/apiCallCount.svg';
import styles from './index.module.css'
import clsx from 'clsx';
import type { DashboardData } from '../../index'
const list = [
{
key: 'totalMemoryCapacity',
icon: totalMemoryCapacity,
// value: '45,678',
// trendValue: '12.5%',
// trend: 'up',
// trendDesc: 'comparedToYesterday',
background: 'linear-gradient(180deg, #E6EFFE 0%, #F9FDFF 100%)',
},
{
key: 'application',
icon: userMemory,
// value: '32,145',
// trendValue: '12.5%',
// trend: 'down',
// trendDesc: 'comparedToYesterday',
background: 'linear-gradient( 180deg, #F1FBF5 0%, #F9FDFF 100%)',
},
{
key: 'knowledgeBaseCount',
icon: knowledgeBaseCount,
// value: '13,533',
// trendValue: '15.7%',
// trend: 'up',
// trendDesc: 'thisWeek',
background: 'linear-gradient( 180deg, #E6F5FE 0%, #FBFDFF 100%)',
},
{
key: 'apiCallCount',
icon: apiCallCount,
// value: '856.2k',
// trendValue: '23.1%',
// trend: 'up',
// trendDesc: 'comparedToYesterday',
background: 'linear-gradient( 180deg, #F8F6F5 0%, #FAFDFF 100%)',
},
]
const TopCardList: FC<{data?: DashboardData}> = ({ data }) => {
const { t } = useTranslation()
return (
<div className="rb:grid rb:grid-cols-4 rb:gap-[16px]">
{list.map((item) => {
return (
<div
key={item.key}
className={styles.card}
style={{
background: item.background,
}}
>
<div className={styles.header}>
<div className={styles.avatar}><img src={item.icon} /></div>
<div className={styles.headerTitle}>{t(`dashboard.${item.key}`)}</div>
</div>
<div className={styles.content}>
{data?.[item.key] || item.value || 0}
<div className={styles.contentRight}>
{item.trendValue && <div className={clsx(styles.trend, styles[item.trend])}>{item.trendValue}</div>}
{item.trendDesc && <div>{t(`dashboard.${item.trendDesc}`)}</div>}
</div>
</div>
</div>
)
})}
</div>
)
}
export default TopCardList