Skip to content

Commit ec45ddf

Browse files
committed
feat: 增加数据统计面板(GLM 模型实现)
1 parent 1d9e746 commit ec45ddf

33 files changed

Lines changed: 10332 additions & 17 deletions

package-lock.json

Lines changed: 8749 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@
1919
"@hookform/resolvers": "^2.9.11",
2020
"@reduxjs/toolkit": "^1.8.3",
2121
"@tanstack/react-query": "^4.2.3",
22+
"@tanstack/react-virtual": "^3.13.19",
23+
"@types/papaparse": "^5.5.2",
2224
"axios": "^0.27.2",
2325
"clsx": "^2.1.0",
2426
"daisyui": "^2.31.0",
2527
"dayjs": "^1.11.10",
28+
"echarts": "^6.0.0",
29+
"echarts-for-react": "^3.0.6",
30+
"html-to-image": "^1.11.13",
2631
"i18next": "^23.10.1",
2732
"jwt-decode": "^3.1.2",
33+
"papaparse": "^5.5.3",
2834
"react": "^18.2.0",
2935
"react-dom": "^18.2.0",
3036
"react-hook-form": "^7.34.2",

src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { useTranslation } from 'react-i18next'
99
import clsx from 'clsx'
1010
import { Authentication, Layout, Loading, Notification } from '@/components'
11-
import { Home, Note, Archive, Trash, NotFound, Login, Signup, Icons } from '@/pages'
11+
import { Home, Note, Archive, Trash, Statistics, NotFound, Login, Signup, Icons } from '@/pages'
1212

1313
export default function App(): JSX.Element {
1414
const { i18n } = useTranslation()
@@ -49,6 +49,10 @@ export default function App(): JSX.Element {
4949
path="trash"
5050
element={<Trash />}
5151
/>
52+
<Route
53+
path="statistics"
54+
element={<Statistics />}
55+
/>
5256
</Route>
5357
<Route
5458
path="/icons"

src/components/Sidebar/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ export default function Sidebar(): JSX.Element {
6969
onClickSidebarItem(ActiveSidebarItem.Trash)
7070
}}
7171
/>
72+
<SidebarItem
73+
shouldExpand={sidebarMode === 'expand' || (shouldExpand && sidebarMode === 'collapse')}
74+
icon={<Icon.Statistics className="fill-black dark:fill-white" />}
75+
title={t('layout:SIDEBAR.TITLE.STATISTICS')}
76+
active={activeSidebarItem === ActiveSidebarItem.Statistics}
77+
onClick={() => {
78+
onClickSidebarItem(ActiveSidebarItem.Statistics)
79+
}}
80+
/>
7281
</div>
7382
)
7483
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useMemo } from 'react'
2+
import ReactECharts from 'echarts-for-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { PriorityStats, PRIORITY_LABELS } from '@/utils'
5+
6+
interface BarChartProps {
7+
data: PriorityStats[]
8+
selectedPriority: 'high' | 'medium' | 'low' | null
9+
onPrioritySelect: (priority: 'high' | 'medium' | 'low' | null) => void
10+
className?: string
11+
}
12+
13+
const PRIORITY_COLORS = {
14+
high: '#ef4444',
15+
medium: '#f59e0b',
16+
low: '#10b981'
17+
}
18+
19+
export default function BarChart({
20+
data,
21+
selectedPriority,
22+
onPrioritySelect,
23+
className
24+
}: BarChartProps) {
25+
const { t, i18n } = useTranslation(['statistics'])
26+
27+
const option = useMemo(() => {
28+
const isDark = document.documentElement.classList.contains('dark')
29+
30+
const priorityLabels = data.map((item) =>
31+
t(`statistics:PRIORITY.${item.priority.toUpperCase()}`)
32+
)
33+
const counts = data.map((item) => item.count)
34+
const colors = data.map((item) => PRIORITY_COLORS[item.priority])
35+
36+
return {
37+
tooltip: {
38+
trigger: 'axis',
39+
axisPointer: {
40+
type: 'shadow'
41+
},
42+
formatter: (params: any) => {
43+
const param = params[0]
44+
return `
45+
<div style="padding: 8px;">
46+
<div style="font-weight: bold; margin-bottom: 4px;">${param.name}</div>
47+
<div>${t('statistics:CHART.TASK_COUNT')}: ${param.value}</div>
48+
<div style="margin-top: 4px; font-size: 12px; color: #666;">${t('statistics:CHART.CLICK_TO_FILTER')}</div>
49+
</div>
50+
`
51+
}
52+
},
53+
grid: {
54+
left: '3%',
55+
right: '4%',
56+
bottom: '3%',
57+
top: '10%',
58+
containLabel: true
59+
},
60+
xAxis: {
61+
type: 'category',
62+
data: priorityLabels,
63+
axisLabel: {
64+
color: isDark ? '#9ca3af' : '#6b7280'
65+
},
66+
axisLine: {
67+
lineStyle: {
68+
color: isDark ? '#4b5563' : '#e5e7eb'
69+
}
70+
}
71+
},
72+
yAxis: {
73+
type: 'value',
74+
minInterval: 1,
75+
axisLabel: {
76+
color: isDark ? '#9ca3af' : '#6b7280'
77+
},
78+
splitLine: {
79+
lineStyle: {
80+
color: isDark ? '#374151' : '#f3f4f6'
81+
}
82+
}
83+
},
84+
series: [
85+
{
86+
name: t('statistics:CHART.TASK_COUNT'),
87+
type: 'bar',
88+
barWidth: '50%',
89+
data: data.map((item, index) => ({
90+
value: item.count,
91+
itemStyle: {
92+
color: PRIORITY_COLORS[item.priority],
93+
borderRadius: [8, 8, 0, 0],
94+
opacity: selectedPriority === null || selectedPriority === item.priority ? 1 : 0.3
95+
}
96+
})),
97+
emphasis: {
98+
itemStyle: {
99+
shadowBlur: 10,
100+
shadowColor: 'rgba(0, 0, 0, 0.3)'
101+
}
102+
}
103+
}
104+
]
105+
}
106+
}, [data, selectedPriority, t, i18n.language])
107+
108+
const onEvents = {
109+
click: (params: any) => {
110+
if (params.componentType === 'series') {
111+
const clickedPriority = data[params.dataIndex]?.priority
112+
if (clickedPriority) {
113+
onPrioritySelect(selectedPriority === clickedPriority ? null : clickedPriority)
114+
}
115+
}
116+
}
117+
}
118+
119+
return (
120+
<div className={className}>
121+
<ReactECharts
122+
option={option}
123+
style={{ height: '300px', width: '100%' }}
124+
onEvents={onEvents}
125+
opts={{ renderer: 'canvas' }}
126+
/>
127+
</div>
128+
)
129+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { useMemo } from 'react'
2+
import ReactECharts from 'echarts-for-react'
3+
import { useTranslation } from 'react-i18next'
4+
import dayjs from 'dayjs'
5+
import { DailyCompletedStats } from '@/utils'
6+
7+
interface LineChartProps {
8+
data: DailyCompletedStats[]
9+
selectedDate: string | null
10+
onDateSelect: (date: string | null) => void
11+
highlightTag?: string | null
12+
className?: string
13+
}
14+
15+
export default function LineChart({
16+
data,
17+
selectedDate,
18+
onDateSelect,
19+
highlightTag,
20+
className
21+
}: LineChartProps) {
22+
const { t, i18n } = useTranslation(['statistics'])
23+
24+
const option = useMemo(() => {
25+
const dates = data.map((d) => d.date)
26+
const counts = data.map((d) => d.count)
27+
28+
const isDark = document.documentElement.classList.contains('dark')
29+
30+
return {
31+
tooltip: {
32+
trigger: 'axis',
33+
formatter: (params: any) => {
34+
const param = params[0]
35+
const dateData = data.find((d) => d.date === param.axisValue)
36+
return `
37+
<div style="padding: 8px;">
38+
<div style="font-weight: bold; margin-bottom: 4px;">${param.axisValue}</div>
39+
<div>${t('statistics:CHART.COMPLETED_TASKS')}: ${param.value}</div>
40+
${dateData?.tasks.length ? `<div style="margin-top: 4px; font-size: 12px; color: #666;">${t('statistics:CHART.CLICK_TO_VIEW')}</div>` : ''}
41+
</div>
42+
`
43+
}
44+
},
45+
grid: {
46+
left: '3%',
47+
right: '4%',
48+
bottom: '3%',
49+
top: '10%',
50+
containLabel: true
51+
},
52+
xAxis: {
53+
type: 'category',
54+
boundaryGap: false,
55+
data: dates,
56+
axisLabel: {
57+
formatter: (value: string) => dayjs(value).format('MM/DD'),
58+
color: isDark ? '#9ca3af' : '#6b7280'
59+
},
60+
axisLine: {
61+
lineStyle: {
62+
color: isDark ? '#4b5563' : '#e5e7eb'
63+
}
64+
}
65+
},
66+
yAxis: {
67+
type: 'value',
68+
minInterval: 1,
69+
axisLabel: {
70+
color: isDark ? '#9ca3af' : '#6b7280'
71+
},
72+
splitLine: {
73+
lineStyle: {
74+
color: isDark ? '#374151' : '#f3f4f6'
75+
}
76+
}
77+
},
78+
series: [
79+
{
80+
name: t('statistics:CHART.COMPLETED_TASKS'),
81+
type: 'line',
82+
smooth: true,
83+
data: counts,
84+
lineStyle: {
85+
width: 3,
86+
color: highlightTag ? '#f59e0b' : '#10b981'
87+
},
88+
areaStyle: {
89+
color: {
90+
type: 'linear',
91+
x: 0,
92+
y: 0,
93+
x2: 0,
94+
y2: 1,
95+
colorStops: [
96+
{
97+
offset: 0,
98+
color: highlightTag ? 'rgba(245, 158, 11, 0.3)' : 'rgba(16, 185, 129, 0.3)'
99+
},
100+
{
101+
offset: 1,
102+
color: highlightTag ? 'rgba(245, 158, 11, 0.05)' : 'rgba(16, 185, 129, 0.05)'
103+
}
104+
]
105+
}
106+
},
107+
itemStyle: {
108+
color: highlightTag ? '#f59e0b' : '#10b981'
109+
},
110+
emphasis: {
111+
itemStyle: {
112+
borderWidth: 3,
113+
borderColor: highlightTag ? '#f59e0b' : '#10b981'
114+
}
115+
},
116+
markPoint:
117+
selectedDate && dates.includes(selectedDate)
118+
? {
119+
data: [
120+
{
121+
coord: [selectedDate, counts[dates.indexOf(selectedDate)]],
122+
itemStyle: { color: '#ef4444' }
123+
}
124+
],
125+
symbol: 'circle',
126+
symbolSize: 12
127+
}
128+
: undefined
129+
}
130+
]
131+
}
132+
}, [data, selectedDate, highlightTag, t, i18n.language])
133+
134+
const onEvents = {
135+
click: (params: any) => {
136+
if (params.componentType === 'series') {
137+
const clickedDate = data[params.dataIndex]?.date
138+
if (clickedDate) {
139+
onDateSelect(selectedDate === clickedDate ? null : clickedDate)
140+
}
141+
}
142+
}
143+
}
144+
145+
return (
146+
<div className={className}>
147+
<ReactECharts
148+
option={option}
149+
style={{ height: '300px', width: '100%' }}
150+
onEvents={onEvents}
151+
opts={{ renderer: 'canvas' }}
152+
/>
153+
</div>
154+
)
155+
}

0 commit comments

Comments
 (0)