save
This commit is contained in:
20
src/pages/dict/index.vue
Normal file
20
src/pages/dict/index.vue
Normal 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>
|
||||
144
src/pages/practice/Footer.vue
Normal file
144
src/pages/practice/Footer.vue
Normal 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>
|
||||
74
src/pages/practice/Options.vue
Normal file
74
src/pages/practice/Options.vue
Normal 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>
|
||||
326
src/pages/practice/Panel.vue
Normal file
326
src/pages/practice/Panel.vue
Normal 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>
|
||||
212
src/pages/practice/Statistics.vue
Normal file
212
src/pages/practice/Statistics.vue
Normal 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>
|
||||
183
src/pages/practice/index.vue
Normal file
183
src/pages/practice/index.vue
Normal 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>
|
||||
640
src/pages/practice/practice-article/TypingArticle.vue
Normal file
640
src/pages/practice/practice-article/TypingArticle.vue
Normal 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 24,100
|
||||
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>
|
||||
364
src/pages/practice/practice-article/index.vue
Normal file
364
src/pages/practice/practice-article/index.vue
Normal 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>
|
||||
279
src/pages/practice/practice-word/Typing.vue
Normal file
279
src/pages/practice/practice-word/Typing.vue
Normal 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>
|
||||
404
src/pages/practice/practice-word/TypingWord.vue
Normal file
404
src/pages/practice/practice-word/TypingWord.vue
Normal 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>
|
||||
57
src/pages/practice/practice-word/index.vue
Normal file
57
src/pages/practice/practice-word/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user