Feature/data analysis page (#545)

Co-authored-by: qintianxing <qintianxing@100.me>
Co-authored-by: KaiyiWing <Zhang.kaiyi42@gmail.com>
This commit is contained in:
NoManPlay 2023-07-18 14:04:36 +08:00 committed by GitHub
parent 4b75820298
commit f772ed7880
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1608 additions and 811 deletions

1
.yarnrc Normal file
View File

@ -0,0 +1 @@
registry "https://registry.npmmirror.com"

View File

@ -13,9 +13,11 @@
"@radix-ui/react-slider": "^1.1.1",
"canvas-confetti": "^1.6.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.8",
"dexie": "^3.2.3",
"dexie-export-import": "^4.0.7",
"dexie-react-hooks": "^1.1.3",
"echarts": "^5.4.2",
"file-saver": "^2.0.5",
"howler": "^2.2.3",
"html-to-image": "^1.11.11",
@ -24,11 +26,13 @@
"mixpanel-browser": "^2.45.0",
"pako": "^2.1.0",
"react": "^18.2.0",
"react-activity-calendar": "^2.0.2",
"react-app-polyfill": "^3.0.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.3.7",
"react-router-dom": "^6.8.2",
"react-timer-hook": "^3.0.5",
"react-tooltip": "^5.18.0",
"source-map-explorer": "^2.5.2",
"swr": "^2.0.4",
"typescript": "^4.0.3",
@ -69,6 +73,7 @@
"@tailwindcss/postcss7-compat": "^2.2.17",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/canvas-confetti": "^1.6.0",
"@types/echarts": "^4.9.18",
"@types/file-saver": "^2.0.5",
"@types/howler": "^2.2.3",
"@types/mixpanel-browser": "^2.38.1",

View File

@ -1,46 +0,0 @@
.overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
}
.child-div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.lds-dual-ring {
display: inline-block;
width: 80px;
height: 80px;
flex-basis: 100%;
}
.lds-dual-ring:after {
content: ' ';
display: block;
width: 64px;
height: 64px;
margin: 8px;
border-radius: 50%;
border: 6px solid #fff;
border-color: #fff transparent #fff transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,14 +1,13 @@
import style from './index.module.css'
import React from 'react'
export type LoadingProps = { message?: string }
const Loading: React.FC<LoadingProps> = ({ message }) => {
const Loading: React.FC = () => {
return (
<div className={style.overlay}>
<div className={style['child-div']}>
<div className={style['lds-dual-ring']}></div>
<div>{message ? message : 'Loading'}</div>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#faf9ff;]">
<div className="flex flex-col items-center justify-center ">
<div
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
role="status"
></div>
</div>
</div>
)

View File

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
const isClient = typeof window === 'object'
const useWindowSize = (initialWidth = Infinity, initialHeight = Infinity) => {
const [state, setState] = useState<{ width: number; height: number }>({
width: isClient ? window.innerWidth : initialWidth,
height: isClient ? window.innerHeight : initialHeight,
})
useEffect(() => {
if (isClient) {
const handler = () => {
setState({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
} else {
return undefined
}
}, [])
return state
}
export default useWindowSize

View File

@ -1,16 +1,18 @@
import Loading from './components/Loading'
import './index.css'
// import GalleryPage from './pages/Gallery'
import GalleryPage from './pages/Gallery-N'
import TypingPage from './pages/Typing'
import { isOpenDarkModeAtom } from '@/store'
import { useAtomValue } from 'jotai'
import mixpanel from 'mixpanel-browser'
import process from 'process'
import React, { useEffect } from 'react'
import React, { Suspense, lazy, useEffect } from 'react'
import 'react-app-polyfill/stable'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
const AnalysisPage = lazy(() => import('./pages/Analysis'))
const GalleryPage = lazy(() => import('./pages/Gallery-N'))
if (process.env.NODE_ENV === 'production') {
// for prod
mixpanel.init('bdc492847e9340eeebd53cc35f321691')
@ -30,11 +32,14 @@ function Root() {
return (
<React.StrictMode>
<BrowserRouter basename={REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner' : ''}>
<Routes>
<Route index element={<TypingPage />} />
<Route path="/gallery" element={<GalleryPage />} />
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
<Suspense fallback={<Loading />}>
<Routes>
<Route index element={<TypingPage />} />
<Route path="/gallery" element={<GalleryPage />} />
<Route path="/analysis" element={<AnalysisPage />} />
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</BrowserRouter>
</React.StrictMode>
)

View File

@ -0,0 +1,57 @@
import { isOpenDarkModeAtom } from '@/store'
import { useAtom } from 'jotai'
import type { FC } from 'react'
import React from 'react'
import type { Activity } from 'react-activity-calendar'
import ActivityCalendar from 'react-activity-calendar'
import { Tooltip as ReactTooltip } from 'react-tooltip'
import 'react-tooltip/dist/react-tooltip.css'
interface HeatmapChartsProps {
title: string
data: Activity[]
}
const HeatmapCharts: FC<HeatmapChartsProps> = ({ data, title }) => {
const [isOpenDarkMode] = useAtom(isOpenDarkModeAtom)
return (
<div className="flex flex-col items-center justify-center">
<div className="text-center text-xl font-bold text-gray-600 dark:text-white">{title}</div>
<ActivityCalendar
fontSize={20}
blockSize={22}
blockRadius={7}
style={{
padding: '40px 60px 20px 100px',
color: isOpenDarkMode ? '#fff' : '#000',
}}
colorScheme={isOpenDarkMode ? 'dark' : 'light'}
data={data}
theme={{
light: ['#f0f0f0', '#6366f1'],
dark: ['hsl(0, 0%, 22%)', '#818cf8'],
}}
renderBlock={(block, activity) =>
React.cloneElement(block, {
'data-tooltip-id': 'react-tooltip',
'data-tooltip-html': `${activity.date} 练习 ${activity.count}`,
})
}
showWeekdayLabels={true}
labels={{
months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
weekdays: ['日', '一', '二', '三', '四', '五', '六'],
totalCount: '过去一年总计 {{count}} 次',
legend: {
less: '少',
more: '多',
},
}}
/>
<ReactTooltip id="react-tooltip" />
</div>
)
}
export default HeatmapCharts

View File

@ -0,0 +1,87 @@
import purple from './purple.json'
import useWindowSize from '@/hooks/useWindowSize'
import { isOpenDarkModeAtom } from '@/store'
import { LineChart } from 'echarts/charts'
import { GridComponent, TitleComponent, TooltipComponent } from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAtom } from 'jotai'
import type { FC } from 'react'
import { useEffect, useRef } from 'react'
echarts.registerTheme('purple', purple)
echarts.use([GridComponent, TitleComponent, TooltipComponent, LineChart, CanvasRenderer])
interface LineChartsProps {
title: string
data: [string, number][]
name: string
suffix?: string
}
const LineCharts: FC<LineChartsProps> = ({ data, title, suffix, name }) => {
const [isOpenDarkMode] = useAtom(isOpenDarkModeAtom)
const chartRef = useRef<HTMLDivElement>(null)
const { width, height } = useWindowSize()
useEffect(() => {
if (!chartRef.current || !data.length) return
let chart = echarts.getInstanceByDom(chartRef.current)
chart?.dispose()
chart = echarts.init(chartRef.current, isOpenDarkMode ? 'purple' : 'light')
const option = {
tooltip: { trigger: 'axis' },
grid: {
left: '10%',
right: '10%',
top: '20%',
bottom: '10%',
},
xAxis: {
type: 'time',
axisPointer: {
label: {
formatter: function (params: { seriesData: [{ data: [string, number] }] }) {
return params.seriesData[0].data[0]
},
},
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: (value: number) => value + (suffix || '') },
},
series: [
{
name,
type: 'line',
smooth: true,
data: data,
emphasis: { focus: 'series' },
},
],
}
chart.setOption(option)
}, [data, title, suffix, name, isOpenDarkMode])
useEffect(() => {
if (!chartRef.current) return
const chart = echarts.getInstanceByDom(chartRef.current)
chart?.resize()
}, [width, height, chartRef])
return (
<div className="flex h-full flex-col">
<div className="text-center text-xl font-bold text-gray-600 dark:text-white">{title}</div>
<div style={{ width: '100%', height: '100%' }} ref={chartRef} className="line-chart flex-grow"></div>
</div>
)
}
export default LineCharts

View File

@ -0,0 +1,354 @@
{
"color": ["#9b8bba", "#e098c7", "#8fd3e8", "#71669e", "#cc70af", "#7cb4cc"],
"backgroundColor": "rgba(0,0,0,0)",
"textStyle": {},
"title": {
"textStyle": {
"color": "#ffffff"
},
"subtextStyle": {
"color": "#cccccc"
}
},
"line": {
"itemStyle": {
"borderWidth": "2"
},
"lineStyle": {
"width": "3"
},
"symbolSize": "7",
"symbol": "circle",
"smooth": true
},
"radar": {
"itemStyle": {
"borderWidth": "2"
},
"lineStyle": {
"width": "3"
},
"symbolSize": "7",
"symbol": "circle",
"smooth": true
},
"bar": {
"itemStyle": {
"barBorderWidth": 0,
"barBorderColor": "#ccc"
}
},
"pie": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"scatter": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"boxplot": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"parallel": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"sankey": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"funnel": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"gauge": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
}
},
"candlestick": {
"itemStyle": {
"color": "#e098c7",
"color0": "transparent",
"borderColor": "#e098c7",
"borderColor0": "#8fd3e8",
"borderWidth": "2"
}
},
"graph": {
"itemStyle": {
"borderWidth": 0,
"borderColor": "#ccc"
},
"lineStyle": {
"width": 1,
"color": "#aaaaaa"
},
"symbolSize": "7",
"symbol": "circle",
"smooth": true,
"color": ["#9b8bba", "#e098c7", "#8fd3e8", "#71669e", "#cc70af", "#7cb4cc"],
"label": {
"color": "#eeeeee"
}
},
"map": {
"itemStyle": {
"areaColor": "#eee",
"borderColor": "#444",
"borderWidth": 0.5
},
"label": {
"color": "#000"
},
"emphasis": {
"itemStyle": {
"areaColor": "#e098c7",
"borderColor": "#444",
"borderWidth": 1
},
"label": {
"color": "#ffffff"
}
}
},
"geo": {
"itemStyle": {
"areaColor": "#eee",
"borderColor": "#444",
"borderWidth": 0.5
},
"label": {
"color": "#000"
},
"emphasis": {
"itemStyle": {
"areaColor": "#e098c7",
"borderColor": "#444",
"borderWidth": 1
},
"label": {
"color": "#ffffff"
}
}
},
"categoryAxis": {
"axisLine": {
"show": true,
"lineStyle": {
"color": "#cccccc"
}
},
"axisTick": {
"show": false,
"lineStyle": {
"color": "#333"
}
},
"axisLabel": {
"show": true,
"color": "#cccccc"
},
"splitLine": {
"show": false,
"lineStyle": {
"color": ["#eeeeee", "#333333"]
}
},
"splitArea": {
"show": true,
"areaStyle": {
"color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"]
}
}
},
"valueAxis": {
"axisLine": {
"show": true,
"lineStyle": {
"color": "#cccccc"
}
},
"axisTick": {
"show": false,
"lineStyle": {
"color": "#333"
}
},
"axisLabel": {
"show": true,
"color": "#cccccc"
},
"splitLine": {
"show": false,
"lineStyle": {
"color": ["#eeeeee", "#333333"]
}
},
"splitArea": {
"show": true,
"areaStyle": {
"color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"]
}
}
},
"logAxis": {
"axisLine": {
"show": true,
"lineStyle": {
"color": "#cccccc"
}
},
"axisTick": {
"show": false,
"lineStyle": {
"color": "#333"
}
},
"axisLabel": {
"show": true,
"color": "#cccccc"
},
"splitLine": {
"show": false,
"lineStyle": {
"color": ["#eeeeee", "#333333"]
}
},
"splitArea": {
"show": true,
"areaStyle": {
"color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"]
}
}
},
"timeAxis": {
"axisLine": {
"show": true,
"lineStyle": {
"color": "#cccccc"
}
},
"axisTick": {
"show": false,
"lineStyle": {
"color": "#333"
}
},
"axisLabel": {
"show": true,
"color": "#cccccc"
},
"splitLine": {
"show": false,
"lineStyle": {
"color": ["#eeeeee", "#333333"]
}
},
"splitArea": {
"show": true,
"areaStyle": {
"color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"]
}
}
},
"toolbox": {
"iconStyle": {
"borderColor": "#999999"
},
"emphasis": {
"iconStyle": {
"borderColor": "#666666"
}
}
},
"legend": {
"textStyle": {
"color": "#cccccc"
}
},
"tooltip": {
"axisPointer": {
"lineStyle": {
"color": "#cccccc",
"width": 1
},
"crossStyle": {
"color": "#cccccc",
"width": 1
}
}
},
"timeline": {
"lineStyle": {
"color": "#8fd3e8",
"width": 1
},
"itemStyle": {
"color": "#8fd3e8",
"borderWidth": 1
},
"controlStyle": {
"color": "#8fd3e8",
"borderColor": "#8fd3e8",
"borderWidth": 0.5
},
"checkpointStyle": {
"color": "#8fd3e8",
"borderColor": "#8a7ca8"
},
"label": {
"color": "#8fd3e8"
},
"emphasis": {
"itemStyle": {
"color": "#8fd3e8"
},
"controlStyle": {
"color": "#8fd3e8",
"borderColor": "#8fd3e8",
"borderWidth": 0.5
},
"label": {
"color": "#8fd3e8"
}
}
},
"visualMap": {
"color": ["#8a7ca8", "#e098c7", "#cceffa"]
},
"dataZoom": {
"backgroundColor": "rgba(0,0,0,0)",
"dataBackgroundColor": "rgba(255,255,255,0.3)",
"fillerColor": "rgba(167,183,204,0.4)",
"handleColor": "#a7b7cc",
"handleSize": "100%",
"textStyle": {
"color": "#333"
}
},
"markPoint": {
"label": {
"color": "#eeeeee"
},
"emphasis": {
"label": {
"color": "#eeeeee"
}
}
}
}

View File

@ -0,0 +1,109 @@
import { db } from '@/utils/db'
import type { IWordRecord } from '@/utils/db/record'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import type { Activity } from 'react-activity-calendar'
interface IWordStats {
isEmpty?: boolean
exerciseRecord: Activity[]
wordRecord: Activity[]
wpmRecord: [string, number][]
accuracyRecord: [string, number][]
}
// 获取两个日期之间的所有日期使用dayjs计算
function getDatesBetween(start: number, end: number) {
const dates = []
let curr = dayjs(start).startOf('day')
const last = dayjs(end).endOf('day')
while (curr.diff(last) < 0) {
dates.push(curr.clone().format('YYYY-MM-DD'))
curr = curr.add(1, 'day')
}
return dates
}
function getLevel(value: number) {
if (value === 0) return 0
else if (value < 4) return 1
else if (value < 8) return 2
else if (value < 12) return 3
else return 4
}
export function useWordStats(startTimeStamp: number, endTimeStamp: number) {
const [wordStats, setWordStats] = useState<IWordStats>({ exerciseRecord: [], wordRecord: [], wpmRecord: [], accuracyRecord: [] })
useEffect(() => {
const fetchWordStats = async () => {
const stats = await getChapterStats(startTimeStamp, endTimeStamp)
setWordStats(stats)
}
fetchWordStats()
}, [startTimeStamp, endTimeStamp])
return wordStats
}
async function getChapterStats(startTimeStamp: number, endTimeStamp: number): Promise<IWordStats> {
// indexedDB查找某个数字范围内的数据
const records: IWordRecord[] = await db.wordRecords.where('timeStamp').between(startTimeStamp, endTimeStamp).toArray()
if (records.length === 0) {
return { isEmpty: true, exerciseRecord: [], wordRecord: [], wpmRecord: [], accuracyRecord: [] }
}
let data: {
[x: string]: {
exerciseTime: number //练习次数
words: string[] //练习词数组(不去重)
totalTime: number //总计用时
wrongCount: number //错误次数
}
} = {}
const dates = getDatesBetween(startTimeStamp * 1000, endTimeStamp * 1000)
data = dates
.map((date) => ({ [date]: { exerciseTime: 0, words: [], totalTime: 0, wrongCount: 0 } }))
.reduce((acc, curr) => ({ ...acc, ...curr }), {})
for (let i = 0; i < records.length; i++) {
const date = dayjs(records[i].timeStamp * 1000).format('YYYY-MM-DD')
data[date].exerciseTime = data[date].exerciseTime + 1
data[date].words = [...data[date].words, records[i].word]
data[date].totalTime = data[date].totalTime + records[i].timing.reduce((acc, curr) => acc + curr, 0)
data[date].wrongCount = data[date].wrongCount + records[i].wrongCount
}
const RecordArray = Object.entries(data)
// 练习次数统计
const exerciseRecord: IWordStats['exerciseRecord'] = RecordArray.map(([date, { exerciseTime }]) => ({
date,
count: exerciseTime,
level: getLevel(exerciseTime),
}))
// 练习词数统计(去重)
const wordRecord: IWordStats['wordRecord'] = RecordArray.map(([date, { words }]) => ({
date,
count: Array.from(new Set(words)).length,
level: getLevel(Array.from(new Set(words)).length),
}))
// wpm=练习词数(不去重)/总时间
const wpmRecord: IWordStats['wpmRecord'] = RecordArray.map<[string, number]>(([date, { words, totalTime }]) => [
date,
Math.round(words.length / (totalTime / 1000 / 60)),
]).filter((d) => d[1])
// 正确率=每个单词的长度合计/(每个单词的长度合计+总错误次数)
const accuracyRecord: IWordStats['accuracyRecord'] = RecordArray.map<[string, number]>(([date, { words, wrongCount }]) => [
date,
Math.round((words.join('').length / (words.join('').length + wrongCount)) * 100),
]).filter((d) => d[1])
return { exerciseRecord, wordRecord, wpmRecord, accuracyRecord }
}

View File

@ -0,0 +1,77 @@
import HeatmapCharts from './components/HeatmapCharts'
import LineCharts from './components/LineCharts'
import { useWordStats } from './hooks/useWordStats'
import Layout from '@/components/Layout'
import { isOpenDarkModeAtom } from '@/store'
import * as ScrollArea from '@radix-ui/react-scroll-area'
import dayjs from 'dayjs'
import { useAtom } from 'jotai'
import { useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom'
import IconX from '~icons/tabler/x'
const Analysis = () => {
const navigate = useNavigate()
const [, setIsOpenDarkMode] = useAtom(isOpenDarkModeAtom)
const onBack = useCallback(() => {
navigate('/')
}, [navigate])
const changeDarkModeState = () => {
setIsOpenDarkMode((old) => !old)
}
useHotkeys(
'ctrl+d',
() => {
changeDarkModeState()
},
{ enableOnFormTags: true, preventDefault: true },
[],
)
useHotkeys('enter,esc', onBack, { preventDefault: true })
const { isEmpty, exerciseRecord, wordRecord, wpmRecord, accuracyRecord } = useWordStats(
dayjs().subtract(1, 'year').unix(),
dayjs().unix(),
)
return (
<Layout>
<div className="flex w-full flex-1 flex-col overflow-y-auto pl-20 pr-20 pt-20">
<IconX className="absolute right-20 top-10 mr-2 h-7 w-7 cursor-pointer text-gray-400" onClick={onBack} />
<ScrollArea.Root className="flex-1 overflow-y-auto">
<ScrollArea.Viewport className="h-full w-auto pb-[20rem]">
{isEmpty ? (
<div className="align-items-center m-4 grid h-80 w-auto place-content-center overflow-hidden rounded-lg shadow-lg dark:bg-gray-600">
<div className="text-2xl text-gray-400"></div>
</div>
) : (
<>
<div className="mx-4 my-8 h-auto w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
<HeatmapCharts title="过去一年练习次数热力图" data={exerciseRecord} />
</div>
<div className="mx-4 my-8 h-auto w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
<HeatmapCharts title="过去一年练习词数热力图" data={wordRecord} />
</div>
<div className="mx-4 my-8 h-80 w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
<LineCharts title="过去一年WPM趋势图" name="WPM" data={wpmRecord} />
</div>
<div className="mx-4 my-8 h-80 w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
<LineCharts title="过去一年正确率趋势图" name="正确率(%)" data={accuracyRecord} suffix="%" />
</div>
</>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className="flex touch-none select-none bg-transparent " orientation="vertical"></ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="overflow-y-auto"></div>
</div>
</Layout>
)
}
export default Analysis

View File

@ -0,0 +1,24 @@
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import ChartPie from '~icons/heroicons/chart-pie-solid'
const AnalysisButton = () => {
const navigate = useNavigate()
const toAnalysis = useCallback(() => {
navigate('/analysis')
}, [navigate])
return (
<button
type="button"
onClick={toAnalysis}
className={`flex items-center justify-center rounded p-[2px] text-lg text-indigo-500 outline-none transition-colors duration-300 ease-in-out hover:bg-indigo-400 hover:text-white`}
title="查看数据统计"
>
<ChartPie className="icon" />
</button>
)
}
export default AnalysisButton

View File

@ -1,4 +1,5 @@
import { TypingContext, TypingStateActionType } from '../../store'
import AnalysisButton from '../AnalysisButton'
import HandPositionIllustration from '../HandPositionIllustration'
import LoopWordSwitcher from '../LoopWordSwitcher'
import Setting from '../Setting'
@ -71,6 +72,11 @@ export default function Switcher() {
{state?.isTransVisible ? <IconLanguage /> : <IconLanguageOff />}
</button>
</Tooltip>
<Tooltip className="h-7 w-7" content="查看数据统计">
<AnalysisButton />
</Tooltip>
<Tooltip className="h-7 w-7" content="开关深色模式Ctrl + D">
<button
className={`p-[2px] text-lg text-indigo-500 focus:outline-none`}

1588
yarn.lock

File diff suppressed because it is too large Load Diff