├── .eslintignore ├── .prettierignore ├── docs ├── alipay.png ├── coder.png ├── kai-fu.png ├── speed.jpeg ├── pics │ ├── 后面.png │ └── 前面图案.png ├── Screenshot.png ├── dictation.png ├── phonetic.jpeg ├── CONTRIBUTING.md └── toBuildDict.md ├── public ├── favicon.ico ├── robots.txt ├── sounds │ ├── beep.wav │ ├── click.wav │ └── correct.wav ├── favicon-16x16.png ├── favicon-32x32.png ├── weChat-group.jpg ├── weChat-group.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── manifest.json ├── dicts │ ├── java-character.json │ ├── python-array.json │ ├── SQL_statement_lower-case.json │ ├── SQL_statement_upper-case.json │ ├── python-class.json │ ├── Child_c++.json │ ├── js-global.json │ ├── Node-path.json │ ├── js-promise.json │ ├── Child_python_code.json │ ├── japanese_test.json │ ├── js-map-set.json │ ├── python-sys.json │ ├── python-file.json │ ├── java-hashmap.json │ ├── java-stringBuffer.json │ ├── chinese_test.json │ ├── python-set.json │ ├── Child_python_turtle_code.json │ ├── java-arraylist.json │ ├── js-number.json │ ├── java-linkedlist.json │ └── python-builtin.json └── 404.html ├── src ├── @types │ ├── png.d.ts │ └── wav.d.ts ├── assets │ ├── alipay.jpg │ ├── weChat.jpg │ ├── book-cover.png │ ├── flags │ │ ├── code.png │ │ ├── de.png │ │ ├── en.png │ │ └── ja.png │ ├── redBook-code.jpg │ └── sharePic │ │ ├── image-1.png │ │ ├── image-2.png │ │ ├── image-3.png │ │ ├── image-4.png │ │ ├── image-5.png │ │ ├── image-6.png │ │ ├── image-7.png │ │ ├── image-8.png │ │ ├── image-9.png │ │ └── keyBackground.svg ├── utils │ ├── noop.ts │ ├── clamp.ts │ ├── shuffle.ts │ ├── groupBy.ts │ ├── range.ts │ ├── db │ │ ├── data-export.ts │ │ ├── index.ts │ │ └── record.ts │ └── index.ts ├── vite-env.d.ts ├── constants │ └── index.ts ├── components │ ├── Layout.tsx │ ├── Loading │ │ ├── index.tsx │ │ └── index.module.css │ ├── Header │ │ └── index.tsx │ ├── Tooltip │ │ └── index.tsx │ ├── InfoPanel │ │ └── index.tsx │ └── StarCard │ │ └── index.tsx ├── pages │ ├── Typing │ │ ├── components │ │ │ ├── WordPanel │ │ │ │ ├── components │ │ │ │ │ ├── WordSound │ │ │ │ │ │ ├── index.module.css │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Translation │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── SoundIcon │ │ │ │ │ │ ├── volume-icons │ │ │ │ │ │ │ ├── VolumeIcon.tsx │ │ │ │ │ │ │ ├── VolumeLowIcon.tsx │ │ │ │ │ │ │ ├── VolumeMediumIcon.tsx │ │ │ │ │ │ │ └── VolumeHieghIcon.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Word │ │ │ │ │ │ ├── index.module.css │ │ │ │ │ │ └── Letter.tsx │ │ │ │ │ ├── Phonetic │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── KeyEventHandler │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── InputHandler │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── TextAreaHandler │ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── Speed │ │ │ │ ├── InfoBox.tsx │ │ │ │ └── index.tsx │ │ │ ├── Setting │ │ │ │ ├── index.module.css │ │ │ │ └── AdvancedSetting.tsx │ │ │ ├── ResultScreen │ │ │ │ ├── index.module.css │ │ │ │ ├── ConclusionBar.tsx │ │ │ │ ├── WordChip.tsx │ │ │ │ └── RemarkRing.tsx │ │ │ ├── Progress │ │ │ │ └── index.tsx │ │ │ ├── ShareButton │ │ │ │ └── index.tsx │ │ │ └── SoundSwitcher │ │ │ │ └── index.tsx │ │ └── hooks │ │ │ └── useWordList.ts │ ├── Gallery │ │ ├── DictionaryGroup.tsx │ │ ├── hooks │ │ │ └── useChapterStats.ts │ │ ├── ChapterGroup.tsx │ │ ├── DictionaryCard.tsx │ │ ├── index.tsx │ │ └── ChapterButton.tsx │ └── Gallery-N │ │ ├── CategoryNavigation.tsx │ │ ├── hooks │ │ ├── useDictStats.ts │ │ └── useChapterStats.ts │ │ ├── DictTagSwitcher.tsx │ │ ├── Dictionary.tsx │ │ ├── DictRequest.tsx │ │ ├── CategoryDicts.tsx │ │ ├── LanguageTabSwitcher.tsx │ │ ├── ChapterList │ │ └── ChapterRow.tsx │ │ ├── DictionaryWithoutCover.tsx │ │ └── index.tsx ├── typings │ ├── index.ts │ └── resource.ts ├── hooks │ ├── useIntersectionObserver.ts │ ├── useKeySounds.ts │ └── usePronunciation.ts ├── index.tsx ├── resources │ └── soundResource.ts └── store │ └── index.ts ├── .gitattributes ├── postcss.config.js ├── .husky └── pre-commit ├── tsconfig.node.json ├── prettier.config.js ├── .gitignore ├── Dockerfile ├── docker-compose.yaml ├── .github ├── stale.yml └── workflows │ └── deploy.yml ├── .vscode └── settings.json ├── scripts └── update-dict-size.js ├── tsconfig.json ├── tailwind.config.js ├── .eslintrc.cjs ├── vite.config.ts ├── package.json └── index.html /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | public/dicts -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | public/dicts 4 | stats.html -------------------------------------------------------------------------------- /docs/alipay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/alipay.png -------------------------------------------------------------------------------- /docs/coder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/coder.png -------------------------------------------------------------------------------- /docs/kai-fu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/kai-fu.png -------------------------------------------------------------------------------- /docs/speed.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/speed.jpeg -------------------------------------------------------------------------------- /docs/pics/后面.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/pics/后面.png -------------------------------------------------------------------------------- /docs/Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/Screenshot.png -------------------------------------------------------------------------------- /docs/dictation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/dictation.png -------------------------------------------------------------------------------- /docs/phonetic.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/phonetic.jpeg -------------------------------------------------------------------------------- /docs/pics/前面图案.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/docs/pics/前面图案.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/sounds/beep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/sounds/beep.wav -------------------------------------------------------------------------------- /src/@types/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const url: string 3 | export default url 4 | } 5 | -------------------------------------------------------------------------------- /src/@types/wav.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.wav' { 2 | const url: string 3 | export default url 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/alipay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/alipay.jpg -------------------------------------------------------------------------------- /src/assets/weChat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/weChat.jpg -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/sounds/click.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/sounds/click.wav -------------------------------------------------------------------------------- /public/sounds/correct.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/sounds/correct.wav -------------------------------------------------------------------------------- /public/weChat-group.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/weChat-group.jpg -------------------------------------------------------------------------------- /public/weChat-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/weChat-group.png -------------------------------------------------------------------------------- /src/assets/book-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/book-cover.png -------------------------------------------------------------------------------- /src/assets/flags/code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/flags/code.png -------------------------------------------------------------------------------- /src/assets/flags/de.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/flags/de.png -------------------------------------------------------------------------------- /src/assets/flags/en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/flags/en.png -------------------------------------------------------------------------------- /src/assets/flags/ja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/flags/ja.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/assets/redBook-code.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/redBook-code.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # .gitattributes 2 | * text eol=lf 3 | *.png binary 4 | *.jpeg binary 5 | *.ico binary 6 | *.wav binary 7 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/sharePic/image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-1.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-2.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-3.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-4.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-5.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-6.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-7.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-8.png -------------------------------------------------------------------------------- /src/assets/sharePic/image-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/src/assets/sharePic/image-9.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/qwerty-learner/master/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/utils/noop.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-function 2 | export default function noop(): void {} 3 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const REACT_APP_DEPLOY_ENV: string 3 | declare const LATEST_COMMIT_HASH: string 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn run lint-staged 5 | yarn run eslint . --fix 6 | yarn run prettier --write . -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const EXPLICIT_SPACE = '␣' 2 | 3 | export const CHAPTER_LENGTH = 20 4 | 5 | export const DISMISS_START_CARD_DATE_KEY = 'dismissStartCardDate' 6 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from './Footer' 2 | import React from 'react' 3 | 4 | export default function Layout({ children }: { children: React.ReactNode }) { 5 | return ( 6 |
7 | {children} 8 |
9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/clamp.ts: -------------------------------------------------------------------------------- 1 | export default function clamp(number: number, lower: number, upper: number): number { 2 | number = +number 3 | lower = +lower 4 | upper = +upper 5 | lower = lower === lower ? lower : 0 6 | upper = upper === upper ? upper : 0 7 | if (number === number) { 8 | number = number <= upper ? number : upper 9 | number = number >= lower ? number : lower 10 | } 11 | return number 12 | } 13 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | printWidth: 140, 5 | bracketSpacing: true, 6 | semi: false, 7 | tabWidth: 2, 8 | jsxSingleQuote: false, 9 | overrides: [ 10 | { 11 | files: '.prettierrc', 12 | options: { parser: 'json' }, 13 | }, 14 | ], 15 | plugins: ['@trivago/prettier-plugin-sort-imports', require('prettier-plugin-tailwindcss')], 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/WordSound/index.module.css: -------------------------------------------------------------------------------- 1 | .word-sound { 2 | position: absolute; 3 | width: 40px; 4 | height: 40px; 5 | transform: translateY(calc(-50% - 23px)); 6 | right: -55px; 7 | cursor: pointer; 8 | fill: theme('colors.gray.600'); 9 | } 10 | .word-sound .prefix__icon { 11 | width: 40px; 12 | height: 40px; 13 | } 14 | .dark .word-sound { 15 | fill: theme('colors.gray.50'); 16 | opacity: 0.8; 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | package-lock.json 27 | 28 | .env 29 | stats.html 30 | 31 | .idea -------------------------------------------------------------------------------- /src/utils/shuffle.ts: -------------------------------------------------------------------------------- 1 | export default function shuffle(array: T[]): T[] { 2 | const length = array == null ? 0 : array.length 3 | if (!length) { 4 | return [] 5 | } 6 | let index = -1 7 | const lastIndex = length - 1 8 | const result = Array.from(array) 9 | while (++index < length) { 10 | const rand = index + Math.floor(Math.random() * (lastIndex - index + 1)) 11 | const value = result[rand] 12 | result[rand] = result[index] 13 | result[index] = value 14 | } 15 | return result 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import style from './index.module.css' 2 | import React from 'react' 3 | 4 | export type LoadingProps = { message?: string } 5 | 6 | const Loading: React.FC = ({ message }) => { 7 | return ( 8 |
9 |
10 |
11 |
{message ? message : 'Loading'}
12 |
13 |
14 | ) 15 | } 16 | 17 | export default React.memo(Loading) 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 运行下面的命令, 构建qwerty-learner镜像 2 | # docker build -t qwertylearner . 3 | # 下面的命令运行镜像, 访问localhost:8990访问应用, 8990可修改成你未占有的端口 4 | # docker run -d -p 8990:3000 --name qwertylearnerapp qwertylearner:latest 5 | 6 | FROM node:14 7 | 8 | LABEL maintainer="sevenyoungairye " 9 | 10 | WORKDIR /app/qwerty-learner 11 | 12 | COPY package*.json . 13 | 14 | COPY yarn.lock . 15 | 16 | RUN npm config set registry https://registry.npm.taobao.org 17 | 18 | RUN npm install yarn -g --force 19 | 20 | RUN yarn install 21 | 22 | COPY . . 23 | 24 | EXPOSE 5173 25 | 26 | CMD yarn start --host=0.0.0.0 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/Translation/index.tsx: -------------------------------------------------------------------------------- 1 | import { isTextSelectableAtom } from '@/store' 2 | import { useAtomValue } from 'jotai' 3 | 4 | export type TranslationProps = { 5 | trans: string 6 | } 7 | export default function Translation({ trans }: TranslationProps) { 8 | const isTextSelectable = useAtomValue(isTextSelectableAtom) 9 | return ( 10 |
15 | {trans} 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Qwerty Learner", 3 | "name": "Qwerty Learner", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/Typing/components/Speed/InfoBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const InfoBox: React.FC = ({ info, description }) => { 4 | return ( 5 |
6 | 7 | {info} 8 | 9 | {description} 10 |
11 | ) 12 | } 13 | 14 | export default React.memo(InfoBox) 15 | 16 | export type InfoBoxProps = { 17 | info: string 18 | description: string 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/Typing/components/Setting/index.module.css: -------------------------------------------------------------------------------- 1 | .tab-content { 2 | @apply flex w-full flex-col items-start justify-start gap-10 overflow-y-auto pb-40 pl-6 pr-9 pt-8; 3 | } 4 | 5 | .section { 6 | @apply flex w-full flex-col items-start gap-4; 7 | } 8 | .section-label { 9 | @apply pb-0 text-xl font-medium text-gray-600; 10 | } 11 | .section-description { 12 | @apply -mt-1 pl-4 text-left text-xs font-normal leading-tight text-gray-600; 13 | } 14 | 15 | .block { 16 | @apply flex w-full flex-col items-start gap-2 py-0 pl-4; 17 | } 18 | .block-label { 19 | @apply font-medium text-gray-600; 20 | } 21 | .switch-block { 22 | composes: block; 23 | @apply flex-row items-center justify-between; 24 | } 25 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | qwertylearner: 5 | image: 'node:16' 6 | user: 'root' 7 | working_dir: '/app/qwerty-learner' 8 | ports: [8990:5173] 9 | volumes: 10 | - $PWD/:/app/qwerty-learner 11 | command: 12 | - /bin/sh 13 | - -c 14 | - | 15 | echo 'run the bash command..' 16 | npm -v 17 | npm config set registry https://registry.npm.taobao.org 18 | npm install -g yarn 19 | cd /app/qwerty-learner 20 | yarn install 21 | yarn build 22 | nohup yarn start --host=0.0.0.0 & 23 | echo 'success.. start..' 24 | pwd 25 | echo '查看输出:...' 26 | cat nohup.out 27 | tail -f /dev/null 28 | tty: true 29 | stdin_open: true 30 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - Category: Announcement 10 | # Label to use when marking an issue as stale 11 | staleLabel: stale-issue 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/SoundIcon/volume-icons/VolumeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function VolumeIcon(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default VolumeIcon 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "css.lint.unknownAtRules": "ignore", 6 | "css.validate": false, 7 | "files.eol": "\n", 8 | "search.exclude": { 9 | "**/public/dicts/": true, 10 | "**/assets/CET4_T.json": true 11 | }, 12 | "cSpell.words": [ 13 | "alipay", 14 | "compat", 15 | "Dexie", 16 | "esae", 17 | "fontawesome", 18 | "fortawesome", 19 | "headlessui", 20 | "heroicons", 21 | "IELTS", 22 | "immer", 23 | "pako", 24 | "romaji", 25 | "svgr", 26 | "tabler", 27 | "tada", 28 | "tailwindcss", 29 | "trivago", 30 | "ukphone", 31 | "ungzip", 32 | "usphone", 33 | "vercel", 34 | "Wechat", 35 | "Weixin", 36 | "wordlist", 37 | "Xiao", 38 | "xiaohongshu" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/groupBy.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '@/typings' 2 | 3 | export default function groupBy(elements: T[], iteratee: (value: T) => string) { 4 | return elements.reduce>((result, value) => { 5 | const key = iteratee(value) 6 | if (Object.prototype.hasOwnProperty.call(result, key)) { 7 | result[key].push(value) 8 | } else { 9 | result[key] = [value] 10 | } 11 | return result 12 | }, {}) 13 | } 14 | 15 | export function groupByDictTags(dicts: Dictionary[]) { 16 | return dicts.reduce>((result, dict) => { 17 | dict.tags.forEach((tag) => { 18 | if (Object.prototype.hasOwnProperty.call(result, tag)) { 19 | result[tag].push(dict) 20 | } else { 21 | result[tag] = [dict] 22 | } 23 | }) 24 | return result 25 | }, {}) 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/Gallery/DictionaryGroup.tsx: -------------------------------------------------------------------------------- 1 | import DictionaryCard from './DictionaryCard' 2 | import { Dictionary } from '@/typings' 3 | import React from 'react' 4 | 5 | const DictionaryGroup: React.FC = ({ title, dictionaries }) => { 6 | return ( 7 |
8 |

9 | {title} 10 |

11 |
12 | {dictionaries.map((dict) => ( 13 | 14 | ))} 15 |
16 |
17 | ) 18 | } 19 | 20 | export default DictionaryGroup 21 | 22 | export type DictionaryGroupProps = { title: string; dictionaries: Dictionary[] } 23 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/Word/index.module.css: -------------------------------------------------------------------------------- 1 | .wrong { 2 | animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; 3 | } 4 | 5 | @keyframes shake { 6 | 10%, 7 | 90% { 8 | transform: translate3d(-1px, 0, 0); 9 | } 10 | 11 | 20%, 12 | 80% { 13 | transform: translate3d(2px, 0, 0); 14 | } 15 | 16 | 30%, 17 | 50%, 18 | 70% { 19 | transform: translate3d(-4px, 0, 0); 20 | } 21 | 22 | 40%, 23 | 60% { 24 | transform: translate3d(4px, 0, 0); 25 | } 26 | } 27 | 28 | .word-sound { 29 | position: absolute; 30 | width: 40px; 31 | height: 40px; 32 | transform: translateY(calc(-50% - 23px)); 33 | right: -55px; 34 | cursor: pointer; 35 | fill: theme('colors.gray.600'); 36 | } 37 | .word-sound .prefix__icon { 38 | width: 40px; 39 | height: 40px; 40 | } 41 | .dark .word-sound { 42 | fill: theme('colors.gray.50'); 43 | opacity: 0.8; 44 | } 45 | -------------------------------------------------------------------------------- /scripts/update-dict-size.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const dictSizeMap = Object.fromEntries( 5 | fs 6 | .readdirSync(path.join(__dirname, '..', 'public', 'dicts')) 7 | .filter((x) => x.endsWith('.json')) 8 | .map((fileName) => { 9 | return [fileName, JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'dicts', fileName), { encoding: 'utf-8' })).length] 10 | }), 11 | ) 12 | 13 | const sourceFilePath = path.join(__dirname, '..', 'src', 'resources', 'dictionary.ts') 14 | fs.writeFileSync( 15 | sourceFilePath, 16 | fs 17 | .readFileSync(sourceFilePath, { encoding: 'utf-8' }) 18 | .replace(/dicts\/([a-zA-Z0-9_-]+.json)',([\n\s]+)length: \d+/gm, (original, dictFileName, whiteSpace) => { 19 | console.log(dictFileName) 20 | return dictSizeMap[dictFileName] ? `dicts/${dictFileName}',${whiteSpace}length: ${dictSizeMap[dictFileName]}` : original 21 | }), 22 | ) 23 | -------------------------------------------------------------------------------- /src/components/Loading/index.module.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | background: rgba(0, 0, 0, 0.5); 8 | z-index: 100; 9 | 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .child-div { 16 | display: flex; 17 | flex-direction: column; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | .lds-dual-ring { 23 | display: inline-block; 24 | width: 80px; 25 | height: 80px; 26 | flex-basis: 100%; 27 | } 28 | .lds-dual-ring:after { 29 | content: ' '; 30 | display: block; 31 | width: 64px; 32 | height: 64px; 33 | margin: 8px; 34 | border-radius: 50%; 35 | border: 6px solid #fff; 36 | border-color: #fff transparent #fff transparent; 37 | animation: lds-dual-ring 1.2s linear infinite; 38 | } 39 | @keyframes lds-dual-ring { 40 | 0% { 41 | transform: rotate(0deg); 42 | } 43 | 100% { 44 | transform: rotate(360deg); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/typings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './resource' 2 | 3 | export type PronunciationType = 'us' | 'uk' | 'romaji' | 'zh' | 'ja' | 'de' 4 | export type PhoneticType = 'us' | 'uk' | 'romaji' | 'zh' | 'ja' | 'de' 5 | export type LanguageType = 'en' | 'romaji' | 'zh' | 'ja' | 'code' | 'de' 6 | export type LanguageCategoryType = 'en' | 'ja' | 'de' | 'code' 7 | 8 | type Pronunciation2PhoneticMap = Record 9 | 10 | export const PRONUNCIATION_PHONETIC_MAP: Pronunciation2PhoneticMap = { 11 | us: 'us', 12 | uk: 'uk', 13 | romaji: 'romaji', 14 | zh: 'zh', 15 | ja: 'ja', 16 | de: 'de', 17 | } 18 | 19 | export type Word = { 20 | name: string 21 | trans: string[] 22 | usphone: string 23 | ukphone: string 24 | } 25 | 26 | export type WordWithIndex = Word & { 27 | // 在 chapter 中的原始索引 28 | index: number 29 | } 30 | 31 | export type InfoPanelType = 'donate' | 'vsc' | 'community' | 'redBook' 32 | 33 | export type InfoPanelState = { 34 | [key in InfoPanelType]: boolean 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/Phonetic/index.tsx: -------------------------------------------------------------------------------- 1 | import { isTextSelectableAtom, phoneticConfigAtom } from '@/store' 2 | import { WordWithIndex } from '@/typings' 3 | import { useAtomValue } from 'jotai' 4 | 5 | export type PhoneticProps = { 6 | word: WordWithIndex 7 | } 8 | 9 | function Phonetic({ word }: PhoneticProps) { 10 | const phoneticConfig = useAtomValue(phoneticConfigAtom) 11 | const isTextSelectable = useAtomValue(isTextSelectableAtom) 12 | 13 | return ( 14 |
19 | {phoneticConfig.type === 'us' && word.usphone && word.usphone.length > 1 && {`AmE: [${word.usphone}]`}} 20 | {phoneticConfig.type === 'uk' && word.ukphone && word.ukphone.length > 1 && {`BrE: [${word.ukphone}]`}} 21 |
22 | ) 23 | } 24 | 25 | export default Phonetic 26 | -------------------------------------------------------------------------------- /src/pages/Typing/components/ResultScreen/index.module.css: -------------------------------------------------------------------------------- 1 | .img-shake { 2 | animation: tada 1.5s ease-in-out 1s 4; 3 | transition: background-color 0.1s ease-in-out; 4 | } 5 | 6 | @keyframes tada { 7 | from { 8 | -webkit-transform: scale3d(1, 1, 1); 9 | transform: scale3d(1, 1, 1); 10 | } 11 | 12 | 10%, 13 | 20% { 14 | -webkit-transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); 15 | transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); 16 | } 17 | 18 | 30%, 19 | 50%, 20 | 70%, 21 | 90% { 22 | -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); 23 | transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); 24 | fill: #818cf8; 25 | } 26 | 27 | 40%, 28 | 60%, 29 | 80% { 30 | -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); 31 | transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); 32 | } 33 | 34 | to { 35 | -webkit-transform: scale3d(1, 1, 1); 36 | transform: scale3d(1, 1, 1); 37 | animation-delay: 2s; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/SoundIcon/volume-icons/VolumeLowIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function VolumeLowIcon(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default VolumeLowIcon 12 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import logo from '@/assets/logo.svg' 2 | import React, { PropsWithChildren } from 'react' 3 | import { NavLink } from 'react-router-dom' 4 | 5 | const Header: React.FC = ({ children }) => { 6 | return ( 7 |
8 |
9 | 13 | Qwerty Learner Logo 14 |

Qwerty Learner

15 |
16 | 19 |
20 |
21 | ) 22 | } 23 | 24 | export default Header 25 | -------------------------------------------------------------------------------- /src/utils/range.ts: -------------------------------------------------------------------------------- 1 | const INFINITY = 1 / 0 2 | const MAX_INTEGER = 1.7976931348623157e308 3 | 4 | function toFinite(value: number): number { 5 | if (value === INFINITY || value === -INFINITY) { 6 | const sign = value < 0 ? -1 : 1 7 | return sign * MAX_INTEGER 8 | } 9 | return value === value ? value : 0 10 | } 11 | 12 | function baseRange(start: number, end: number, step: number): number[] { 13 | let index = -1 14 | let length = Math.max(Math.ceil((end - start) / (step || 1)), 0) 15 | const result = new Array(length) 16 | 17 | while (length--) { 18 | result[++index] = start 19 | start += step 20 | } 21 | return result 22 | } 23 | 24 | export default function range(start: number, end: number, step: number): number[] { 25 | // Ensure the sign of `-0` is preserved. 26 | start = toFinite(start) 27 | if (end === undefined) { 28 | end = start 29 | start = 0 30 | } else { 31 | end = toFinite(end) 32 | } 33 | step = step === undefined ? (start < end ? 1 : -1) : toFinite(step) 34 | return baseRange(start, end, step) 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | on: 3 | push: 4 | branches: 5 | - master 6 | env: 7 | REACT_APP_DEPLOY_ENV: pages 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [16.8.0] 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Install Packages 21 | run: yarn 22 | - name: Build page 23 | run: yarn build 24 | - name: Deploy to gh-pages 25 | uses: peaceiris/actions-gh-pages@v3 26 | with: 27 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 28 | publish_dir: ./build 29 | - name: Sync to Gitee 30 | uses: wearerequired/git-mirror-action@master 31 | env: 32 | SSH_PRIVATE_KEY: ${{ secrets.GITEE }} 33 | with: 34 | source-repo: git@github.com:Kaiyiwing/qwerty-learner.git 35 | destination-repo: git@gitee.com:KaiyiWing/qwerty-learner.git 36 | -------------------------------------------------------------------------------- /public/dicts/java-character.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "isLetter()", 4 | "trans": [ 5 | "isLetter():是否是一个字母" 6 | ] 7 | }, 8 | { 9 | "name": "isDigit()", 10 | "trans": [ 11 | "isDigit():是否是一个数字字符" 12 | ] 13 | }, 14 | { 15 | "name": "isWhitespace()", 16 | "trans": [ 17 | "isWhitespace():是否是一个空白字符" 18 | ] 19 | }, 20 | { 21 | "name": "isUpperCase()", 22 | "trans": [ 23 | "isUpperCase():是否是大写字母" 24 | ] 25 | }, 26 | { 27 | "name": "isLowerCase()", 28 | "trans": [ 29 | "isLowerCase():是否是小写字母" 30 | ] 31 | }, 32 | { 33 | "name": "toUpperCase()", 34 | "trans": [ 35 | "toUpperCase():指定字母的大写形式" 36 | ] 37 | }, 38 | { 39 | "name": "toLowerCase()", 40 | "trans": [ 41 | "toLowerCase():指定字母的小写形式" 42 | ] 43 | }, 44 | { 45 | "name": "toString()", 46 | "trans": [ 47 | "toString():返回字符的字符串形式,字符串的长度仅为1" 48 | ] 49 | } 50 | ] -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/Word/Letter.tsx: -------------------------------------------------------------------------------- 1 | import { EXPLICIT_SPACE } from '@/constants' 2 | import React from 'react' 3 | 4 | export type LetterState = 'normal' | 'correct' | 'wrong' 5 | 6 | const stateClassNameMap: Record> = { 7 | true: { 8 | normal: 'text-gray-400', 9 | correct: 'text-green-400 dark:text-green-700', 10 | wrong: 'text-red-400 dark:text-red-600', 11 | }, 12 | false: { 13 | normal: 'text-gray-600 dark:text-gray-50', 14 | correct: 'text-green-600 dark:text-green-400', 15 | wrong: 'text-red-600 dark:text-red-400', 16 | }, 17 | } 18 | 19 | export type LetterProps = { 20 | letter: string 21 | state?: LetterState 22 | visible?: boolean 23 | } 24 | 25 | const Letter: React.FC = ({ letter, state = 'normal', visible = true }) => ( 26 | 31 | {visible ? letter : '_'} 32 | 33 | ) 34 | 35 | export default React.memo(Letter) 36 | -------------------------------------------------------------------------------- /src/components/Tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from '@/utils' 2 | import { ReactNode, useState } from 'react' 3 | 4 | const Tooltip = ({ children, content, className, placement = 'top' }: TooltipProps) => { 5 | const [visible, setVisible] = useState(false) 6 | 7 | const placementClasses = { 8 | top: 'bottom-full pb-2', 9 | bottom: 'top-full pt-2', 10 | }[placement] 11 | 12 | return ( 13 |
14 |
setVisible(true)} onMouseLeave={() => setVisible(false)} onBlur={() => setVisible(false)}> 15 | {children} 16 |
17 |
22 | {content} 23 |
24 |
25 | ) 26 | } 27 | 28 | export type TooltipProps = { 29 | children: ReactNode 30 | /** 显示文本 */ 31 | content: string 32 | /** 位置 */ 33 | placement?: 'top' | 'bottom' 34 | className?: string 35 | } 36 | 37 | export default Tooltip 38 | -------------------------------------------------------------------------------- /src/pages/Typing/components/Speed/index.tsx: -------------------------------------------------------------------------------- 1 | import { TypingContext } from '../../store' 2 | import InfoBox from './InfoBox' 3 | import { useContext } from 'react' 4 | 5 | export default function Speed() { 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | const { state } = useContext(TypingContext)! 8 | const seconds = state.timerData.time % 60 9 | const minutes = Math.floor(state.timerData.time / 60) 10 | const secondsString = seconds < 10 ? '0' + seconds : seconds + '' 11 | const minutesString = minutes < 10 ? '0' + minutes : minutes + '' 12 | const inputNumber = state.chapterData.correctCount + state.chapterData.wrongCount 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "types": ["node", "unplugin-icons/types/react"], 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["src/*"] 22 | // "@/assets/*": ["assets/*"], 23 | // "@/components/*": ["components/*"], 24 | // "@/hooks/*": ["hooks/*"], 25 | // "@/pages/*": ["pages/*"], 26 | // "@/resources/*": ["resources/*"], 27 | // "@/store/*": ["store/*"], 28 | // "@/utils/*": ["utils/*"] 29 | }, 30 | "plugins": [ 31 | { 32 | "name": "typescript-plugin-css-modules", 33 | "options": { 34 | "classnameTransform": "camelCaseOnly" 35 | } 36 | } 37 | ] 38 | }, 39 | "include": ["src"] 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/CategoryNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { RadioGroup } from '@headlessui/react' 2 | import { useState } from 'react' 3 | 4 | interface Props { 5 | titles?: string[] 6 | } 7 | 8 | export default function CategoryNavigation({ titles = ['中国考试', '留学考试', '代码练习'] }: Props) { 9 | const [selectedTitle, setSelectedTitle] = useState(titles[0]) 10 | 11 | return ( 12 |
13 | 14 | {titles.map((title) => ( 15 | `flex cursor-pointer items-center space-x-2 ${checked ? 'text-gray-800' : 'text-gray-500'}`} 19 | > 20 | {({ checked }) => ( 21 | <> 22 |
23 | {title} 24 | 25 | )} 26 | 27 | ))} 28 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/KeyEventHandler/index.tsx: -------------------------------------------------------------------------------- 1 | import { WordUpdateAction } from '../InputHandler' 2 | import { TypingContext } from '@/pages/Typing/store' 3 | import { isChineseSymbol, isLegal } from '@/utils' 4 | import { useCallback, useContext, useEffect } from 'react' 5 | 6 | export default function KeyEventHandler({ updateInput }: { updateInput: (updateObj: WordUpdateAction) => void }) { 7 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 8 | const { state } = useContext(TypingContext)! 9 | 10 | const onKeydown = useCallback( 11 | (e: KeyboardEvent) => { 12 | const char = e.key 13 | 14 | if (isChineseSymbol(char)) { 15 | alert('您正在使用输入法,请关闭输入法。') 16 | return 17 | } 18 | 19 | if (isLegal(char) && !e.altKey && !e.ctrlKey && !e.metaKey) { 20 | updateInput({ type: 'add', value: char, event: e }) 21 | } 22 | }, 23 | [updateInput], 24 | ) 25 | 26 | useEffect(() => { 27 | if (!state.isTyping) return 28 | 29 | window.addEventListener('keydown', onKeydown) 30 | return () => { 31 | window.removeEventListener('keydown', onKeydown) 32 | } 33 | }, [onKeydown, state.isTyping]) 34 | 35 | return <> 36 | } 37 | -------------------------------------------------------------------------------- /public/dicts/python-array.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "append()", 4 | "trans": [ 5 | "用于在列表末尾添加新的对象。" 6 | ] 7 | }, 8 | { 9 | "name": "buffer_info()", 10 | "trans": [ 11 | "返回一个元组(address,length)以给出用于存放数组内容的缓冲区元素的当前内存地址和长度" 12 | ] 13 | }, 14 | { 15 | "name": "byteswap()", 16 | "trans": [ 17 | "更改基础数据的字节顺序" 18 | ] 19 | }, 20 | { 21 | "name": "count()", 22 | "trans": [ 23 | "用于统计某个元素在列表中出现的次数。" 24 | ] 25 | }, 26 | { 27 | "name": "extend()", 28 | "trans": [ 29 | "用于在列表末尾一次性追加另一个序列中的多个值(用新列表扩展原来的列表)" 30 | ] 31 | }, 32 | { 33 | "name": "fromfile()", 34 | "trans": [ 35 | "用于从列表中找出某个值第一个匹配项的索引位置" 36 | ] 37 | }, 38 | { 39 | "name": "index()", 40 | "trans": [ 41 | "用于从列表中找出某个值第一个匹配项的索引位置" 42 | ] 43 | }, 44 | { 45 | "name": "insert()", 46 | "trans": [ 47 | "用于将指定对象插入列表的指定位置" 48 | ] 49 | }, 50 | { 51 | "name": "pop()", 52 | "trans": [ 53 | "用于移除列表中的一个元素(默认最后一个元素),并且返回该元素的值" 54 | ] 55 | }, 56 | { 57 | "name": "remove()", 58 | "trans": [ 59 | "用于移除列表中某个值的第一个匹配项" 60 | ] 61 | }, 62 | { 63 | "name": "reverse()", 64 | "trans": [ 65 | "用于反向列表中元素。" 66 | ] 67 | } 68 | ] -------------------------------------------------------------------------------- /src/pages/Gallery-N/hooks/useDictStats.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/utils/db' 2 | import { IChapterRecord } from '@/utils/db/record' 3 | import { useEffect, useState } from 'react' 4 | 5 | export function useDictStats(dictID: string, isStartLoad: boolean) { 6 | const [dictStats, setDictStats] = useState(null) 7 | 8 | useEffect(() => { 9 | const fetchDictStats = async () => { 10 | const stats = await getDictStats(dictID) 11 | setDictStats(stats) 12 | } 13 | 14 | if (isStartLoad && !dictStats) { 15 | fetchDictStats() 16 | } 17 | // eslint-disable-next-line react-hooks/exhaustive-deps 18 | }, [dictID, isStartLoad]) 19 | 20 | return dictStats 21 | } 22 | 23 | interface IDictStats { 24 | exercisedChapterCount: number 25 | } 26 | 27 | async function getDictStats(dict: string): Promise { 28 | const records: IChapterRecord[] = await db.chapterRecords.where({ dict }).toArray() 29 | const allChapter = records.map(({ chapter }) => chapter).filter((item) => item !== null) as number[] 30 | const uniqueChapter = allChapter.filter((value, index, self) => { 31 | return self.indexOf(value) === index 32 | }) 33 | 34 | const exercisedChapterCount = uniqueChapter.length 35 | 36 | return { exercisedChapterCount } 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/DictTagSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { RadioGroup } from '@headlessui/react' 2 | import { useCallback } from 'react' 3 | 4 | type Props = { 5 | tagList: string[] 6 | currentTag: string 7 | onChangeCurrentTag: (tag: string) => void 8 | } 9 | 10 | export default function DictTagSwitcher({ tagList, currentTag, onChangeCurrentTag }: Props) { 11 | const onChangeTag = useCallback( 12 | (tag: string) => { 13 | onChangeCurrentTag(tag) 14 | }, 15 | [onChangeCurrentTag], 16 | ) 17 | 18 | return ( 19 | 20 |
21 | {tagList.map((option) => ( 22 | 26 | `cursor-pointer whitespace-nowrap rounded-[3rem] px-4 py-2 ${ 27 | checked ? 'bg-indigo-400 text-white' : 'bg-white text-gray-600 dark:bg-gray-800 dark:text-gray-200' 28 | } ${!checked && 'hover:bg-indigo-100 dark:hover:bg-gray-600'}` 29 | } 30 | > 31 |

{option}

32 |
33 | ))} 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/Dictionary.tsx: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '@/typings' 2 | import * as Progress from '@radix-ui/react-progress' 3 | 4 | interface Props { 5 | dictionary: Dictionary 6 | onClick?: () => void 7 | } 8 | 9 | function DictionaryComponent({ dictionary, onClick }: Props) { 10 | return ( 11 |
12 |
13 |
14 |
15 |
16 |

{dictionary.name}

17 |

{dictionary.length}

18 |
19 | 20 | 24 | 25 |
26 |
27 |
28 | ) 29 | } 30 | 31 | export default DictionaryComponent 32 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function VolumeMediumIcon(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default VolumeMediumIcon 12 | -------------------------------------------------------------------------------- /src/typings/resource.ts: -------------------------------------------------------------------------------- 1 | import { LanguageCategoryType, LanguageType, PronunciationType } from '.' 2 | 3 | export type DictionaryResource = { 4 | id: string 5 | name: string 6 | description: string 7 | category: string 8 | tags: string[] 9 | url: string 10 | length: number 11 | language: LanguageType 12 | languageCategory: LanguageCategoryType 13 | //override default pronunciation when not undefined 14 | defaultPronIndex?: number 15 | } 16 | 17 | export type Dictionary = { 18 | id: string 19 | name: string 20 | description: string 21 | category: string 22 | tags: string[] 23 | url: string 24 | length: number 25 | language: LanguageType 26 | languageCategory: LanguageCategoryType 27 | // calculated in the store 28 | chapterCount: number 29 | //override default pronunciation when not undefined 30 | defaultPronIndex?: number 31 | } 32 | 33 | export type PronunciationConfig = { 34 | name: string 35 | pron: PronunciationType 36 | } 37 | 38 | export type LanguagePronunciationMapConfig = { 39 | defaultPronIndex: number 40 | pronunciation: PronunciationConfig[] 41 | } 42 | 43 | export type LanguagePronunciationMap = { 44 | [key in LanguageType]: LanguagePronunciationMapConfig 45 | } 46 | 47 | export type SoundResource = { 48 | key: string 49 | name: string 50 | filename: string 51 | } 52 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 开源代码贡献准则 2 | 3 | ## PR 来源 4 | 5 | 1. 我们会在 GitHub 上将需要完成的 feature 和修复的 bug 标记为 "Help Wanted" 6 | 2. 用户在用户社群(在项目官方部署的 footer 中有二维码)提出的需求和 bug 7 | 3. 根据大家对项目的理解和兴趣,认为需要做的 feature 和修复的 bug 8 | 9 | ## 在开始做 PR 之前 10 | 11 | 1. 在 **开发者社群或 issue 区** 进行讨论,确认这个 PR 符合项目需求,并考虑对现有代码和未来计划的影响 12 | 2. 在 GitHub 上创建相关的 issue(已有 issue 则无须创建),并进行回复。尽可能在 issue 描述了问题、解决方案、相关细节和贡献者预期的工作,方便大家进行讨论 13 | 3. 确认开始 issue 后,尽可能在一周内解决(相对复杂的 issue 除外),以免 PR 长期无法推进,其他开发者也无法参与 14 | 15 | ## 在 PR 过程中 16 | 17 | 1. 在开始 coding 后,尽早提出一个 draft PR,方便其他开发者参与讨论,给出建议和帮助 18 | 2. 遇到任何技术问题和实现路线问题,可以在 开发者社群或 issue 区 进行讨论。确保 PR 满足项目的开发规范和质量标准,经过充分测试和文档支持 19 | 3. 在 PR 中友好协作,回应 Code Review。接受反馈并进行改进,遵守代码风格和注释规范,确保代码的可读性和可维护性 20 | 4. 其他人也可以对 PR 进行 Review,帮助发现代码中的问题,提出自己的建议和想法 21 | 22 | ## 完成 PR 后 23 | 24 | 1. 标记相关 issue 为完成。确保代码被合并到主分支,并在生产环境中经过充分测试和部署 25 | 2. 如果是用户社群的相关需求,可以在社群内对用户进行回复 26 | 27 | ## 行为准则 28 | 29 | 1. 尊重所有贡献者,不论其技术水平、经验、性别、性取向、种族、宗教信仰或国籍 30 | 2. 保持开放的心态,愿意接受其他人的批评和建议,并根据反馈进行改进 31 | 3. 提交有价值的贡献,符合项目需求并遵守项目的开发规范和质量标准 32 | 4. 不要在 PR 或讨论中使用不礼貌或侮辱性的语言,不要发布任何垃圾或攻击性的内容 33 | 5. 遵守代码行为准则,不要进行不道德或不法的行为,如抄袭、篡改他人代码、恶意破坏等 34 | 6. 避免进行灌水式的讨论或评论,尽量保持话题与项目相关,并尊重他人的意见和观点 35 | 7. 遵守相关法律法规和 GitHub 平台的规定,不发布含有违法、政治或淫秽内容的 PR 或评论 36 | 37 | ## 反馈 38 | 39 | 如有任何问题或建议,请随时在 issue 区或者群内提出,我们将及时解决。同时,欢迎参与项目的讨论和开发,共同打造更加优秀的开源项目! 40 | 41 | ## 写在最后 42 | 43 | 不要害怕提出问题,不要害怕提出 PR,不要害怕提出建议,不要害怕提出想法,不要害怕提出质疑,不要害怕提出帮助,不要害怕提出改进 44 | 45 | ![kai-fu](./kai-fu.png) 46 | -------------------------------------------------------------------------------- /src/hooks/useIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from 'react' 2 | 3 | /** 4 | * source: https://usehooks-ts.com/react-hook/use-intersection-observer 5 | */ 6 | interface Args extends IntersectionObserverInit { 7 | freezeOnceVisible?: boolean 8 | } 9 | 10 | function useIntersectionObserver( 11 | elementRef: RefObject, 12 | { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false }: Args, 13 | ): IntersectionObserverEntry | undefined { 14 | const [entry, setEntry] = useState() 15 | 16 | const frozen = entry?.isIntersecting && freezeOnceVisible 17 | 18 | const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { 19 | setEntry(entry) 20 | } 21 | 22 | useEffect(() => { 23 | const node = elementRef?.current // DOM Ref 24 | const hasIOSupport = !!window.IntersectionObserver 25 | 26 | if (!hasIOSupport || frozen || !node) return 27 | 28 | const observerParams = { threshold, root, rootMargin } 29 | const observer = new IntersectionObserver(updateEntry, observerParams) 30 | 31 | observer.observe(node) 32 | 33 | return () => observer.disconnect() 34 | 35 | // eslint-disable-next-line react-hooks/exhaustive-deps 36 | }, [elementRef?.current, JSON.stringify(threshold), root, rootMargin, frozen]) 37 | 38 | return entry 39 | } 40 | 41 | export default useIntersectionObserver 42 | -------------------------------------------------------------------------------- /public/dicts/SQL_statement_lower-case.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "alter table", 4 | "trans": [ 5 | "alter table 用来更新现存表的模式。可以用 create table 来创建一个新表。" 6 | ] 7 | }, 8 | { 9 | "name": "commit", 10 | "trans": [ 11 | "commit 用来将事务写入数据库。" 12 | ] 13 | }, 14 | { 15 | "name": "create index", 16 | "trans": [ 17 | "create index 用来为一列或多列创建索引。" 18 | ] 19 | }, 20 | { 21 | "name": "create table", 22 | "trans": [ 23 | "create table 用来创建新的数据库表。可以用 alter table 来更新一个现存表的模式。" 24 | ] 25 | }, 26 | { 27 | "name": "create view", 28 | "trans": [ 29 | "create view 用来创建一个或多个表的视图。" 30 | ] 31 | }, 32 | { 33 | "name": "delete", 34 | "trans": [ 35 | "delete 用来从表中删除一行或多行。" 36 | ] 37 | }, 38 | { 39 | "name": "drop", 40 | "trans": [ 41 | "drop 用来永久性地删除数据库对象(表、视图和索引等)" 42 | ] 43 | }, 44 | { 45 | "name": "insert", 46 | "trans": [ 47 | "insert 用来对表添加一个新行。" 48 | ] 49 | }, 50 | { 51 | "name": "insert select", 52 | "trans": [ 53 | "insert select 用来将 select 的结果插入到表中。" 54 | ] 55 | }, 56 | { 57 | "name": "rollback", 58 | "trans": [ 59 | "rollback 用来撤销事务块。" 60 | ] 61 | }, 62 | { 63 | "name": "select", 64 | "trans": [ 65 | "select 用来从一个或多个表(或视图)中检索数据。" 66 | ] 67 | }, 68 | { 69 | "name": "update", 70 | "trans": [ 71 | "update 用来对表中的一行或多行进行更新。" 72 | ] 73 | } 74 | ] -------------------------------------------------------------------------------- /public/dicts/SQL_statement_upper-case.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "ALTER TABLE", 4 | "trans": [ 5 | "ALTER TABLE 用来更新现存表的模式。可以用 CREATE TABLE 来创建一个新表。" 6 | ] 7 | }, 8 | { 9 | "name": "COMMIT", 10 | "trans": [ 11 | "COMMIT 用来将事务写入数据库。" 12 | ] 13 | }, 14 | { 15 | "name": "CREATE INDEX", 16 | "trans": [ 17 | "CREATE INDEX 用来为一列或多列创建索引。" 18 | ] 19 | }, 20 | { 21 | "name": "CREATE TABLE", 22 | "trans": [ 23 | "CREATE TABLE 用来创建新的数据库表。可以用 ALTER TABLE 来更新一个现存表的模式。" 24 | ] 25 | }, 26 | { 27 | "name": "CREATE VIEW", 28 | "trans": [ 29 | "CREATE VIEW 用来创建一个或多个表的视图。" 30 | ] 31 | }, 32 | { 33 | "name": "DELETE", 34 | "trans": [ 35 | "DELETE 用来从表中删除一行或多行。" 36 | ] 37 | }, 38 | { 39 | "name": "DROP", 40 | "trans": [ 41 | "DROP 用来永久性地删除数据库对象(表、视图和索引等)" 42 | ] 43 | }, 44 | { 45 | "name": "INSERT", 46 | "trans": [ 47 | "INSERT 用来对表添加一个新行。" 48 | ] 49 | }, 50 | { 51 | "name": "INSERT SELECT", 52 | "trans": [ 53 | "INSERT SELECT 用来将 SELECT 的结果插入到表中。" 54 | ] 55 | }, 56 | { 57 | "name": "ROLLBACK", 58 | "trans": [ 59 | "ROLLBACK 用来撤销事务块。" 60 | ] 61 | }, 62 | { 63 | "name": "SELECT", 64 | "trans": [ 65 | "SELECT 用来从一个或多个表(或视图)中检索数据。" 66 | ] 67 | }, 68 | { 69 | "name": "UPDATE", 70 | "trans": [ 71 | "UPDATE 用来对表中的一行或多行进行更新。" 72 | ] 73 | } 74 | ] -------------------------------------------------------------------------------- /src/pages/Gallery/hooks/useChapterStats.ts: -------------------------------------------------------------------------------- 1 | import { currentDictIdAtom } from '@/store' 2 | import { db } from '@/utils/db' 3 | import { IChapterRecord } from '@/utils/db/record' 4 | import { useAtomValue } from 'jotai' 5 | import { useEffect, useState } from 'react' 6 | 7 | export function useChapterStats(chapter: number, isStartLoad: boolean) { 8 | const dictID = useAtomValue(currentDictIdAtom) 9 | const [chapterStats, setChapterStats] = useState(null) 10 | 11 | useEffect(() => { 12 | const fetchChapterStats = async () => { 13 | const stats = await getChapterStats(dictID, chapter) 14 | setChapterStats(stats) 15 | } 16 | 17 | if (isStartLoad && !chapterStats) { 18 | fetchChapterStats() 19 | } 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | }, [dictID, chapter, isStartLoad]) 22 | 23 | return chapterStats 24 | } 25 | 26 | interface IChapterStats { 27 | exerciseCount: number 28 | avgWrongCount: number 29 | } 30 | 31 | async function getChapterStats(dict: string, chapter: number | null): Promise { 32 | const records: IChapterRecord[] = await db.chapterRecords.where({ dict, chapter }).toArray() 33 | 34 | const exerciseCount = records.length 35 | const totalWrongCount = records.reduce((total, { wrongCount }) => total + (wrongCount || 0), 0) 36 | const avgWrongCount = exerciseCount > 0 ? totalWrongCount / exerciseCount : 0 37 | 38 | return { exerciseCount, avgWrongCount } 39 | } 40 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], 3 | darkMode: 'class', 4 | theme: { 5 | extend: { 6 | transitionDuration: { 7 | 0: '0ms', 8 | }, 9 | padding: { 10 | 0.8: '0.2rem', 11 | }, 12 | width: { 13 | 3.5: '0.875rem', 14 | 5.5: '1.375rem', 15 | 15: '3.75rem', 16 | 18: '4.5rem', 17 | 70: '17.5rem', 18 | 75: '18.75rem', 19 | 84: '21rem', 20 | 85: '21.25rem', 21 | 100: '25rem', 22 | 116: '29rem', 23 | 150: '37.5rem', 24 | 160: '40rem', 25 | 200: '50rem', 26 | }, 27 | height: { 28 | 3.5: '0.875rem', 29 | 5.5: '1.375rem', 30 | 15: '3.75rem', 31 | 18: '4.5rem', 32 | 22: '5.5rem', 33 | 112: '28rem', 34 | 120: '30rem', 35 | 152: '38rem', 36 | }, 37 | borderWidth: { 38 | 3: '3px', 39 | }, 40 | screens: { 41 | sm: '640px', 42 | md: '768px', 43 | lg: '1024px', 44 | xl: '1280px', 45 | '2xl': '1536px', 46 | dic3: '1100px', 47 | dic4: '1440px', 48 | }, 49 | }, 50 | }, 51 | variants: { 52 | extend: { 53 | visibility: ['hover', 'group-hover'], 54 | textOpacity: ['dark'], 55 | backgroundOpacity: ['dark'], 56 | }, 57 | }, 58 | plugins: [require('@headlessui/tailwindcss'), require('@tailwindcss/forms')], 59 | } 60 | -------------------------------------------------------------------------------- /src/hooks/useKeySounds.ts: -------------------------------------------------------------------------------- 1 | import { SOUND_URL_PREFIX } from '@/resources/soundResource' 2 | import { keySoundsConfigAtom, hintSoundsConfigAtom } from '@/store' 3 | import noop from '@/utils/noop' 4 | import { useAtomValue } from 'jotai' 5 | import useSound from 'use-sound' 6 | 7 | export type PlayFunction = ReturnType[0] 8 | 9 | export default function useKeySound(): [PlayFunction, PlayFunction, PlayFunction] { 10 | const { isOpen: isKeyOpen, isOpenClickSound, volume: keyVolume, resource: keyResource } = useAtomValue(keySoundsConfigAtom) 11 | const { 12 | isOpen: isHintOpen, 13 | isOpenWrongSound, 14 | isOpenCorrectSound, 15 | volume: hintVolume, 16 | wrongResource, 17 | correctResource, 18 | } = useAtomValue(hintSoundsConfigAtom) 19 | 20 | const [playClickSound] = useSound(`${SOUND_URL_PREFIX}${keyResource.filename}`, { 21 | volume: keyVolume, 22 | interrupt: true, 23 | }) 24 | const [playWrongSound] = useSound(`${SOUND_URL_PREFIX}${wrongResource.filename}`, { 25 | volume: hintVolume, 26 | interrupt: true, 27 | }) 28 | const [playCorrectSound] = useSound(`${SOUND_URL_PREFIX}${correctResource.filename}`, { 29 | volume: hintVolume, 30 | interrupt: true, 31 | }) 32 | 33 | // todo: add volume control 34 | 35 | return [ 36 | isKeyOpen && isOpenClickSound ? playClickSound : noop, 37 | isHintOpen && isOpenWrongSound ? playWrongSound : noop, 38 | isHintOpen && isOpenCorrectSound ? playCorrectSound : noop, 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/Gallery/ChapterGroup.tsx: -------------------------------------------------------------------------------- 1 | import ChapterButton from './ChapterButton' 2 | import { CHAPTER_LENGTH } from '@/constants' 3 | import { currentChapterAtom, currentDictInfoAtom } from '@/store' 4 | import range from '@/utils/range' 5 | import { useAtom, useAtomValue } from 'jotai' 6 | import React from 'react' 7 | 8 | const ChapterGroup: React.FC = ({ totalWords }) => { 9 | const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom) 10 | const { id: dictID, chapterCount } = useAtomValue(currentDictInfoAtom) 11 | 12 | return ( 13 |
14 | {range(0, chapterCount, 1).map((index) => 15 | index + 1 === chapterCount ? ( 16 | setCurrentChapter(index)} 22 | /> 23 | ) : ( 24 | setCurrentChapter(index)} 30 | /> 31 | ), 32 | )} 33 |
34 | ) 35 | } 36 | 37 | export default ChapterGroup 38 | 39 | export type ChapterGroupProps = { 40 | totalWords: number 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/Typing/components/Progress/index.tsx: -------------------------------------------------------------------------------- 1 | import { TypingContext } from '../../store' 2 | import { useContext, useEffect, useState } from 'react' 3 | 4 | export default function Progress() { 5 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 6 | const { state } = useContext(TypingContext)! 7 | const [progress, setProgress] = useState(0) 8 | const [phase, setPhase] = useState(0) 9 | 10 | const colorSwitcher: { [key: number]: string } = { 11 | 0: 'bg-indigo-200 dark:bg-indigo-300', 12 | 1: 'bg-indigo-300 dark:bg-indigo-400', 13 | 2: 'bg-indigo-400 dark:bg-indigo-500', 14 | } 15 | 16 | useEffect(() => { 17 | const newProgress = Math.floor((state.chapterData.index / state.chapterData.words.length) * 100) 18 | setProgress(newProgress) 19 | const colorPhase = Math.floor(newProgress / 33.4) 20 | setPhase(colorPhase) 21 | }, [state.chapterData.index, state.chapterData.words.length]) 22 | 23 | return ( 24 |
25 |
26 |
32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/InputHandler/index.tsx: -------------------------------------------------------------------------------- 1 | import KeyEventHandler from '../KeyEventHandler' 2 | import TextAreaHandler from '../TextAreaHandler' 3 | import { currentDictInfoAtom } from '@/store' 4 | import { useAtomValue } from 'jotai' 5 | import { FormEvent, useMemo } from 'react' 6 | 7 | export default function InputHandler({ updateInput }: { updateInput: (updateObj: WordUpdateAction) => void }) { 8 | const dictInfo = useAtomValue(currentDictInfoAtom) 9 | 10 | const handler = useMemo(() => { 11 | switch (dictInfo.language) { 12 | case 'en': 13 | return 14 | case 'de': 15 | return 16 | case 'romaji': 17 | return 18 | case 'code': 19 | return 20 | default: 21 | return 22 | } 23 | }, [dictInfo.language, updateInput]) 24 | 25 | return <>{handler} 26 | } 27 | export type WordUpdateAction = WordAddAction | WordDeleteAction | WordCompositionAction 28 | 29 | export type WordAddAction = { 30 | type: 'add' 31 | value: string 32 | event: FormEvent | KeyboardEvent 33 | } 34 | 35 | export type WordDeleteAction = { 36 | type: 'delete' 37 | length: number 38 | } 39 | 40 | // composition api is not ready yet 41 | export type WordCompositionAction = { 42 | type: 'composition' 43 | value: string 44 | } 45 | -------------------------------------------------------------------------------- /public/dicts/python-class.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "__call__(self, args, kwargs)", 4 | "trans": [ 5 | "该方法的功能类似于在类中重载 () 运算符,使得类实例对象可以像调用普通函数那样,以“对象名()”的形式使用" 6 | ] 7 | }, 8 | { 9 | "name": "__cmp__(self, other)", 10 | "trans": [ 11 | "对象比较" 12 | ] 13 | }, 14 | { 15 | "name": "__del__(self)", 16 | "trans": [ 17 | "析构方法, 删除一个对象" 18 | ] 19 | }, 20 | { 21 | "name": "__delattr__(self, name)", 22 | "trans": [ 23 | "用于删除对象的属性" 24 | ] 25 | }, 26 | { 27 | "name": "__getattr__(self, name)", 28 | "trans": [ 29 | "内置方法,当使用点号获取实例属性时,如果属性不存在就自动调用__getattr__方法" 30 | ] 31 | }, 32 | { 33 | "name": "__getattribute__(self, name)", 34 | "trans": [ 35 | "属性访问拦截器" 36 | ] 37 | }, 38 | { 39 | "name": "__index__(self)", 40 | "trans": [ 41 | "对象被作为索引使用的时候" 42 | ] 43 | }, 44 | { 45 | "name": "__init__(self, args)", 46 | "trans": [ 47 | "构造函数" 48 | ] 49 | }, 50 | { 51 | "name": "__new__(cls)", 52 | "trans": [ 53 | "负责创建类实例的静态方法" 54 | ] 55 | }, 56 | { 57 | "name": "__nonzero__(self)", 58 | "trans": [ 59 | "通常在用类进行判断和将类转换成布尔值时调用" 60 | ] 61 | }, 62 | { 63 | "name": "__repr__(self)", 64 | "trans": [ 65 | "转化为供解释器读取的形式" 66 | ] 67 | }, 68 | { 69 | "name": "__setattr__(self, name, attr)", 70 | "trans": [ 71 | "用于设置属性值,该属性不一定是存在的" 72 | ] 73 | }, 74 | { 75 | "name": "__str__(self)", 76 | "trans": [ 77 | "用于将值转化为适于人阅读的形式" 78 | ] 79 | } 80 | ] -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").ESLint.ConfigData} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | env: { 6 | es2021: true, 7 | }, 8 | extends: ['prettier'], 9 | ignorePatterns: ['build'], 10 | overrides: [ 11 | { 12 | files: ['scripts/*.cjs', '.eslintrc.cjs'], 13 | env: { node: true }, 14 | extends: ['eslint:recommended'], 15 | parser: 'espree', 16 | parserOptions: { sourceType: 'script' }, 17 | }, 18 | { 19 | files: ['vite.config.ts'], 20 | env: { node: true }, 21 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 22 | parser: '@typescript-eslint/parser', 23 | parserOptions: { 24 | ecmaVersion: 'latest', 25 | sourceType: 'module', 26 | }, 27 | plugins: ['@typescript-eslint'], 28 | }, 29 | { 30 | files: ['src/**/*.ts', 'src/**/*.tsx', 'test/**/*.ts', 'test/**/*.tsx'], 31 | env: { browser: true }, 32 | extends: [ 33 | 'eslint:recommended', 34 | 'plugin:react/recommended', 35 | 'plugin:react-hooks/recommended', 36 | 'plugin:react/jsx-runtime', 37 | 'plugin:@typescript-eslint/recommended', 38 | ], 39 | parser: '@typescript-eslint/parser', 40 | parserOptions: { 41 | ecmaVersion: 'latest', 42 | sourceType: 'module', 43 | }, 44 | plugins: ['react', '@typescript-eslint'], 45 | settings: { 46 | react: { 47 | version: 'detect', 48 | }, 49 | }, 50 | }, 51 | ], 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function VolumeHighIcon(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default VolumeHighIcon 13 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | // import GalleryPage from './pages/Gallery' 3 | import GalleryPage from './pages/Gallery-N' 4 | import TypingPage from './pages/Typing' 5 | import { isOpenDarkModeAtom } from '@/store' 6 | import { useAtomValue } from 'jotai' 7 | import mixpanel from 'mixpanel-browser' 8 | import process from 'process' 9 | import React, { useEffect } from 'react' 10 | import 'react-app-polyfill/stable' 11 | import { createRoot } from 'react-dom/client' 12 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' 13 | 14 | if (process.env.NODE_ENV === 'production') { 15 | // for prod 16 | mixpanel.init('bdc492847e9340eeebd53cc35f321691') 17 | } else { 18 | // for dev 19 | mixpanel.init('5474177127e4767124c123b2d7846e2a', { debug: true }) 20 | } 21 | 22 | const container = document.getElementById('root') 23 | 24 | function Root() { 25 | const darkMode = useAtomValue(isOpenDarkModeAtom) 26 | useEffect(() => { 27 | darkMode ? document.documentElement.classList.add('dark') : document.documentElement.classList.remove('dark') 28 | }, [darkMode]) 29 | 30 | return ( 31 | 32 | 33 | 34 | } /> 35 | } /> 36 | } /> 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | container && createRoot(container).render() 44 | -------------------------------------------------------------------------------- /src/pages/Typing/components/ResultScreen/ConclusionBar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { ElementType, SVGAttributes } from 'react' 3 | import IconExclamationTriangle from '~icons/heroicons/exclamation-triangle-solid' 4 | import IconHandThumbUp from '~icons/heroicons/hand-thumb-up-solid' 5 | import IconHeart from '~icons/heroicons/heart-solid' 6 | 7 | type IconMapper = { 8 | icon: ElementType> 9 | className: string 10 | text: (mistakeCount: number) => string 11 | } 12 | 13 | const ICON_MAPPER: IconMapper[] = [ 14 | { 15 | icon: IconHeart, 16 | className: 'text-indigo-600', 17 | text: (mistakeCount: number) => `表现不错!只错了 ${mistakeCount} 个单词`, 18 | }, 19 | { 20 | icon: IconHandThumbUp, 21 | className: 'text-indigo-600', 22 | text: () => '有些小问题哦,下一次可以做得更好!', 23 | }, 24 | { 25 | icon: IconExclamationTriangle, 26 | className: 'text-indigo-600', 27 | text: () => '错误太多,再来一次如何?', 28 | }, 29 | ] 30 | 31 | const ConclusionBar = ({ mistakeLevel, mistakeCount }: ConclusionBarProps) => { 32 | const { icon: Icon, className, text } = ICON_MAPPER[mistakeLevel] 33 | 34 | return ( 35 |
36 | 37 | 38 | {text(mistakeCount)} 39 | 40 |
41 | ) 42 | } 43 | 44 | export type ConclusionBarProps = { 45 | mistakeLevel: number 46 | mistakeCount: number 47 | } 48 | 49 | export default ConclusionBar 50 | -------------------------------------------------------------------------------- /public/dicts/Child_c++.json: -------------------------------------------------------------------------------- 1 | [{"name": "include", "trans": ["包含"]}, {"name": "iostream", "trans": ["输入输出头文件"]}, {"name": "using", "trans": ["使用"]}, {"name": "namespace", "trans": ["命名空间"]}, {"name": "std", "trans": ["标准的缩写"]}, {"name": "main", "trans": ["主函数"]}, {"name": "int", "trans": ["声明整型变量或函数"]}, {"name": "char", "trans": ["声明字符型变量或函数"]}, {"name": "double", "trans": ["声明双精度变量或函数"]}, {"name": "float", "trans": ["声明浮点型变量或函数"]}, {"name": "long", "trans": ["声明长整型变量或函数"]}, {"name": "short", "trans": ["声明短整型变量或函数"]}, {"name": "signed", "trans": ["声明有符号类型变量或函数"]}, {"name": "unsigned", "trans": ["声明无符号类型变量或函数"]}, {"name": "enum", "trans": ["声明枚举类型"]}, {"name": "struct", "trans": ["声明结构体变量或函数"]}, {"name": "union", "trans": ["声明共用体(联合)数据类型"]}, {"name": "cin", "trans": ["输入命令"]}, {"name": "cout", "trans": ["输出命令"]}, {"name": "endl", "trans": ["end line的缩写,换行"]}, {"name": "scanf", "trans": ["输入命令"]}, {"name": "printf", "trans": ["输出命令"]}, {"name": "void", "trans": ["声明函数无返回值或无参数,声明无类型指针"]}, {"name": "for", "trans": ["一种循环语句"]}, {"name": "do", "trans": ["循环语句的循环体"]}, {"name": "while", "trans": ["循环语句的循环条件"]}, {"name": "break", "trans": ["跳出当前循环"]}, {"name": "continue", "trans": ["结束当前循环,开始下一轮循环"]}, {"name": "if", "trans": ["条件语句"]}, {"name": "else", "trans": ["条件语句否定分支(与 if 连用)"]}, {"name": "goto", "trans": ["无条件跳转语句"]}, {"name": "switch", "trans": ["用于开关语句"]}, {"name": "case", "trans": ["开关语句分支"]}, {"name": "default", "trans": ["开关语句中的“其他”分支"]}, {"name": "return", "trans": ["子程序返回语句(可以带参数,也看不带参数)"]}, {"name": "static", "trans": ["声明静态变量"]}, {"name": "const", "trans": ["声明只读变量 (*注意是变量*)"]}, {"name": "sizeof", "trans": ["计算数据类型长度"]}, {"name": "typedef", "trans": ["用以给数据类型取别名(当然还有其他作用)"]}] -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/WordSound/index.tsx: -------------------------------------------------------------------------------- 1 | import { SoundIcon, SoundIconProps } from '../SoundIcon' 2 | import styles from './index.module.css' 3 | import Tooltip from '@/components/Tooltip' 4 | import usePronunciationSound from '@/hooks/usePronunciation' 5 | import { pronunciationIsOpenAtom } from '@/store' 6 | import { useAtomValue } from 'jotai' 7 | import { useEffect, useCallback } from 'react' 8 | import { useHotkeys } from 'react-hotkeys-hook' 9 | 10 | const WordSound = ({ word, inputWord, ...rest }: WordSoundProps) => { 11 | const { play, stop, isPlaying } = usePronunciationSound(word) 12 | const pronunciationIsOpen = useAtomValue(pronunciationIsOpenAtom) 13 | 14 | useHotkeys( 15 | 'ctrl+j', 16 | () => { 17 | stop() 18 | play() 19 | }, 20 | [play, stop], 21 | { enableOnFormTags: true, preventDefault: true }, 22 | ) 23 | 24 | useEffect(() => { 25 | if (inputWord.length === 0) { 26 | stop() 27 | play() 28 | } 29 | }, [play, inputWord, stop]) 30 | 31 | useEffect(() => { 32 | return stop 33 | }, [word, stop]) 34 | 35 | const handleClickSoundIcon = useCallback(() => { 36 | stop() 37 | play() 38 | }, [play, stop]) 39 | 40 | return ( 41 | <> 42 | {pronunciationIsOpen && ( 43 | 44 | 45 | 46 | )} 47 | 48 | ) 49 | } 50 | 51 | export type WordSoundProps = { 52 | word: string 53 | inputWord: string 54 | } & SoundIconProps 55 | 56 | export default WordSound 57 | -------------------------------------------------------------------------------- /public/dicts/js-global.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "eval()", 4 | "trans": [ 5 | "eval() 函数会将传入的字符串当做 JavaScript 代码进行执行。" 6 | ] 7 | }, 8 | { 9 | "name": "isFinite()", 10 | "trans": [ 11 | "该全局 isFinite() 函数用来判断被传入的参数值是否为一个有限数值(finite number)。在必要情况下,参数会首先转为一个数值。" 12 | ] 13 | }, 14 | { 15 | "name": "isNaN()", 16 | "trans": [ 17 | "isNaN() 函数用来确定一个值是否为NaN 。注:isNaN函数内包含一些非常有趣的规则;你也可以使用 ECMAScript 2015 中定义的 Number.isNaN() 来判断。" 18 | ] 19 | }, 20 | { 21 | "name": "parseFloat()", 22 | "trans": [ 23 | "parseFloat() 函数解析一个参数(必要时先转换为字符串)并返回一个浮点数。" 24 | ] 25 | }, 26 | { 27 | "name": "parseInt()", 28 | "trans": [ 29 | "parseInt(string, radix) 将一个字符串 string 转换为 radix 进制的整数, radix 为介于2-36之间的数。" 30 | ] 31 | }, 32 | { 33 | "name": "decodeURI()", 34 | "trans": [ 35 | "decodeURI() 函数解码一个由encodeURI 先前创建的统一资源标识符(URI)或类似的例程。" 36 | ] 37 | }, 38 | { 39 | "name": "decodeURIComponent()", 40 | "trans": [ 41 | "decodeURIComponent() 方法用于解码由 encodeURIComponent 方法或者其它类似方法编码的部分统一资源标识符(URI)。" 42 | ] 43 | }, 44 | { 45 | "name": "encodeURI()", 46 | "trans": [ 47 | "encodeURI() 函数通过将特定字符的每个实例替换为一个、两个、三或四转义序列来对统一资源标识符 (URI) 进行编码 (该字符的 UTF-8 编码仅为四转义序列)由两个 \"代理\" 字符组成)。" 48 | ] 49 | }, 50 | { 51 | "name": "encodeURIComponent()", 52 | "trans": [ 53 | "encodeURIComponent()是对统一资源标识符(URI)的组成部分进行编码的方法。它使用一到四个转义序列来表示字符串中的每个字符的UTF-8编码(只有由两个Unicode代理区字符组成的字符才用四个转义字符编码)。" 54 | ] 55 | } 56 | ] -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { promises as fs } from 'fs' 3 | import { getLastCommit } from 'git-last-commit' 4 | import jotaiDebugLabel from 'jotai/babel/plugin-debug-label' 5 | import jotaiReactRefresh from 'jotai/babel/plugin-react-refresh' 6 | import path from 'node:path' 7 | import { visualizer } from 'rollup-plugin-visualizer' 8 | import Icons from 'unplugin-icons/vite' 9 | import { defineConfig, type PluginOption } from 'vite' 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig(async () => { 13 | const latestCommitHash = await new Promise((resolve) => { 14 | return getLastCommit((err, commit) => (err ? 'unknown' : resolve(commit.shortHash))) 15 | }) 16 | return { 17 | plugins: [ 18 | react({ babel: { plugins: [jotaiDebugLabel, jotaiReactRefresh] } }), 19 | visualizer() as PluginOption, 20 | Icons({ 21 | compiler: 'jsx', 22 | jsx: 'react', 23 | customCollections: { 24 | 'my-icons': { 25 | xiaohongshu: () => fs.readFile('./src/assets/xiaohongshu.svg', 'utf-8'), 26 | }, 27 | }, 28 | }), 29 | ], 30 | build: { 31 | minify: true, 32 | outDir: 'build', 33 | sourcemap: true, 34 | }, 35 | define: { 36 | REACT_APP_DEPLOY_ENV: JSON.stringify(process.env.REACT_APP_DEPLOY_ENV), 37 | LATEST_COMMIT_HASH: JSON.stringify(latestCommitHash + (process.env.NODE_ENV === 'production' ? '' : ' (dev)')), 38 | }, 39 | resolve: { 40 | alias: { 41 | '@': path.resolve(__dirname, 'src'), 42 | }, 43 | }, 44 | css: { 45 | modules: { 46 | localsConvention: 'camelCaseOnly', 47 | }, 48 | }, 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /public/dicts/Node-path.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "name": "path.basename()", "trans": ["path.basename() 方法会返回参数 path 的最后一部分,类似于 Unix 的 basename 命令。"] }, 3 | { "name": "path.delimiter", "trans": ["提供平台特定的路径定界符:'; 用于 Windows', ': 用于 POSIX' "] }, 4 | { "name": "path.dirname()", "trans": ["path.dirname() 方法会返回 path 的目录名,类似于 Unix 的 dirname 命令。 尾部的目录分隔符会被忽略"] }, 5 | { "name": "path.extname()", "trans": ["path.extname() 方法会返回 path 的扩展名,即 path 的最后一部分中从最后一次出现 .(句点)字符直到字符串结束。 如果在 path 的最后一部分中没有 .,或者如果 path 的基本名称(参见 path.basename())除了第一个字符以外没有 .,则返回空字符串。"] }, 6 | { "name": "path.format()", "trans": ["path.format() 方法从对象返回路径字符串。 与 path.parse() 相反。"] }, 7 | { "name": "path.isAbsolute()", "trans": ["path.isAbsolute() 方法检测 path 是否为绝对路径。"] }, 8 | { "name": "path.join()", "trans": ["path.join() 方法会将所有给定的 path 片段连接到一起(使用平台特定的分隔符作为定界符),然后规范化生成的路径。长度为零的 path 片段会被忽略。 如果连接后的路径字符串为长度为零的字符串,则返回 '.',表示当前工作目录。 "] }, 9 | { "name": "path.normalize()", "trans": ["path.normalize() 方法规范化给定的 path,解析 '..' 和 '.' 片段。 "] }, 10 | { "name": "path.parse()", "trans": ["path.parse() 方法会返回一个对象,其属性表示 path 的有效元素。 尾部的目录分隔符会被忽略。"] }, 11 | { "name": "path.posix", "trans": ["path.posix 属性提供对 path 方法的 POSIX 特定实现的访问。"] }, 12 | { "name": "path.relative()", "trans": ["path.relative() 方法根据当前工作目录返回 from 到 to 的相对路径。 如果 from 和 to 各自解析到相同的路径(分别调用 path.resolve() 之后),则返回零长度的字符串。"] }, 13 | { "name": "path.resolve()", "trans": ["path.resolve() 方法会将路径或路径片段的序列解析为绝对路径。 "] }, 14 | { "name": "path.sep", "trans": ["提供平台特定的路径片段分隔符:Windows 上是 /\\; POSIX 上是 /。 "] }, 15 | { "name": "path.toNamespacedPath()", "trans": ["仅在 Windows 系统上,返回给定 path 的等效名称空间前缀路径。 如果 path 不是字符串,则将返回 path 而不进行修改。 "] }, 16 | { "name": "path.win32", "trans": ["path.win32 属性提供对特定于 Windows 的 path 方法的实现的访问。"] } 17 | ] 18 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/hooks/useChapterStats.ts: -------------------------------------------------------------------------------- 1 | import { toFixedNumber } from '@/utils' 2 | import { db } from '@/utils/db' 3 | import { IChapterRecord } from '@/utils/db/record' 4 | import { useEffect, useState } from 'react' 5 | 6 | export function useChapterStats(chapter: number, dictID: string, isStartLoad: boolean) { 7 | const [chapterStats, setChapterStats] = useState(null) 8 | 9 | useEffect(() => { 10 | const fetchChapterStats = async () => { 11 | const stats = await getChapterStats(dictID, chapter) 12 | setChapterStats(stats) 13 | } 14 | 15 | if (isStartLoad && !chapterStats) { 16 | fetchChapterStats() 17 | } 18 | // eslint-disable-next-line react-hooks/exhaustive-deps 19 | }, [dictID, chapter, isStartLoad]) 20 | 21 | return chapterStats 22 | } 23 | 24 | interface IChapterStats { 25 | exerciseCount: number 26 | avgWrongWordCount: number 27 | avgWrongInputCount: number 28 | } 29 | 30 | async function getChapterStats(dict: string, chapter: number | null): Promise { 31 | const records: IChapterRecord[] = await db.chapterRecords.where({ dict, chapter }).toArray() 32 | 33 | const exerciseCount = records.length 34 | const totalWrongWordCount = records.reduce( 35 | (total, { wordNumber, correctWordIndexes }) => total + (wordNumber - correctWordIndexes.length), 36 | 0, 37 | ) 38 | const avgWrongWordCount = exerciseCount > 0 ? toFixedNumber(totalWrongWordCount / exerciseCount, 2) : 0 39 | 40 | const totalWrongInputCount = records.reduce((total, { wrongCount }) => total + (wrongCount ?? 0), 0) 41 | const avgWrongInputCount = exerciseCount > 0 ? toFixedNumber(totalWrongInputCount / exerciseCount, 2) : 0 42 | 43 | return { exerciseCount, avgWrongWordCount, avgWrongInputCount } 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/Typing/components/ResultScreen/WordChip.tsx: -------------------------------------------------------------------------------- 1 | import usePronunciationSound from '@/hooks/usePronunciation' 2 | import { WordWithIndex } from '@/typings' 3 | import { flip, offset, shift, useFloating, useHover, useInteractions, useRole } from '@floating-ui/react' 4 | import { useCallback, useState } from 'react' 5 | 6 | export default function WordChip({ word }: { word: WordWithIndex }) { 7 | const [showTranslation, setShowTranslation] = useState(false) 8 | const { x, y, strategy, refs, context } = useFloating({ 9 | open: showTranslation, 10 | onOpenChange: setShowTranslation, 11 | middleware: [offset(4), shift(), flip()], 12 | }) 13 | const hover = useHover(context) 14 | const role = useRole(context, { role: 'tooltip' }) 15 | const { getReferenceProps, getFloatingProps } = useInteractions([hover, role]) 16 | const { play, stop } = usePronunciationSound(word.name, false) 17 | 18 | const onClickWord = useCallback(() => { 19 | stop() 20 | play() 21 | }, [play, stop]) 22 | 23 | return ( 24 | <> 25 | 35 | {showTranslation && ( 36 |
47 | {word.trans} 48 |
49 | )} 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/TextAreaHandler/index.tsx: -------------------------------------------------------------------------------- 1 | import { WordUpdateAction } from '../InputHandler' 2 | import { TypingContext } from '@/pages/Typing/store' 3 | import { FormEvent, useCallback, useContext, useEffect, useRef } from 'react' 4 | 5 | export default function TextAreaHandler({ updateInput }: { updateInput: (updateObj: WordUpdateAction) => void }) { 6 | const textareaRef = useRef(null) 7 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 8 | const { state } = useContext(TypingContext)! 9 | 10 | useEffect(() => { 11 | if (!textareaRef.current) return 12 | 13 | if (state.isTyping) { 14 | textareaRef.current.focus() 15 | } else { 16 | textareaRef.current.blur() 17 | } 18 | }, [state.isTyping]) 19 | 20 | const onInput = (e: FormEvent) => { 21 | const nativeEvent = e.nativeEvent as InputEvent 22 | if (!nativeEvent.isComposing && nativeEvent.data !== null) { 23 | updateInput({ type: 'add', value: nativeEvent.data, event: e }) 24 | 25 | if (textareaRef.current) { 26 | textareaRef.current.value = '' 27 | } 28 | } 29 | } 30 | 31 | const onBlur = useCallback(() => { 32 | if (!textareaRef.current) return 33 | 34 | if (state.isTyping) { 35 | textareaRef.current.focus() 36 | } 37 | }, [state.isTyping]) 38 | 39 | return ( 40 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/resources/soundResource.ts: -------------------------------------------------------------------------------- 1 | import { SoundResource, LanguagePronunciationMap } from '@/typings' 2 | 3 | export const SOUND_URL_PREFIX = REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner/sounds/' : './sounds/' 4 | 5 | // will add more sound resource and add config ui in the future 6 | export const keySoundResources: SoundResource[] = [{ key: '1', name: '声音1', filename: 'click.wav' }] 7 | 8 | export const wrongSoundResources: SoundResource[] = [{ key: '1', name: '声音1', filename: 'beep.wav' }] 9 | 10 | export const correctSoundResources: SoundResource[] = [{ key: '1', name: '声音1', filename: 'correct.wav' }] 11 | 12 | export const LANG_PRON_MAP: LanguagePronunciationMap = { 13 | en: { 14 | defaultPronIndex: 0, 15 | pronunciation: [ 16 | { 17 | name: '美音', 18 | pron: 'us', 19 | }, 20 | { 21 | name: '英音', 22 | pron: 'uk', 23 | }, 24 | ], 25 | }, 26 | code: { 27 | defaultPronIndex: 0, 28 | pronunciation: [ 29 | { 30 | name: '美音', 31 | pron: 'us', 32 | }, 33 | { 34 | name: '英音', 35 | pron: 'uk', 36 | }, 37 | ], 38 | }, 39 | de: { 40 | defaultPronIndex: 0, 41 | pronunciation: [ 42 | { 43 | name: '德语', 44 | pron: 'de', 45 | }, 46 | ], 47 | }, 48 | romaji: { 49 | defaultPronIndex: 0, 50 | pronunciation: [ 51 | { 52 | name: '罗马音', 53 | pron: 'romaji', 54 | }, 55 | ], 56 | }, 57 | zh: { 58 | defaultPronIndex: 0, 59 | pronunciation: [ 60 | { 61 | name: '普通话', 62 | pron: 'zh', 63 | }, 64 | ], 65 | }, 66 | ja: { 67 | defaultPronIndex: 0, 68 | pronunciation: [ 69 | { 70 | name: '日语', 71 | pron: 'ja', 72 | }, 73 | ], 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/DictRequest.tsx: -------------------------------------------------------------------------------- 1 | import InfoPanel from '@/components/InfoPanel' 2 | import { useCallback, useState } from 'react' 3 | import IconBook2 from '~icons/tabler/book-2' 4 | 5 | export default function DictRequest() { 6 | const [showPanel, setShowPanel] = useState(false) 7 | 8 | const onOpenPanel = useCallback(() => { 9 | setShowPanel(true) 10 | }, []) 11 | 12 | const onClosePanel = useCallback(() => { 13 | setShowPanel(false) 14 | }, []) 15 | 16 | return ( 17 | <> 18 | {showPanel && ( 19 | 27 |

28 | 如果您有相关的编程基础,可以参考 29 | 35 | 导入词典 36 | 37 | ,给项目贡献新的词典。 38 |
39 |
40 | 如果您没有相关的编程基础,可以将您的字典需求发送邮件到{' '} 41 | 42 | me@kaiyi.cool 43 | 44 | ,或者在网页底部添加我们的用户社群进行反馈。 45 |

46 |
47 |
48 | )} 49 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/components/SoundIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import VolumeHighIcon from './volume-icons/VolumeHieghIcon' 2 | import VolumeIcon from './volume-icons/VolumeIcon' 3 | import VolumeLowIcon from './volume-icons/VolumeLowIcon' 4 | import VolumeMediumIcon from './volume-icons/VolumeMediumIcon' 5 | import React, { MouseEventHandler, useEffect, useState } from 'react' 6 | 7 | const volumeIcons = [VolumeIcon, VolumeLowIcon, VolumeMediumIcon, VolumeHighIcon] 8 | 9 | export const SoundIcon = ({ duration = 500, animated = false, onClick, ...rest }: SoundIconProps) => { 10 | const [animationFrameIndex, setAnimationFrameIndex] = useState(0) 11 | 12 | useEffect(() => { 13 | if (animated) { 14 | const timer = window.setTimeout(() => { 15 | const index = animationFrameIndex < volumeIcons.length - 1 ? animationFrameIndex + 1 : 0 16 | setAnimationFrameIndex(index) 17 | }, duration) 18 | return () => { 19 | clearTimeout(timer) 20 | } 21 | } 22 | // eslint-disable-next-line react-hooks/exhaustive-deps 23 | }, [animated, animationFrameIndex]) 24 | 25 | useEffect(() => { 26 | if (!animated) { 27 | const timer = setTimeout(() => { 28 | setAnimationFrameIndex(0) 29 | }, duration) 30 | return () => clearTimeout(timer) 31 | } 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | }, [animated]) 34 | 35 | const Icon = volumeIcons[animationFrameIndex] 36 | 37 | return ( 38 | 41 | ) 42 | } 43 | 44 | export type SoundIconProps = { 45 | animated?: boolean 46 | duration?: number 47 | onClick?: MouseEventHandler 48 | } & Omit, 'ref'> 49 | 50 | export type SoundIconRef = { 51 | playAnimation(): void 52 | stopAnimation(): void 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/CategoryDicts.tsx: -------------------------------------------------------------------------------- 1 | import DictTagSwitcher from './DictTagSwitcher' 2 | import DictionaryComponent from './DictionaryWithoutCover' 3 | import { GalleryContext } from './index' 4 | import { currentDictInfoAtom } from '@/store' 5 | import { Dictionary } from '@/typings' 6 | import { findCommonValues } from '@/utils' 7 | import { useAtomValue } from 'jotai' 8 | import { useCallback, useContext, useEffect, useMemo, useState } from 'react' 9 | 10 | export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByTag: Record }) { 11 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 12 | const { setState } = useContext(GalleryContext)! 13 | const tagList = useMemo(() => Object.keys(groupedDictsByTag), [groupedDictsByTag]) 14 | const [currentTag, setCurrentTag] = useState(tagList[0]) 15 | const currentDictInfo = useAtomValue(currentDictInfoAtom) 16 | 17 | const onChangeCurrentTag = useCallback((tag: string) => { 18 | setCurrentTag(tag) 19 | }, []) 20 | 21 | const onClickDict = useCallback( 22 | (dict: Dictionary) => { 23 | setState((state) => { 24 | state.chapterListDict = dict 25 | }) 26 | }, 27 | [setState], 28 | ) 29 | 30 | useEffect(() => { 31 | const commonTags = findCommonValues(tagList, currentDictInfo.tags) 32 | if (commonTags.length > 0) { 33 | setCurrentTag(commonTags[0]) 34 | } 35 | }, [currentDictInfo.tags, tagList]) 36 | 37 | return ( 38 |
39 | 40 |
41 | {groupedDictsByTag[currentTag].map((dict) => ( 42 | onClickDict(dict)} /> 43 | ))} 44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/LanguageTabSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { GalleryContext } from '.' 2 | import codeFlag from '@/assets/flags/code.png' 3 | import deFlag from '@/assets/flags/de.png' 4 | import enFlag from '@/assets/flags/en.png' 5 | import jpFlag from '@/assets/flags/ja.png' 6 | import { LanguageCategoryType } from '@/typings' 7 | import { RadioGroup } from '@headlessui/react' 8 | import { useCallback, useContext } from 'react' 9 | 10 | export type LanguageTabOption = { 11 | id: LanguageCategoryType 12 | name: string 13 | flag: string 14 | } 15 | 16 | const options: LanguageTabOption[] = [ 17 | { id: 'en', name: '英语', flag: enFlag }, 18 | { id: 'ja', name: '日语', flag: jpFlag }, 19 | { id: 'de', name: '德语', flag: deFlag }, 20 | { id: 'code', name: 'Code', flag: codeFlag }, 21 | ] 22 | 23 | export function LanguageTabSwitcher() { 24 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 25 | const { state, setState } = useContext(GalleryContext)! 26 | 27 | const onChangeTab = useCallback( 28 | (tab: string) => { 29 | setState((draft) => { 30 | draft.currentLanguageTab = tab as LanguageCategoryType 31 | }) 32 | }, 33 | [setState], 34 | ) 35 | 36 | return ( 37 | 38 |
39 | {options.map((option) => ( 40 | 41 | {({ checked }) => ( 42 |
43 | 44 |

{option.name}

45 |
46 | )} 47 |
48 | ))} 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/Typing/components/WordPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { TypingContext, TypingStateActionType } from '../../store' 2 | import Phonetic from './components/Phonetic' 3 | import Translation from './components/Translation' 4 | import { default as WordComponent } from './components/Word' 5 | import { phoneticConfigAtom } from '@/store' 6 | import { useAtomValue } from 'jotai' 7 | import { useCallback, useContext, useState } from 'react' 8 | 9 | export default function WordPanel() { 10 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 11 | const { state, dispatch } = useContext(TypingContext)! 12 | const phoneticConfig = useAtomValue(phoneticConfigAtom) 13 | const [wordComponentKey, setWordComponentKey] = useState(0) 14 | 15 | const currentWord = state.chapterData.words[state.chapterData.index] 16 | 17 | const reloadCurrentWordComponent = useCallback(() => { 18 | setWordComponentKey((old) => old + 1) 19 | }, []) 20 | 21 | const onFinish = useCallback(() => { 22 | if (state.chapterData.index < state.chapterData.words.length - 1 || state.isLoopSingleWord) { 23 | // 用户完成当前单词 24 | if (state.isLoopSingleWord) { 25 | dispatch({ type: TypingStateActionType.LOOP_CURRENT_WORD }) 26 | reloadCurrentWordComponent() 27 | } else { 28 | dispatch({ type: TypingStateActionType.NEXT_WORD }) 29 | } 30 | } else { 31 | // 用户完成当前章节 32 | dispatch({ type: TypingStateActionType.FINISH_CHAPTER }) 33 | } 34 | }, [state, dispatch, reloadCurrentWordComponent]) 35 | 36 | return ( 37 |
38 | {currentWord && ( 39 | <> 40 | 41 | {phoneticConfig.isOpen && } 42 | {state.isTransVisible && } 43 | 44 | )} 45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Single Page Apps for GitHub Pages 6 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/dicts/js-promise.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Promise.all()", 4 | "trans": [ 5 | "Promise.all(iterable) 方法返回一个 Promise 实例,此实例在 iterable 参数内所有的 promise 都“完成(resolved)”或参数中不包含 promise 时回调完成(resolve);如果参数中 promise 有一个失败(rejected),此实例回调失败(reject),失败原因的是第一个失败 promise 的结果。" 6 | ] 7 | }, 8 | { 9 | "name": "Promise.allSettled()", 10 | "trans": [ 11 | "该Promise.allSettled()方法返回一个在所有给定的promise已被决议或被拒绝后决议的promise,并带有一个对象数组,每个对象表示对应的promise结果。" 12 | ] 13 | }, 14 | { 15 | "name": "Promise.any()", 16 | "trans": [ 17 | "Promise.any() 接收一个Promise可迭代对象,只要其中的一个 promise 完成,就返回那个已经有完成值的 promise 。如果可迭代对象中没有一个 promise 完成(即所有的 promises 都失败/拒绝),就返回一个拒绝的 promise,返回值还有待商榷:无非是拒绝原因数组或AggregateError类型的实例,它是 Error 的一个子类,用于把单一的错误集合在一起。本质上,这个方法和Promise.all()是相反的。" 18 | ] 19 | }, 20 | { 21 | "name": "Promise.prototype.catch()", 22 | "trans": [ 23 | "catch() 方法返回一个Promise,并且处理拒绝的情况。它的行为与调用Promise.prototype.then(undefined, onRejected) 相同。 (事实上, calling obj.catch(onRejected) 内部calls obj.then(undefined, onRejected))." 24 | ] 25 | }, 26 | { 27 | "name": "Promise.prototype.finally()", 28 | "trans": [ 29 | "返回一个设置了 finally 回调函数的Promise对象。" 30 | ] 31 | }, 32 | { 33 | "name": "Promise.prototype.then()", 34 | "trans": [ 35 | "then() 方法返回一个 Promise。它最多需要有两个参数:Promise 的成功和失败情况的回调函数。" 36 | ] 37 | }, 38 | { 39 | "name": "Promise.race()", 40 | "trans": [ 41 | "Promise.race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。" 42 | ] 43 | }, 44 | { 45 | "name": "Promise.reject()", 46 | "trans": [ 47 | "Promise.reject()方法返回一个带有拒绝原因的Promise对象。" 48 | ] 49 | }, 50 | { 51 | "name": "Promise.resolve()", 52 | "trans": [ 53 | "Promise.resolve()方法返回一个以给定值解析后的Promise 对象。" 54 | ] 55 | } 56 | ] -------------------------------------------------------------------------------- /src/pages/Typing/components/ResultScreen/RemarkRing.tsx: -------------------------------------------------------------------------------- 1 | import clamp from '@/utils/clamp' 2 | import classNames from 'classnames' 3 | import { useMemo } from 'react' 4 | 5 | export type RemarkRingProps = { 6 | remark: string 7 | caption: string 8 | /** 9 | * `null` if the percentage is not appliable. 10 | * Otherwise, this is an integer between 0 and 100. 11 | */ 12 | percentage?: number | null 13 | /** 14 | * Default to 7 rem. 15 | */ 16 | size?: number 17 | } 18 | 19 | const rootFontSize = parseInt(window.getComputedStyle(document.documentElement).getPropertyValue('font-size')) 20 | 21 | export default function RemarkRing({ remark, caption, percentage = null, size = 7 }: RemarkRingProps) { 22 | const clipPath = useMemo((): string | undefined => { 23 | if (percentage === null) { 24 | return undefined 25 | } 26 | const clamped = clamp(percentage, 0, 100) 27 | if (clamped === 100) { 28 | return undefined 29 | } 30 | const alpha = Math.PI * 2 * (clamped / 100) 31 | const r = (rootFontSize * size) / 2 32 | const path = `M ${r},0 A ${r},${r} 0 ${clamped > 50 ? 1 : 0},1 ${r + Math.sin(alpha) * r},${r + -Math.cos(alpha) * r} L ${r},${r} Z` 33 | return `path("${path}")` 34 | }, [percentage, size]) 35 | return ( 36 |
45 | {percentage !== null && ( 46 |
51 | )} 52 | {remark} 53 | {caption} 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/Typing/components/ShareButton/index.tsx: -------------------------------------------------------------------------------- 1 | import SharePicDialog from './SharePicDialog' 2 | import { recordShareAction } from '@/utils' 3 | import { flip, offset, shift, useFloating, useHover, useInteractions, useRole } from '@floating-ui/react' 4 | import { useCallback, useMemo, useState } from 'react' 5 | import IconShare2 from '~icons/tabler/share-2' 6 | 7 | export default function ShareButton() { 8 | const [isShowSharePanel, setIsShowSharePanel] = useState(false) 9 | 10 | const [showTranslation, setShowTranslation] = useState(true) 11 | const { x, y, strategy, refs, context } = useFloating({ 12 | open: showTranslation, 13 | onOpenChange: setShowTranslation, 14 | middleware: [offset(11), shift(), flip()], 15 | placement: 'top-start', 16 | }) 17 | const hover = useHover(context) 18 | const role = useRole(context, { role: 'tooltip' }) 19 | const { getReferenceProps, getFloatingProps } = useInteractions([hover, role]) 20 | 21 | const randomChoose = useMemo( 22 | () => ({ 23 | picRandom: Math.random(), 24 | promoteRandom: Math.random(), 25 | }), 26 | [], 27 | ) 28 | 29 | const onClickShare = useCallback(() => { 30 | recordShareAction('open') 31 | setIsShowSharePanel(true) 32 | }, []) 33 | 34 | return ( 35 | <> 36 | {isShowSharePanel && } 37 | 38 | 48 | 49 | {showTranslation && ( 50 |
61 | ✨ 分享你的成绩给朋友 62 |
63 | )} 64 | 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/pages/Gallery/DictionaryCard.tsx: -------------------------------------------------------------------------------- 1 | import { currentChapterAtom, currentDictIdAtom } from '@/store' 2 | import { Dictionary } from '@/typings' 3 | import { useAtom, useSetAtom } from 'jotai' 4 | import React, { useEffect, useRef } from 'react' 5 | import IconCheckCircle from '~icons/heroicons/check-circle-solid' 6 | 7 | const DictionaryCard: React.FC = ({ dictionary }) => { 8 | const buttonRef = useRef(null) 9 | const [currentDictId, setCurrentDictId] = useAtom(currentDictIdAtom) 10 | const setCurrentChapter = useSetAtom(currentChapterAtom) 11 | 12 | useEffect(() => { 13 | if (currentDictId === dictionary.id && buttonRef.current !== null) { 14 | const button = buttonRef.current 15 | const container = button.parentElement?.parentElement?.parentElement 16 | const halfHeight = button.getBoundingClientRect().height / 2 17 | container?.scrollTo({ top: Math.max(button.offsetTop - container.offsetTop - halfHeight, 0), behavior: 'smooth' }) 18 | } 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, []) 21 | return ( 22 | 39 | ) 40 | } 41 | 42 | DictionaryCard.displayName = 'DictionaryCard' 43 | 44 | export type DictionaryCardProps = { 45 | dictionary: Dictionary 46 | } 47 | 48 | export default DictionaryCard 49 | -------------------------------------------------------------------------------- /public/dicts/Child_python_code.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "if", 4 | "trans": [ 5 | "如果" 6 | ] 7 | }, 8 | { 9 | "name": "else", 10 | "trans": [ 11 | "否则" 12 | ] 13 | }, 14 | { 15 | "name": "while", 16 | "trans": [ 17 | "while 型循环" 18 | ] 19 | }, 20 | { 21 | "name": "for", 22 | "trans":[ 23 | "for 型循环" 24 | ] 25 | }, 26 | { 27 | "name": "and", 28 | "trans": [ 29 | "逻辑与运算符" 30 | ] 31 | }, 32 | { 33 | "name": "or", 34 | "trans": [ 35 | "逻辑或运算符" 36 | ] 37 | }, 38 | { 39 | "name": "not", 40 | "trans": [ 41 | "逻辑非运算符" 42 | ] 43 | }, 44 | { 45 | "name": "TRUE", 46 | "trans": [ 47 | "真" 48 | ] 49 | }, 50 | { 51 | "name": "FALSE", 52 | "trans": [ 53 | "假" 54 | ] 55 | }, 56 | { 57 | "name": "None", 58 | "trans": [ 59 | "空值" 60 | ] 61 | }, 62 | { 63 | "name": "continue", 64 | "trans": [ 65 | "跳出本次循环,继续下一轮循环" 66 | ] 67 | }, 68 | { 69 | "name": "break", 70 | "trans": [ 71 | "跳出整个循环" 72 | ] 73 | }, 74 | { 75 | "name": "pass", 76 | "trans": [ 77 | "空语句,不做任何事情" 78 | ] 79 | }, 80 | { 81 | "name": "def", 82 | "trans": [ 83 | "define的缩写,定义一个函数" 84 | ] 85 | }, 86 | { 87 | "name": "return", 88 | "trans": [ 89 | "返回语句,退出def语句块" 90 | ] 91 | }, 92 | { 93 | "name": "global", 94 | "trans": [ 95 | "声明全局变量" 96 | ] 97 | }, 98 | { 99 | "name": "class", 100 | "trans": [ 101 | "定义一个类" 102 | ] 103 | }, 104 | { 105 | "name": "import", 106 | "trans": [ 107 | "导入模块" 108 | ] 109 | }, 110 | { 111 | "name": "from", 112 | "trans": [ 113 | "与import配合导入模块" 114 | ] 115 | } 116 | ] -------------------------------------------------------------------------------- /public/dicts/japanese_test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "こんにちは", 4 | "trans": [ 5 | "你好" 6 | ] 7 | }, 8 | { 9 | "name": "ありがとう", 10 | "trans": [ 11 | "谢谢" 12 | ] 13 | }, 14 | { 15 | "name": "ごめんなさい", 16 | "trans": [ 17 | "对不起" 18 | ] 19 | }, 20 | { 21 | "name": "さようなら", 22 | "trans": [ 23 | "再见" 24 | ] 25 | }, 26 | { 27 | "name": "はい", 28 | "trans": [ 29 | "是的" 30 | ] 31 | }, 32 | { 33 | "name": "いいえ", 34 | "trans": [ 35 | "不" 36 | ] 37 | }, 38 | { 39 | "name": "おはようございます", 40 | "trans": [ 41 | "早上好" 42 | ] 43 | }, 44 | { 45 | "name": "おやすみなさい", 46 | "trans": [ 47 | "晚安" 48 | ] 49 | }, 50 | { 51 | "name": "お願いします", 52 | "trans": [ 53 | "请" 54 | ] 55 | }, 56 | { 57 | "name": "ごめんください", 58 | "trans": [ 59 | "打扰一下" 60 | ] 61 | }, 62 | { 63 | "name": "元気ですか", 64 | "trans": [ 65 | "你好吗" 66 | ] 67 | }, 68 | { 69 | "name": "どうもありがとうございます", 70 | "trans": [ 71 | "非常感谢" 72 | ] 73 | }, 74 | { 75 | "name": "すみません", 76 | "trans": [ 77 | "不好意思" 78 | ] 79 | }, 80 | { 81 | "name": "お疲れ様でした", 82 | "trans": [ 83 | "辛苦了" 84 | ] 85 | }, 86 | { 87 | "name": "おめでとうございます", 88 | "trans": [ 89 | "恭喜" 90 | ] 91 | }, 92 | { 93 | "name": "よろしくお願いします", 94 | "trans": [ 95 | "请多关照" 96 | ] 97 | }, 98 | { 99 | "name": "大丈夫ですか", 100 | "trans": [ 101 | "你没事吧" 102 | ] 103 | }, 104 | { 105 | "name": "気をつけて", 106 | "trans": [ 107 | "小心", 108 | "保重" 109 | ] 110 | }, 111 | { 112 | "name": "がんばって", 113 | "trans": [ 114 | "加油" 115 | ] 116 | }, 117 | { 118 | "name": "楽しい時間を過ごしましょう", 119 | "trans": [ 120 | "愉快的时光过得去" 121 | ] 122 | } 123 | ] -------------------------------------------------------------------------------- /src/pages/Gallery-N/ChapterList/ChapterRow.tsx: -------------------------------------------------------------------------------- 1 | import useIntersectionObserver from '@/hooks/useIntersectionObserver' 2 | import { useChapterStats } from '@/pages/Gallery-N/hooks/useChapterStats' 3 | import noop from '@/utils/noop' 4 | import { useEffect, useRef } from 'react' 5 | 6 | type ChapterRowProps = { 7 | index: number 8 | checked: boolean 9 | dictID: string 10 | onChange: (index: number) => void 11 | } 12 | export default function ChapterRow({ index, dictID, checked, onChange }: ChapterRowProps) { 13 | const rowRef = useRef(null) 14 | 15 | const entry = useIntersectionObserver(rowRef, {}) 16 | const isVisible = !!entry?.isIntersecting 17 | const chapterStatus = useChapterStats(index, dictID, isVisible) 18 | 19 | useEffect(() => { 20 | if (checked && rowRef.current !== null) { 21 | const button = rowRef.current 22 | const container = button.parentElement?.parentElement?.parentElement 23 | container?.scroll({ 24 | top: button.offsetTop - container.offsetTop - 300, 25 | behavior: 'smooth', 26 | }) 27 | } 28 | }, [checked]) 29 | 30 | return ( 31 | onChange(index)} 35 | > 36 | 37 | 44 | 45 | {index + 1} 46 | 47 | {chapterStatus ? chapterStatus.exerciseCount : 0} 48 | 49 | 50 | {chapterStatus ? chapterStatus.avgWrongWordCount : 0} 51 | 52 | 53 | {chapterStatus ? chapterStatus.avgWrongInputCount : 0} 54 | 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/Gallery/index.tsx: -------------------------------------------------------------------------------- 1 | import ChapterGroup from './ChapterGroup' 2 | import DictionaryGroup from './DictionaryGroup' 3 | import Header from '@/components/Header' 4 | import Layout from '@/components/Layout' 5 | import Tooltip from '@/components/Tooltip' 6 | import { dictionaries } from '@/resources/dictionary' 7 | import { currentDictInfoAtom } from '@/store' 8 | import groupBy from '@/utils/groupBy' 9 | import { useAtomValue } from 'jotai' 10 | import React from 'react' 11 | import { useHotkeys } from 'react-hotkeys-hook' 12 | import { NavLink, useNavigate } from 'react-router-dom' 13 | 14 | const GalleryPage: React.FC = () => { 15 | const currentDictInfo = useAtomValue(currentDictInfoAtom) 16 | const groups = Object.entries(groupBy(dictionaries, (dict) => dict.category)) 17 | const navigate = useNavigate() 18 | useHotkeys( 19 | 'enter,esc', 20 | () => { 21 | navigate('/') 22 | }, 23 | { preventDefault: true }, 24 | ) 25 | 26 | return ( 27 | 28 |
29 | 30 | 31 | 完成选择 32 | 33 | 34 |
35 |
36 |
37 |

38 | 词典选择 39 |

40 |
41 | {groups.map(([name, items]) => ( 42 | 43 | ))} 44 |
45 |
46 |
47 |

48 | 章节选择 49 |

50 |
51 | 52 |
53 |
54 |
55 |
56 | ) 57 | } 58 | 59 | GalleryPage.displayName = 'GalleryPage' 60 | 61 | export default GalleryPage 62 | -------------------------------------------------------------------------------- /src/utils/db/data-export.ts: -------------------------------------------------------------------------------- 1 | import { db } from '.' 2 | import { getCurrentDate, recordDataAction } from '..' 3 | 4 | export type ExportProgress = { 5 | totalRows?: number 6 | completedRows: number 7 | done: boolean 8 | } 9 | 10 | export type ImportProgress = { 11 | totalRows?: number 12 | completedRows: number 13 | done: boolean 14 | } 15 | 16 | export async function exportDatabase(callback: (exportProgress: ExportProgress) => boolean) { 17 | const [pako, { saveAs }] = await Promise.all([import('pako'), import('file-saver'), import('dexie-export-import')]) 18 | 19 | const blob = await db.export({ 20 | progressCallback: ({ totalRows, completedRows, done }) => { 21 | return callback({ totalRows, completedRows, done }) 22 | }, 23 | }) 24 | const [wordCount, chapterCount] = await Promise.all([db.wordRecords.count(), db.chapterRecords.count()]) 25 | 26 | const json = await blob.text() 27 | const compressed = pako.gzip(json) 28 | const compressedBlob = new Blob([compressed]) 29 | const currentDate = getCurrentDate() 30 | saveAs(compressedBlob, `Qwerty-Learner-User-Data-${currentDate}.gz`) 31 | recordDataAction({ type: 'export', size: compressedBlob.size, wordCount, chapterCount }) 32 | } 33 | 34 | export async function importDatabase(onStart: () => void, callback: (importProgress: ImportProgress) => boolean) { 35 | const [pako] = await Promise.all([import('pako'), import('dexie-export-import')]) 36 | 37 | const input = document.createElement('input') 38 | input.type = 'file' 39 | input.accept = 'application/gzip' 40 | input.addEventListener('change', async () => { 41 | const file = input.files?.[0] 42 | if (!file) return 43 | 44 | onStart() 45 | 46 | const compressed = await file.arrayBuffer() 47 | const json = pako.ungzip(compressed, { to: 'string' }) 48 | const blob = new Blob([json]) 49 | 50 | await db.import(blob, { 51 | acceptVersionDiff: true, 52 | acceptMissingTables: true, 53 | acceptNameDiff: false, 54 | acceptChangedPrimaryKey: false, 55 | overwriteValues: true, 56 | clearTablesBeforeImport: true, 57 | progressCallback: ({ totalRows, completedRows, done }) => { 58 | return callback({ totalRows, completedRows, done }) 59 | }, 60 | }) 61 | 62 | const [wordCount, chapterCount] = await Promise.all([db.wordRecords.count(), db.chapterRecords.count()]) 63 | recordDataAction({ type: 'import', size: file.size, wordCount, chapterCount }) 64 | }) 65 | 66 | input.click() 67 | } 68 | -------------------------------------------------------------------------------- /src/hooks/usePronunciation.ts: -------------------------------------------------------------------------------- 1 | import { pronunciationConfigAtom } from '@/store' 2 | import { PronunciationType } from '@/typings' 3 | import { addHowlListener } from '@/utils' 4 | import noop from '@/utils/noop' 5 | import { Howl } from 'howler' 6 | import { useAtomValue } from 'jotai' 7 | import { useEffect, useMemo, useState } from 'react' 8 | import useSound from 'use-sound' 9 | import { HookOptions } from 'use-sound/dist/types' 10 | 11 | const pronunciationApi = 'https://dict.youdao.com/dictvoice?audio=' 12 | function generateWordSoundSrc(word: string, pronunciation: Exclude) { 13 | switch (pronunciation) { 14 | case 'uk': 15 | return `${pronunciationApi}${word}&type=1` 16 | case 'us': 17 | return `${pronunciationApi}${word}&type=2` 18 | case 'romaji': 19 | return `${pronunciationApi}${word}&le=jap` 20 | case 'zh': 21 | return `${pronunciationApi}${word}&le=zh` 22 | case 'ja': 23 | return `${pronunciationApi}${word}&le=jap` 24 | case 'de': 25 | return `${pronunciationApi}${word}&le=de` 26 | } 27 | } 28 | 29 | export default function usePronunciationSound(word: string, isLoop?: boolean) { 30 | const pronunciationConfig = useAtomValue(pronunciationConfigAtom) 31 | const loop = useMemo(() => (typeof isLoop === 'boolean' ? isLoop : pronunciationConfig.isLoop), [isLoop, pronunciationConfig.isLoop]) 32 | const [isPlaying, setIsPlaying] = useState(false) 33 | 34 | const [play, { stop, sound }] = useSound(generateWordSoundSrc(word, pronunciationConfig.type as Exclude), { 35 | html5: true, 36 | format: ['mp3'], 37 | loop, 38 | volume: pronunciationConfig.volume, 39 | rate: pronunciationConfig.rate, 40 | } as HookOptions) 41 | 42 | useEffect(() => { 43 | if (!sound) return 44 | sound.loop(loop) 45 | return noop 46 | }, [loop, sound]) 47 | 48 | useEffect(() => { 49 | if (!sound) return 50 | const unListens: Array<() => void> = [] 51 | 52 | unListens.push(addHowlListener(sound, 'play', () => setIsPlaying(true))) 53 | unListens.push(addHowlListener(sound, 'end', () => setIsPlaying(false))) 54 | unListens.push(addHowlListener(sound, 'pause', () => setIsPlaying(false))) 55 | unListens.push(addHowlListener(sound, 'playerror', () => setIsPlaying(false))) 56 | 57 | return () => { 58 | setIsPlaying(false) 59 | unListens.forEach((unListen) => unListen()) 60 | ;(sound as Howl).unload() 61 | } 62 | }, [sound]) 63 | 64 | return { play, stop, isPlaying } 65 | } 66 | -------------------------------------------------------------------------------- /public/dicts/js-map-set.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "clear()", 4 | "trans": [ 5 | "clear()方法会移除Map对象中的所有元素。" 6 | ] 7 | }, 8 | { 9 | "name": "delete()", 10 | "trans": [ 11 | " delete() 方法用于移除 Map 对象中指定的元素。" 12 | ] 13 | }, 14 | { 15 | "name": "entries()", 16 | "trans": [ 17 | "entries() 方法返回一个新的包含 [key, value] 对的 Iterator 对象,返回的迭代器的迭代顺序与 Map 对象的插入顺序相同。" 18 | ] 19 | }, 20 | { 21 | "name": "forEach()", 22 | "trans": [ 23 | "forEach() 方法将会以插入顺序对 Map 对象中的每一个键值对执行一次参数中提供的回调函数。" 24 | ] 25 | }, 26 | { 27 | "name": "get()", 28 | "trans": [ 29 | "get() 方法返回某个 Map 对象中的一个指定元素。" 30 | ] 31 | }, 32 | { 33 | "name": "has()", 34 | "trans": [ 35 | "方法has() 返回一个bool值,用来表明map 中是否存在指定元素." 36 | ] 37 | }, 38 | { 39 | "name": "keys()", 40 | "trans": [ 41 | "keys() 返回一个新的 Iterator 对象。它包含按照顺序插入 Map 对象中每个元素的key值。" 42 | ] 43 | }, 44 | { 45 | "name": "set()", 46 | "trans": [ 47 | "set() 方法为 Map 对象添加或更新一个指定了键(key)和值(value)的(新)键值对。" 48 | ] 49 | }, 50 | { 51 | "name": "values()", 52 | "trans": [ 53 | "一个新的 Map 可迭代对象." 54 | ] 55 | }, 56 | { 57 | "name": "add()", 58 | "trans": [ 59 | "add() 方法用来向一个 Set 对象的末尾添加一个指定的值。" 60 | ] 61 | }, 62 | { 63 | "name": "clear()", 64 | "trans": [ 65 | "clear() 方法用来清空一个 Set 对象中的所有元素。" 66 | ] 67 | }, 68 | { 69 | "name": "delete()", 70 | "trans": [ 71 | "delete() 方法可以从一个 Set 对象中删除指定的元素。" 72 | ] 73 | }, 74 | { 75 | "name": "entries()", 76 | "trans": [ 77 | "entries() 方法返回一个新的迭代器对象 ,这个对象的元素是类似 [value, value] 形式的数组,value 是集合对象中的每个元素,迭代器对象元素的顺序即集合对象中元素插入的顺序。由于集合对象不像 Map 对象那样拥有 key,然而,为了与 Map 对象的 API 形式保持一致,故使得每一个 entry 的 key 和 value 都拥有相同的值,因而最终返回一个 [value, value] 形式的数组。" 78 | ] 79 | }, 80 | { 81 | "name": "forEach()", 82 | "trans": [ 83 | "forEach 方法会根据集合中元素的插入顺序,依次执行提供的回调函数。" 84 | ] 85 | }, 86 | { 87 | "name": "has()", 88 | "trans": [ 89 | "has() 方法返回一个布尔值来指示对应的值value是否存在Set对象中。" 90 | ] 91 | }, 92 | { 93 | "name": "values()", 94 | "trans": [ 95 | "values() 方法返回一个 Iterator 对象,该对象按照原Set 对象元素的插入顺序返回其所有元素。" 96 | ] 97 | } 98 | ] -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { DISMISS_START_CARD_DATE_KEY } from '@/constants' 2 | import { idDictionaryMap } from '@/resources/dictionary' 3 | import { keySoundResources, wrongSoundResources, correctSoundResources } from '@/resources/soundResource' 4 | import { PronunciationType, PhoneticType, Dictionary, InfoPanelState } from '@/typings' 5 | import { atom } from 'jotai' 6 | import { atomWithStorage } from 'jotai/utils' 7 | 8 | export const currentDictIdAtom = atomWithStorage('currentDict', 'cet4') 9 | export const currentDictInfoAtom = atom((get) => { 10 | const id = get(currentDictIdAtom) 11 | let dict = idDictionaryMap[id] 12 | // 如果 dict 不存在,则返回 cet4. Typing 中会检查 DictId 是否存在,如果不存在则会重置为 cet4 13 | if (!dict) { 14 | dict = idDictionaryMap.cet4 15 | } 16 | return dict 17 | }) 18 | 19 | export const currentChapterAtom = atomWithStorage('currentChapter', 0) 20 | 21 | export const keySoundsConfigAtom = atomWithStorage('keySoundsConfig', { 22 | isOpen: true, 23 | isOpenClickSound: true, 24 | volume: 1, 25 | resource: keySoundResources[0], 26 | }) 27 | 28 | export const hintSoundsConfigAtom = atomWithStorage('hintSoundsConfig', { 29 | isOpen: true, 30 | volume: 1, 31 | isOpenWrongSound: true, 32 | isOpenCorrectSound: true, 33 | wrongResource: wrongSoundResources[0], 34 | correctResource: correctSoundResources[0], 35 | }) 36 | 37 | export const pronunciationConfigAtom = atomWithStorage('pronunciation', { 38 | isOpen: true, 39 | volume: 1, 40 | type: 'us' as PronunciationType, 41 | name: '美音', 42 | isLoop: false, 43 | rate: 1, 44 | }) 45 | export const pronunciationIsOpenAtom = atom((get) => get(pronunciationConfigAtom).isOpen) 46 | 47 | export const randomConfigAtom = atomWithStorage('randomConfig', { 48 | isOpen: false, 49 | }) 50 | 51 | export const isIgnoreCaseAtom = atomWithStorage('isIgnoreCase', true) 52 | 53 | export const isTextSelectableAtom = atomWithStorage('isTextSelectable', false) 54 | 55 | export const phoneticConfigAtom = atomWithStorage('phoneticConfig', { 56 | isOpen: true, 57 | type: 'us' as PhoneticType, 58 | }) 59 | 60 | export const isOpenDarkModeAtom = atomWithStorage('isOpenDarkModeAtom', window.matchMedia('(prefers-color-scheme: dark)').matches) 61 | 62 | export const isShowSkipAtom = atom(false) 63 | 64 | export const isInDevModeAtom = atom(false) 65 | 66 | export const infoPanelStateAtom = atom({ 67 | donate: false, 68 | vsc: false, 69 | community: false, 70 | redBook: false, 71 | }) 72 | 73 | export const dismissStartCardDateAtom = atomWithStorage(DISMISS_START_CARD_DATE_KEY, null) 74 | 75 | // for dev test 76 | // dismissStartCardDateAtom = atom(new Date()) 77 | -------------------------------------------------------------------------------- /src/pages/Gallery/ChapterButton.tsx: -------------------------------------------------------------------------------- 1 | import { useChapterStats } from './hooks/useChapterStats' 2 | import useIntersectionObserver from '@/hooks/useIntersectionObserver' 3 | import React, { useEffect, useRef } from 'react' 4 | import IconCheckCircle from '~icons/heroicons/check-circle-solid' 5 | 6 | export const ChapterButton: React.FC = ({ index, selected, wordCount, onClick }) => { 7 | const buttonRef = useRef(null) 8 | 9 | const entry = useIntersectionObserver(buttonRef, {}) 10 | const isVisible = !!entry?.isIntersecting 11 | const chapterStatus = useChapterStats(index, isVisible) 12 | 13 | useEffect(() => { 14 | if (selected && buttonRef.current !== null) { 15 | const button = buttonRef.current 16 | const container = button.parentElement?.parentElement 17 | const halfHeight = button.getBoundingClientRect().height / 2 18 | container?.scrollTo({ top: Math.max(button.offsetTop - container.offsetTop - halfHeight, 0), behavior: 'smooth' }) 19 | } 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | }, [selected]) 22 | 23 | return ( 24 | 56 | ) 57 | } 58 | 59 | export default ChapterButton 60 | 61 | export type ChapterButtonProps = { 62 | index: number 63 | selected: boolean 64 | wordCount: number 65 | onClick: () => void 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { CHAPTER_LENGTH } from '@/constants' 2 | import { Howl } from 'howler' 3 | 4 | export * from './mixpanel' 5 | 6 | const bannedKeys = [ 7 | 'Enter', 8 | 'Backspace', 9 | 'Delete', 10 | 'Tab', 11 | 'CapsLock', 12 | 'Shift', 13 | 'Control', 14 | 'Alt', 15 | 'Meta', 16 | 'Escape', 17 | 'Fn', 18 | 'FnLock', 19 | 'Hyper', 20 | 'Super', 21 | 'OS', 22 | ] 23 | 24 | export const isLegal = (key: string): boolean => { 25 | if (bannedKeys.includes(key)) return false 26 | return true 27 | } 28 | 29 | export const isChineseSymbol = (val: string): boolean => 30 | /[\u3002|\uff1f|\uff01|\uff0c|\u3001|\uff1b|\uff1a|\u201c|\u201d|\u2018|\u2019|\uff08|\uff09|\u300a|\u300b|\u3008|\u3009|\u3010|\u3011|\u300e|\u300f|\u300c|\u300d|\ufe43|\ufe44|\u3014|\u3015|\u2026|\u2014|\uff5e|\ufe4f|\uffe5]/.test( 31 | val, 32 | ) 33 | 34 | export const IsDesktop = () => { 35 | const userAgentInfo = navigator.userAgent 36 | const Agents = ['Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod'] 37 | 38 | let flag = true 39 | for (let v = 0; v < Agents.length; v++) { 40 | if (userAgentInfo.indexOf(Agents[v]) > 0) { 41 | flag = false 42 | break 43 | } 44 | } 45 | return flag 46 | } 47 | 48 | export function addHowlListener(howl: Howl, ...args: Parameters) { 49 | howl.on(...args) 50 | 51 | return () => howl.off(...args) 52 | } 53 | 54 | export function classNames(...classNames: Array) { 55 | const finallyClassNames: string[] = [] 56 | 57 | for (const className of classNames) { 58 | if (className) { 59 | finallyClassNames.push(className.trim()) 60 | } 61 | } 62 | 63 | return finallyClassNames.join(' ') 64 | } 65 | 66 | export function getCurrentDate() { 67 | const date = new Date() 68 | const year = date.getFullYear() 69 | const month = ('0' + (date.getMonth() + 1)).slice(-2) 70 | const day = ('0' + date.getDate()).slice(-2) 71 | 72 | return `${year}${month}${day}` 73 | } 74 | 75 | export function calcChapterCount(length: number) { 76 | return Math.ceil(length / CHAPTER_LENGTH) 77 | } 78 | 79 | export function findCommonValues(xs: T[], ys: T[]): T[] { 80 | const set = new Set(ys) 81 | return xs.filter((x) => set.has(x)) 82 | } 83 | 84 | export function toFixedNumber(number: number, fractionDigits: number) { 85 | return Number((number ?? 0).toFixed(fractionDigits)) 86 | } 87 | 88 | export function getUTCUnixTimestamp() { 89 | const now = new Date() 90 | return Math.floor( 91 | Date.UTC( 92 | now.getUTCFullYear(), 93 | now.getUTCMonth(), 94 | now.getUTCDate(), 95 | now.getUTCHours(), 96 | now.getUTCMinutes(), 97 | now.getUTCSeconds(), 98 | now.getUTCMilliseconds(), 99 | ) / 1000, 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /public/dicts/python-sys.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "altsep", 4 | "trans": [ 5 | "另一种可以替代使用的文件路径分隔符,如果所在的系统支持其他的分隔符,那么可以使用os.altsep来使用系统支持的其他类型的分隔符,如果系统不支持,那么该值为None,如在Windows中,os.altsep为‘/’。" 6 | ] 7 | }, 8 | { 9 | "name": "curdir", 10 | "trans": [ 11 | "返回当前目录: ('.')。" 12 | ] 13 | }, 14 | { 15 | "name": "defpath", 16 | "trans": [ 17 | "当使用exec函数族的时候,如果没有制定PATH环境变量,则默认会查找os.defpath中的值作为子进程PATH的值。" 18 | ] 19 | }, 20 | { 21 | "name": "devnull", 22 | "trans": [ 23 | "在不同的系统上null设备的路径,在Windows下为‘nul’,在POSIX下为‘/dev/null’" 24 | ] 25 | }, 26 | { 27 | "name": "extsep", 28 | "trans": [ 29 | "文件名和文件扩展名之间分隔的符号,在Windows下为‘.’" 30 | ] 31 | }, 32 | { 33 | "name": "linesep", 34 | "trans": [ 35 | "输出当前平台使用的行终止符,win下为'\t\n',Linux下为'\n'。" 36 | ] 37 | }, 38 | { 39 | "name": "pardir", 40 | "trans": [ 41 | "获取当前目录的父目录字符串名:('..')" 42 | ] 43 | }, 44 | { 45 | "name": "pathsep", 46 | "trans": [ 47 | "PATH环境变量中的分隔符,在POSIX系统中为‘:’,在Windows中为‘;’" 48 | ] 49 | }, 50 | { 51 | "name": "sep", 52 | "trans": [ 53 | "不同的平台有不同的路径表示方法,为了在编写代码的时候方便处理,增加可移植性,可以使用os.sep作为路径的分隔符" 54 | ] 55 | }, 56 | { 57 | "name": "argv", 58 | "trans": [ 59 | "命令行参数" 60 | ] 61 | }, 62 | { 63 | "name": "builtin_module_names", 64 | "trans": [ 65 | "链接c模块" 66 | ] 67 | }, 68 | { 69 | "name": "byteorder", 70 | "trans": [ 71 | "返回本机字节顺序" 72 | ] 73 | }, 74 | { 75 | "name": "check_-interval", 76 | "trans": [ 77 | "信号检测频率" 78 | ] 79 | }, 80 | { 81 | "name": "exec_prefix", 82 | "trans": [ 83 | "根目录" 84 | ] 85 | }, 86 | { 87 | "name": "executable", 88 | "trans": [ 89 | "可执行文件的名称" 90 | ] 91 | }, 92 | { 93 | "name": "exitfunc", 94 | "trans": [ 95 | "退出函数名" 96 | ] 97 | }, 98 | { 99 | "name": "modules", 100 | "trans": [ 101 | "加载模块" 102 | ] 103 | }, 104 | { 105 | "name": "path", 106 | "trans": [ 107 | "搜索路径" 108 | ] 109 | }, 110 | { 111 | "name": "platform", 112 | "trans": [ 113 | "查看当前操作系统的信息,来采集系统版本位数计算机类型名称内核等一系列信息" 114 | ] 115 | }, 116 | { 117 | "name": "stdin", 118 | "trans": [ 119 | "标准输入,stdin提供了read()和readline()函数,如果想按一行行来读取,可以考虑使用它" 120 | ] 121 | }, 122 | { 123 | "name": "stdout", 124 | "trans": [ 125 | "标准输出" 126 | ] 127 | }, 128 | { 129 | "name": "stderr", 130 | "trans": [ 131 | "错误输出" 132 | ] 133 | }, 134 | { 135 | "name": "version_info", 136 | "trans": [ 137 | "获取python版本号" 138 | ] 139 | }, 140 | { 141 | "name": "winver", 142 | "trans": [ 143 | "版本号" 144 | ] 145 | } 146 | ] -------------------------------------------------------------------------------- /src/pages/Gallery-N/DictionaryWithoutCover.tsx: -------------------------------------------------------------------------------- 1 | import { useDictStats } from './hooks/useDictStats' 2 | import bookCover from '@/assets/book-cover.png' 3 | import useIntersectionObserver from '@/hooks/useIntersectionObserver' 4 | import { currentDictIdAtom } from '@/store' 5 | import { Dictionary } from '@/typings' 6 | import { calcChapterCount } from '@/utils' 7 | import * as Progress from '@radix-ui/react-progress' 8 | import { useAtomValue } from 'jotai' 9 | import { useMemo, useRef } from 'react' 10 | 11 | interface Props { 12 | dictionary: Dictionary 13 | onClick?: () => void 14 | } 15 | 16 | export default function DictionaryComponent({ dictionary, onClick }: Props) { 17 | const currentDictID = useAtomValue(currentDictIdAtom) 18 | 19 | const divRef = useRef(null) 20 | const entry = useIntersectionObserver(divRef, {}) 21 | const isVisible = !!entry?.isIntersecting 22 | const dictStats = useDictStats(dictionary.id, isVisible) 23 | const chapterCount = useMemo(() => calcChapterCount(dictionary.length), [dictionary.length]) 24 | const isSelected = currentDictID === dictionary.id 25 | const progress = useMemo( 26 | () => (dictStats ? Math.ceil((dictStats.exercisedChapterCount / chapterCount) * 100) : 0), 27 | [dictStats, chapterCount], 28 | ) 29 | 30 | return ( 31 |
39 |
40 |

45 | {dictionary.name} 46 |

47 |

{dictionary.description}

48 |

{dictionary.length} 词

49 |
50 | {progress > 0 && ( 51 | 56 | 60 | 61 | )} 62 | 63 |
64 |
65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /docs/toBuildDict.md: -------------------------------------------------------------------------------- 1 | # 如何导入新的词典 📚 2 | 3 | 注意,我们的词典主要来源于社区贡献。当你想要导入新的词典时,最好准备好词典的源文件,以便我们能够更好的帮助你。 4 | 5 | ## 0. 交给我们!🤝 6 | 7 | ### 0.1 如果你没有任何编程基础 🚫💻 8 | 9 | 我们推荐你加入 qwerty learner 社区群,在群中反映你的需求,我们的开发者会帮助你导入词典。 10 | 11 | ![groupQRcode](../public/weChat-group.jpg) 12 | 13 | ### 0.2 如果你不会编程,但会使用 github🐙 14 | 15 | 我们推荐你以“Dictionary Request”为开头发起 Issue,描述你的词典需求并提供词典来源。 16 | 17 | ## 1. 亲自动手!🛠️ 18 | 19 | ### 1.1 词典的目标文件格式 📄 20 | 21 | 词典的文件格式是 `词典名.json` ,其内容结构应当是: 22 | 23 | ```json 24 | [ 25 | { 26 | "name" : "xxx" , 27 | "trans" : ["xxx", "xxx",...] 28 | }, 29 | ... 30 | ] 31 | ``` 32 | 33 | 例如: 34 | 35 | ```json 36 | { "name": "file", "trans": ["n. 档案,公文箱,锉刀,[计算机] 文件 vt. 列队行进,归档,申请"] }, 37 | { 38 | "name": "command", 39 | "trans": [ 40 | "n.命令,指挥; 司令部,指挥部; [计算机]指令; 控制力 vt.指挥,控制,命令; 命令; 应得,值得 vi.给出命令; 命令,指令 adj.指挥的,根据命令(或要求)而作的" 41 | ] 42 | }, 43 | { "name": "use", "trans": ["n. 运用,用法,使用权,适用 vt. 使用,利用,对待 vi. 吸毒"] }, 44 | { "name": "program", "trans": ["n. 节目(单),程序,计划 vt. 规划,拟定计划,制作节目"] }, 45 | { "name": "line", "trans": ["n. 行,线,航线,场界,皱纹,家族 vt. &vi. 用做衬里,排成一行,顺...排列 vi. 排成一行,顺...排列,划线于"] }, 46 | { "name": "if", "trans": ["conj. 如果,是否,即使 n. 条件,设想"] }, 47 | 48 | ``` 49 | 50 | #### 1.1.0 如何将词典的源文件转换为目标文件格式?🔄 51 | 52 | 由于词典的源文件格式、来源各异,我们无法为你提供统一的转换方法,但是我们可以提供一些思路: 53 | 54 | #### 1.1.1 你可以将部分词典源文件的内容发送给 ChatGPT 并描述需求,让 ChatGPT 生成转换脚本 🤖 55 | 56 | #### 1.1.2 你也可以使用在线工具将词典源文件转换为目标文件格式,此类在线工具有很多,如 🔧 57 | 58 | #### 1.1.3 如果内容不多,你也可以手动将词典源文件转换为目标文件格式,或批量交给 ChatGPT 生成 ✍️ 59 | 60 | #### 1.1.4 如果你卡在了这一步,可以回到 0 部分,交给我们来帮你完成这一步 🔄 61 | 62 | ### 1.2 词典的目标文件位置 📍 63 | 64 | 词典的目标文件位置是 `/public/dicts/`,请将处理好的词典文件放置在该目录下 65 | 66 | ### 1.3 词典的索引建立 🔍 67 | 68 | 词典的索引建立是在 `/resources/dictionary.ts` 中完成的,你需要在该文件中添加一行代码,格式如下: 69 | 70 | ```json 71 | { 72 | "id": "xxx", 73 | "name": "xxx", 74 | "description": "xxx", 75 | "category": "xxx", 76 | "url": "./dicts/xxx.json", 77 | "length": xxx 78 | } 79 | ``` 80 | 81 | 例如: 82 | 83 | ```json 84 | { 85 | "id": "cet4", 86 | "name": "CET-4", 87 | "description": "大学英语四级词库", 88 | "category": "英语学习", 89 | "url": "/dicts/CET4_T.json", 90 | "length": 2607, 91 | "language": "en", 92 | }, 93 | { 94 | "id": "cet6", 95 | "name": "CET-6", 96 | "description": "大学英语六级词库", 97 | "category": "英语学习", 98 | "url": "/dicts/CET6_T.json", 99 | "length": 2345, 100 | "language": "en", 101 | }, 102 | ``` 103 | 104 | 其中, 105 | `id` 需要是所有词典中唯一的 106 | `name` 是展示给所有用户的词典名 107 | `description` 是词典描述 108 | `category` 是词典分类(你可以事先阅读所有已存在的词典分类,来为新的词典选择合适的分类) 109 | `url` 是词典的目标文件位置 110 | `length` 是词典的单词数量(可以通过运行脚本 `scripts/update-dict-size.js` 来自动计算) 111 | `language` 表示词典的语言 112 | 113 | ### 1.4 测试 🧪 114 | 115 | 使用 yarn 指令安装依赖,然后使用 yarn dev 启动开发服务器,访问 "http://localhost:5173" 116 | 117 | 如果你的词典已经成功导入,你将在词典列表中看到你的词典。🎉 118 | 119 | ### 1.5 提交 PR 📝 120 | 121 | 现在你可以提交 PR 了,我们会尽快 review 你的代码,如果一切顺利,你的词典将会在下一个版本中发布。🎉 122 | 123 | ## 别忘了,在任何步骤遇到困难时,你都可以转向 qwerty learner 社区寻求帮助。我们是一个非常友好的社区,随时欢迎你的加入!🤝 124 | -------------------------------------------------------------------------------- /src/pages/Typing/components/Setting/AdvancedSetting.tsx: -------------------------------------------------------------------------------- 1 | import styles from './index.module.css' 2 | import { isIgnoreCaseAtom, isTextSelectableAtom, randomConfigAtom } from '@/store' 3 | import { Switch } from '@headlessui/react' 4 | import { useAtom } from 'jotai' 5 | import { useCallback } from 'react' 6 | 7 | export default function AdvancedSetting() { 8 | const [randomConfig, setRandomConfig] = useAtom(randomConfigAtom) 9 | const [isIgnoreCase, setIsIgnoreCase] = useAtom(isIgnoreCaseAtom) 10 | const [isTextSelectable, setIsTextSelectable] = useAtom(isTextSelectableAtom) 11 | 12 | const onToggleRandom = useCallback( 13 | (checked: boolean) => { 14 | setRandomConfig((prev) => ({ 15 | ...prev, 16 | isOpen: checked, 17 | })) 18 | }, 19 | [setRandomConfig], 20 | ) 21 | 22 | const onToggleIgnoreCase = useCallback( 23 | (checked: boolean) => { 24 | setIsIgnoreCase(checked) 25 | }, 26 | [setIsIgnoreCase], 27 | ) 28 | 29 | const onToggleTextSelectable = useCallback( 30 | (checked: boolean) => { 31 | setIsTextSelectable(checked) 32 | }, 33 | [setIsTextSelectable], 34 | ) 35 | 36 | return ( 37 |
38 |
39 | 章节乱序 40 | 开启后,每次练习章节中单词会随机排序。下一章节生效 41 |
42 | 43 | 45 | {`随机已${ 46 | randomConfig.isOpen ? '开启' : '关闭' 47 | }`} 48 |
49 |
50 |
51 | 是否忽略大小写 52 | 开启后,输入时不区分大小写,如输入“hello”和“Hello”都会被认为是正确的 53 |
54 | 55 | 57 | {`忽略大小写已${ 58 | isIgnoreCase ? '开启' : '关闭' 59 | }`} 60 |
61 |
62 |
63 | 是否允许选择文本 64 | 开启后,可以通过鼠标选择文本 65 |
66 | 67 | 69 | {`选择文本已${ 70 | isTextSelectable ? '开启' : '关闭' 71 | }`} 72 |
73 |
74 |
75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qwerty-learner", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": ".", 6 | "dependencies": { 7 | "@floating-ui/react": "^0.20.1", 8 | "@headlessui/react": "^1.7.13", 9 | "@headlessui/tailwindcss": "^0.1.2", 10 | "@radix-ui/react-progress": "^1.0.2", 11 | "@radix-ui/react-scroll-area": "^1.0.3", 12 | "@radix-ui/react-slider": "^1.1.1", 13 | "classnames": "^2.3.2", 14 | "dexie": "^3.2.3", 15 | "dexie-export-import": "^4.0.7", 16 | "dexie-react-hooks": "^1.1.3", 17 | "file-saver": "^2.0.5", 18 | "howler": "^2.2.3", 19 | "html-to-image": "^1.11.11", 20 | "immer": "^9.0.21", 21 | "jotai": "^2.0.3", 22 | "mixpanel-browser": "^2.45.0", 23 | "pako": "^2.1.0", 24 | "react": "^18.2.0", 25 | "react-app-polyfill": "^3.0.0", 26 | "react-dom": "^18.2.0", 27 | "react-hotkeys-hook": "^4.3.7", 28 | "react-router-dom": "^6.8.2", 29 | "react-timer-hook": "^3.0.5", 30 | "source-map-explorer": "^2.5.2", 31 | "swr": "^2.0.4", 32 | "typescript": "^4.0.3", 33 | "use-immer": "^0.9.0", 34 | "use-sound": "^4.0.1" 35 | }, 36 | "scripts": { 37 | "dev": "vite", 38 | "start": "vite", 39 | "build": "cross-env CI=false vite build --base=./", 40 | "test": "echo \"No tests\"", 41 | "lint": "eslint .", 42 | "prettier": "prettier --write .", 43 | "prepare": "husky install" 44 | }, 45 | "lint-staged": { 46 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 47 | "prettier --write" 48 | ] 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | }, 62 | "devDependencies": { 63 | "@iconify/json": "^2.2.56", 64 | "@svgr/core": "^7.0.0", 65 | "@svgr/plugin-jsx": "^7.0.0", 66 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 67 | "@types/file-saver": "^2.0.5", 68 | "@types/howler": "^2.2.3", 69 | "@types/mixpanel-browser": "^2.38.1", 70 | "@types/node": "18.14.6", 71 | "@types/pako": "^2.0.0", 72 | "@types/react": "^18.0.28", 73 | "@types/react-dom": "^18.0.11", 74 | "@types/react-router-dom": "^5.1.7", 75 | "@vitejs/plugin-react": "^3.1.0", 76 | "cross-env": "^7.0.3", 77 | "eslint-config-prettier": "^8.7.0", 78 | "eslint-config-react-app": "^7.0.1", 79 | "eslint-plugin-prettier": "^4.2.1", 80 | "eslint-plugin-react": "^7.32.2", 81 | "eslint-plugin-react-hooks": "^4.6.0", 82 | "git-last-commit": "^1.0.1", 83 | "husky": "^8.0.0", 84 | "lint-staged": "^13.1.2", 85 | "prettier": "^2.8.4", 86 | "prettier-plugin-tailwindcss": "^0.2.7", 87 | "rollup-plugin-visualizer": "^5.9.0", 88 | "tailwindcss": "^3.3.1", 89 | "autoprefixer": "^10.4.13", 90 | "eslint": "^8.35.0", 91 | "postcss": "^8.4.21", 92 | "@tailwindcss/forms": "^0.5.3", 93 | "@tailwindcss/postcss7-compat": "^2.2.17", 94 | "typescript-plugin-css-modules": "^5.0.1", 95 | "unplugin-icons": "^0.16.1", 96 | "vite": "^4.1.1" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /public/dicts/python-file.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "close()", 4 | "trans": [ 5 | "close() 方法用于关闭一个已打开的文件。关闭后的文件不能再进行读写操作, 否则会触发 ValueError 错误。 close() 方法允许调用多次。当 file 对象,被引用到操作另外一个文件时,Python 会自动关闭之前的 file 对象。 使用 close() 方法关闭文件是一个好的习惯。" 6 | ] 7 | }, 8 | { 9 | "name": "fileno()", 10 | "trans": [ 11 | "fileno() 方法返回一个整型的文件描述符(file descriptor FD 整型),可用于底层操作系统的 I/O 操作。" 12 | ] 13 | }, 14 | { 15 | "name": "flush()", 16 | "trans": [ 17 | "flush() 方法是用来刷新缓冲区的,即将缓冲区中的数据立刻写入文件,同时清空缓冲区,不需要是被动的等待输出缓冲区写入。一般情况下,文件关闭后会自动刷新缓冲区,但有时你需要在关闭前刷新它,这时就可以使用 flush() 方法。" 18 | ] 19 | }, 20 | { 21 | "name": "isatty()", 22 | "trans": [ 23 | "isatty() 方法检测文件是否连接到一个终端设备,如果是返回 True,否则返回 False。" 24 | ] 25 | }, 26 | { 27 | "name": "next()", 28 | "trans": [ 29 | "next() 方法在文件使用迭代器时会使用到,在循环中,next()方法会在每次循环中调用,该方法返回文件的下一行,如果到达结尾(EOF),则触发 StopIteration。" 30 | ] 31 | }, 32 | { 33 | "name": "read()", 34 | "trans": [ 35 | "read() 方法用于从文件读取指定的字节数,如果未给定或为负则读取所有。" 36 | ] 37 | }, 38 | { 39 | "name": "readline()", 40 | "trans": [ 41 | "readline() 方法用于从文件读取整行,包括 '\n' 字符。如果指定了一个非负数的参数,则返回指定大小的字节数,包括 '\n' 字符。" 42 | ] 43 | }, 44 | { 45 | "name": "readlines()", 46 | "trans": [ 47 | "readlines() 方法用于读取所有行(直到结束符 EOF)并返回列表,若给定sizeint>0,返回总和大约为sizeint字节的行, 实际读取值可能比sizhint较大, 因为需要填充缓冲区。如果碰到结束符 EOF 则返回空字符串。" 48 | ] 49 | }, 50 | { 51 | "name": "seek()", 52 | "trans": [ 53 | "seek() 方法用于移动文件读取指针到指定位置。" 54 | ] 55 | }, 56 | { 57 | "name": "tell()", 58 | "trans": [ 59 | "tell() 方法返回文件的当前位置,即文件指针当前位置。" 60 | ] 61 | }, 62 | { 63 | "name": "truncate()", 64 | "trans": [ 65 | "truncate() 方法用于截断文件,如果指定了可选参数 size,则表示截断文件为 size 个字符。 如果没有指定 size,则从当前位置起截断;截断之后 size 后面的所有字符被删除。" 66 | ] 67 | }, 68 | { 69 | "name": "write()", 70 | "trans": [ 71 | "write() 方法用于向文件中写入指定字符串。在文件关闭前或缓冲区刷新前,字符串内容存储在缓冲区中,这时你在文件中是看不到写入的内容的。" 72 | ] 73 | }, 74 | { 75 | "name": "writelines()", 76 | "trans": [ 77 | "writelines() 方法用于向文件中写入一序列的字符串。这一序列字符串可以是由迭代对象产生的,如一个字符串列表。 换行需要制定换行符\n。" 78 | ] 79 | }, 80 | { 81 | "name": "xreadlines()", 82 | "trans": [ 83 | "返回一个生成器,来循环操作文件的每一行。循环使用时和readlines基本一样,但是直接打印就不同" 84 | ] 85 | }, 86 | { 87 | "name": "closed", 88 | "trans": [ 89 | "如果文件已被关闭返回 True,否则返回 False。" 90 | ] 91 | }, 92 | { 93 | "name": "encoding()", 94 | "trans": [ 95 | " encoding 指定的编码格式编码字符串。errors参数可以指定不同的错误处理方案。" 96 | ] 97 | }, 98 | { 99 | "name": "errors", 100 | "trans": [ 101 | "如果该文件无法被打开,会被抛出" 102 | ] 103 | }, 104 | { 105 | "name": "name", 106 | "trans": [ 107 | "文件名" 108 | ] 109 | }, 110 | { 111 | "name": "mode", 112 | "trans": [ 113 | "文件打开模式" 114 | ] 115 | }, 116 | { 117 | "name": "newlines", 118 | "trans": [ 119 | "表示文件所采用的分隔符" 120 | ] 121 | }, 122 | { 123 | "name": "softspace", 124 | "trans": [ 125 | "文件末尾强制加空格标志,为0表示在输出一数据后,要再加上一个空格符,为1表示不加,这个属性一般用不到" 126 | ] 127 | } 128 | ] -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 40 | 41 | Qwerty Learner 42 | 46 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 70 | 71 | 72 | 76 |
77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/utils/db/index.ts: -------------------------------------------------------------------------------- 1 | import { WordRecord, ChapterRecord, IWordRecord, IChapterRecord, LetterMistakes } from './record' 2 | import { TypingContext, TypingState, TypingStateActionType } from '@/pages/Typing/store' 3 | import { currentChapterAtom, currentDictIdAtom } from '@/store' 4 | import Dexie, { Table } from 'dexie' 5 | import { useAtomValue } from 'jotai' 6 | import { useCallback, useContext } from 'react' 7 | 8 | class RecordDB extends Dexie { 9 | wordRecords!: Table 10 | chapterRecords!: Table 11 | 12 | constructor() { 13 | super('RecordDB') 14 | this.version(1).stores({ 15 | wordRecords: '++id,word,timeStamp,dict,chapter,errorCount,[dict+chapter]', 16 | chapterRecords: '++id,timeStamp,dict,chapter,time,[dict+chapter]', 17 | }) 18 | } 19 | } 20 | 21 | export const db = new RecordDB() 22 | 23 | db.wordRecords.mapToClass(WordRecord) 24 | db.chapterRecords.mapToClass(ChapterRecord) 25 | 26 | export function useSaveChapterRecord() { 27 | const currentChapter = useAtomValue(currentChapterAtom) 28 | const dictID = useAtomValue(currentDictIdAtom) 29 | 30 | const saveChapterRecord = useCallback( 31 | (typingState: TypingState) => { 32 | const { 33 | chapterData: { correctCount, wrongCount, wordCount, correctWordIndexes, words, wordRecordIds }, 34 | timerData: { time }, 35 | } = typingState 36 | 37 | const chapterRecord = new ChapterRecord( 38 | dictID, 39 | currentChapter, 40 | time, 41 | correctCount, 42 | wrongCount, 43 | wordCount, 44 | correctWordIndexes, 45 | words.length, 46 | wordRecordIds ?? [], 47 | ) 48 | db.chapterRecords.add(chapterRecord) 49 | }, 50 | [currentChapter, dictID], 51 | ) 52 | 53 | return saveChapterRecord 54 | } 55 | 56 | export type WordKeyLogger = { 57 | letterTimeArray: number[] 58 | letterMistake: LetterMistakes 59 | } 60 | 61 | export function useSaveWordRecord() { 62 | const currentChapter = useAtomValue(currentChapterAtom) 63 | const dictID = useAtomValue(currentDictIdAtom) 64 | 65 | const { dispatch } = useContext(TypingContext) ?? {} 66 | 67 | const saveWordRecord = useCallback( 68 | async ({ 69 | word, 70 | wrongCount, 71 | letterTimeArray, 72 | letterMistake, 73 | }: { 74 | word: string 75 | wrongCount: number 76 | letterTimeArray: number[] 77 | letterMistake: LetterMistakes 78 | }) => { 79 | const timing = [] 80 | for (let i = 1; i < letterTimeArray.length; i++) { 81 | const diff = letterTimeArray[i] - letterTimeArray[i - 1] 82 | timing.push(diff) 83 | } 84 | 85 | const wordRecord = new WordRecord(word, dictID, currentChapter, timing, wrongCount, letterMistake) 86 | 87 | let dbID = -1 88 | try { 89 | dbID = await db.wordRecords.add(wordRecord) 90 | } catch (e) { 91 | console.error(e) 92 | } 93 | if (dispatch) { 94 | dbID > 0 && dispatch({ type: TypingStateActionType.ADD_WORD_RECORD_ID, payload: dbID }) 95 | dispatch({ type: TypingStateActionType.SET_IS_SAVING_RECORD, payload: false }) 96 | } 97 | }, 98 | [currentChapter, dictID, dispatch], 99 | ) 100 | 101 | return saveWordRecord 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/db/record.ts: -------------------------------------------------------------------------------- 1 | import { getUTCUnixTimestamp } from '../index' 2 | 3 | export interface IWordRecord { 4 | word: string 5 | timeStamp: number 6 | // 正常章节为 dictKey, 其他功能则为对应的类型 7 | dict: string 8 | // 用户可能是在 错题/其他类似组件中 进行的练习则为 null, start from 0 9 | chapter: number | null 10 | // 正确次数中输入每个字母的时间差,可以据此计算出总时间 11 | timing: number[] 12 | // 出错的次数 13 | wrongCount: number 14 | // 每个字母被错误输入成什么, index 为字母的索引, 数组内为错误的 e.key 15 | mistakes: LetterMistakes 16 | } 17 | 18 | export interface LetterMistakes { 19 | // 每个字母被错误输入成什么, index 为字母的索引, 数组内为错误的 e.key 20 | [index: number]: string[] 21 | } 22 | 23 | export class WordRecord implements IWordRecord { 24 | word: string 25 | timeStamp: number 26 | dict: string 27 | chapter: number | null 28 | timing: number[] 29 | wrongCount: number 30 | mistakes: LetterMistakes 31 | 32 | constructor(word: string, dict: string, chapter: number | null, timing: number[], wrongCount: number, mistakes: LetterMistakes) { 33 | this.word = word 34 | this.timeStamp = getUTCUnixTimestamp() 35 | this.dict = dict 36 | this.chapter = chapter 37 | this.timing = timing 38 | this.wrongCount = wrongCount 39 | this.mistakes = mistakes 40 | } 41 | 42 | get totalTime() { 43 | return this.timing.reduce((acc, curr) => acc + curr, 0) 44 | } 45 | } 46 | 47 | export interface IChapterRecord { 48 | // 正常章节为 dictKey, 其他功能则为对应的类型 49 | dict: string 50 | // 用户可能是在 错题/其他类似组件中 进行的练习则为 null 51 | chapter: number | null 52 | timeStamp: number 53 | // 单位为 s,章节的记录没必要到毫秒级 54 | time: number 55 | // 正确按键次数,输对一个字母即记录 56 | correctCount: number 57 | // 错误的按键次数。 出错会清空整个输入,但只记录一次错误 58 | wrongCount: number 59 | // 用户输入的单词总数,可能会使用循环等功能使输入总数大于 20 60 | wordCount: number 61 | // 一次打对未犯错的单词列表, 可以和 wordNumber 对比得出出错的单词 indexes 62 | correctWordIndexes: number[] 63 | // 章节总单词数 64 | wordNumber: number 65 | // 单词 record 的 id 列表 66 | wordRecordIds: number[] 67 | } 68 | 69 | export class ChapterRecord implements IChapterRecord { 70 | dict: string 71 | chapter: number | null 72 | timeStamp: number 73 | time: number 74 | correctCount: number 75 | wrongCount: number 76 | wordCount: number 77 | correctWordIndexes: number[] 78 | wordNumber: number 79 | wordRecordIds: number[] 80 | 81 | constructor( 82 | dict: string, 83 | chapter: number | null, 84 | time: number, 85 | correctCount: number, 86 | wrongCount: number, 87 | wordCount: number, 88 | correctWordIndexes: number[], 89 | wordNumber: number, 90 | wordRecordIds: number[], 91 | ) { 92 | this.dict = dict 93 | this.chapter = chapter 94 | this.timeStamp = getUTCUnixTimestamp() 95 | this.time = time 96 | this.correctCount = correctCount 97 | this.wrongCount = wrongCount 98 | this.wordCount = wordCount 99 | this.correctWordIndexes = correctWordIndexes 100 | this.wordNumber = wordNumber 101 | this.wordRecordIds = wordRecordIds 102 | } 103 | 104 | get wpm() { 105 | return Math.round((this.wordCount / this.time) * 60) 106 | } 107 | 108 | get inputAccuracy() { 109 | return Math.round((this.correctCount / this.correctCount + this.wrongCount) * 100) 110 | } 111 | 112 | get wordAccuracy() { 113 | return Math.round((this.correctWordIndexes.length / this.wordNumber) * 100) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /public/dicts/java-hashmap.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "clear()", 4 | "trans": [ 5 | "clear():删除 hashMap 中的所有键值对" 6 | ] 7 | }, 8 | { 9 | "name": "clone()", 10 | "trans": [ 11 | "clone():复制一份 hashMap" 12 | ] 13 | }, 14 | { 15 | "name": "isEmpty()", 16 | "trans": [ 17 | "isEmpty():判断 hashMap 是否为空" 18 | ] 19 | }, 20 | { 21 | "name": "size()", 22 | "trans": [ 23 | "size():计算 hashMap 中键值对的数量" 24 | ] 25 | }, 26 | { 27 | "name": "put()", 28 | "trans": [ 29 | "put():将键值对添加到 hashMap 中" 30 | ] 31 | }, 32 | { 33 | "name": "putAll()", 34 | "trans": [ 35 | "putAll():将所有键值对添加到 hashMap 中" 36 | ] 37 | }, 38 | { 39 | "name": "putIfAbsent()", 40 | "trans": [ 41 | "putIfAbsent():如果 hashMap 中不存在指定的键,则将指定的键值对插入到 hashMap 中。" 42 | ] 43 | }, 44 | { 45 | "name": "remove()", 46 | "trans": [ 47 | "remove():删除 hashMap 中指定键 key 的映射关系" 48 | ] 49 | }, 50 | { 51 | "name": "containsKey()", 52 | "trans": [ 53 | "containsKey():检查 hashMap 中是否存在指定的 key 对应的映射关系。" 54 | ] 55 | }, 56 | { 57 | "name": "containsValue()", 58 | "trans": [ 59 | "containsValue():检查 hashMap 中是否存在指定的 value 对应的映射关系。" 60 | ] 61 | }, 62 | { 63 | "name": "replace()", 64 | "trans": [ 65 | "replace():替换 hashMap 中是指定的 key 对应的 value。" 66 | ] 67 | }, 68 | { 69 | "name": "replaceAll()", 70 | "trans": [ 71 | "replaceAll():将 hashMap 中的所有映射关系替换成给定的函数所执行的结果。" 72 | ] 73 | }, 74 | { 75 | "name": "get()", 76 | "trans": [ 77 | "get():获取指定 key 对应对 value" 78 | ] 79 | }, 80 | { 81 | "name": "getOrDefault()", 82 | "trans": [ 83 | "getOrDefault():获取指定 key 对应对 value,如果找不到 key ,则返回设置的默认值" 84 | ] 85 | }, 86 | { 87 | "name": "forEach()", 88 | "trans": [ 89 | "forEach():对 hashMap 中的每个映射执行指定的操作。" 90 | ] 91 | }, 92 | { 93 | "name": "entrySet()", 94 | "trans": [ 95 | "entrySet():返回 hashMap 中所有映射项的集合集合视图。" 96 | ] 97 | }, 98 | { 99 | "name": "keySet()", 100 | "trans": [ 101 | "keySet():返回 hashMap 中所有 key 组成的集合视图。" 102 | ] 103 | }, 104 | { 105 | "name": "values()", 106 | "trans": [ 107 | "values():返回 hashMap 中存在的所有 value 值。" 108 | ] 109 | }, 110 | { 111 | "name": "merge()", 112 | "trans": [ 113 | "merge():添加键值对到 hashMap 中" 114 | ] 115 | }, 116 | { 117 | "name": "compute()", 118 | "trans": [ 119 | "compute():对 hashMap 中指定 key 的值进行重新计算" 120 | ] 121 | }, 122 | { 123 | "name": "computeIfAbsent()", 124 | "trans": [ 125 | "computeIfAbsent():对 hashMap 中指定 key 的值进行重新计算,如果不存在这个 key,则添加到 hasMap 中" 126 | ] 127 | }, 128 | { 129 | "name": "computeIfPresent()", 130 | "trans": [ 131 | "computeIfPresent():对 hashMap 中指定 key 的值进行重新计算,前提是该 key 存在于 hashMap 中。" 132 | ] 133 | } 134 | ] -------------------------------------------------------------------------------- /public/dicts/java-stringBuffer.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "append()", 4 | "trans": [ 5 | "public StringBuffer append(String s):将指定的字符串追加到此字符序列。" 6 | ] 7 | }, 8 | { 9 | "name": "reverse()", 10 | "trans": [ 11 | "public StringBuffer reverse():将此字符序列用其反转形式取代。" 12 | ] 13 | }, 14 | { 15 | "name": "delete()", 16 | "trans": [ 17 | "public delete(int start, int end):移除此序列的子字符串中的字符。" 18 | ] 19 | }, 20 | { 21 | "name": "insert()", 22 | "trans": [ 23 | "public insert(int offset, int i):将 int 参数的字符串表示形式插入此序列中。" 24 | ] 25 | }, 26 | { 27 | "name": "replace()", 28 | "trans": [ 29 | "replace(int start, int end, String str):使用给定 String 中的字符替换此序列的子字符串中的字符。" 30 | ] 31 | }, 32 | { 33 | "name": "capacity()", 34 | "trans": [ 35 | "int capacity():返回当前容量。" 36 | ] 37 | }, 38 | { 39 | "name": "charAt()", 40 | "trans": [ 41 | "char charAt(int index):返回此序列中指定索引处的 char 值。" 42 | ] 43 | }, 44 | { 45 | "name": "ensureCapacity()", 46 | "trans": [ 47 | "void ensureCapacity(int minimumCapacity):确保容量至少等于指定的最小值。" 48 | ] 49 | }, 50 | { 51 | "name": "getChars()", 52 | "trans": [ 53 | "void getChars(int srcBegin, int srcEnd, char[] dst, int dstBegin):将字符从此序列复制到目标字符数组 dst。" 54 | ] 55 | }, 56 | { 57 | "name": "indexOf()", 58 | "trans": [ 59 | "int indexOf(String str):返回第一次出现的指定子字符串在该字符串中的索引。" 60 | ] 61 | }, 62 | { 63 | "name": "indexOf()", 64 | "trans": [ 65 | "int indexOf(String str, int fromIndex):从指定的索引处开始,返回第一次出现的指定子字符串在该字符串中的索引。" 66 | ] 67 | }, 68 | { 69 | "name": "lastIndexOf()", 70 | "trans": [ 71 | "int lastIndexOf(String str):返回最右边出现的指定子字符串在此字符串中的索引。" 72 | ] 73 | }, 74 | { 75 | "name": "lastIndexOf()", 76 | "trans": [ 77 | "int lastIndexOf(String str, int fromIndex):返回 String 对象中子字符串最后出现的位置。" 78 | ] 79 | }, 80 | { 81 | "name": "length()", 82 | "trans": [ 83 | "int length():返回长度(字符数)。" 84 | ] 85 | }, 86 | { 87 | "name": "setCharAt()", 88 | "trans": [ 89 | "void setCharAt(int index, char ch):将给定索引处的字符设置为 ch。" 90 | ] 91 | }, 92 | { 93 | "name": "setLength()", 94 | "trans": [ 95 | "void setLength(int newLength):设置字符序列的长度。" 96 | ] 97 | }, 98 | { 99 | "name": "subSequence()", 100 | "trans": [ 101 | "CharSequence subSequence(int start, int end):返回一个新的字符序列,该字符序列是此序列的子序列。" 102 | ] 103 | }, 104 | { 105 | "name": "substring()", 106 | "trans": [ 107 | "String substring(int start):返回一个新的 String,它包含此字符序列当前所包含的字符子序列。" 108 | ] 109 | }, 110 | { 111 | "name": "substring()", 112 | "trans": [ 113 | "String substring(int start, int end):返回一个新的 String,它包含此序列当前所包含的字符子序列。" 114 | ] 115 | }, 116 | { 117 | "name": "toString()", 118 | "trans": [ 119 | "String toString():返回此序列中数据的字符串表示形式。" 120 | ] 121 | } 122 | ] -------------------------------------------------------------------------------- /public/dicts/chinese_test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "谢谢", 4 | "trans": [ 5 | "thank you" 6 | ] 7 | }, 8 | { 9 | "name": "对不起", 10 | "trans": [ 11 | "sorry" 12 | ] 13 | }, 14 | { 15 | "name": "再见", 16 | "trans": [ 17 | "goodbye" 18 | ] 19 | }, 20 | { 21 | "name": "喜欢", 22 | "trans": [ 23 | "like" 24 | ] 25 | }, 26 | { 27 | "name": "爱", 28 | "trans": [ 29 | "love" 30 | ] 31 | }, 32 | { 33 | "name": "好的", 34 | "trans": [ 35 | "okay", 36 | "alright" 37 | ] 38 | }, 39 | { 40 | "name": "是的", 41 | "trans": [ 42 | "yes" 43 | ] 44 | }, 45 | { 46 | "name": "不", 47 | "trans": [ 48 | "no" 49 | ] 50 | }, 51 | { 52 | "name": "可能", 53 | "trans": [ 54 | "maybe", 55 | "perhaps" 56 | ] 57 | }, 58 | { 59 | "name": "真的吗", 60 | "trans": [ 61 | "really", 62 | "seriously" 63 | ] 64 | }, 65 | { 66 | "name": "不用谢", 67 | "trans": [ 68 | "you're welcome" 69 | ] 70 | }, 71 | { 72 | "name": "对的", 73 | "trans": [ 74 | "right", 75 | "correct" 76 | ] 77 | }, 78 | { 79 | "name": "错的", 80 | "trans": [ 81 | "wrong", 82 | "incorrect" 83 | ] 84 | }, 85 | { 86 | "name": "好久不见", 87 | "trans": [ 88 | "long time no see" 89 | ] 90 | }, 91 | { 92 | "name": "加油", 93 | "trans": [ 94 | "come on", 95 | "let's go" 96 | ] 97 | }, 98 | { 99 | "name": "放心", 100 | "trans": [ 101 | "rest assured", 102 | "don't worry" 103 | ] 104 | }, 105 | { 106 | "name": "好吃", 107 | "trans": [ 108 | "delicious", 109 | "tasty" 110 | ] 111 | }, 112 | { 113 | "name": "好玩", 114 | "trans": [ 115 | "fun", 116 | "entertaining" 117 | ] 118 | }, 119 | { 120 | "name": "好看", 121 | "trans": [ 122 | "good-looking", 123 | "beautiful" 124 | ] 125 | }, 126 | { 127 | "name": "难过", 128 | "trans": [ 129 | "sad", 130 | "upset" 131 | ] 132 | }, 133 | { 134 | "name": "高兴", 135 | "trans": [ 136 | "happy", 137 | "glad" 138 | ] 139 | }, 140 | { 141 | "name": "累了", 142 | "trans": [ 143 | "tired", 144 | "exhausted" 145 | ] 146 | }, 147 | { 148 | "name": "干杯", 149 | "trans": [ 150 | "cheers" 151 | ] 152 | }, 153 | { 154 | "name": "好的主意", 155 | "trans": [ 156 | "good idea" 157 | ] 158 | }, 159 | { 160 | "name": "祝福", 161 | "trans": [ 162 | "blessing", 163 | "wishing well" 164 | ] 165 | }, 166 | { 167 | "name": "恭喜", 168 | "trans": [ 169 | "congratulations" 170 | ] 171 | } 172 | ] -------------------------------------------------------------------------------- /src/assets/sharePic/keyBackground.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/InfoPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Transition } from '@headlessui/react' 2 | import classNames from 'classnames' 3 | import React, { ElementType, Fragment, SVGProps } from 'react' 4 | 5 | type InfoPanelProps = { 6 | openState: boolean 7 | onClose: () => void 8 | title: string 9 | icon: ElementType> 10 | iconClassName: string 11 | buttonClassName: string 12 | children: React.ReactNode 13 | } 14 | 15 | const InfoPanel: React.FC = ({ openState, title, onClose, icon: Icon, iconClassName, buttonClassName, children }) => { 16 | return ( 17 | 18 | onClose()}> 19 | 28 |
29 | 30 | 31 |
32 |
33 | 42 | 43 |
44 |
45 |
51 | 52 |
53 |
54 | 55 | {title} 56 | 57 |
{children}
58 |
59 |
60 |
61 |
62 | 65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | ) 73 | } 74 | 75 | export default InfoPanel 76 | -------------------------------------------------------------------------------- /public/dicts/python-set.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "x in s", 4 | "trans": [ 5 | "判断键是否存在于字典中,如果键在字典 dict 里返回 true,否则返回 false" 6 | ] 7 | }, 8 | { 9 | "name": "add()", 10 | "trans": [ 11 | "用于给集合添加元素,如果添加的元素在集合中已存在,则不执行任何操作" 12 | ] 13 | }, 14 | { 15 | "name": "clear()", 16 | "trans": [ 17 | "用于移除集合中的所有元素" 18 | ] 19 | }, 20 | { 21 | "name": "copy()", 22 | "trans": [ 23 | "用于拷贝一个集合" 24 | ] 25 | }, 26 | { 27 | "name": "difference_update()", 28 | "trans": [ 29 | "用于移除两个集合中都存在的元素,没有返回值" 30 | ] 31 | }, 32 | { 33 | "name": "difference()", 34 | "trans": [ 35 | "返回一个移除相同元素的新集合" 36 | ] 37 | }, 38 | { 39 | "name": "discard()", 40 | "trans": [ 41 | "用于移除指定的集合元素" 42 | ] 43 | }, 44 | { 45 | "name": "intersection_update()", 46 | "trans": [ 47 | "用于获取两个或更多集合中都重叠的元素,原始的集合上移除不重叠的元素,即计算交集" 48 | ] 49 | }, 50 | { 51 | "name": "intersection()", 52 | "trans": [ 53 | "返回两个或更多集合中都包含的元素,即交集,返回一个新的集合" 54 | ] 55 | }, 56 | { 57 | "name": "len()", 58 | "trans": [ 59 | "返回对象(字符、列表、元组等)长度或项目个数" 60 | ] 61 | }, 62 | { 63 | "name": "pop()", 64 | "trans": [ 65 | "随机移除一个元素" 66 | ] 67 | }, 68 | { 69 | "name": "remove()", 70 | "trans": [ 71 | "移除集合中的指定元素" 72 | ] 73 | }, 74 | { 75 | "name": "isdisjoint()", 76 | "trans": [ 77 | "判断两个集合是否包含相同的元素,如果没有返回 True,否则返回 False。" 78 | ] 79 | }, 80 | { 81 | "name": "issubset()", 82 | "trans": [ 83 | "判断集合的所有元素是否都包含在指定集合中,如果是则返回 True,否则返回 False" 84 | ] 85 | }, 86 | { 87 | "name": "issuperset()", 88 | "trans": [ 89 | "判断指定集合的所有元素是否都包含在原始的集合中,如果是则返回 True,否则返回 False" 90 | ] 91 | }, 92 | { 93 | "name": "symmetric_difference_update()", 94 | "trans": [ 95 | "移除当前集合中在另外一个指定集合相同的元素,并将另外一个指定集合中不同的元素插入到当前集合中" 96 | ] 97 | }, 98 | { 99 | "name": "symmetric_difference()", 100 | "trans": [ 101 | "返回两个集合中不重复的元素集合,即会移除两个集合中都存在的元素。" 102 | ] 103 | }, 104 | { 105 | "name": "union()", 106 | "trans": [ 107 | "返回两个集合的并集,即包含了所有集合的元素,重复的元素只会出现一次" 108 | ] 109 | }, 110 | { 111 | "name": "update()", 112 | "trans": [ 113 | "修改当前集合,可以添加新的元素或集合到当前集合中,如果添加的元素在集合中已存在,则该元素只会出现一次,重复的会忽略" 114 | ] 115 | }, 116 | { 117 | "name": "fromkeys()", 118 | "trans": [ 119 | "创建一个新字典,以序列 seq 中元素做字典的键,value 为字典所有键对应的初始值" 120 | ] 121 | }, 122 | { 123 | "name": "get()", 124 | "trans": [ 125 | "返回指定键的值" 126 | ] 127 | }, 128 | { 129 | "name": "has_key()", 130 | "trans": [ 131 | "判断键是否存在于字典中,如果键在字典dict里返回true,否则返回false" 132 | ] 133 | }, 134 | { 135 | "name": "items()", 136 | "trans": [ 137 | "以列表返回可遍历的(键, 值) 元组数组" 138 | ] 139 | }, 140 | { 141 | "name": "iteritems()", 142 | "trans": [ 143 | "将一个字典以列表的形式返回,因为字典是无序的,所以返回的列表也是无序的" 144 | ] 145 | }, 146 | { 147 | "name": "iterkeys()", 148 | "trans": [ 149 | "返回一个迭代器" 150 | ] 151 | }, 152 | { 153 | "name": "keys()", 154 | "trans": [ 155 | "返回一个视图对象" 156 | ] 157 | }, 158 | { 159 | "name": "popitem()", 160 | "trans": [ 161 | "返回并删除字典中的最后一对键和值" 162 | ] 163 | }, 164 | { 165 | "name": "setdefault()", 166 | "trans": [ 167 | "如果键不存在于字典中,将会添加键并将值设为默认值" 168 | ] 169 | }, 170 | { 171 | "name": "values()", 172 | "trans": [ 173 | "返回一个视图对象" 174 | ] 175 | } 176 | ] -------------------------------------------------------------------------------- /public/dicts/Child_python_turtle_code.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "screensize", 4 | "trans": [ 5 | "设置画布大小及颜色" 6 | ] 7 | }, 8 | { 9 | "name": "setup", 10 | "trans": [ 11 | "设置窗口大小和位置" 12 | ] 13 | }, 14 | { 15 | "name": "forward", 16 | "trans": [ 17 | "向当前画笔方向移动dist像素长,简化为fd(dist)" 18 | ] 19 | }, 20 | { 21 | "name": "backward", 22 | "trans": [ 23 | "向当前画笔相反方向移动dist像素长度,简化为bk(dist)" 24 | ] 25 | }, 26 | { 27 | "name": "right", 28 | "trans": [ 29 | "顺时针转动degree°" 30 | ] 31 | }, 32 | { 33 | "name": "left", 34 | "trans": [ 35 | "逆时针转动degree°" 36 | ] 37 | }, 38 | { 39 | "name": "circle", 40 | "trans": [ 41 | "逆时针画半径为r整圆" 42 | ] 43 | }, 44 | { 45 | "name": "dot", 46 | "trans": [ 47 | "按给定直径画圆点" 48 | ] 49 | }, 50 | { 51 | "name": "speed", 52 | "trans": [ 53 | "画笔绘制的速度,s为0-10的整数(1-10越来越快,0表示最快)" 54 | ] 55 | }, 56 | { 57 | "name": "pendown", 58 | "trans": [ 59 | "移动时绘制图形,缺省时也为绘制,简化为down()" 60 | ] 61 | }, 62 | { 63 | "name": "penup", 64 | "trans": [ 65 | "移动时不绘制图形,提起笔,用于另起一个地方绘制时用,简化为up()" 66 | ] 67 | }, 68 | { 69 | "name": "goto", 70 | "trans": [ 71 | "将画笔移动到坐标为x,y的位置" 72 | ] 73 | }, 74 | { 75 | "name": "setx", 76 | "trans": [ 77 | "将当前x轴移动到指定位置" 78 | ] 79 | }, 80 | { 81 | "name": "sety", 82 | "trans": [ 83 | "将当前y轴移动到指定位置" 84 | ] 85 | }, 86 | { 87 | "name": "setheading", 88 | "trans": [ 89 | "设置当前朝向为angle角度,简化为seth(angle)" 90 | ] 91 | }, 92 | { 93 | "name": "home", 94 | "trans": [ 95 | "设置当前画笔位置为原点(0,0),并恢复默认朝向为向东(0)" 96 | ] 97 | }, 98 | { 99 | "name": "pensize", 100 | "trans": [ 101 | "绘制图形时的画笔宽度" 102 | ] 103 | }, 104 | { 105 | "name": "pencolor", 106 | "trans": [ 107 | "画笔颜色" 108 | ] 109 | }, 110 | { 111 | "name": "shape", 112 | "trans": [ 113 | "画笔外观形状,可选classic/arrow/turtle/circle/square/triangle" 114 | ] 115 | }, 116 | { 117 | "name": "fillcolor", 118 | "trans": [ 119 | "图形的填充颜色" 120 | ] 121 | }, 122 | { 123 | "name": "color", 124 | "trans": [ 125 | "同时设置pencolor=c1, fillcolor=c2" 126 | ] 127 | }, 128 | { 129 | "name": "begin_fill", 130 | "trans": [ 131 | "准备开始填充图形" 132 | ] 133 | }, 134 | { 135 | "name": "end_fill", 136 | "trans": [ 137 | "填充完成" 138 | ] 139 | }, 140 | { 141 | "name": "hideturtle", 142 | "trans": [ 143 | "隐藏箭头显示,简化为ht()" 144 | ] 145 | }, 146 | { 147 | "name": "showturtle", 148 | "trans": [ 149 | "与hideturtle()函数对应,简化为st()" 150 | ] 151 | }, 152 | { 153 | "name": "clear", 154 | "trans": [ 155 | "清空turtle窗口,但是turtle的位置和状态不会改变" 156 | ] 157 | }, 158 | { 159 | "name": "reset", 160 | "trans": [ 161 | "清空窗口,重置turtle状态为起始状态" 162 | ] 163 | } 164 | ] -------------------------------------------------------------------------------- /public/dicts/java-arraylist.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "add()", 4 | "trans": [ 5 | "add():将元素插入到指定位置的 arraylist 中" 6 | ] 7 | }, 8 | { 9 | "name": "addAll()", 10 | "trans": [ 11 | "addAll():添加集合中的所有元素到 arraylist 中" 12 | ] 13 | }, 14 | { 15 | "name": "clear()", 16 | "trans": [ 17 | "clear():删除 arraylist 中的所有元素" 18 | ] 19 | }, 20 | { 21 | "name": "clone()", 22 | "trans": [ 23 | "clone():复制一份 arraylist" 24 | ] 25 | }, 26 | { 27 | "name": "contains()", 28 | "trans": [ 29 | "contains():判断元素是否在 arraylist" 30 | ] 31 | }, 32 | { 33 | "name": "get()", 34 | "trans": [ 35 | "get():通过索引值获取 arraylist 中的元素" 36 | ] 37 | }, 38 | { 39 | "name": "indexOf()", 40 | "trans": [ 41 | "indexOf():返回 arraylist 中元素的索引值" 42 | ] 43 | }, 44 | { 45 | "name": "removeAll()", 46 | "trans": [ 47 | "removeAll():删除存在于指定集合中的 arraylist 里的所有元素" 48 | ] 49 | }, 50 | { 51 | "name": "remove()", 52 | "trans": [ 53 | "remove():删除 arraylist 里的单个元素" 54 | ] 55 | }, 56 | { 57 | "name": "size()", 58 | "trans": [ 59 | "size():返回 arraylist 里元素数量" 60 | ] 61 | }, 62 | { 63 | "name": "isEmpty()", 64 | "trans": [ 65 | "isEmpty():判断 arraylist 是否为空" 66 | ] 67 | }, 68 | { 69 | "name": "subList()", 70 | "trans": [ 71 | "subList():截取部分 arraylist 的元素" 72 | ] 73 | }, 74 | { 75 | "name": "set()", 76 | "trans": [ 77 | "set():替换 arraylist 中指定索引的元素" 78 | ] 79 | }, 80 | { 81 | "name": "sort()", 82 | "trans": [ 83 | "sort():对 arraylist 元素进行排序" 84 | ] 85 | }, 86 | { 87 | "name": "toArray()", 88 | "trans": [ 89 | "toArray():将 arraylist 转换为数组" 90 | ] 91 | }, 92 | { 93 | "name": "toString()", 94 | "trans": [ 95 | "toString():将 arraylist 转换为字符串" 96 | ] 97 | }, 98 | { 99 | "name": "ensureCapacity()", 100 | "trans": [ 101 | "ensureCapacity():设置指定容量大小的 arraylist" 102 | ] 103 | }, 104 | { 105 | "name": "lastIndexOf()", 106 | "trans": [ 107 | "lastIndexOf():返回指定元素在 arraylist 中最后一次出现的位置" 108 | ] 109 | }, 110 | { 111 | "name": "retainAll()", 112 | "trans": [ 113 | "retainAll():保留 arraylist 中在指定集合中也存在的那些元素" 114 | ] 115 | }, 116 | { 117 | "name": "containsAll()", 118 | "trans": [ 119 | "containsAll():查看 arraylist 是否包含指定集合中的所有元素" 120 | ] 121 | }, 122 | { 123 | "name": "trimToSize()", 124 | "trans": [ 125 | "trimToSize():将 arraylist 中的容量调整为数组中的元素个数" 126 | ] 127 | }, 128 | { 129 | "name": "removeRange()", 130 | "trans": [ 131 | "removeRange():删除 arraylist 中指定索引之间存在的元素" 132 | ] 133 | }, 134 | { 135 | "name": "replaceAll()", 136 | "trans": [ 137 | "replaceAll():将给定的操作内容替换掉数组中每一个元素" 138 | ] 139 | }, 140 | { 141 | "name": "removeIf()", 142 | "trans": [ 143 | "removeIf():删除所有满足特定条件的 arraylist 元素" 144 | ] 145 | }, 146 | { 147 | "name": "forEach()", 148 | "trans": [ 149 | "forEach():遍历 arraylist 中每一个元素并执行特定操作" 150 | ] 151 | } 152 | ] -------------------------------------------------------------------------------- /public/dicts/js-number.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Number.EPSILON", 4 | "trans": [ 5 | "Number.EPSILON 属性表示 1 和大于 1 的最小的浮点数(可表示为 Number)的差值。" 6 | ] 7 | }, 8 | { 9 | "name": "Number.MAX_SAFE_INTEGER", 10 | "trans": [ 11 | "Number.MAX_SAFE_INTEGER 常量表示在 JavaScript 中最大的安全整数(maxinum safe integer)(253 - 1)。" 12 | ] 13 | }, 14 | { 15 | "name": "Number.MAX_VALUE", 16 | "trans": [ 17 | "Number.MAX_VALUE 属性表示在 JavaScript 里所能表示的最大数值。" 18 | ] 19 | }, 20 | { 21 | "name": "Number.MIN_SAFE_INTEGER", 22 | "trans": [ 23 | "Number.MIN_SAFE_INTEGER 代表在 JavaScript中最小的安全的integer型数字 (-(253 - 1))." 24 | ] 25 | }, 26 | { 27 | "name": "Number.MIN_VALUE", 28 | "trans": [ 29 | "Number.MIN_VALUE 属性表示在 JavaScript 中所能表示的最小的正值。" 30 | ] 31 | }, 32 | { 33 | "name": "Number.NEGATIVE_INFINITY", 34 | "trans": [ 35 | "Number.NEGATIVE_INFINITY 属性表示负无穷大。" 36 | ] 37 | }, 38 | { 39 | "name": "Number.NaN", 40 | "trans": [ 41 | "Number.NaN 表示“非数字”(Not-A-Number)。和 NaN 相同。" 42 | ] 43 | }, 44 | { 45 | "name": "Number.POSITIVE_INFINITY", 46 | "trans": [ 47 | "Number.POSITIVE_INFINITY 属性表示正无穷大。" 48 | ] 49 | }, 50 | { 51 | "name": "Number.isFinite()", 52 | "trans": [ 53 | "Number.isFinite() 方法用来检测传入的参数是否是一个有穷数(finite number)。" 54 | ] 55 | }, 56 | { 57 | "name": "Number.isInteger()", 58 | "trans": [ 59 | "Number.isInteger() 方法用来判断给定的参数是否为整数。" 60 | ] 61 | }, 62 | { 63 | "name": "Number.isNaN()", 64 | "trans": [ 65 | "Number.isNaN() 方法确定传递的值是否为 NaN和其类型是 Number。它是原始的全局isNaN()的更强大的版本。" 66 | ] 67 | }, 68 | { 69 | "name": "Number.isSafeInteger()", 70 | "trans": [ 71 | "Number.isSafeInteger() 方法用来判断传入的参数值是否是一个“安全整数”(safe integer)。一个安全整数是一个符合下面条件的整数:" 72 | ] 73 | }, 74 | { 75 | "name": "Number.parseFloat()", 76 | "trans": [ 77 | "Number.parseFloat() 方法可以把一个字符串解析成浮点数。该方法与全局的 parseFloat() 函数相同,并且处于 ECMAScript 6 规范中(用于全局变量的模块化)。" 78 | ] 79 | }, 80 | { 81 | "name": "Number.parseInt()", 82 | "trans": [ 83 | "Number.parseInt() 方法可以根据给定的进制数把一个字符串解析成整数。" 84 | ] 85 | }, 86 | { 87 | "name": "toExponential()", 88 | "trans": [ 89 | "toExponential() 方法以指数表示法返回该数值字符串表示形式。" 90 | ] 91 | }, 92 | { 93 | "name": "toFixed()", 94 | "trans": [ 95 | "toFixed() 方法使用定点表示法来格式化一个数。" 96 | ] 97 | }, 98 | { 99 | "name": "toLocaleString()", 100 | "trans": [ 101 | "toLocaleString() 方法返回这个数字在特定语言环境下的表示字符串。" 102 | ] 103 | }, 104 | { 105 | "name": "toPrecision()", 106 | "trans": [ 107 | "toPrecision() 方法以指定的精度返回该数值对象的字符串表示。" 108 | ] 109 | }, 110 | { 111 | "name": "toSource()", 112 | "trans": [ 113 | "toSource() 方法返回该对象源码的字符串表示。" 114 | ] 115 | }, 116 | { 117 | "name": "toString()", 118 | "trans": [ 119 | "toString() 方法返回指定 Number 对象的字符串表示形式。" 120 | ] 121 | }, 122 | { 123 | "name": "valueOf()", 124 | "trans": [ 125 | "valueOf() 方法返回一个被 Number 对象包装的原始值。" 126 | ] 127 | }, 128 | { 129 | "name": "Number.toInteger()", 130 | "trans": [ 131 | "Number.toInteger() 用来将参数转换成整数,但该方法的实现已被移除." 132 | ] 133 | } 134 | ] -------------------------------------------------------------------------------- /src/pages/Typing/hooks/useWordList.ts: -------------------------------------------------------------------------------- 1 | import { CHAPTER_LENGTH } from '@/constants' 2 | import { currentDictInfoAtom, currentChapterAtom } from '@/store' 3 | import { Word, WordWithIndex } from '@/typings/index' 4 | import { useAtom, useAtomValue } from 'jotai' 5 | import { useMemo } from 'react' 6 | import useSWR from 'swr' 7 | 8 | export type UseWordListResult = { 9 | words: WordWithIndex[] | undefined 10 | isLoading: boolean 11 | error: Error | undefined 12 | } 13 | 14 | /** 15 | * Use word lists from the current selected dictionary. 16 | */ 17 | export function useWordList(): UseWordListResult { 18 | const currentDictInfo = useAtomValue(currentDictInfoAtom) 19 | const [currentChapter, setCurrentChapter] = useAtom(currentChapterAtom) 20 | 21 | const isFirstChapter = currentDictInfo.id === 'cet4' && currentChapter === 0 22 | 23 | // Reset current chapter to 0, when currentChapter is greater than chapterCount. 24 | if (currentChapter >= currentDictInfo.chapterCount) { 25 | setCurrentChapter(0) 26 | } 27 | 28 | const { data: wordList, error, isLoading } = useSWR(currentDictInfo.url, wordListFetcher) 29 | 30 | const words: WordWithIndex[] = useMemo(() => { 31 | const newWords = isFirstChapter 32 | ? firstChapter 33 | : wordList 34 | ? wordList.slice(currentChapter * CHAPTER_LENGTH, (currentChapter + 1) * CHAPTER_LENGTH) 35 | : [] 36 | 37 | // 记录原始 index 38 | return newWords.map((word, index) => ({ ...word, index })) 39 | }, [isFirstChapter, wordList, currentChapter]) 40 | 41 | return { words: wordList === undefined ? undefined : words, isLoading, error } 42 | } 43 | 44 | async function wordListFetcher(url: string): Promise { 45 | const URL_PREFIX: string = REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner' : '' 46 | 47 | const response = await fetch(URL_PREFIX + url) 48 | const words: Word[] = await response.json() 49 | return words 50 | } 51 | 52 | const firstChapter = [ 53 | { name: 'cancel', trans: ['取消, 撤销; 删去'], usphone: "'kænsl", ukphone: "'kænsl" }, 54 | { name: 'explosive', trans: ['爆炸的; 极易引起争论的', '炸药'], usphone: "ɪk'splosɪv; ɪk'splozɪv", ukphone: "ɪk'spləusɪv" }, 55 | { name: 'numerous', trans: ['众多的'], usphone: "'numərəs", ukphone: "'njuːmərəs" }, 56 | { name: 'govern', trans: ['居支配地位, 占优势', '统治,治理,支配'], usphone: "'ɡʌvɚn", ukphone: "'gʌvn" }, 57 | { name: 'analyse', trans: ['分析; 分解; 解析'], usphone: "'æn(ə)laɪz", ukphone: "'ænəlaɪz" }, 58 | { name: 'discourage', trans: ['使泄气, 使灰心; 阻止, 劝阻'], usphone: "dɪs'kɝɪdʒ", ukphone: "dɪs'kʌrɪdʒ" }, 59 | { name: 'resemble', trans: ['像, 类似于'], usphone: "rɪ'zɛmbl", ukphone: "rɪ'zembl" }, 60 | { 61 | name: 'remote', 62 | trans: ['遥远的; 偏僻的; 关系疏远的; 脱离的; 微乎其微的; 孤高的, 冷淡的; 遥控的'], 63 | usphone: "rɪ'mot", 64 | ukphone: "rɪ'məut", 65 | }, 66 | { name: 'salary', trans: ['薪金, 薪水'], usphone: "'sæləri", ukphone: "'sæləri" }, 67 | { name: 'pollution', trans: ['污染, 污染物'], usphone: "pə'luʃən", ukphone: "pə'luːʃn" }, 68 | { name: 'pretend', trans: ['装作, 假装'], usphone: "prɪ'tɛnd", ukphone: "prɪ'tend" }, 69 | { name: 'kettle', trans: ['水壶'], usphone: "'kɛtl", ukphone: "'ketl" }, 70 | { name: 'wreck', trans: ['失事;残骸;精神或身体已垮的人', '破坏'], usphone: 'rɛk', ukphone: 'rek' }, 71 | { name: 'drunk', trans: ['醉的; 陶醉的'], usphone: 'drʌŋk', ukphone: 'drʌŋk' }, 72 | { name: 'calculate', trans: ['计算; 估计; 计划'], usphone: "'kælkjulet", ukphone: "'kælkjuleɪt" }, 73 | { name: 'persistent', trans: ['坚持的, 不屈不挠的; 持续不断的; 反复出现的'], usphone: "pə'zɪstənt", ukphone: "pə'sɪstənt" }, 74 | { name: 'sake', trans: ['缘故, 理由'], usphone: 'sek', ukphone: 'seɪk' }, 75 | { name: 'conceal', trans: ['把…隐藏起来, 掩盖, 隐瞒'], usphone: "kən'sil", ukphone: "kən'siːl" }, 76 | { name: 'audience', trans: ['听众, 观众, 读者'], usphone: "'ɔdɪəns", ukphone: "'ɔːdiəns" }, 77 | { name: 'meanwhile', trans: ['与此同时'], usphone: "'minwaɪl", ukphone: "'miːnwaɪl" }, 78 | ] 79 | -------------------------------------------------------------------------------- /public/dicts/java-linkedlist.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "offerLast()", 4 | "trans": [ 5 | "public boolean offerLast(E e):尾部插入元素,返回是否成功,成功为 true,失败为 false。" 6 | ] 7 | }, 8 | { 9 | "name": "clear()", 10 | "trans": [ 11 | "public void clear():清空链表。" 12 | ] 13 | }, 14 | { 15 | "name": "removeFirst()", 16 | "trans": [ 17 | "public E removeFirst():删除并返回第一个元素。" 18 | ] 19 | }, 20 | { 21 | "name": "removeLast()", 22 | "trans": [ 23 | "public E removeLast():删除并返回最后一个元素。" 24 | ] 25 | }, 26 | { 27 | "name": "remove()", 28 | "trans": [ 29 | "public boolean remove(Object o):删除某一元素,返回是否成功,成功为 true,失败为 false。" 30 | ] 31 | }, 32 | { 33 | "name": "remove()", 34 | "trans": [ 35 | "public E remove(int index):删除指定位置的元素。" 36 | ] 37 | }, 38 | { 39 | "name": "poll()", 40 | "trans": [ 41 | "public E poll():删除并返回第一个元素。" 42 | ] 43 | }, 44 | { 45 | "name": "remove()", 46 | "trans": [ 47 | "public E remove():删除并返回第一个元素。" 48 | ] 49 | }, 50 | { 51 | "name": "contains()", 52 | "trans": [ 53 | "public boolean contains(Object o):判断是否含有某一元素。" 54 | ] 55 | }, 56 | { 57 | "name": "get()", 58 | "trans": [ 59 | "public E get(int index):返回指定位置的元素。" 60 | ] 61 | }, 62 | { 63 | "name": "getFirst()", 64 | "trans": [ 65 | "public E getFirst():返回第一个元素。" 66 | ] 67 | }, 68 | { 69 | "name": "getLast()", 70 | "trans": [ 71 | "public E getLast():返回最后一个元素。" 72 | ] 73 | }, 74 | { 75 | "name": "indexOf()", 76 | "trans": [ 77 | "public int indexOf(Object o):查找指定元素从前往后第一次出现的索引。" 78 | ] 79 | }, 80 | { 81 | "name": "lastIndexOf()", 82 | "trans": [ 83 | "public int lastIndexOf(Object o):查找指定元素最后一次出现的索引。" 84 | ] 85 | }, 86 | { 87 | "name": "peek()", 88 | "trans": [ 89 | "public E peek():返回第一个元素。" 90 | ] 91 | }, 92 | { 93 | "name": "element()", 94 | "trans": [ 95 | "public E element():返回第一个元素。" 96 | ] 97 | }, 98 | { 99 | "name": "peekFirst()", 100 | "trans": [ 101 | "public E peekFirst():返回头部元素。" 102 | ] 103 | }, 104 | { 105 | "name": "peekLast()", 106 | "trans": [ 107 | "public E peekLast():返回尾部元素。" 108 | ] 109 | }, 110 | { 111 | "name": "set()", 112 | "trans": [ 113 | "public E set(int index, E element):设置指定位置的元素。" 114 | ] 115 | }, 116 | { 117 | "name": "clone()", 118 | "trans": [ 119 | "public Object clone():克隆该列表。" 120 | ] 121 | }, 122 | { 123 | "name": "descendingIterator()", 124 | "trans": [ 125 | "public Iterator descendingIterator():返回倒序迭代器。" 126 | ] 127 | }, 128 | { 129 | "name": "size()", 130 | "trans": [ 131 | "public int size():返回链表元素个数。" 132 | ] 133 | }, 134 | { 135 | "name": "listIterator()", 136 | "trans": [ 137 | "public ListIterator listIterator(int index):返回从指定位置开始到末尾的迭代器。" 138 | ] 139 | }, 140 | { 141 | "name": "toArray()", 142 | "trans": [ 143 | "public Object[] toArray():返回一个由链表元素组成的数组。" 144 | ] 145 | }, 146 | { 147 | "name": "toArray()", 148 | "trans": [ 149 | "public T[] toArray(T[] a):返回一个由链表元素转换类型而成的数组。" 150 | ] 151 | } 152 | ] -------------------------------------------------------------------------------- /src/pages/Typing/components/SoundSwitcher/index.tsx: -------------------------------------------------------------------------------- 1 | import { keySoundsConfigAtom, hintSoundsConfigAtom } from '@/store' 2 | import { Popover, Transition, Switch } from '@headlessui/react' 3 | import { useAtom } from 'jotai' 4 | import { Fragment, useCallback } from 'react' 5 | import IconSpeakerWave from '~icons/heroicons/speaker-wave-solid' 6 | 7 | export default function SoundSwitcher() { 8 | const [keySoundsConfig, setKeySoundsConfig] = useAtom(keySoundsConfigAtom) 9 | const [hintSoundsConfig, setHintSoundsConfig] = useAtom(hintSoundsConfigAtom) 10 | 11 | const onChangeKeySound = useCallback( 12 | (checked: boolean) => { 13 | setKeySoundsConfig((old) => ({ ...old, isOpen: checked })) 14 | }, 15 | [setKeySoundsConfig], 16 | ) 17 | 18 | const onChangeHintSound = useCallback( 19 | (checked: boolean) => { 20 | setHintSoundsConfig((old) => ({ ...old, isOpen: checked })) 21 | }, 22 | [setHintSoundsConfig], 23 | ) 24 | 25 | return ( 26 | 27 | {({ open }) => ( 28 | <> 29 | { 34 | e.target.blur() 35 | }} 36 | aria-label="音效设置" 37 | title="音效设置" 38 | > 39 | 40 | 41 | 42 | 51 | 52 |
53 |
54 | 开关按键音 55 |
56 | 57 | 59 | {`发音已${ 60 | keySoundsConfig.isOpen ? '开启' : '关闭' 61 | }`} 62 |
63 |
64 |
65 | 开关效果音 66 |
67 | 68 | 70 | {`发音已${ 71 | hintSoundsConfig.isOpen ? '开启' : '关闭' 72 | }`} 73 |
74 |
75 |
76 |
77 |
78 | 79 | )} 80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /public/dicts/python-builtin.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "name": "abs", "trans": ["返回一个数的绝对值"] }, 3 | { 4 | "name": "all", 5 | "trans": ["如果 iterable对象 的所有元素均为真值则返回 True"] 6 | }, 7 | { "name": "any", "trans": ["如果 iterable对象 的任一元素为真值则返回 True"] }, 8 | { "name": "ascii", "trans": ["返回一个包含对象的可打印表示形式的字符串"] }, 9 | { "name": "bin", "trans": ["将一个整数转变为二进制字符串"] }, 10 | { "name": "bool", "trans": ["布尔"] }, 11 | { "name": "breakpoint", "trans": ["此函数会在调用时将你陷入调试器中"] }, 12 | { "name": "bytearray", "trans": ["返回一个新的 bytes 数组"] }, 13 | { "name": "bytes", "trans": ["返回一个新的 bytes 对象"] }, 14 | { "name": "callable", "trans": ["检测对象是否可调用"] }, 15 | { "name": "chr", "trans": ["返回 Unicode 码位为整数 i 的字符的字符串格式"] }, 16 | { "name": "complex", "trans": ["返回值为 real + imag*1j 的复数"] }, 17 | { "name": "delattr", "trans": ["删除对象指定的属性"] }, 18 | { "name": "dict", "trans": ["创建一个新的字典"] }, 19 | { 20 | "name": "dir", 21 | "trans": [ 22 | "如果没有实参,则返回当前本地作用域中的名称列表。如果有实参,它会尝试返回该对象的有效属性列表。" 23 | ] 24 | }, 25 | { "name": "divmod", "trans": ["执行整数除法时返回一对商和余数"] }, 26 | { "name": "enumerate", "trans": ["返回一个枚举对象"] }, 27 | { "name": "eval", "trans": ["解析并求值一个 Python 表达式"] }, 28 | { "name": "exec", "trans": ["执行 Python 代码"] }, 29 | { 30 | "name": "filter", 31 | "trans": ["用 iterable 中函数 function 返回真的那些元素"] 32 | }, 33 | { "name": "float", "trans": ["返回从数字或字符串 x 生成的浮点数"] }, 34 | { 35 | "name": "format", 36 | "trans": ["将 value 转换为 format_spec 控制的 格式化 表示"] 37 | }, 38 | { "name": "frozenset", "trans": ["返回一个新的 不可变集合 对象"] }, 39 | { "name": "getattr", "trans": ["返回对象命名属性的值"] }, 40 | { "name": "globals", "trans": ["返回表示当前全局符号表的字典"] }, 41 | { "name": "hasattr", "trans": ["检测对象是否含有指定属性"] }, 42 | { "name": "hash", "trans": ["返回该对象的哈希值"] }, 43 | { "name": "help", "trans": ["启动内置的帮助系统"] }, 44 | { "name": "hex", "trans": ["将整数转换为以 0x 为前缀的小写十六进制字符串"] }, 45 | { "name": "id", "trans": ["返回对象的 标识值 "] }, 46 | { "name": "input", "trans": ["从输入中读取一行"] }, 47 | { "name": "int", "trans": ["返回一个基于数字或字符串 x 构造的整数对象"] }, 48 | { "name": "isinstance", "trans": ["检测对象是否为类的实例"] }, 49 | { "name": "issubclass", "trans": ["检测类是否为类的子类"] }, 50 | { "name": "iter", "trans": ["返回一个 iterable 对象"] }, 51 | { "name": "len", "trans": ["返回对象的长度(元素个数)"] }, 52 | { "name": "list", "trans": ["创建一个新的列表"] }, 53 | { "name": "locals", "trans": ["更新并返回表示当前本地符号表的字典"] }, 54 | { 55 | "name": "map", 56 | "trans": [ 57 | "返回一个将 function 应用于 iterable 中每一项并输出其结果的迭代器" 58 | ] 59 | }, 60 | { "name": "max", "trans": ["返回可迭代对象中最大的元素"] }, 61 | { "name": "memoryview", "trans": ["返回由给定实参创建的 内存视图 对象"] }, 62 | { "name": "min", "trans": ["返回可迭代对象中最小的元素"] }, 63 | { 64 | "name": "next", 65 | "trans": ["通过调用 iterator 的 __next__() 方法获取下一个元素"] 66 | }, 67 | { "name": "object", "trans": ["返回一个没有特征的新对象"] }, 68 | { "name": "oct", "trans": ["将一个整数转变为一个前缀为 0o 的八进制字符串"] }, 69 | { "name": "open", "trans": ["打开 file 并返回对应的 file object"] }, 70 | { "name": "ord", "trans": ["对表示单个 Unicode 字符的字符串"] }, 71 | { "name": "pow", "trans": ["返回 base 的 exp 次幂"] }, 72 | { "name": "print", "trans": ["将 objects 打印到 file 指定的文本流"] }, 73 | { "name": "property", "trans": ["返回 property 属性"] }, 74 | { "name": "range", "trans": ["创建一个整数列表"] }, 75 | { "name": "repr", "trans": ["返回包含一个对象的可打印表示形式的字符串"] }, 76 | { "name": "reversed", "trans": ["返回一个反向的 iterator"] }, 77 | { "name": "round", "trans": ["回 number 舍入到小数点后 ndigits 位精度的值"] }, 78 | { "name": "set", "trans": ["返回一个新的 set 对象"] }, 79 | { "name": "setattr", "trans": ["设置对象属性"] }, 80 | { "name": "slice", "trans": ["返回一个指定索引集的 slice 对象"] }, 81 | { "name": "sorted", "trans": ["根据 iterable 中的项返回一个新的已排序列表"] }, 82 | { "name": "str", "trans": ["返回一个 str 版本的 object"] }, 83 | { "name": "sum", "trans": ["自左向右对 iterable 的项求和并返回总计值"] }, 84 | { "name": "super", "trans": ["返回一个代理对象"] }, 85 | { "name": "tuple", "trans": ["返回一个新的 元组 对象"] }, 86 | { "name": "type", "trans": ["返回 object 的类型"] }, 87 | { 88 | "name": "vars", 89 | "trans": [ 90 | "返回模块、类、实例或任何其它具有 __dict__ 属性的对象的 __dict__ 属性" 91 | ] 92 | }, 93 | { 94 | "name": "zip", 95 | "trans": ["创建一个聚合了来自每个可迭代对象中的元素的迭代器"] 96 | } 97 | ] 98 | -------------------------------------------------------------------------------- /src/pages/Gallery-N/index.tsx: -------------------------------------------------------------------------------- 1 | import DictionaryGroup from './CategoryDicts' 2 | import ChapterList from './ChapterList' 3 | import DictRequest from './DictRequest' 4 | import { LanguageTabSwitcher } from './LanguageTabSwitcher' 5 | import Layout from '@/components/Layout' 6 | import { dictionaries } from '@/resources/dictionary' 7 | import { currentDictInfoAtom } from '@/store' 8 | import { Dictionary, LanguageCategoryType } from '@/typings' 9 | import groupBy, { groupByDictTags } from '@/utils/groupBy' 10 | import * as ScrollArea from '@radix-ui/react-scroll-area' 11 | import { useAtomValue } from 'jotai' 12 | import { createContext, useCallback, useEffect, useMemo } from 'react' 13 | import { useHotkeys } from 'react-hotkeys-hook' 14 | import { useNavigate } from 'react-router-dom' 15 | import { Updater, useImmer } from 'use-immer' 16 | import IconX from '~icons/tabler/x' 17 | 18 | export type GalleryState = { 19 | currentLanguageTab: LanguageCategoryType 20 | chapterListDict: Dictionary | null 21 | } 22 | 23 | const initialGalleryState: GalleryState = { 24 | currentLanguageTab: 'en', 25 | chapterListDict: null, 26 | } 27 | 28 | export const GalleryContext = createContext<{ 29 | state: GalleryState 30 | setState: Updater 31 | } | null>(null) 32 | 33 | export default function GalleryPage() { 34 | const [galleryState, setGalleryState] = useImmer(initialGalleryState) 35 | const navigate = useNavigate() 36 | const currentDictInfo = useAtomValue(currentDictInfoAtom) 37 | 38 | const { groupedByCategoryAndTag } = useMemo(() => { 39 | const currentLanguageCategoryDicts = dictionaries.filter((dict) => dict.languageCategory === galleryState.currentLanguageTab) 40 | const groupedByCategory = Object.entries(groupBy(currentLanguageCategoryDicts, (dict) => dict.category)) 41 | const groupedByCategoryAndTag = groupedByCategory.map( 42 | ([category, dicts]) => [category, groupByDictTags(dicts)] as [string, Record], 43 | ) 44 | 45 | return { 46 | groupedByCategoryAndTag, 47 | } 48 | }, [galleryState.currentLanguageTab]) 49 | 50 | const onBack = useCallback(() => { 51 | navigate('/') 52 | }, [navigate]) 53 | 54 | useHotkeys('enter,esc', onBack, { preventDefault: true }) 55 | 56 | useEffect(() => { 57 | if (currentDictInfo) { 58 | setGalleryState((state) => { 59 | state.currentLanguageTab = currentDictInfo.languageCategory 60 | }) 61 | } 62 | }, [currentDictInfo, setGalleryState]) 63 | 64 | return ( 65 | 66 | 67 | 68 |
69 | 70 |
71 |
72 |
73 | 74 | 75 |
76 | 77 | 78 |
79 | {groupedByCategoryAndTag.map(([category, groupeByTag]) => ( 80 | 81 | ))} 82 |
83 |
84 | 85 |
86 | {/* todo: 增加导航 */} 87 | {/*
88 | 89 |
*/} 90 |
91 |
92 |
93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/StarCard/index.tsx: -------------------------------------------------------------------------------- 1 | import starBar from '@/assets/starBar.svg' 2 | import { DISMISS_START_CARD_DATE_KEY } from '@/constants' 3 | import { dismissStartCardDateAtom } from '@/store' 4 | import { recordStarAction } from '@/utils' 5 | import { Transition } from '@headlessui/react' 6 | import { useSetAtom } from 'jotai' 7 | import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' 8 | import IconCircleX from '~icons/tabler/circle-x' 9 | 10 | export default function StarCard() { 11 | const [countdown, setCountdown] = useState(5) 12 | const [isCounting, setIsCounting] = useState(false) 13 | const setDismissStartCardDate = useSetAtom(dismissStartCardDateAtom) 14 | const [isShow, setIsShow] = useState(false) 15 | 16 | useLayoutEffect(() => { 17 | // 直接使用 jotai 的 dismissStartCardDate 其值先是默认值,然后才是 localStorage 中的值 18 | const value = window.localStorage.getItem(DISMISS_START_CARD_DATE_KEY) as Date | null 19 | if (value === null) { 20 | setIsShow(true) 21 | } 22 | }, []) 23 | 24 | const onClickCloseStar = useCallback(() => { 25 | setIsShow(false) 26 | setDismissStartCardDate(new Date()) 27 | if (!isCounting) { 28 | recordStarAction('dismiss') 29 | } 30 | }, [setIsShow, setDismissStartCardDate, isCounting]) 31 | 32 | const onClickWantStar = useCallback(() => { 33 | setIsCounting(true) 34 | setDismissStartCardDate(new Date()) 35 | recordStarAction('star') 36 | }, [setDismissStartCardDate]) 37 | 38 | useEffect(() => { 39 | let countdownId: number 40 | if (isCounting && countdown > 0) { 41 | countdownId = window.setInterval(() => { 42 | setCountdown((prevCount) => prevCount - 1) 43 | }, 1000) 44 | } 45 | if (countdown === 0) { 46 | setIsCounting(false) 47 | setIsShow(false) 48 | } 49 | 50 | return () => clearInterval(countdownId) 51 | }, [isCounting, countdown, setIsShow]) 52 | 53 | const content = useMemo(() => { 54 | return ( 55 | <> 56 | {isCounting ? ( 57 |
58 | star project 59 | 60 | 收藏快捷键cmd + d 61 | 62 |
63 | ) : ( 64 |
65 | 73 |
74 | )} 75 | 76 | ) 77 | }, [isCounting, onClickWantStar]) 78 | 79 | return ( 80 | 91 |
92 |
93 | {isCounting && ( 94 | 95 | {countdown}s 96 | 后自动关闭 97 | 98 | )} 99 | 102 |
103 | 104 | 坚持练习,提高语言能力。将 「Qwerty Learner」保存到收藏夹,永不迷失! 105 | 106 | {content} 107 |
108 |
109 | ) 110 | } 111 | --------------------------------------------------------------------------------