Merge branch 'master' into yubo_master

This commit is contained in:
YuboSound
2025-11-04 13:40:52 +08:00
committed by GitHub
27 changed files with 49768 additions and 257 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": ["WebSearch"],
"deny": [],
"ask": []
}
}

View File

@@ -21,11 +21,10 @@
<a><img src="https://img.shields.io/badge/Powered%20by-React-blue"/></a>
<a><img src="https://img.shields.io/github/stars/RealKai42/qwerty-learner"/></a>
<a><img src="https://img.shields.io/github/forks/RealKai42/qwerty-learner"/></a>
<a href="https://gitcode.com/RealKai42/qwerty-learner/overview"><img src="https://gitcode.com/RealKai42/qwerty-learner/star/badge.svg"/></a>
<a href="https://api.gitsponsors.com/api/badge/link?p=4DNHTkFshpqUNxrflL7n6ChVhCijBEN9zvnj50mFETUw2frmOXfrHW37m71uOnlp+eo/Vm8a0w94m0JeT4UAYxOAQwzuFiJS0UYIQ8H3km39ZcUnZ7wHNE05WpqBzTVcgZM26FijLiYv9m2p7mxx1w==">
<img src="https://api.gitsponsors.com/api/badge/img?id=331603334" height="20" alt="GitSponsors">
</a>
</p>
<div align=center>
<a href="https://trendshift.io/repositories/3239" target="_blank" class="trendshift-badge"><img src="https://trendshift.io/api/badge/repositories/3239" alt="RealKai42%2Fqwerty-learner | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
<div align=center>
<img src="docs/Screenshot.png"/>

View File

@@ -38,26 +38,150 @@
})(window.location)
</script>
<title>Qwerty Learner — 为键盘工作者设计的单词与肌肉记忆锻炼软件</title>
<title>Qwerty Learner 官网 — 为键盘工作者设计的单词与肌肉记忆锻炼软件</title>
<meta
name="description"
content="Qwerty Learner, 为键盘工作者设计的单词记忆与英语肌肉记忆锻炼软件 / Words learning and English muscle memory training software designed for keyboard workers"
content="Qwerty Learner 官方网站 - 专为键盘工作者设计的免费英语学习软件,结合打字练习与单词记忆,支持音标发音、默写模式、多样词库,高效提升英语水平和打字技能。"
/>
<meta
name="keywords"
content="Qwerty Learner, 打字练习软件, 单词记忆工具, 英语学习, 背单词, 英语肌肉记忆锻炼, 键盘工作者, 免费背单词软件"
content="Qwerty Learner官网, 官方网站, 打字练习软件, 单词记忆工具, 英语学习软件, 背单词神器, 英语肌肉记忆, 键盘工作者, 免费英语学习, 音标发音, 默写练习, 在线学英语, CET-4, CET-6, TOEFL, IELTS, GRE, GMAT, SAT, 考研英语, 专四专八, 程序员英语, JavaScript API, Node.js API, Java API, Linux命令, 编程词汇, 技术英语, VSCode插件, 开源项目, GitHub趋势榜, V2EX热搜, Gitee GVP, 少数派推荐, 英语打字训练, WPM统计, 准确率分析, 商务英语, BEC, 雅思听力, 日语学习, 多语言学习, 英语口语练习, 单词拼写训练"
/>
<meta name="author" content="Kaiyi" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<link rel="canonical" href="https://qwerty.kaiyi.cool/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://qwerty.kaiyi.cool/" />
<meta property="og:title" content="Qwerty Learner 官网 — 为键盘工作者设计的英语学习软件" />
<meta
property="og:description"
content="专为键盘工作者设计的免费英语学习软件,结合打字练习与单词记忆,支持音标发音、默写模式、多样词库,高效提升英语水平和打字技能。"
/>
<meta property="og:image" content="https://qwerty.kaiyi.cool/og-image.png" />
<meta property="og:site_name" content="Qwerty Learner" />
<meta property="og:locale" content="zh_CN" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://qwerty.kaiyi.cool/" />
<meta property="twitter:title" content="Qwerty Learner 官网 — 为键盘工作者设计的英语学习软件" />
<meta
property="twitter:description"
content="专为键盘工作者设计的免费英语学习软件,结合打字练习与单词记忆,支持音标发音、默写模式、多样词库,高效提升英语水平和打字技能。"
/>
<meta property="twitter:image" content="https://qwerty.kaiyi.cool/og-image.png" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#818CF8" />
<meta name="application-name" content="Qwerty Learner" />
<meta name="msapplication-TileColor" content="#818CF8" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Qwerty Learner" />
<meta name="format-detection" content="telephone=no" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<meta name="referrer" content="origin-when-cross-origin" />
<meta name="color-scheme" content="light dark" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://qwerty.kaiyi.cool" />
<link rel="source" href="https://github.com/Kaiyiwing/qwerty-learner" />
<link rel="source" href="https://qwerty.kaiyi.cool" />
<link rel="manifest" href="/manifest.json" />
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Qwerty Learner",
"alternateName": "QWERTY Learner",
"url": "https://qwerty.kaiyi.cool/",
"description": "专为键盘工作者设计的免费英语学习软件,结合打字练习与单词记忆,支持音标发音、默写模式、多样词库,高效提升英语水平和打字技能。",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Person",
"name": "Kaiyi"
},
"publisher": {
"@type": "Organization",
"name": "Qwerty Learner",
"url": "https://qwerty.kaiyi.cool/"
},
"inLanguage": "zh-CN",
"featureList": [
"单词记忆训练",
"音标发音练习",
"默写模式",
"多样化词库",
"打字技能提升",
"英语肌肉记忆锻炼",
"CET-4/CET-6词库",
"TOEFL/IELTS词库",
"GRE/GMAT词库",
"考研英语词库",
"专四专八词库",
"程序员专用词库",
"JavaScript API练习",
"Node.js API练习",
"Java API练习",
"Linux命令练习",
"商务英语词库",
"BEC词库",
"雅思听力词库",
"日语N1-N5词库",
"VSCode插件支持",
"实时速度统计",
"准确率分析",
"进度跟踪",
"错误纠正",
"多平台支持",
"开源免费"
],
"screenshot": "https://qwerty.kaiyi.cool/og-image.png",
"softwareVersion": "latest",
"dateModified": "2024-01-01",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"reviewCount": "1000"
},
"creator": {
"@type": "Person",
"name": "Kaiyi",
"@id": "https://github.com/RealKai42"
},
"maintainer": {
"@type": "Organization",
"@id": "https://github.com/RealKai42/qwerty-learner",
"name": "Qwerty Learner Team"
},
"audience": {
"@type": "Audience",
"audienceType": ["程序员", "学生", "上班族", "英语学习者", "键盘工作者"]
},
"educationalLevel": "初级到高级",
"educationalUse": "英语学习",
"learningResourceType": "在线工具",
"isAccessibleForFree": true,
"license": "https://github.com/Realkai42/qwerty-learner/blob/master/LICENSE"
}
</script>
<script>
// Dark mode init
if (

View File

@@ -20,6 +20,7 @@
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-table": "^8.10.7",
"@vercel/analytics": "^1.5.0",
"animate.css": "^4.1.1",
"canvas-confetti": "^1.6.0",
"class-variance-authority": "^0.7.0",
@@ -94,6 +95,7 @@
"@types/canvas-confetti": "^1.6.0",
"@types/echarts": "^4.9.18",
"@types/file-saver": "^2.0.5",
"@types/gtag.js": "^0.0.20",
"@types/howler": "^2.2.3",
"@types/mixpanel-browser": "^2.38.1",
"@types/node": "18.14.6",

File diff suppressed because it is too large Load Diff

View File

@@ -12848,7 +12848,7 @@
"ukphone": null
},
{
"name": "large- scale housing",
"name": "large-scale housing",
"trans": [
"大面积住房区域"
],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,168 @@
import noop from '../../utils/noop'
import { hasSeenEnhancedPromotionAtom } from '@/store'
import { trackPromotionEvent } from '@/utils/trackEvent'
import { Dialog, Transition } from '@headlessui/react'
import { useAtom } from 'jotai'
import type React from 'react'
import { Fragment, useEffect, useState } from 'react'
import IconStar from '~icons/heroicons/star-solid'
import IconX from '~icons/tabler/x'
const EnhancedPromotionModal: React.FC = () => {
const [hasSeenPromotion, setHasSeenPromotion] = useAtom(hasSeenEnhancedPromotionAtom)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
// Only show modal if user hasn't seen it before
if (!hasSeenPromotion) {
const timer = setTimeout(() => {
setIsOpen(true)
}, 2000) // Show after 2 seconds to let the page load
return () => clearTimeout(timer)
}
}, [hasSeenPromotion])
const handleTryNow = () => {
trackPromotionEvent('promotion_event', {
from: 'promotion_modal',
action: 'open',
action_detail: 'promotion_modal_open',
})
setHasSeenPromotion(true)
// setIsOpen(false)
// Open in new tab
window.open('https://qwertylearner.ai', '_blank')
}
const handleDismiss = () => {
trackPromotionEvent('promotion_event', {
from: 'promotion_modal',
action: 'close',
action_detail: 'promotion_modal_close',
})
setHasSeenPromotion(true)
setIsOpen(false)
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={noop}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-40 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="dark:via-gray-850 relative transform overflow-hidden rounded-2xl border border-blue-200 bg-gradient-to-br from-white via-blue-50 to-indigo-100 p-0 text-left shadow-2xl transition-all dark:border-gray-700 dark:from-gray-800 dark:to-gray-900 sm:my-8 sm:w-full sm:max-w-xl">
{/* Header with close button */}
<div className="absolute right-4 top-4 z-10">
<button
type="button"
onClick={handleDismiss}
className="rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="关闭"
>
<IconX className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="px-8 pb-8 pt-10">
{/* Icon and title */}
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-20 w-20 animate-pulse items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-600 shadow-lg">
<IconStar className="h-10 w-10 text-white" />
</div>
<h3 className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-3xl font-bold text-transparent">
QwertyLearner.ai
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> </p>
</div>
{/* Main content */}
<div className="space-y-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<p className="text-center font-medium text-gray-900 dark:text-white">
<br />
<div className="my-2"></div>
DeepLearningAI QwertyLearner.ai
</p>
<div className="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<h4 className="mb-3 font-semibold text-gray-900 dark:text-white">🚀 </h4>
<ul className="space-y-2.5">
<li className="flex items-start">
<span className="mr-2 mt-0.5 text-blue-500"></span>
<span>
<strong>AI </strong> -
</span>
</li>
<li className="flex items-start">
<span className="mr-2 mt-0.5 text-blue-500"></span>
<span>
<strong></strong> -
</span>
</li>
<li className="flex items-start">
<span className="mr-2 mt-0.5 text-blue-500"></span>
<span>
<strong></strong> -
</span>
</li>
<li className="flex items-start">
<span className="mr-2 mt-0.5 text-blue-500"></span>
<span>
<strong></strong> -
</span>
</li>
</ul>
</div>
<div className="rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
<p>
<strong></strong>QwertyLearner.ai DeepLearningAI QwertyLearner
</p>
</div>
</div>
{/* Action buttons */}
<div className="mt-8 space-y-3">
<button
type="button"
onClick={handleTryNow}
className="w-full transform rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 px-8 py-4 text-lg font-bold text-white shadow-lg transition-all duration-200 hover:scale-105 hover:from-blue-600 hover:to-purple-700 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
🚀 QwertyLearner.ai
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default EnhancedPromotionModal

View File

@@ -5,6 +5,7 @@ import { FriendLinks } from './pages/FriendLinks'
import MobilePage from './pages/Mobile'
import TypingPage from './pages/Typing'
import { isOpenDarkModeAtom } from '@/store'
import { Analytics } from '@vercel/analytics/react'
import 'animate.css'
import { useAtomValue } from 'jotai'
import mixpanel from 'mixpanel-browser'
@@ -67,6 +68,7 @@ function Root() {
</Routes>
</Suspense>
</BrowserRouter>
<Analytics />
</React.StrictMode>
)
}

View File

@@ -1,14 +1,18 @@
import { idDictionaryMap } from '@/resources/dictionary'
import { wordListFetcher } from '@/utils/wordListFetcher'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { saveAs } from 'file-saver'
import type { FC } from 'react'
import { useState } from 'react'
import * as XLSX from 'xlsx'
type DropdownProps = {
renderRecords: any
paraphrases: any
}
const DropdownExport: FC<DropdownProps> = ({ renderRecords, paraphrases }) => {
const DropdownExport: FC<DropdownProps> = ({ renderRecords }) => {
const [isExporting, setIsExporting] = useState(false)
const formatTimestamp = (date: any) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') // 月份从0开始
@@ -20,37 +24,77 @@ const DropdownExport: FC<DropdownProps> = ({ renderRecords, paraphrases }) => {
return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`
}
const handleExport = (bookType: string) => {
const ExportData: Array<{ 单词: string; 释义: string; 错误次数: number; 词典: string }> = []
const handleExport = async (bookType: string) => {
setIsExporting(true)
renderRecords.forEach((item: any) => {
const word = paraphrases.find((w: any) => w.name === item.word)
ExportData.push({
单词: item.word,
释义: word ? word.trans.join('') : '',
错误次数: item.wrongCount,
词典: item.dict,
try {
// 获取所有需要的词典数据
const dictUrls: string[] = []
renderRecords.forEach((item: any) => {
const dictInfo = idDictionaryMap[item.dict]
if (dictInfo?.url && !dictUrls.includes(dictInfo.url)) {
dictUrls.push(dictInfo.url)
}
})
})
let blob: Blob
// 并行获取所有词典数据
const dictDataPromises = dictUrls.map(async (url) => {
try {
const data = await wordListFetcher(url)
return { url, data }
} catch (error) {
console.error(`Failed to fetch dictionary data from ${url}:`, error)
return { url, data: [] }
}
})
if (bookType === 'txt') {
const content = ExportData.map((item: any) => `${item.}: ${item.}`).join('\n')
blob = new Blob([content], { type: 'text/plain' })
} else {
const worksheet = XLSX.utils.json_to_sheet(ExportData)
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
const excelBuffer = XLSX.write(workbook, { bookType: bookType as XLSX.BookType, type: 'array' })
blob = new Blob([excelBuffer], { type: 'application/octet-stream' })
}
const dictDataResults = await Promise.all(dictDataPromises)
const dictDataMap = new Map(dictDataResults.map((result) => [result.url, result.data]))
const timestamp = formatTimestamp(new Date())
const fileName = `ErrorBook_${timestamp}.${bookType}`
const ExportData: Array<{ 单词: string; 释义: string; 错误次数: number; 词典: string }> = []
if (blob && fileName) {
saveAs(blob, fileName)
renderRecords.forEach((item: any) => {
const dictInfo = idDictionaryMap[item.dict]
let translation = ''
if (dictInfo?.url && dictDataMap.has(dictInfo.url)) {
const wordList = dictDataMap.get(dictInfo.url) || []
const word = wordList.find((w: any) => w.name === item.word)
translation = word ? word.trans.join('') : ''
}
ExportData.push({
单词: item.word,
释义: translation,
错误次数: item.wrongCount,
词典: dictInfo?.name || item.dict,
})
})
let blob: Blob
if (bookType === 'txt') {
const content = ExportData.map((item: any) => `${item.}: ${item.}`).join('\n')
blob = new Blob([content], { type: 'text/plain' })
} else {
const worksheet = XLSX.utils.json_to_sheet(ExportData)
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
const excelBuffer = XLSX.write(workbook, { bookType: bookType as XLSX.BookType, type: 'array' })
blob = new Blob([excelBuffer], { type: 'application/octet-stream' })
}
const timestamp = formatTimestamp(new Date())
const fileName = `ErrorBook_${timestamp}.${bookType}`
if (blob && fileName) {
saveAs(blob, fileName)
}
} catch (error) {
console.error('Export failed:', error)
alert('导出失败,请重试')
} finally {
setIsExporting(false)
}
}
@@ -58,18 +102,22 @@ const DropdownExport: FC<DropdownProps> = ({ renderRecords, paraphrases }) => {
<div className="z-10">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="my-btn-primary h-8 shadow transition hover:bg-indigo-600"></button>
<button className="my-btn-primary h-8 shadow transition hover:bg-indigo-600 disabled:opacity-50" disabled={isExporting}>
{isExporting ? '导出中...' : '导出'}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="mt-1 rounded bg-indigo-500 text-white shadow-lg">
<DropdownMenu.Item
className="cursor-pointer rounded px-4 py-2 hover:bg-indigo-400 focus:bg-indigo-600 focus:outline-none"
onClick={() => handleExport('xlsx')}
disabled={isExporting}
>
.xlsx
</DropdownMenu.Item>
<DropdownMenu.Item
className="cursor-pointer rounded px-4 py-2 hover:bg-indigo-600 focus:bg-indigo-600 focus:outline-none"
onClick={() => handleExport('csv')}
disabled={isExporting}
>
.csv
</DropdownMenu.Item>

View File

@@ -7,34 +7,24 @@ import { idDictionaryMap } from '@/resources/dictionary'
import { recordErrorBookAction } from '@/utils'
import { useSetAtom } from 'jotai'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback } from 'react'
import DeleteIcon from '~icons/weui/delete-filled'
type IErrorRowProps = {
record: groupedWordRecords
onDelete: () => void
onWordUpdate: (word: any) => void
}
const ErrorRow: FC<IErrorRowProps> = ({ record, onDelete, onWordUpdate }) => {
const ErrorRow: FC<IErrorRowProps> = ({ record, onDelete }) => {
const setCurrentRowDetail = useSetAtom(currentRowDetailAtom)
const dictInfo = idDictionaryMap[record.dict]
const { word, isLoading, hasError } = useGetWord(record.word, dictInfo)
const prevWordRef = useRef<any>()
const stableWord = useMemo(() => word, [word])
const onClick = useCallback(() => {
setCurrentRowDetail(record)
recordErrorBookAction('detail')
}, [record, setCurrentRowDetail])
useEffect(() => {
if (stableWord && stableWord !== prevWordRef.current) {
onWordUpdate(stableWord)
prevWordRef.current = stableWord
}
}, [stableWord, onWordUpdate])
return (
<li
className="opacity-85 flex w-full cursor-pointer items-center justify-between rounded-lg bg-white px-6 py-3 text-black shadow-md dark:bg-gray-800 dark:text-white"

View File

@@ -23,7 +23,6 @@ export function ErrorBook() {
const currentRowDetail = useAtomValue(currentRowDetailAtom)
const { deleteWordRecord } = useDeleteWordRecord()
const [reload, setReload] = useState(false)
const [paraphrases, setParaphrases] = useState<any[]>([])
const onBack = useCallback(() => {
navigate('/')
@@ -95,10 +94,6 @@ export function ErrorBook() {
setReload((prev) => !prev)
}
const handleWordUpdate = (paraphrases: object) => {
setParaphrases((prevWords) => [...prevWords, paraphrases])
}
return (
<>
<div className={`relative flex h-screen w-full flex-col items-center pb-4 ease-in ${currentRowDetail && 'blur-sm'}`}>
@@ -114,7 +109,7 @@ export function ErrorBook() {
<span className="basis-6/12"></span>
<HeadWrongNumber className="basis-1/12" sortType={sortType} setSortType={setSort} />
<span className="basis-1/12"></span>
<DropdownExport renderRecords={renderRecords} paraphrases={paraphrases} />
<DropdownExport renderRecords={sortedRecords} />
</div>
<ScrollArea.Root className="flex-1 overflow-y-auto pt-5">
<ScrollArea.Viewport className="h-full ">
@@ -124,7 +119,6 @@ export function ErrorBook() {
key={`${record.dict}-${record.word}`}
record={record}
onDelete={() => handleDelete(record.word, record.dict)}
onWordUpdate={handleWordUpdate}
/>
))}
</div>

View File

@@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByTag: Record<string, Dictionary[]> }) {
const tagList = useMemo(() => Object.keys(groupedDictsByTag), [groupedDictsByTag])
const [currentTag, setCurrentTag] = useState(tagList[0])
const [currentTag, setCurrentTag] = useState(tagList.length > 0 ? tagList[0] : '')
const currentDictInfo = useAtomValue(currentDictInfoAtom)
const onChangeCurrentTag = useCallback((tag: string) => {
@@ -26,9 +26,11 @@ export default function DictionaryGroup({ groupedDictsByTag }: { groupedDictsByT
<div>
<DictTagSwitcher tagList={tagList} currentTag={currentTag} onChangeCurrentTag={onChangeCurrentTag} />
<div className="mt-8 grid gap-x-5 gap-y-10 px-1 pb-4 sm:grid-cols-1 md:grid-cols-2 dic3:grid-cols-3 dic4:grid-cols-4">
{groupedDictsByTag[currentTag].map((dict) => (
<DictionaryComponent key={dict.id} dictionary={dict} />
))}
{currentTag && groupedDictsByTag[currentTag] ? (
groupedDictsByTag[currentTag].map((dict) => <DictionaryComponent key={dict.id} dictionary={dict} />)
) : (
<div className="col-span-full text-center text-gray-500"></div>
)}
</div>
</div>
)

View File

@@ -1,4 +1,5 @@
import InfoPanel from '@/components/InfoPanel'
import { trackPromotionEvent } from '@/utils/trackEvent'
import { useCallback, useState } from 'react'
import IconBook2 from '~icons/tabler/book-2'
@@ -7,10 +8,20 @@ export default function DictRequest() {
const onOpenPanel = useCallback(() => {
setShowPanel(true)
trackPromotionEvent('promotion_event', {
from: 'dict_request_button',
action: 'open',
action_detail: 'dict_request_button_open',
})
}, [])
const onClosePanel = useCallback(() => {
setShowPanel(false)
trackPromotionEvent('promotion_event', {
from: 'dict_request_panel',
action: 'close',
action_detail: 'dict_request_panel_close',
})
}, [])
return (
@@ -18,36 +29,91 @@ export default function DictRequest() {
{showPanel && (
<InfoPanel
openState={showPanel}
title="申请词典"
title="寻找更多词典"
icon={IconBook2}
buttonClassName="bg-indigo-500 hover:bg-indigo-400"
iconClassName="text-indigo-500 bg-indigo-100 dark:text-indigo-300 dark:bg-indigo-500"
onClose={onClosePanel}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
<a
href="https://github.com/Kaiyiwing/qwerty-learner/blob/master/docs/toBuildDict.md"
className="px-2 text-blue-500"
target="_blank"
rel="noreferrer"
>
</a>
<br />
<br />
{' '}
<a href="mailto:me@kaiyi.cool" className="px-2 text-blue-500" aria-label="发送邮件到 me@kaiyi.cool">
me@kaiyi.cool
</a>
</p>
<br />
<div className="space-y-5">
<div className="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<h4 className="mb-3 font-semibold text-gray-900 dark:text-white">👨💻 </h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
<a
href="https://github.com/Kaiyiwing/qwerty-learner/blob/master/docs/toBuildDict.md"
className="mx-1 font-medium text-blue-500 hover:text-blue-600"
target="_blank"
rel="noreferrer"
>
</a>
</p>
</div>
<div className="rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-4 shadow-sm dark:from-gray-800 dark:to-gray-700">
<h4 className="mb-3 font-semibold text-gray-900 dark:text-white">🚀 QwertyLearner.ai</h4>
<p className="mb-3 text-sm text-gray-600 dark:text-gray-300">
<br />
<div className="my-2"></div>
DeepLearningAI
<span className="mx-1 font-semibold text-blue-600 dark:text-blue-400">QwertyLearner.ai</span>
</p>
<div className="space-y-2 text-sm">
<div className="flex items-center">
<span className="mr-2 text-blue-500"></span>
<span className="text-gray-700 dark:text-gray-300">
<strong>AI </strong> -
</span>
</div>
<div className="flex items-center">
<span className="mr-2 text-blue-500"></span>
<span className="text-gray-700 dark:text-gray-300">
<strong></strong> -
</span>
</div>
<div className="flex items-center">
<span className="mr-2 text-blue-500"></span>
<span className="text-gray-700 dark:text-gray-300">
<strong></strong> -
</span>
</div>
<div className="flex items-center">
<span className="mr-2 text-blue-500"></span>
<span className="text-gray-700 dark:text-gray-300">
<strong></strong> -
</span>
</div>
</div>
<button
onClick={() => {
window.open('https://qwertylearner.ai', '_blank')
onClosePanel()
}}
className="mt-4 w-full transform rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 px-4 py-2 text-sm font-semibold text-white transition-all duration-200 hover:scale-105 hover:from-blue-600 hover:to-purple-700 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
🚀 QwertyLearner.ai
</button>
</div>
<div className="rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
<p>
<strong></strong>QwertyLearner.ai DeepLearningAI QwertyLearner
</p>
</div>
</div>
</InfoPanel>
)}
<button className="cursor-pointer pr-6 text-sm text-indigo-500" onClick={onOpenPanel}>
<button
onClick={onOpenPanel}
className="group flex items-center space-x-2 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-50 to-blue-50 px-4 py-2.5 text-sm font-medium text-indigo-600 shadow-sm transition-all duration-200 hover:scale-105 hover:border-indigo-300 hover:from-indigo-100 hover:to-blue-100 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-indigo-400 dark:from-gray-800 dark:to-gray-700 dark:text-indigo-400 dark:hover:from-gray-700 dark:hover:to-gray-600"
>
<IconBook2 className="h-4 w-4" />
<span></span>
<span className="transform transition-transform group-hover:translate-x-1"></span>
</button>
</>
)

View File

@@ -67,7 +67,7 @@ export default function GalleryPage() {
<IconX className="absolute right-20 top-10 mr-2 h-7 w-7 cursor-pointer text-gray-400" onClick={onBack} />
<div className="mt-20 flex w-full flex-1 flex-col items-center justify-center overflow-y-auto">
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex h-20 w-full items-center justify-between pb-6">
<div className="flex h-20 w-full items-center justify-between pb-6 pr-20">
<LanguageTabSwitcher />
<DictRequest />
</div>

View File

@@ -1,51 +0,0 @@
import type React from 'react'
const Flow: React.FC = () => {
const waveStyle = {
animation: 'move 3s linear infinite both',
}
return (
<div className="h-[15rem] w-full">
<svg className="h-full w-full rotate-180 transform blur-[2px]" viewBox="0 24 150 24" preserveAspectRatio="none">
<defs>
<path id="wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v60h-352z" />
</defs>
<g>
<use
className="wave"
xlinkHref="#wave"
fill="#ced2fc"
x="50"
y="0"
style={{ ...waveStyle, animationDelay: '-2s', animationDuration: '12s' }}
/>
<use
className="wave"
xlinkHref="#wave"
fill="#a8b0f6"
x="50"
y="2"
style={{ ...waveStyle, animationDelay: '-4s', animationDuration: '9s' }}
/>
<use
className="wave"
xlinkHref="#wave"
fill="#818cf8"
x="50"
y="4"
style={{ ...waveStyle, animationDelay: '-6s', animationDuration: '6s' }}
/>
</g>
<style>{`
@keyframes move {
from { transform: translate(85px, 0%); }
to { transform: translate(-90px, 0%); }
}
`}</style>
</svg>
</div>
)
}
export default Flow

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ import {
wordDictationConfigAtom,
} from '@/store'
import type { Word } from '@/typings'
import { CTRL, getUtcStringForMixpanel, useMixPanelWordLogUploader } from '@/utils'
import { CTRL, getUtcStringForMixpanel } from '@/utils'
import { useSaveWordRecord } from '@/utils/db'
import { useAtomValue } from 'jotai'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
@@ -41,7 +41,7 @@ export default function WordComponent({ word, onFinish }: { word: Word; onFinish
const isIgnoreCase = useAtomValue(isIgnoreCaseAtom)
const isShowAnswerOnHover = useAtomValue(isShowAnswerOnHoverAtom)
const saveWordRecord = useSaveWordRecord()
const wordLogUploader = useMixPanelWordLogUploader(state)
// const wordLogUploader = useMixPanelWordLogUploader(state)
const [playKeySound, playBeepSound, playHintSound] = useKeySounds()
const pronunciationIsOpen = useAtomValue(pronunciationIsOpenAtom)
const [isHoveringWord, setIsHoveringWord] = useState(false)
@@ -254,14 +254,14 @@ export default function WordComponent({ word, onFinish }: { word: Word; onFinish
if (wordState.isFinished) {
dispatch({ type: TypingStateActionType.SET_IS_SAVING_RECORD, payload: true })
wordLogUploader({
headword: word.name,
timeStart: wordState.startTime,
timeEnd: wordState.endTime,
countInput: wordState.correctCount + wordState.wrongCount,
countCorrect: wordState.correctCount,
countTypo: wordState.wrongCount,
})
// wordLogUploader({
// headword: word.name,
// timeStart: wordState.startTime,
// timeEnd: wordState.endTime,
// countInput: wordState.correctCount + wordState.wrongCount,
// countCorrect: wordState.correctCount,
// countTypo: wordState.wrongCount,
// })
saveWordRecord({
word: word.name,
wrongCount: wordState.wrongCount,

View File

@@ -12,7 +12,6 @@ import { useWordList } from './hooks/useWordList'
import { TypingContext, TypingStateActionType, initialState, typingReducer } from './store'
import { DonateCard } from '@/components/DonateCard'
import Header from '@/components/Header'
import StarCard from '@/components/StarCard'
import Tooltip from '@/components/Tooltip'
import { idDictionaryMap } from '@/resources/dictionary'
import { currentChapterAtom, currentDictIdAtom, isReviewModeAtom, randomConfigAtom, reviewModeInfoAtom } from '@/store'
@@ -129,7 +128,6 @@ const App: React.FC = () => {
return (
<TypingContext.Provider value={{ state: state, dispatch }}>
<StarCard />
{state.isFinished && <DonateCard />}
{state.isFinished && <ResultScreen />}
<Layout>

View File

@@ -157,6 +157,17 @@ const chinaExam: DictionaryResource[] = [
language: 'en',
languageCategory: 'en',
},
{
id: 'hongbaoshu-2026',
name: '2026考研英语 hongbaoshu',
description: '2026 考研词汇 (必考词+基础词+超纲词)',
category: '中国考试',
tags: ['考研'],
url: '/dicts/hongbaoshu-2026.json',
length: 4858,
language: 'en',
languageCategory: 'en',
},
{
id: 'English_II',
name: '英语二单词书',
@@ -880,17 +891,6 @@ const internationalExam: DictionaryResource[] = [
language: 'en',
languageCategory: 'en',
},
{
id: 'IELTSLiuHongbo538',
name: '雅思阅读 liuhongbo 538考点词',
description: '雅思阅读 liuhongbo 538考点词',
category: '国际考试',
tags: ['IELTS'],
url: '/dicts/IELTSLiuHongbo538.json',
length: 376,
language: 'en',
languageCategory: 'en',
},
{
id: 'IELTSKingLu807',
name: 'wanglu807雅思词汇听力第2版',
@@ -1408,17 +1408,17 @@ const internationalExam: DictionaryResource[] = [
language: 'en',
languageCategory: 'en',
},
{
id: 'PTE_WFDgaopinci.json',
name: 'PTE WFD高频词汇',
description: 'WFD高频词汇从高到低最低掌握350个',
category: '国际考试',
tags: ['PTE'],
url: '/dicts/PTE_WFDgaopinci.json',
length: 1175,
language: 'en',
languageCategory: 'en',
},
// {
// id: 'PTE_WFDgaopinci.json',
// name: 'PTE WFD高频词汇',
// description: 'WFD高频词汇从高到低最低掌握350个',
// category: '国际考试',
// tags: ['PTE'],
// url: '/dicts/PTE_WFDgaopinci.json',
// length: 1175,
// language: 'en',
// languageCategory: 'en',
// },
{
id: 'PTE_senior',
name: 'PTE 高阶词汇',
@@ -1885,6 +1885,7 @@ const childrenEnglish: DictionaryResource[] = [
language: 'en',
languageCategory: 'en',
},
{
id: 'pep-sl-er2',
name: '人教版(新起点)二年级下',
@@ -2491,7 +2492,17 @@ const childrenEnglish: DictionaryResource[] = [
language: 'en',
languageCategory: 'en',
},
{
id: 'shanghai-6-2word',
name: '上海新教材六年级下',
description: '上海新教材六年级下',
category: '青少年英语',
tags: ['上海版'],
url: '/dicts/shanghai-6-2-word.json',
length: 274,
language: 'en',
languageCategory: 'en',
},
{
id: 'Yilin1',
name: '高中必修1',
@@ -3330,6 +3341,17 @@ const childrenEnglish: DictionaryResource[] = [
language: 'en',
languageCategory: 'en',
},
{
id: 'Cambridge_JOIN_IN',
name: '剑桥小学英语 JOIN IN',
description: '外研社和剑桥大学出版社依据《义务教育英语课程标准》要求联合为我国小学生开发的一套英语教材。',
category: '青少年英语',
tags: ['其他'],
url: '/dicts/Cambridge_JOIN_IN.json',
length: 1350,
language: 'en',
languageCategory: 'en',
},
]
// 编程字典
@@ -3837,8 +3859,8 @@ const programming: DictionaryResource[] = [
tags: ['RAZ'],
url: '/dicts/AudioKey_Category.json',
length: 535,
language: 'en',
languageCategory: 'en',
language: 'code',
languageCategory: 'code',
},
]

View File

@@ -110,5 +110,8 @@ export const wordDictationConfigAtom = atomForConfig('wordDictationConfig', {
export const dismissStartCardDateAtom = atomWithStorage<Date | null>(DISMISS_START_CARD_DATE_KEY, null)
// Enhanced version promotion popup state
export const hasSeenEnhancedPromotionAtom = atomWithStorage('hasSeenEnhancedPromotion', false)
// for dev test
// dismissStartCardDateAtom = atom<Date | null>(new Date())

17
src/utils/trackEvent.ts Normal file
View File

@@ -0,0 +1,17 @@
import { track } from '@vercel/analytics'
export const trackPromotionEvent = (event: string, properties: Record<string, string>) => {
track(event, properties)
// @ts-expect-error gtag is not defined in the window object
if (typeof window !== 'undefined' && window?.gtag) {
try {
window.gtag('event', event, { ...properties })
if (properties.action_detail) {
window.gtag('event', properties.action_detail)
}
} catch (error) {
console.error(error)
}
}
}

View File

@@ -2368,6 +2368,11 @@
resolved "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.5.tgz"
integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==
"@types/gtag.js@^0.0.20":
version "0.0.20"
resolved "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.20.tgz#e47edabb4ed5ecac90a079275958e6c929d7c08a"
integrity sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg==
"@types/history@^4.7.11":
version "4.7.11"
resolved "https://registry.npmmirror.com/@types/history/-/history-4.7.11.tgz"
@@ -2566,6 +2571,11 @@
"@typescript-eslint/types" "5.59.6"
eslint-visitor-keys "^3.3.0"
"@vercel/analytics@^1.5.0":
version "1.5.0"
resolved "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz#073f93694897414b21a8495e2619bbf64447dcaa"
integrity sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==
"@vitejs/plugin-react@^3.1.0":
version "3.1.0"
resolved "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-3.1.0.tgz"