This commit is contained in:
zyronon
2023-11-22 09:20:44 +08:00
parent 5be2401537
commit fcc42f1dd3
17 changed files with 87 additions and 35 deletions

20
src/pages/dict/index.vue Normal file
View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
</script>
<template>
<div id="page">
dict
</div>
</template>
<style scoped lang="scss">
#page {
width: 100%;
height: 100%;
color: white;
position: relative;
z-index: 2;
font-size: 14rem;
}
</style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import {$computed, $ref} from "vue/macros"
import {onMounted, onUnmounted} from "vue"
import {useBaseStore} from "@/stores/base.ts"
import {usePracticeStore} from "@/stores/practice.ts";
import {useSettingStore} from "@/stores/setting.ts";
const practiceStore = usePracticeStore()
const settingStore = useSettingStore()
function format(val: number, suffix: string = '', check: number = -1) {
return val === check ? '-' : (val + suffix)
}
const progress = $computed(() => {
if (!practiceStore.total) return 0
if (practiceStore.index > practiceStore.total) return 100
return ((practiceStore.index / practiceStore.total) * 100)
})
let speedMinute = $ref(0)
let timer = $ref(0)
onMounted(() => {
timer = setInterval(() => {
speedMinute = Math.floor((Date.now() - practiceStore.startDate) / 1000 / 60)
}, 1000)
})
onUnmounted(() => {
timer && clearInterval(timer)
})
</script>
<template>
<div class="footer " :class="!settingStore.showToolbar && 'hide'">
<div class="bottom anim">
<el-progress
:percentage="progress"
:stroke-width="8"
:show-text="false"/>
<div class="stat">
<div class="row">
<div class="num">{{ speedMinute }}分钟</div>
<div class="line"></div>
<div class="name">时间</div>
</div>
<div class="row">
<div class="num">{{ practiceStore.total }}</div>
<div class="line"></div>
<div class="name">单词总数</div>
</div>
<div class="row">
<div class="num">{{ format(practiceStore.inputWordNumber, '', 0) }}</div>
<div class="line"></div>
<div class="name">输入数</div>
</div>
<div class="row">
<div class="num">{{ format(practiceStore.wrongWordNumber, '', 0) }}</div>
<div class="line"></div>
<div class="name">错误数</div>
</div>
<div class="row">
<div class="num">{{ format(practiceStore.correctRate, '%') }}</div>
<div class="line"></div>
<div class="name">正确率</div>
</div>
</div>
</div>
<div class="progress">
<el-progress :percentage="progress"
:stroke-width="8"
:show-text="false"/>
</div>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/variable";
.footer {
width: var(--toolbar-width);
margin-bottom: 10rem;
transition: all .3s;
position: relative;
margin-top: 15rem;
&.hide {
margin-bottom: -90rem;
margin-top: 50rem;
.progress {
bottom: calc(100% + 20rem);
}
}
.bottom {
position: relative;
width: 100%;
box-sizing: border-box;
border-radius: 10rem;
background: var(--color-second-bg);
padding: 3rem var(--space) 6rem var(--space);
z-index: 2;
border: 1px solid var(--color-item-border);
.stat {
margin-top: 8rem;
display: flex;
justify-content: space-around;
.row {
display: flex;
flex-direction: column;
align-items: center;
gap: 5rem;
width: 80rem;
.line {
height: 1px;
width: 100%;
//background: gainsboro;
background: var(--color-font-1);
}
}
}
}
.progress {
width: 100%;
transition: all .3s;
padding: 0 10rem;
box-sizing: border-box;
position: absolute;
bottom: 0;
}
:deep(.el-progress-bar__inner) {
background: var(--color-scrollbar);
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import Tooltip from "@/components/Tooltip.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import {Icon} from "@iconify/vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {useWordOptions} from "@/hooks/dict.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {ShortcutKey} from "@/types.ts";
defineProps<{
showEdit?: boolean,
isCollect: boolean,
isSimple: boolean
}>()
const emit = defineEmits<{
toggleCollect: [],
toggleSimple: [],
edit: [],
skip: [],
}>()
const settingStore = useSettingStore()
</script>
<template>
<div class="options">
<BaseIcon
v-if="!isSimple"
class-name="collect"
@click="$emit('toggleSimple')"
:title="`标记为简单词(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleSimple]})`"
icon="material-symbols:check-circle-outline-rounded"/>
<BaseIcon
v-else
class-name="fill"
@click="$emit('toggleSimple')"
:title="`取消标记简单词(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleSimple]})`"
icon="material-symbols:check-circle-rounded"/>
<BaseIcon
v-if="!isCollect"
class-name="collect"
@click="$emit('toggleCollect')"
:title="`收藏(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star"/>
<BaseIcon
v-else
class-name="fill"
@click="$emit('toggleCollect')"
:title="`取消收藏(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star-fill"/>
<Tooltip
:title="`跳过(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
>
<IconWrapper>
<Icon icon="icon-park-outline:go-ahead" class="menu"
@click="emit('skip')"/>
</IconWrapper>
</Tooltip>
</div>
</template>
<style scoped lang="scss">
.options {
margin-top: 10rem;
display: flex;
gap: 15rem;
font-size: 18rem;
}
</style>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts"
import {$ref} from "vue/macros"
import {computed, onMounted, provide, watch} from "vue"
import {Dict, DictType, ShortcutKey} from "@/types.ts"
import PopConfirm from "@/components/PopConfirm.vue"
import BaseButton from "@/components/BaseButton.vue";
import {useSettingStore} from "@/stores/setting.ts";
import Close from "@/components/icon/Close.vue";
import Empty from "@/components/Empty.vue";
import ArticleList from "@/components/article/ArticleList-FQ.vue";
import {useArticleOptions, useWordOptions} from "@/hooks/dict.ts";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import CommonWordList from "@/components/list/CommonWordList.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import ArticleList2 from "@/components/list/ArticleList2.vue";
const store = useBaseStore()
const settingStore = useSettingStore()
let tabIndex = $ref(0)
provide('tabIndex', computed(() => tabIndex))
watch(() => settingStore.showPanel, n => {
if (n) {
tabIndex = 0
}
})
let practiceType = $ref(DictType.word)
function changeIndex(i: number, dict: Dict) {
store.changeDict(dict, dict.chapterIndex, i, practiceType)
}
onMounted(() => {
emitter.on(EventKey.changeDict, () => {
tabIndex = 0
})
})
const {
delWrongWord,
delSimpleWord,
toggleWordCollect,
} = useWordOptions()
const {
isArticleCollect,
toggleArticleCollect
} = useArticleOptions()
</script>
<template>
<Transition name="fade">
<div class="panel anim" v-show="settingStore.showPanel">
<header>
<Transition name="fade">
<Tooltip
v-if="!settingStore.showToolbar"
:title="`关闭(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
>
<Close @click="settingStore.showPanel = false"/>
</Tooltip>
</Transition>
<div class="tabs">
<div class="tab" :class="tabIndex === 0 && 'active'" @click="tabIndex = 0">当前</div>
<div class="tab" :class="tabIndex === 1 && 'active'" @click="tabIndex = 1">{{ store.collect.name }}</div>
<div class="tab" :class="tabIndex === 2 && 'active'" @click="tabIndex = 2">{{ store.simple.name }}</div>
<div class="tab" :class="tabIndex === 3 && 'active'" @click="tabIndex = 3">{{ store.wrong.name }}</div>
</div>
</header>
<div class="slide">
<div class="slide-list" :class="`step${tabIndex}`">
<div class="slide-item">
<slot :active="tabIndex === 0 && settingStore.showPanel"></slot>
</div>
<div class="slide-item">
<div class="panel-page-item">
<div class="list-header">
<div class="left">
<el-radio-group v-model="practiceType">
<el-radio-button border :label="DictType.word">单词</el-radio-button>
<el-radio-button border :label="DictType.article">文章</el-radio-button>
</el-radio-group>
<div class="dict-name" v-if="practiceType === DictType.word && store.collect.words.length">
{{ store.collect.words.length }}个单词
</div>
<div class="dict-name" v-if="practiceType === DictType.article && store.collect.articles.length">
{{ store.collect.articles.length }}篇文章
</div>
<Tooltip title="添加">
<IconWrapper>
<Icon icon="fluent:add-12-regular" @click="emitter.emit(EventKey.openDictModal,'collect')"/>
</IconWrapper>
</Tooltip>
</div>
<template v-if="store.currentDict.type !== DictType.collect &&
(
( practiceType === DictType.word && store.collect.words.length) ||
( practiceType === DictType.article && store.collect.articles.length)
)">
<PopConfirm
:title="`确认切换?`"
@confirm="changeIndex(0,store.collect)"
>
<BaseButton size="small">切换</BaseButton>
</PopConfirm>
</template>
</div>
<template v-if="practiceType === DictType.word">
<CommonWordList
v-if="store.collect.words.length"
class="word-list"
:list="store.collect.words">
<template v-slot="{word,index}">
<BaseIcon
class-name="del"
@click="toggleWordCollect(word)"
title="移除"
icon="solar:trash-bin-minimalistic-linear"/>
</template>
</CommonWordList>
<Empty v-else/>
</template>
<template v-else>
<ArticleList2
v-if="store.collect.articles.length"
:show-translate="true"
v-model:list="store.collect.articles">
<template v-slot="{source,index}">
<BaseIcon
class-name="del"
@click="toggleArticleCollect(source)"
title="移除"
icon="solar:trash-bin-minimalistic-linear"/>
</template>
</ArticleList2>
<Empty v-else/>
</template>
</div>
</div>
<div class="slide-item">
<div class="panel-page-item">
<div class="list-header">
<div class="left">
<div class="dict-name">总词数:{{ store.simple.words.length }}</div>
<Tooltip title="添加">
<IconWrapper>
<Icon icon="fluent:add-12-regular" @click="emitter.emit(EventKey.openDictModal,'simple')"/>
</IconWrapper>
</Tooltip>
</div>
<template v-if="store.currentDict.type !== DictType.simple && store.simple.words.length">
<PopConfirm
:title="`确认切换?`"
@confirm="changeIndex(0,store.simple)"
>
<BaseButton size="small">切换</BaseButton>
</PopConfirm>
</template>
</div>
<CommonWordList
v-if="store.simple.words.length"
class="word-list"
:list="store.simple.words">
<template v-slot="{word,index}">
<BaseIcon
class-name="del"
@click="delSimpleWord(word)"
title="移除"
icon="solar:trash-bin-minimalistic-linear"/>
</template>
</CommonWordList>
<Empty v-else/>
</div>
</div>
<div class="slide-item">
<div class="panel-page-item" v-if="store.wrong.words.length">
<div class="list-header">
<div class="dict-name">总词数:{{ store.wrong.words.length }}</div>
<template
v-if="store.currentDict.type !== DictType.wrong && store.wrong.words.length">
<PopConfirm
:title="`确认切换?`"
@confirm="changeIndex(0,store.wrong)"
>
<BaseButton size="small">切换</BaseButton>
</PopConfirm>
</template>
</div>
<CommonWordList
class="word-list"
:list="store.wrong.words">
<template v-slot="{word,index}">
<BaseIcon
class-name="del"
@click="delWrongWord(word)"
title="移除"
icon="solar:trash-bin-minimalistic-linear"/>
</template>
</CommonWordList>
</div>
<Empty v-else/>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped lang="scss">
@import "@/assets/css/variable";
$header-height: 50rem;
.slide {
width: 100%;
flex: 1;
overflow: hidden;
.slide-list {
width: 400%;
height: 100%;
display: flex;
transition: all .5s;
.slide-item {
width: var(--panel-width);
height: 100%;
display: flex;
flex-direction: column;
> header {
padding: 0 var(--space);
height: $header-height;
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10rem;
font-size: 16rem;
color: black;
}
.content {
flex: 1;
overflow: auto;
padding-bottom: var(--space);
}
footer {
padding-right: var(--space);
margin-bottom: 10rem;
align-items: center;
}
}
}
.step1 {
transform: translate3d(-25%, 0, 0);
}
.step2 {
transform: translate3d(-50%, 0, 0);
}
.step3 {
transform: translate3d(-75%, 0, 0);
}
}
.panel {
border-radius: 8rem;
width: var(--panel-width);
background: var(--color-second-bg);
height: 100%;
display: flex;
flex-direction: column;
transition: all .3s;
z-index: 1;
border: 1px solid var(--color-item-border);
& > header {
min-height: 50rem;
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
padding: 10rem 15rem;
border-bottom: 1px solid #e1e1e1;
gap: 15rem;
.close {
cursor: pointer;
}
.tabs {
justify-content: flex-end;
width: 100%;
display: flex;
align-items: center;
gap: 15rem;
font-size: 14rem;
color: gray;
.tab {
cursor: pointer;
word-break: keep-all;
font-size: 16rem;
&.active {
color: rgb(36, 127, 255);
font-weight: bold;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import {useBaseStore} from "@/stores/base.ts";
import Ring from "@/components/Ring.vue";
import Tooltip from "@/components/Tooltip.vue";
import Fireworks from "@/components/Fireworks.vue";
import BaseButton from "@/components/BaseButton.vue";
import {DefaultDisplayStatistics, DisplayStatistics, ShortcutKey} from "@/types.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {onMounted, reactive} from "vue";
import {cloneDeep} from "lodash-es";
import {Icon} from '@iconify/vue';
import {$computed, $ref} from "vue/macros";
import BaseIcon from "@/components/BaseIcon.vue";
import {useSettingStore} from "@/stores/setting.ts";
const store = useBaseStore()
const settingStore = useSettingStore()
let statModalIsOpen = $ref(false)
let currentStat = reactive<DisplayStatistics>(cloneDeep(DefaultDisplayStatistics))
onMounted(() => {
emitter.on(EventKey.openStatModal, (stat: DisplayStatistics) => {
if (stat) {
currentStat = {...DefaultDisplayStatistics, ...stat}
store.saveStatistics(stat)
console.log('stat', stat)
}
statModalIsOpen = true
})
const close = () => {
statModalIsOpen = false
}
emitter.on(ShortcutKey.NextChapter, close)
emitter.on(ShortcutKey.RepeatChapter, close)
emitter.on(ShortcutKey.DictationChapter, close)
})
function options(emitType: 'write' | 'repeat' | 'next') {
statModalIsOpen = false
emitter.emit(EventKey[emitType])
}
const isEnd = $computed(() => {
return store.isArticle ?
store.currentDict.chapterIndex === store.currentDict.articles.length - 1 :
store.currentDict.chapterIndex === store.currentDict.chapterWords.length - 1
})
</script>
<template>
<Dialog
:header="false"
v-model="statModalIsOpen">
<div class="statistics">
<header>
<div class="title">{{ store.currentDict.name }}</div>
</header>
<div class="content">
<div class="rings">
<Ring
:value="currentStat.correctRate + '%'"
desc="正确率"
:percentage="currentStat.correctRate"/>
<Ring
:value="currentStat.wrongWordNumber"
desc="错误数"
:percentage="0"
/>
<Ring
:value="currentStat.inputWordNumber"
desc="输入数"
:percentage="0"
/>
<Ring
:value="currentStat.total"
desc="单词总数"
:percentage="0"
style="margin-bottom: 0;"/>
</div>
<div class="result">
<div class="wrong-words-wrapper">
<div class="wrong-words">
<div class="word" v-for="i in currentStat.wrongWords">{{ i.name }}</div>
<!-- <div class="word" v-for="i in 100">{{ i }}</div>-->
</div>
</div>
<div class="notice" v-if="!currentStat.wrongWords.length">
<!-- <div class="notice">-->
<Icon class="hvr-grow pointer" icon="flat-color-icons:like" width="20" color="#929596"/>
表现不错全对了
</div>
</div>
<div class="shares">
<Tooltip title="分享给朋友">
<Icon class="hvr-grow pointer" icon="ph:share-light" width="20" color="#929596"/>
</Tooltip>
<Tooltip title="请我喝杯咖啡">
<Icon class="hvr-grow pointer" icon="twemoji:teacup-without-handle" width="20" color="#929596"/>
</Tooltip>
</div>
</div>
<div class="footer">
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.DictationChapter]"
@click="options('write')">
默写本章
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
@click="options('repeat')">
重复本章
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
@click="options('next')">
{{ isEnd ? '重新练习' : '下一章' }}
</BaseButton>
</div>
</div>
</Dialog>
<Fireworks v-if="statModalIsOpen"/>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.statistics {
width: 800rem;
padding: var(--space);
background: $dark-second-bg;
border-radius: $card-radius;
$header-height: 40rem;
$footer-height: 60rem;
header {
display: flex;
align-items: center;
justify-content: center;
height: $header-height;
font-size: 24rem;
margin-bottom: 15rem;
}
.content {
display: flex;
gap: var(--space);
margin-bottom: 15rem;
.result {
box-sizing: border-box;
overflow: hidden;
height: 340rem;
display: flex;
flex-direction: column;
border-radius: $card-radius;
background: $item-hover;
flex: 1;
.wrong-words-wrapper {
flex: 1;
overflow: auto;
padding: var(--space);
}
.wrong-words {
box-sizing: border-box;
display: flex;
margin-right: 5rem;
flex-wrap: wrap;
gap: 10rem;
align-items: flex-start;
.word {
display: inline-block;
border-radius: 6rem;
padding: 5rem 15rem;
background: $dark-second-bg;
}
}
.notice {
background: $main;
height: 40rem;
display: flex;
gap: 10rem;
align-items: center;
padding-left: var(--space);
}
}
.shares {
display: flex;
flex-direction: column;
gap: var(--space);
}
}
.footer {
height: $footer-height;
display: flex;
align-items: center;
justify-content: center;
gap: 20rem;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import Toolbar from "@/components/toolbar/index.vue"
import {onMounted, onUnmounted, watch} from "vue";
import {usePracticeStore} from "@/stores/practice.ts";
import Footer from "@/pages/practice/Footer.vue";
import {useBaseStore} from "@/stores/base.ts";
import {$ref} from "vue/macros";
import Statistics from "@/pages/practice/Statistics.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import PracticeArticle from "@/pages/practice/practice-article/index.vue";
import PracticeWord from "@/pages/practice/practice-word/index.vue";
import {ShortcutKey} from "@/types.ts";
import useTheme from "@/hooks/useTheme.ts";
import SettingDialog from "@/components/dialog/SettingDialog.vue";
import DictModal from "@/components/dialog/DictDiglog.vue";
const practiceStore = usePracticeStore()
const store = useBaseStore()
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
const practiceRef: any = $ref()
const {toggleTheme} = useTheme()
watch(practiceStore, () => {
if (practiceStore.inputWordNumber < 1) {
return practiceStore.correctRate = -1
}
if (practiceStore.wrongWordNumber > practiceStore.inputWordNumber) {
return practiceStore.correctRate = 0
}
practiceStore.correctRate = 100 - Math.trunc(((practiceStore.wrongWordNumber) / (practiceStore.inputWordNumber)) * 100)
})
function test() {
MessageBox.confirm(
'2您选择了“本地翻译”但译文内容却为空白是否修改为“不需要翻译”并保存?',
'1提示',
() => {
console.log('ok')
},
() => {
console.log('cencal')
})
}
function write() {
// console.log('write')
settingStore.dictation = true
repeat()
}
//TODO 需要判断是否已忽略
function repeat() {
// console.log('repeat')
emitter.emit(EventKey.resetWord)
practiceRef.getCurrentPractice()
}
function next() {
// console.log('next')
if (store.isArticle) {
if (store.currentDict.chapterIndex >= store.currentDict.articles.length - 1) {
store.currentDict.chapterIndex = 0
} else store.currentDict.chapterIndex++
} else {
if (store.currentDict.chapterIndex >= store.currentDict.chapterWords.length - 1) {
store.currentDict.chapterIndex = 0
} else store.currentDict.chapterIndex++
}
repeat()
}
function prev() {
// console.log('next')
if (store.currentDict.chapterIndex === 0) {
ElMessage.warning('已经在第一章了~')
} else {
store.currentDict.chapterIndex--
repeat()
}
}
function toggleShowTranslate() {
settingStore.translate = !settingStore.translate
}
function toggleDictation() {
settingStore.dictation = !settingStore.dictation
}
function openSetting() {
runtimeStore.showSettingModal = true
}
function openDictDetail() {
emitter.emit(EventKey.openDictModal, 'detail')
}
function toggleConciseMode() {
settingStore.showToolbar = !settingStore.showToolbar
settingStore.showPanel = settingStore.showToolbar
}
function togglePanel() {
settingStore.showPanel = !settingStore.showPanel
}
function jumpSpecifiedChapter(val: number) {
store.currentDict.chapterIndex = val
repeat()
}
onMounted(() => {
emitter.on(EventKey.next, next)
emitter.on(EventKey.write, write)
emitter.on(EventKey.repeat, repeat)
emitter.on(EventKey.jumpSpecifiedChapter, jumpSpecifiedChapter)
emitter.on(ShortcutKey.NextChapter, next)
emitter.on(ShortcutKey.PreviousChapter, prev)
emitter.on(ShortcutKey.RepeatChapter, repeat)
emitter.on(ShortcutKey.DictationChapter, write)
emitter.on(ShortcutKey.ToggleShowTranslate, toggleShowTranslate)
emitter.on(ShortcutKey.ToggleDictation, toggleDictation)
emitter.on(ShortcutKey.OpenSetting, openSetting)
emitter.on(ShortcutKey.OpenDictDetail, openDictDetail)
emitter.on(ShortcutKey.ToggleTheme, toggleTheme)
emitter.on(ShortcutKey.ToggleConciseMode, toggleConciseMode)
emitter.on(ShortcutKey.TogglePanel, togglePanel)
practiceRef.getCurrentPractice()
})
onUnmounted(() => {
emitter.off(EventKey.next, next)
emitter.off(EventKey.write, write)
emitter.off(EventKey.repeat, repeat)
emitter.off(EventKey.jumpSpecifiedChapter, jumpSpecifiedChapter)
emitter.off(ShortcutKey.NextChapter, next)
emitter.off(ShortcutKey.PreviousChapter, prev)
emitter.off(ShortcutKey.RepeatChapter, repeat)
emitter.off(ShortcutKey.DictationChapter, write)
emitter.off(ShortcutKey.ToggleShowTranslate, toggleShowTranslate)
emitter.off(ShortcutKey.ToggleDictation, toggleDictation)
emitter.off(ShortcutKey.OpenSetting, openSetting)
emitter.off(ShortcutKey.OpenDictDetail, openDictDetail)
emitter.off(ShortcutKey.ToggleTheme, toggleTheme)
emitter.off(ShortcutKey.ToggleConciseMode, toggleConciseMode)
emitter.off(ShortcutKey.TogglePanel, togglePanel)
})
</script>
<template>
<div class="practice-wrapper">
<Toolbar/>
<!-- <BaseButton @click="test">test</BaseButton>-->
<PracticeArticle ref="practiceRef" v-if="store.isArticle"/>
<PracticeWord ref="practiceRef" v-else/>
<Footer/>
</div>
<DictModal/>
<SettingDialog v-if="runtimeStore.showSettingModal" @close="runtimeStore.showSettingModal = false"/>
<Statistics/>
</template>
<style scoped lang="scss">
.practice-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding-right: var(--practice-wrapper-padding-right);
}
</style>

View File

@@ -0,0 +1,640 @@
<script setup lang="ts">
import {computed, nextTick, onMounted, onUnmounted, watch} from "vue"
import {$computed, $ref} from "vue/macros";
import {Article, ArticleWord, DefaultArticle, ShortcutKey, ShortcutKeyMap, Word} from "@/types.ts";
import {useBaseStore} from "@/stores/base.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio} from "@/hooks/sound.ts";
import {useOnKeyboardEventListener} from "@/hooks/event.ts";
import {cloneDeep} from "lodash-es";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import Options from "@/pages/practice/Options.vue";
import {Icon} from "@iconify/vue";
import IconWrapper from "@/components/IconWrapper.vue";
import Tooltip from "@/components/Tooltip.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {useArticleOptions} from "@/hooks/dict.ts";
interface IProps {
article: Article,
sectionIndex?: number,
sentenceIndex?: number,
wordIndex?: number,
stringIndex?: number,
active: boolean,
}
const props = withDefaults(defineProps<IProps>(), {
article: () => cloneDeep(DefaultArticle),
sectionIndex: 0,
sentenceIndex: 0,
wordIndex: 0,
stringIndex: 0,
active: true,
})
const emit = defineEmits<{
ignore: [],
wrong: [val: Word],
nextWord: [val: ArticleWord],
over: [],
edit: [val: Article]
}>()
let isPlay = $ref(false)
let articleWrapperRef = $ref<HTMLInputElement>(null)
let sectionIndex = $ref(0)
let sentenceIndex = $ref(0)
let wordIndex = $ref(0)
let stringIndex = $ref(0)
let input = $ref('')
let wrong = $ref('')
let isSpace = $ref(false)
let hoverIndex = $ref({
sectionIndex: -1,
sentenceIndex: -1,
})
const currentIndex = computed(() => {
return `${sectionIndex}${sentenceIndex}${wordIndex}`
})
const collectIndex = $computed(() => {
return store.collect.articles.findIndex((v: Article) => v.title.toLowerCase() === props.article.title.toLowerCase())
})
const playBeep = usePlayBeep()
const playCorrect = usePlayCorrect()
const playKeyboardAudio = usePlayKeyboardAudio()
const playWordAudio = usePlayWordAudio()
const store = useBaseStore()
const practiceStore = usePracticeStore()
const settingStore = useSettingStore()
watch(() => props.article, () => {
sectionIndex = props.sectionIndex
sentenceIndex = props.sentenceIndex
wordIndex = props.wordIndex
stringIndex = props.stringIndex
calcTranslateLocation()
}, {immediate: true})
watch(() => settingStore.dictation, () => {
calcTranslateLocation()
})
onMounted(() => {
emitter.on(EventKey.resetWord, () => {
wrong = input = ''
})
emitter.on(EventKey.onTyping, onTyping)
})
onUnmounted(() => {
emitter.off(EventKey.resetWord,)
emitter.off(EventKey.onTyping, onTyping)
})
function nextSentence() {
// wordData.words = [
// {"name": "pharmacy", "trans": ["药房;配药学,药剂学;制药业;一批备用药品"], "usphone": "'fɑrməsi", "ukphone": "'fɑːməsɪ"},
// // {"name": "foregone", "trans": ["过去的;先前的;预知的;预先决定的", "发生在…之前forego的过去分词"], "usphone": "'fɔrɡɔn", "ukphone": "fɔː'gɒn"}, {"name": "president", "trans": ["总统;董事长;校长;主席"], "usphone": "'prɛzɪdənt", "ukphone": "'prezɪd(ə)nt"}, {"name": "plastic", "trans": ["塑料的;(外科)造型的;可塑的", "塑料制品;整形;可塑体"], "usphone": "'plæstɪk", "ukphone": "'plæstɪk"}, {"name": "provisionally", "trans": ["临时地,暂时地"], "usphone": "", "ukphone": ""}, {"name": "incentive", "trans": ["动机;刺激", "激励的;刺激的"], "usphone": "ɪn'sɛntɪv", "ukphone": "ɪn'sentɪv"}, {"name": "calculate", "trans": ["计算;以为;作打算"], "usphone": "'kælkjulet", "ukphone": "'kælkjʊleɪt"}
// ]
// return
let currentSection = props.article.sections[sectionIndex]
isSpace = false
stringIndex = 0
wordIndex = 0
input = wrong = ''
//todo 计得把略过的单词加上统计里面去
// if (!store.skipWordNamesWithSimpleWords.includes(currentWord.name.toLowerCase()) && !currentWord.isSymbol) {
// practiceStore.inputNumber++
// }
sentenceIndex++
if (!currentSection[sentenceIndex]) {
sentenceIndex = 0
sectionIndex++
if (!props.article.sections[sectionIndex]) {
console.log('打完了')
emit('over')
}
} else {
if (settingStore.dictation) {
calcTranslateLocation()
}
playWordAudio(currentSection[sentenceIndex].text)
}
}
function onTyping(e: KeyboardEvent) {
if (!props.active) return
if (!props.article.sections.length) return
// console.log('keyDown', e.key, e.code, e.keyCode)
wrong = ''
let currentSection = props.article.sections[sectionIndex]
let currentSentence = currentSection[sentenceIndex]
let currentWord: ArticleWord = currentSentence.words[wordIndex]
const nextWord = () => {
isSpace = false
stringIndex = 0
wordIndex++
emit('nextWord', currentWord)
if (!currentSentence.words[wordIndex]) {
wordIndex = 0
sentenceIndex++
if (!currentSection[sentenceIndex]) {
sentenceIndex = 0
sectionIndex++
if (!props.article.sections[sectionIndex]) {
console.log('打完了')
}
} else {
if (settingStore.dictation) {
calcTranslateLocation()
}
playWordAudio(currentSection[sentenceIndex].text)
}
}
}
if (isSpace) {
if (e.code === 'Space') {
nextWord()
} else {
wrong = ' '
playBeep()
setTimeout(() => {
wrong = ''
wrong = input = ''
}, 500)
}
playKeyboardAudio()
} else {
let letter = e.key
let key = currentWord.name[stringIndex]
// console.log('key', key,)
let isRight = false
if (settingStore.ignoreCase) {
isRight = key.toLowerCase() === letter.toLowerCase()
} else {
isRight = key === letter
}
if (isRight) {
input += letter
wrong = ''
// console.log('匹配上了')
stringIndex++
//如果当前词没有index说明这个词完了下一个是空格
if (!currentWord.name[stringIndex]) {
input = wrong = ''
if (!currentWord.isSymbol) {
playCorrect()
}
if (currentWord.nextSpace) {
isSpace = true
} else {
nextWord()
}
}
} else {
emit('wrong', currentWord)
wrong = letter
playBeep()
setTimeout(() => {
wrong = ''
}, 500)
// console.log('未匹配')
}
playKeyboardAudio()
}
e.preventDefault()
}
function calcTranslateLocation() {
nextTick(() => {
setTimeout(() => {
let articleRect = articleWrapperRef.getBoundingClientRect()
props.article.sections.map((v, i) => {
v.map((w, j) => {
let location = i + '-' + j
let wordClassName = `.word${location}`
let word = document.querySelector(wordClassName)
let wordRect = word.getBoundingClientRect()
let translateClassName = `.translate${location}`
let translate: HTMLDivElement = document.querySelector(translateClassName)
translate.style.opacity = '1'
translate.style.top = wordRect.top - articleRect.top - 22 + 'px'
// @ts-ignore
translate.firstChild.style.width = wordRect.left - articleRect.left + 'px'
// console.log(word, wordRect.left - articleRect.left)
// console.log('word-wordRect', wordRect)
})
})
}, 300)
})
}
function play() {
return playWordAudio('article1')
if (isPlay) {
isPlay = false
return window.speechSynthesis.pause();
}
let msg = new SpeechSynthesisUtterance();
msg.text = 'article1'
msg.rate = 0.5;
msg.pitch = 1;
msg.lang = 'en-US';
// msg.lang = 'zh-HK';
isPlay = true
window.speechSynthesis.speak(msg);
}
function onKeyDown(e: KeyboardEvent) {
if (!props.active) return
switch (e.key) {
case 'Backspace':
if (wrong) {
wrong = ''
} else {
input = input.slice(0, -1)
}
break
case ShortcutKeyMap.Collect:
break
case ShortcutKeyMap.Remove:
break
case ShortcutKeyMap.Ignore:
nextSentence()
break
case ShortcutKeyMap.Show:
if (settingStore.allowWordTip) {
hoverIndex = {
sectionIndex: sectionIndex,
sentenceIndex: sentenceIndex,
}
}
break
}
// console.log(
// 'sectionIndex', sectionIndex,
// 'sentenceIndex', sentenceIndex,
// 'wordIndex', wordIndex,
// 'stringIndex', stringIndex,
// )
e.preventDefault()
}
function onKeyUp() {
hoverIndex = {
sectionIndex: -1,
sentenceIndex: -1,
}
}
useOnKeyboardEventListener(onKeyDown, onKeyUp)
// useEventListener('keydown', onKeyDown)
// useEventListener('keyup', onKeyUp)
function playWord(word: ArticleWord) {
playWordAudio(word.name)
}
function currentWordInput(word: ArticleWord, i: number, i2: number,) {
let str = word.name.slice(input.length + wrong.length, input.length + wrong.length + 1)
if (word.isSymbol) {
return str
}
if (hoverIndex.sectionIndex === i && hoverIndex.sentenceIndex === i2) {
return str
}
if (settingStore.dictation) {
return '_'
}
return str
}
function currentWordEnd(word: ArticleWord, i: number, i2: number,) {
let str = word.name.slice(input.length + wrong.length + (wrong ? 0 : 1))
if (hoverIndex.sectionIndex === i && hoverIndex.sentenceIndex === i2) {
return str
}
if (settingStore.dictation) {
return str.split('').map(v => '_').join('')
}
return str
}
function otherWord(word: ArticleWord, i: number, i2: number, i3: number) {
let str = word.name
if (word.isSymbol) {
return str
}
if (hoverIndex.sectionIndex === i && hoverIndex.sentenceIndex === i2) {
return str
}
//剩100是因为可能存在特殊情况比如003,010这种0 12 24100
if (sectionIndex * 10000 + sentenceIndex * 100 + wordIndex < i * 10000 + i2 * 100 + i3
&& settingStore.dictation
) {
return str.split('').map(v => '_').join('')
}
return str
}
function toggleCollect() {
if (collectIndex === -1) {
store.collect.articles.push(props.article)
ElMessage.success('收藏成功')
} else {
store.collect.articles.splice(collectIndex, 1)
ElMessage.success('取消成功')
}
}
const {
isArticleCollect,
toggleArticleCollect
} = useArticleOptions()
</script>
<template>
<div class="typing-article">
<header>
<div class="title">{{ props.article.title }}</div>
<div class="titleTranslate" v-if="settingStore.translate">{{ props.article.titleTranslate }}</div>
<div class="options-wrapper">
<div class="flex gap10">
<BaseIcon
:title="`编辑(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.EditArticle]})`"
icon="tabler:edit"
@click="emit('edit',props.article)"
/>
<BaseIcon
v-if="!isArticleCollect(props.article)"
class-name="collect"
@click="toggleArticleCollect(props.article)"
:title="`收藏(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star"/>
<BaseIcon
v-else
class-name="fill"
@click="toggleArticleCollect(props.article)"
:title="`取消收藏(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`"
icon="ph:star-fill"/>
<BaseIcon
:title="`跳过(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
icon="icon-park-outline:go-ahead"
@click="emit('over')"/>
</div>
</div>
</header>
<div class="article-content" ref="articleWrapperRef">
<article>
<div class="section"
v-for="(section,indexI) in props.article.sections">
<span class="sentence"
:class="[
sectionIndex === indexI && sentenceIndex === indexJ && settingStore.dictation
?'dictation':''
]"
@mouseenter="settingStore.allowWordTip && (hoverIndex = {sectionIndex : indexI,sentenceIndex :indexJ})"
@mouseleave="hoverIndex = {sectionIndex : -1,sentenceIndex :-1}"
@click="playWordAudio(sentence.text)"
v-for="(sentence,indexJ) in section">
<span
v-for="(word,indexW) in sentence.words"
class="word"
:class="[(sectionIndex>indexI
?'wrote':
(sectionIndex>=indexI &&sentenceIndex>indexJ)
?'wrote' :
(sectionIndex>=indexI &&sentenceIndex>=indexJ && wordIndex>indexW)
?'wrote':
(sectionIndex>=indexI &&sentenceIndex>=indexJ && wordIndex>=indexW && stringIndex>=word.name.length)
?'wrote':
''),
(`${indexI}${indexJ}${indexW}` === currentIndex && !isSpace && wrong )?'word-wrong':'',
indexW === 0 && `word${indexI}-${indexJ}`
]"
@click="playWord(word)">
<span v-if="`${indexI}${indexJ}${indexW}` === currentIndex && !isSpace">
<span class="input" v-if="input">{{ input }}</span>
<span class="wrong" :class="wrong === ' ' && 'bg-wrong'" v-if="wrong">{{ wrong }}</span>
<span class="bottom-border" v-else>{{ currentWordInput(word, indexI, indexJ) }}</span>
<span>{{ currentWordEnd(word, indexI, indexJ,) }}</span>
</span>
<span v-else>{{ otherWord(word, indexI, indexJ, indexW) }}</span>
<span
v-if="word.nextSpace"
:class="[
`${indexI}${indexJ}${indexW}`,
(`${indexI}${indexJ}${indexW}` === currentIndex && isSpace && wrong) && 'bg-wrong',
(`${indexI}${indexJ}${indexW}` === currentIndex && isSpace && !wrong) && 'bottom-border',
(`${indexI}${indexJ}${indexW}` === currentIndex && isSpace && !wrong && settingStore.dictation) && 'word-space',
]">
{{
(`${indexI}${indexJ}${indexW}` === currentIndex && isSpace && settingStore.dictation) ? '_' : ' '
}}
</span>
</span>
</span>
</div>
</article>
<div class="translate" v-show="settingStore.translate">
<template v-for="(v,i) in props.article.sections">
<div class="row"
:class="`translate${i+'-'+j}`"
v-for="(item,j) in v">
<span class="space"></span>
<Transition name="fade">
<span class="text" v-if="item.translate">{{ item.translate }}</span>
</Transition>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
.wrote {
//color: green;
color: rgb(22, 163, 74);
}
$article-width: 1000px;
.typing-article {
header {
word-wrap: break-word;
position: relative;
padding: 15rem 0;
.title {
text-align: center;
color: rgba(gray, .8);
font-size: 36rem;
font-weight: 500;
word-spacing: 3rem;
//opacity: 0;
}
.titleTranslate {
@extend .title;
font-size: 20rem;
}
.options-wrapper {
position: absolute;
right: 20rem;
top: 0;
display: flex;
gap: 10rem;
font-size: 18rem;
}
}
.article-content {
position: relative;
//opacity: 0;
}
article {
//height: 100%;
font-size: 24rem;
line-height: 2.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
color: gray;
word-break: keep-all;
word-wrap: break-word;
white-space: pre-wrap;
padding-top: 20rem;
.section {
margin-bottom: var(--space);
.sentence {
transition: all .3s;
&.dictation {
letter-spacing: 3rem;
}
}
.word {
display: inline-block;
}
}
}
.translate {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
font-size: 18rem;
color: gray;
line-height: 3.5;
letter-spacing: 3rem;
//display: none;
.row {
position: absolute;
left: 0;
width: 100%;
opacity: 0;
.space {
transition: all .3s;
display: inline-block;
}
}
}
.word-space {
position: relative;
color: gray;
&::after {
content: ' ';
position: absolute;
width: 1.5rem;
height: 4rem;
background: gray;
bottom: 2rem;
right: 2.5rem;
}
&::before {
content: ' ';
position: absolute;
width: 1.5rem;
height: 4rem;
background: gray;
bottom: 2rem;
left: 0;
}
}
.bottom-border {
animation: underline 1s infinite steps(1, start);
}
.input {
//font-weight: bold;
color: var(--color-main-active);
}
.wrong {
color: rgba(red, 0.6);
}
.word-wrong {
display: inline-block;
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
.bg-wrong {
display: inline-block;
color: gray;
background: rgba(red, 0.6);
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
}
@keyframes underline {
0%, 100% {
border-left: 1.3rem solid black;
}
50% {
border-left: 1.3rem solid transparent;
}
}
</style>

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import {$ref} from "vue/macros";
import TypingArticle from "./TypingArticle.vue";
import {
Article,
ArticleWord,
DefaultArticle,
DefaultWord,
DisplayStatistics,
ShortcutKey,
TranslateType,
Word
} from "@/types.ts";
import {cloneDeep} from "lodash-es";
import TypingWord from "@/pages/practice/practice-word/TypingWord.vue";
import Panel from "../Panel.vue";
import {onMounted, watch} from "vue";
import {renewSectionTexts, renewSectionTranslates} from "@/hooks/translate.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {useBaseStore} from "@/stores/base.ts";
import EditSingleArticleModal from "@/components/article/EditSingleArticleModal.vue";
import {usePracticeStore} from "@/stores/practice.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import ArticleList from "@/components/article/ArticleList-FQ.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useSettingStore} from "@/stores/setting.ts";
import ArticleList2 from "@/components/list/ArticleList2.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {useArticleOptions} from "@/hooks/dict.ts";
const store = useBaseStore()
const practiceStore = usePracticeStore()
const runtimeStore = useRuntimeStore()
let tabIndex = $ref(0)
let wordData = $ref({
words: [],
index: -1
})
let articleData = $ref({
article: cloneDeep(DefaultArticle),
sectionIndex: 0,
sentenceIndex: 0,
wordIndex: 0,
stringIndex: 0,
})
let showEditArticle = $ref(false)
let editArticle = $ref<Article>(cloneDeep(DefaultArticle))
watch([
() => store.current.index,
() => store.load,
() => store.currentDict.type,
() => store.currentDict.chapterIndex,
], n => {
console.log('n', n)
getCurrentPractice()
})
onMounted(() => {
getCurrentPractice()
})
function setArticle(val: Article) {
store.currentDict.articles[store.currentDict.chapterIndex] = cloneDeep(val)
articleData.article = cloneDeep(val)
practiceStore.inputWordNumber = 0
practiceStore.wrongWordNumber = 0
practiceStore.repeatNumber = 0
practiceStore.total = 0
practiceStore.wrongWords = []
practiceStore.startDate = Date.now()
articleData.article.sections.map((v, i) => {
v.map((w, j) => {
w.words.map(s => {
if (!store.skipWordNamesWithSimpleWords.includes(s.name.toLowerCase()) && !s.isSymbol) {
practiceStore.total++
}
})
})
})
}
function getCurrentPractice() {
// console.log('store.currentDict',store.currentDict)
// return
if (!store.currentDict.articles.length) return
tabIndex = 0
articleData.article = cloneDeep(DefaultArticle)
let currentArticle = store.currentDict.articles[store.currentDict.chapterIndex]
let tempArticle = {...DefaultArticle, ...currentArticle}
console.log('article', tempArticle)
if (tempArticle.sections.length) {
setArticle(tempArticle)
} else {
if (tempArticle.useTranslateType === TranslateType.none) {
renewSectionTexts(tempArticle)
setArticle(tempArticle)
} else {
if (tempArticle.useTranslateType === TranslateType.custom) {
if (tempArticle.textCustomTranslate.trim()) {
if (tempArticle.textCustomTranslateIsFormat) {
renewSectionTexts(tempArticle)
renewSectionTranslates(tempArticle, tempArticle.textCustomTranslate)
setArticle(tempArticle)
} else {
//说明有本地翻译,但是没格式化成一行一行的
MessageBox.confirm('检测到存在本地翻译,但未格式化,是否进行编辑?',
'提示',
() => {
editArticle = tempArticle
showEditArticle = true
},
() => {
renewSectionTexts(tempArticle)
tempArticle.useTranslateType = TranslateType.none
setArticle(tempArticle)
},
{
confirmButtonText: '去编辑',
cancelButtonText: '不需要翻译',
})
}
} else {
//没有本地翻译
MessageBox.confirm(
'没有本地翻译,是否进行编辑?',
'提示',
() => {
editArticle = tempArticle
showEditArticle = true
},
() => {
renewSectionTexts(tempArticle)
tempArticle.useTranslateType = TranslateType.none
setArticle(tempArticle)
},
{
confirmButtonText: '去编辑',
cancelButtonText: '不需要翻译',
})
}
}
if (tempArticle.useTranslateType === TranslateType.network) {
renewSectionTexts(tempArticle)
renewSectionTranslates(tempArticle, tempArticle.textNetworkTranslate)
setArticle(tempArticle)
}
}
}
}
function saveArticle(val: Article) {
console.log('saveArticle', val)
showEditArticle = false
// articleData.article = cloneDeep(store.currentDict.articles[store.currentDict.chapterIndex])
setArticle(val)
}
function edit(val: Article) {
// tabIndex = 1
// wordData.words = [
// {
// ...cloneDeep(DefaultWord),
// name: 'test'
// }
// ]
// wordData.index = 0
// return
editArticle = val
showEditArticle = true
}
function wrong(word: Word) {
let lowerName = word.name.toLowerCase();
if (!store.wrong.originWords.find((v: Word) => v.name.toLowerCase() === lowerName)) {
store.wrong.originWords.push(word)
}
if (!store.skipWordNamesWithSimpleWords.includes(lowerName)) {
if (!practiceStore.wrongWords.find((v) => v.name.toLowerCase() === lowerName)) {
practiceStore.wrongWords.push(word)
practiceStore.wrongWordNumber++
}
}
}
function over() {
if (practiceStore.wrongWordNumber === 0) {
// if (false) {
console.log('这章节完了')
let now = Date.now()
let stat: DisplayStatistics = {
startDate: practiceStore.startDate,
endDate: now,
spend: now - practiceStore.startDate,
total: practiceStore.total,
correctRate: -1,
wrongWordNumber: practiceStore.wrongWordNumber,
wrongWords: practiceStore.wrongWords,
}
stat.correctRate = 100 - Math.trunc(((stat.wrongWordNumber) / (stat.total)) * 100)
emitter.emit(EventKey.openStatModal, stat)
} else {
tabIndex = 1
wordData.words = practiceStore.wrongWords
wordData.index = 0
}
}
function nextWord(word: ArticleWord) {
if (!store.skipWordNamesWithSimpleWords.includes(word.name.toLowerCase()) && !word.isSymbol) {
practiceStore.inputWordNumber++
}
}
function changePracticeArticle(val: Article) {
let rIndex = store.currentDict.articles.findIndex(v => v.id === val.id)
if (rIndex > -1) {
store.currentDict.chapterIndex = rIndex
}
}
defineExpose({getCurrentPractice})
const settingStore = useSettingStore()
const {
isArticleCollect,
toggleArticleCollect
} = useArticleOptions()
</script>
<template>
<div class="practice-article">
<div class="swiper-wrapper">
<div class="swiper-list" :class="`step${tabIndex}`">
<div class="swiper-item">
<TypingArticle
:active="tabIndex === 0"
@edit="edit"
@wrong="wrong"
@over="over"
@nextWord="nextWord"
:article="articleData.article"
/>
</div>
<div class="swiper-item">
<div class="typing-word-wrapper">
<TypingWord
:words="wordData.words"
:index="wordData.index"
v-if="tabIndex === 1"
/>
</div>
</div>
</div>
</div>
<div class="panel-wrapper">
<Panel v-if="tabIndex === 0">
<template v-slot="{active}">
<div class="panel-page-item">
<div class="list-header">
<div class="left">
<BaseIcon title="切换词典"
@click="emitter.emit(EventKey.openDictModal,'list')"
icon="carbon:change-catalog"/>
<div class="title">
{{ store.dictTitle }}
</div>
<Tooltip
:title="`下一章(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`"
v-if="store.currentDict.chapterIndex < store.currentDict.articles.length - 1">
<IconWrapper>
<Icon @click="emitter.emit(EventKey.next)" icon="octicon:arrow-right-24"/>
</IconWrapper>
</Tooltip>
</div>
<div class="right">
{{ store.currentDict.articles.length }}篇文章
</div>
</div>
<ArticleList2
:isActive="active"
:show-translate="settingStore.translate"
@select-item="changePracticeArticle"
:active-index="store.currentDict.chapterIndex"
v-model:list="store.currentDict.articles">
<template v-slot="{source,index}">
<BaseIcon
v-if="!isArticleCollect(source)"
class-name="collect"
@click="toggleArticleCollect(source)"
title="收藏" icon="ph:star"/>
<BaseIcon
v-else
class-name="fill"
@click="toggleArticleCollect(source)"
title="取消收藏" icon="ph:star-fill"/>
</template>
</ArticleList2>
</div>
</template>
</Panel>
</div>
<EditSingleArticleModal
v-model="showEditArticle"
:article="editArticle"
@save="saveArticle"
/>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/style";
$article-width: 50vw;
.swiper-wrapper {
height: 100%;
overflow: hidden;
.swiper-list {
transition: transform .3s;
height: 200%;
.swiper-item {
height: 50%;
overflow: auto;
display: flex;
justify-content: center;
}
}
.step1 {
transform: translate3d(0, -50%, 0);
}
}
.practice-article {
flex: 1;
overflow: hidden;
width: $article-width;
}
.typing-word-wrapper {
width: var(--toolbar-width);
}
.panel-wrapper {
position: fixed;
left: 0;
top: 10rem;
z-index: 1;
margin-left: calc(50% + ($article-width / 2) + var(--space));
height: calc(100% - 20rem);
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import {DefaultWord, ShortcutKey, Word} from "@/types.ts";
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
import {$computed, $ref} from "vue/macros";
import {useBaseStore} from "@/stores/base.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio, useTTsPlayAudio} from "@/hooks/sound.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {cloneDeep} from "lodash-es";
import {onUnmounted, watch, onMounted} from "vue";
import Tooltip from "@/components/Tooltip.vue";
interface IProps {
word: Word,
}
const props = withDefaults(defineProps<IProps>(), {
word: () => cloneDeep(DefaultWord),
})
const emit = defineEmits<{
next: [],
wrong: []
}>()
let input = $ref('')
let wrong = $ref('')
let showFullWord = $ref(false)
//输入锁定因为跳转到下一个单词有延时如果重复在延时期间内重复输入导致会跳转N次
let inputLock = false
let wordRepeatCount = 0
const settingStore = useSettingStore()
const playBeep = usePlayBeep()
const playCorrect = usePlayCorrect()
const playKeyboardAudio = usePlayKeyboardAudio()
const playWordAudio = usePlayWordAudio()
const ttsPlayAudio = useTTsPlayAudio()
const volumeIconRef: any = $ref()
const volumeTranslateIconRef: any = $ref()
let displayWord = $computed(() => {
return props.word.name.slice(input.length + wrong.length)
})
watch(() => props.word, () => {
wrong = input = ''
wordRepeatCount = 0
inputLock = false
if (settingStore.wordSound) {
volumeIconRef?.play(400, true)
}
})
onMounted(() => {
emitter.on(EventKey.resetWord, () => {
wrong = input = ''
})
emitter.on(EventKey.onTyping, onTyping)
})
onUnmounted(() => {
emitter.off(EventKey.resetWord)
emitter.off(EventKey.onTyping, onTyping)
})
function repeat() {
setTimeout(() => {
wrong = input = ''
wordRepeatCount++
inputLock = false
if (settingStore.wordSound) {
volumeIconRef?.play()
}
}, settingStore.waitTimeForChangeWord)
}
async function onTyping(e: KeyboardEvent) {
if (inputLock) return
inputLock = true
let letter = e.key
let isTypingRight = false
let isWordRight = false
if (settingStore.ignoreCase) {
isTypingRight = letter.toLowerCase() === props.word.name[input.length].toLowerCase()
isWordRight = (input + letter).toLowerCase() === props.word.name.toLowerCase()
} else {
isTypingRight = letter === props.word.name[input.length]
isWordRight = (input + letter) === props.word.name
}
if (isTypingRight) {
input += letter
wrong = ''
playKeyboardAudio()
} else {
emit('wrong')
wrong = letter
playKeyboardAudio()
playBeep()
volumeIconRef?.play()
setTimeout(() => {
wrong = ''
}, 500)
}
if (isWordRight) {
playCorrect()
if (settingStore.repeatCount == 100) {
if (settingStore.repeatCustomCount <= wordRepeatCount + 1) {
setTimeout(() => emit('next'), settingStore.waitTimeForChangeWord)
} else {
repeat()
}
} else {
if (settingStore.repeatCount <= wordRepeatCount + 1) {
setTimeout(() => emit('next'), settingStore.waitTimeForChangeWord)
} else {
repeat()
}
}
} else {
inputLock = false
}
}
function del() {
playKeyboardAudio()
if (wrong) {
wrong = ''
} else {
input = input.slice(0, -1)
}
}
function showWord() {
if (settingStore.allowWordTip) {
showFullWord = true
}
}
function hideWord() {
showFullWord = false
}
function play() {
volumeIconRef?.play()
}
defineExpose({del, showWord, hideWord, play})
</script>
<template>
<div class="typing-word">
<div class="translate"
:style="{
fontSize: settingStore.fontSize.wordTranslateFontSize +'rem',
opacity: settingStore.translate ? 1 : 0
}"
>
<div class="translate-item" v-for="(v,i) in word.trans">
<span>{{ v }}</span>
<!-- <div class="volumeIcon">-->
<!-- <Tooltip-->
<!-- v-if="i === word.trans.length - 1"-->
<!-- :title="`发音(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.PlayTranslatePronunciation]})`"-->
<!-- >-->
<!-- <VolumeIcon-->
<!-- ref="volumeTranslateIconRef"-->
<!-- :simple="true"-->
<!-- :cb="()=>ttsPlayAudio(word.trans.join(';'))"/>-->
<!-- </Tooltip>-->
<!-- </div>-->
</div>
</div>
<div class="word-wrapper">
<div class="word"
:class="wrong && 'is-wrong'"
:style="{fontSize: settingStore.fontSize.wordForeignFontSize +'rem'}"
>
<span class="input" v-if="input">{{ input }}</span>
<span class="wrong" v-if="wrong">{{ wrong }}</span>
<template v-if="settingStore.dictation">
<span class="letter" v-if="!showFullWord"
@mouseenter="settingStore.allowWordTip && (showFullWord = true)">{{
displayWord.split('').map(() => '_').join('')
}}</span>
<span class="letter" v-else @mouseleave="showFullWord = false">{{ displayWord }}</span>
</template>
<span class="letter" v-else>{{ displayWord }}</span>
</div>
<Tooltip
:title="`发音(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
>
<VolumeIcon ref="volumeIconRef" :simple="true" :cb="() => playWordAudio(word.name)"/>
</Tooltip>
</div>
<div class="phonetic" v-if="settingStore.wordSoundType === 'us' && word.usphone">[{{ word.usphone}}]</div>
<div class="phonetic" v-if="settingStore.wordSoundType === 'uk' && word.ukphone">[{{ word.ukphone }}]</div>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/variable";
.typing-word {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
word-break: break-word;
.phonetic, .translate {
font-size: 20rem;
margin-left: -30rem;
transition: all .3s;
}
.phonetic {
margin-top: 5rem;
font-family: $word-font-family;
}
.translate {
position: absolute;
transform: translateY(-50%);
margin-bottom: 90rem;
color: var(--color-font-2);
&:hover {
.volumeIcon {
opacity: 1;
}
}
.translate-item {
display: flex;
align-items: center;
gap: 10rem;
}
.volumeIcon {
transition: opacity .3s;
opacity: 0;
}
}
.word-wrapper {
display: flex;
align-items: center;
gap: 10rem;
color: var(--color-font-1);
.word {
font-size: 48rem;
line-height: 1;
font-family: $word-font-family;
letter-spacing: 5rem;
.input {
color: rgb(22, 163, 74);
}
.wrong {
color: rgba(red, 0.6);
}
&.is-wrong {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
}
}
}
</style>

View File

@@ -0,0 +1,404 @@
<script setup lang="ts">
import {onMounted, onUnmounted, watch} from "vue"
import {$computed, $ref} from "vue/macros"
import {useBaseStore} from "@/stores/base.ts"
import {DefaultDisplayStatistics, DictType, ShortcutKey, Word} from "../../../types.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts"
import {cloneDeep} from "lodash-es"
import {usePracticeStore} from "@/stores/practice.ts"
import {useSettingStore} from "@/stores/setting.ts";
import {useOnKeyboardEventListener} from "@/hooks/event.ts";
import {Icon} from "@iconify/vue";
import Tooltip from "@/components/Tooltip.vue";
import Options from "@/pages/practice/Options.vue";
import Typing from "@/pages/practice/practice-word/Typing.vue";
import Panel from "@/pages/practice/Panel.vue";
import IconWrapper from "@/components/IconWrapper.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useWordOptions} from "@/hooks/dict.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import CommonWordList from "@/components/list/CommonWordList.vue";
interface IProps {
words: Word[],
index: number,
}
const props = withDefaults(defineProps<IProps>(), {
words: [],
index: -1
})
const typingRef: any = $ref()
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const practiceStore = usePracticeStore()
const settingStore = useSettingStore()
const {
isWordCollect,
toggleWordCollect,
isWordSimple,
toggleWordSimple
} = useWordOptions()
let data = $ref({
index: props.index,
words: props.words,
wrongWords: [],
})
let stat = cloneDeep(DefaultDisplayStatistics)
watch(() => props.words, () => {
data.words = props.words
data.index = props.index
data.wrongWords = []
practiceStore.wrongWords = []
practiceStore.repeatNumber = 0
practiceStore.startDate = Date.now()
practiceStore.correctRate = -1
practiceStore.inputWordNumber = 0
practiceStore.wrongWordNumber = 0
stat = cloneDeep(DefaultDisplayStatistics)
}, {immediate: true})
watch(data, () => {
practiceStore.total = data.words.length
practiceStore.index = data.index
})
const word = $computed(() => {
return data.words[data.index] ?? {
trans: [],
name: '',
usphone: '',
ukphone: '',
}
})
const prevWord: Word = $computed(() => {
return data.words?.[data.index - 1] ?? undefined
})
const nextWord: Word = $computed(() => {
return data.words?.[data.index + 1] ?? undefined
})
function next(isTyping: boolean = true) {
if (data.index === data.words.length - 1) {
//复制当前错词,因为第一遍错词是最多的,后续的练习都是从错词中练习
if (stat.total === -1) {
let now = Date.now()
stat = {
startDate: practiceStore.startDate,
endDate: now,
spend: now - practiceStore.startDate,
total: props.words.length,
correctRate: -1,
inputWordNumber: practiceStore.inputWordNumber,
wrongWordNumber: data.wrongWords.length,
wrongWords: data.wrongWords,
}
stat.correctRate = 100 - Math.trunc(((stat.wrongWordNumber) / (stat.total)) * 100)
}
if (data.wrongWords.length) {
console.log('当前背完了,但还有错词')
data.words = cloneDeep(data.wrongWords)
practiceStore.total = data.words.length
practiceStore.index = data.index = 0
practiceStore.inputWordNumber = 0
practiceStore.wrongWordNumber = 0
practiceStore.repeatNumber++
data.wrongWords = []
} else {
console.log('这章节完了')
isTyping && practiceStore.inputWordNumber++
let now = Date.now()
stat.endDate = now
stat.spend = now - stat.startDate
emitter.emit(EventKey.openStatModal, stat)
}
} else {
data.index++
isTyping && practiceStore.inputWordNumber++
console.log('这个词完了')
if ([DictType.customWord, DictType.word].includes(store.currentDict.type)
&& store.skipWordNames.includes(word.name.toLowerCase())) {
next()
}
}
}
function onKeyUp(e: KeyboardEvent) {
typingRef.hideWord()
}
function wordWrong() {
if (!store.wrong.originWords.find((v: Word) => v.name.toLowerCase() === word.name.toLowerCase())) {
store.wrong.originWords.push(word)
}
if (!data.wrongWords.find((v: Word) => v.name.toLowerCase() === word.name.toLowerCase())) {
data.wrongWords.push(word)
practiceStore.wrongWordNumber++
}
}
async function onKeyDown(e: KeyboardEvent) {
// console.log('e', e)
switch (e.key) {
case 'Backspace':
typingRef.del()
break
}
}
useOnKeyboardEventListener(onKeyDown, onKeyUp)
//TODO 略过忽略的单词上
function prev() {
if (data.index === 0) {
ElMessage.warning('已经是第一个了~')
} else {
data.index--
}
}
function skip(e: KeyboardEvent) {
next(false)
// e.preventDefault()
}
function show(e: KeyboardEvent) {
typingRef.showWord()
}
function collect(e: KeyboardEvent) {
toggleWordCollect(word)
}
function toggleWordSimpleWrapper() {
if (!isWordSimple(word)) {
toggleWordSimple(word)
//延迟一下,不知道为什么不延迟会导致当前条目不自动定位到列表中间
setTimeout(() => next(false))
} else {
toggleWordSimple(word)
}
}
function play() {
typingRef.play()
}
onMounted(() => {
emitter.on(ShortcutKey.ShowWord, show)
emitter.on(ShortcutKey.Previous, prev)
emitter.on(ShortcutKey.Next, skip)
emitter.on(ShortcutKey.ToggleCollect, collect)
emitter.on(ShortcutKey.ToggleSimple, toggleWordSimpleWrapper)
emitter.on(ShortcutKey.PlayWordPronunciation, play)
})
onUnmounted(() => {
emitter.off(ShortcutKey.ShowWord, show)
emitter.off(ShortcutKey.Previous, prev)
emitter.off(ShortcutKey.Next, skip)
emitter.off(ShortcutKey.ToggleCollect, collect)
emitter.off(ShortcutKey.ToggleSimple, toggleWordSimpleWrapper)
emitter.off(ShortcutKey.PlayWordPronunciation, play)
})
</script>
<template>
<div class="practice-word">
<div class="near-word" v-if="settingStore.showNearWord">
<div class="prev"
@click="prev"
v-if="prevWord">
<Icon class="arrow" icon="bi:arrow-left" width="22"/>
<Tooltip
:title="`上一个(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.Previous]})`"
>
<div class="word">{{ prevWord.name }}</div>
</Tooltip>
</div>
<div class="next"
@click="next(false)"
v-if="nextWord">
<Tooltip
:title="`下一个(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
>
<div class="word" :class="settingStore.dictation && 'text-shadow'">{{ nextWord.name }}</div>
</Tooltip>
<Icon class="arrow" icon="bi:arrow-right" width="22"/>
</div>
</div>
<Typing
v-loading="!store.load"
ref="typingRef"
:word="word"
@wrong="wordWrong"
@next="next"
/>
<div class="options-wrapper">
<Options
:is-simple="isWordSimple(word)"
@toggle-simple="toggleWordSimpleWrapper"
:is-collect="isWordCollect(word)"
@toggle-collect="toggleWordCollect(word)"
@skip="next(false)"
/>
</div>
<Teleport to="body">
<div class="word-panel-wrapper">
<Panel>
<template v-slot="{active}">
<div class="panel-page-item"
v-loading="!store.load"
>
<div class="list-header">
<div class="left">
<BaseIcon title="切换词典"
@click="emitter.emit(EventKey.openDictModal,'list')"
icon="carbon:change-catalog"/>
<div class="title">
{{ store.dictTitle }}
</div>
<Tooltip
:title="`下一章(快捷键:${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`"
v-if="store.currentDict.chapterIndex < store.currentDict.chapterWords.length - 1">
<IconWrapper>
<Icon @click="emitter.emit(EventKey.next)" icon="octicon:arrow-right-24"/>
</IconWrapper>
</Tooltip>
</div>
<div class="right">
{{ data.words.length }}个单词
</div>
</div>
<CommonWordList
class="word-list"
:is-active="active"
@change="(val:any) => data.index = val.index"
:show-word="!settingStore.dictation"
:show-translate="settingStore.translate"
:list="data.words"
:activeIndex="data.index">
<template v-slot="{word,index}">
<BaseIcon
v-if="!isWordCollect(word)"
class-name="collect"
@click="toggleWordCollect(word)"
title="收藏" icon="ph:star"/>
<BaseIcon
v-else
class-name="fill"
@click="toggleWordCollect(word)"
title="取消收藏" icon="ph:star-fill"/>
<BaseIcon
v-if="!isWordSimple(word)"
class-name="easy"
@click="toggleWordSimple(word)"
title="标记为简单词"
icon="material-symbols:check-circle-outline-rounded"/>
<BaseIcon
v-else
class-name="fill"
@click="toggleWordSimple(word)"
title="取消标记简单词"
icon="material-symbols:check-circle-rounded"/>
</template>
</CommonWordList>
</div>
</template>
</Panel>
</div>
</Teleport>
</div>
</template>
<style scoped lang="scss">
@import "@/assets/css/variable";
.practice-word {
height: 100%;
flex: 1;
display: flex;
//display: none;
align-items: center;
justify-content: center;
flex-direction: column;
font-size: 14rem;
color: gray;
gap: 6rem;
position: relative;
width: var(--toolbar-width);
.near-word {
position: absolute;
top: 0;
width: 100%;
& > div {
width: 45%;
align-items: center;
.arrow {
min-width: 22rem;
min-height: 22rem;
}
}
.word {
font-size: 24rem;
margin-bottom: 4rem;
font-family: $word-font-family;
}
.prev {
cursor: pointer;
display: flex;
float: left;
gap: 10rem;
}
.next {
cursor: pointer;
display: flex;
justify-content: flex-end;
gap: 10rem;
float: right;
}
}
.options-wrapper {
position: absolute;
//bottom: 0;
margin-left: -30rem;
margin-top: 120rem;
}
}
$article-width: 50vw;
.word-panel-wrapper {
position: fixed;
left: 0;
top: 10rem;
z-index: 1;
//margin-left: calc(50% + (var(--toolbar-width) / 2) + var(--space));
margin-left: var(--panel-margin-left);
height: calc(100% - 20rem);
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import TypingWord from "@/pages/practice/practice-word/TypingWord.vue";
import {$ref} from "vue/macros";
import {chunk, cloneDeep} from "lodash-es";
import {useBaseStore} from "@/stores/base.ts";
import {onMounted, onUnmounted, watch} from "vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {Word} from "@/types.ts";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
let wordData = $ref({
words: [],
index: -1
})
watch([
() => store.load,
() => store.currentDict.words,
], n => {
getCurrentPractice()
})
function getCurrentPractice() {
// console.log('store.currentDict',store.currentDict)
if (store.currentDict.translateLanguage === 'common') {
store.chapter.map((w: Word) => {
let res = runtimeStore.translateWordList.find(a => a.name === w.name)
if (res) w = Object.assign(w, res)
})
}
wordData.words = cloneDeep(store.chapter)
wordData.index = 0
// console.log('wordData', wordData)
}
defineExpose({getCurrentPractice})
</script>
<template>
<div class="practice">
<TypingWord :words="wordData.words" :index="wordData.index"/>
</div>
</template>
<style scoped lang="scss">
.practice {
//height: 100%;
flex: 1;
}
</style>