wip
This commit is contained in:
132
src/components/article/components/ArticleAudio.vue
Normal file
132
src/components/article/components/ArticleAudio.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article } from '@/types/types.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get } from 'idb-keyval'
|
||||
import Audio from '@/components/base/Audio.vue'
|
||||
import { LOCAL_FILE_KEY } from '@/config/env.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
article: Article
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'ended'): []
|
||||
(e: 'update-volume', volume: number): void
|
||||
(e: 'update-speed', volume: number): void
|
||||
}>()
|
||||
|
||||
let file = $ref(null)
|
||||
let instance = $ref<{ audioRef: HTMLAudioElement }>({ audioRef: null })
|
||||
const pendingUpdates = ref({})
|
||||
|
||||
const handleVolumeUpdate = (volume: number) => {
|
||||
emit('update-volume', volume)
|
||||
}
|
||||
|
||||
const handleSpeedUpdate = (speed: number) => {
|
||||
emit('update-speed', speed)
|
||||
}
|
||||
|
||||
const setAudioRefValue = (key: string, value: any) => {
|
||||
if (instance?.audioRef) {
|
||||
switch (key) {
|
||||
case 'currentTime':
|
||||
instance.audioRef.currentTime = value
|
||||
break
|
||||
case 'volume':
|
||||
instance.audioRef.volume = value
|
||||
break
|
||||
case 'playbackRate':
|
||||
instance.audioRef.playbackRate = value
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 如果audioRef还未初始化,先存起来,等初始化后再设置 => watch监听instance变化
|
||||
pendingUpdates.value[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.article.audioFileId,
|
||||
async () => {
|
||||
if (!props.article.audioSrc && props.article.audioFileId) {
|
||||
let list = await get(LOCAL_FILE_KEY)
|
||||
if (list) {
|
||||
let rItem = list.find(file => file.id === props.article.audioFileId)
|
||||
if (rItem) {
|
||||
file = URL.createObjectURL(rItem.file)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
file = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听instance变化,设置之前pending的值
|
||||
watch(
|
||||
() => instance,
|
||||
newVal => {
|
||||
Object.entries(pendingUpdates.value).forEach(([key, value]) => {
|
||||
setAudioRefValue(key, value)
|
||||
})
|
||||
pendingUpdates.value = {}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
//转发一遍,这里Proxy的默认值不能为{},可能是vue做了什么
|
||||
defineExpose(
|
||||
new Proxy(
|
||||
{
|
||||
currentTime: 0,
|
||||
played: false,
|
||||
src: '',
|
||||
volume: 0,
|
||||
playbackRate: 1,
|
||||
play: () => void 0,
|
||||
pause: () => void 0,
|
||||
},
|
||||
{
|
||||
get(target, key) {
|
||||
if (key === 'currentTime') return instance?.audioRef?.currentTime
|
||||
if (key === 'played') return instance?.audioRef?.played
|
||||
if (key === 'src') return instance?.audioRef?.src
|
||||
if (key === 'volume') return instance?.audioRef?.volume
|
||||
if (key === 'playbackRate') return instance?.audioRef?.playbackRate
|
||||
if (key === 'play') instance?.audioRef?.play()
|
||||
if (key === 'pause') instance?.audioRef?.pause()
|
||||
return target[key]
|
||||
},
|
||||
set(_, key, value) {
|
||||
setAudioRefValue(key as string, value)
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Audio
|
||||
v-bind="$attrs"
|
||||
ref="instance"
|
||||
v-if="props.article.audioSrc"
|
||||
:src="props.article.audioSrc"
|
||||
@ended="emit('ended')"
|
||||
@update-volume="handleVolumeUpdate"
|
||||
@update-speed="handleSpeedUpdate"
|
||||
/>
|
||||
<Audio
|
||||
v-bind="$attrs"
|
||||
ref="instance"
|
||||
v-else-if="file"
|
||||
:src="file"
|
||||
@ended="emit('ended')"
|
||||
@update-volume="handleVolumeUpdate"
|
||||
@update-speed="handleSpeedUpdate"
|
||||
/>
|
||||
</template>
|
||||
914
src/components/article/components/EditArticle.vue
Normal file
914
src/components/article/components/EditArticle.vue
Normal file
@@ -0,0 +1,914 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article, Sentence } from '@/types/types.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import EditAbleText from '@/components/EditAbleText.vue'
|
||||
import { getNetworkTranslate, getSentenceAllText, getSentenceAllTranslateText } from '@/hooks/translate.ts'
|
||||
import { genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentenceAudio } from '@/hooks/article.ts'
|
||||
import { _nextTick, _parseLRC, cloneDeep, last } from '@/utils'
|
||||
import { defineAsyncComponent, watch } from 'vue'
|
||||
import Empty from '@/components/Empty.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import * as Comparison from 'string-comparison'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { getDefaultArticle } from '@/types/func.ts'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import InputNumber from '@/components/base/InputNumber.vue'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { update } from 'idb-keyval'
|
||||
import ArticleAudio from '@/components/article/components/ArticleAudio.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import Textarea from '@/components/base/Textarea.vue'
|
||||
import { LOCAL_FILE_KEY } from '@/config/env.ts'
|
||||
import PopConfirm from '@/components/PopConfirm.vue'
|
||||
import {TranslateEngine} from "@/types/enum.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
interface IProps {
|
||||
article?: Article
|
||||
type?: 'single' | 'batch'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
article: () => getDefaultArticle(),
|
||||
type: 'single',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [val: Article]
|
||||
saveAndNext: [val: Article]
|
||||
}>()
|
||||
|
||||
let networkTranslateEngine = $ref('baidu')
|
||||
let progress = $ref(0)
|
||||
let failCount = $ref(0)
|
||||
let resultRef = $ref<HTMLDivElement>()
|
||||
const TranslateEngineOptions = [
|
||||
// {value: 'youdao', label: '有道'},
|
||||
{ value: 'baidu', label: '百度' },
|
||||
]
|
||||
|
||||
let editArticle = $ref<Article>(getDefaultArticle())
|
||||
|
||||
watch(
|
||||
() => props.article,
|
||||
val => {
|
||||
editArticle = getDefaultArticle(val)
|
||||
progress = 0
|
||||
failCount = 0
|
||||
apply(false)
|
||||
_nextTick(() => {
|
||||
resultRef?.scrollTo(0,0)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => editArticle.text,
|
||||
s => {
|
||||
if (!s.trim()) {
|
||||
editArticle.sections = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function apply(isHandle: boolean = true) {
|
||||
let text = editArticle.text.trim()
|
||||
if (!text && isHandle) {
|
||||
// text = "Last week I went to the theatre. I had a very good seat. The play was very interesting. I did not enjoy it. A young man and a young woman were sitting behind me. They were talking loudly. I got very angry. I could not hear the actors. I turned round. I looked at the man and the woman angrily. They did not pay any attention. In the end, I could not bear it. I turned round again. 'I can't hear a word!' I said angrily.\n\n 'It's none of your business,' the young man said rudely. 'This is a private conversation!'"
|
||||
// text = `While it is yet to be seen what direction the second Trump administration will take globally in its China policy, VOA traveled to the main island of Mahe in Seychelles to look at how China and the U.S. have impacted the country, and how each is fairing in that competition for influence there.`
|
||||
// text = "It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again.' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train,' she said. 'I'm coming to see you.'\n\n 'But I'm still having breakfast,' I said.\n\n 'What are you doing?' she asked.\n\n 'I'm having breakfast,' I repeated.\n\n 'Dear me,' she said. 'Do you always get up so late? It's one o'clock!'"
|
||||
editArticle.sections = []
|
||||
Toast.error('请填写原文!')
|
||||
return
|
||||
}
|
||||
failCount = genArticleSectionData(editArticle)
|
||||
}
|
||||
|
||||
//分句原文
|
||||
function splitText() {
|
||||
editArticle.text = splitEnArticle2(editArticle.text)
|
||||
}
|
||||
|
||||
//分句翻译
|
||||
function splitTranslateText() {
|
||||
editArticle.textTranslate = splitCNArticle2(editArticle.textTranslate.trim())
|
||||
}
|
||||
|
||||
//TODO
|
||||
async function startNetworkTranslate() {
|
||||
if (!editArticle.title.trim()) {
|
||||
return Toast.error('请填写标题!')
|
||||
}
|
||||
if (!editArticle.text.trim()) {
|
||||
return Toast.error('请填写正文!')
|
||||
}
|
||||
editArticle.titleTranslate = ''
|
||||
editArticle.textTranslate = ''
|
||||
apply()
|
||||
//注意!!!
|
||||
//这里需要用异步,因为watch了article.networkTranslate,改变networkTranslate了之后,会重新设置article.sections
|
||||
//导致getNetworkTranslate里面拿到的article.sections是废弃的值
|
||||
setTimeout(async () => {
|
||||
await getNetworkTranslate(editArticle, TranslateEngine.Baidu, false, (v: number) => {
|
||||
progress = v
|
||||
})
|
||||
failCount = 0
|
||||
})
|
||||
}
|
||||
|
||||
function saveSentenceTranslate(sentence: Sentence, val: string) {
|
||||
sentence.translate = val
|
||||
editArticle.textTranslate = getSentenceAllTranslateText(editArticle)
|
||||
apply()
|
||||
}
|
||||
|
||||
function saveSentenceText(sentence: Sentence, val: string) {
|
||||
sentence.text = val
|
||||
editArticle.text = getSentenceAllText(editArticle)
|
||||
apply()
|
||||
}
|
||||
|
||||
function save(option: 'save' | 'saveAndNext') {
|
||||
return new Promise((resolve: Function) => {
|
||||
// console.log('article', article)
|
||||
// copy(JSON.stringify(article))
|
||||
|
||||
editArticle.title = editArticle.title.trim()
|
||||
editArticle.titleTranslate = editArticle.titleTranslate.trim()
|
||||
editArticle.text = editArticle.text.trim()
|
||||
editArticle.textTranslate = editArticle.textTranslate.trim()
|
||||
|
||||
if (!editArticle.title) {
|
||||
Toast.error('请填写标题!')
|
||||
return resolve(false)
|
||||
}
|
||||
if (!editArticle.text) {
|
||||
Toast.error('请填写正文!')
|
||||
return resolve(false)
|
||||
}
|
||||
|
||||
editArticle.lrcPosition = editArticle.sections
|
||||
.map(v => {
|
||||
return v.map((w, j) => {
|
||||
return w.audioPosition ?? []
|
||||
})
|
||||
})
|
||||
.flat()
|
||||
|
||||
console.log(editArticle)
|
||||
|
||||
let d = cloneDeep(editArticle)
|
||||
if (!d.id) d.id = nanoid(6)
|
||||
delete d.sections
|
||||
//这个console.json方法特意将array压缩了,而不压缩其他,方便可视化复制到文章的json里面去
|
||||
copy(console.json(d, 2))
|
||||
// copy(JSON.stringify(d, null, 2))
|
||||
emit(option as any, editArticle)
|
||||
resolve(true)
|
||||
})
|
||||
}
|
||||
|
||||
//不知道为什么直接用editArticle,取到是空的默认值
|
||||
defineExpose({ save, getEditArticle: () => cloneDeep(editArticle) })
|
||||
|
||||
// 处理音频文件上传
|
||||
async function handleAudioChange(e: any) {
|
||||
let uploadFile = e.target?.files?.[0]
|
||||
if (!uploadFile) return
|
||||
let data = {
|
||||
id: nanoid(),
|
||||
file: uploadFile,
|
||||
}
|
||||
//把文件存到indexDB
|
||||
await update(LOCAL_FILE_KEY, val => {
|
||||
if (val) val.push(data)
|
||||
else val = [data]
|
||||
return val
|
||||
})
|
||||
//保存id,后续从indexDb里读文件来使用
|
||||
editArticle.audioFileId = data.id
|
||||
editArticle.audioSrc = ''
|
||||
// 重置input,确保即使选择同一个文件也能触发change事件
|
||||
e.target.value = ''
|
||||
Toast.success('音频添加成功')
|
||||
}
|
||||
|
||||
// 处理LRC文件上传
|
||||
function handleChange(e: any) {
|
||||
// 获取上传的文件
|
||||
let uploadFile = e.target?.files?.[0]
|
||||
if (!uploadFile) return
|
||||
|
||||
// 读取文件内容
|
||||
let reader = new FileReader()
|
||||
reader.readAsText(uploadFile, 'UTF-8')
|
||||
reader.onload = function (e) {
|
||||
let lrc: string = e.target.result as string
|
||||
console.log(lrc)
|
||||
if (lrc.trim()) {
|
||||
let lrcList = _parseLRC(lrc)
|
||||
console.log('lrcList', lrcList)
|
||||
if (lrcList.length) {
|
||||
editArticle.lrcPosition = editArticle.sections
|
||||
.map((v, i) => {
|
||||
return v.map((w, j) => {
|
||||
for (let k = 0; k < lrcList.length; k++) {
|
||||
let s = lrcList[k]
|
||||
// let d = Comparison.default.cosine.similarity(w.text, s.text)
|
||||
// d = Comparison.default.levenshtein.similarity(w.text, s.text)
|
||||
let d = Comparison.default.longestCommonSubsequence.similarity(w.text, s.text)
|
||||
// d = Comparison.default.metricLcs.similarity(w.text, s.text)
|
||||
// console.log(w.text, s.text, d)
|
||||
if (d >= 0.8) {
|
||||
w.audioPosition = [s.start, s.end ?? -1]
|
||||
break
|
||||
}
|
||||
}
|
||||
return w.audioPosition ?? []
|
||||
})
|
||||
})
|
||||
.flat()
|
||||
|
||||
Toast.success('LRC文件解析成功')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置input,确保即使选择同一个文件也能触发change事件
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
let currentSentence = $ref<Sentence>({} as any)
|
||||
let editSentence = $ref<Sentence>({} as any)
|
||||
let preSentence = $ref<Sentence>({} as any)
|
||||
let showEditAudioDialog = $ref(false)
|
||||
let showAudioDialog = $ref(false)
|
||||
let showNameDialog = $ref(false)
|
||||
let sentenceAudioRef = $ref<HTMLAudioElement>()
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
|
||||
let nameListRef = $ref<string[]>([])
|
||||
watch(
|
||||
() => showNameDialog,
|
||||
v => {
|
||||
if (v) {
|
||||
nameListRef = cloneDeep(Array.isArray(editArticle.nameList) ? editArticle.nameList : [])
|
||||
nameListRef.push('')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function addName() {
|
||||
nameListRef.push('')
|
||||
}
|
||||
|
||||
function removeName(i: number) {
|
||||
nameListRef.splice(i, 1)
|
||||
}
|
||||
|
||||
function saveNameList() {
|
||||
const cleaned = Array.from(new Set(nameListRef.map(s => (s ?? '').trim()).filter(Boolean)))
|
||||
editArticle.nameList = cleaned as any
|
||||
}
|
||||
|
||||
function handleShowEditAudioDialog(val: Sentence, i: number, j: number) {
|
||||
showEditAudioDialog = true
|
||||
currentSentence = val
|
||||
editSentence = cloneDeep(val)
|
||||
preSentence = null
|
||||
audioRef.pause()
|
||||
if (j == 0) {
|
||||
if (i != 0) {
|
||||
preSentence = last(editArticle.sections[i - 1])
|
||||
}
|
||||
} else {
|
||||
preSentence = editArticle.sections[i][j - 1]
|
||||
}
|
||||
if (!editSentence.audioPosition?.length) {
|
||||
editSentence.audioPosition = [0, 0]
|
||||
if (preSentence) {
|
||||
editSentence.audioPosition = [preSentence.audioPosition[1] ?? 0, 0]
|
||||
}
|
||||
}
|
||||
_nextTick(() => {
|
||||
sentenceAudioRef.currentTime = editSentence.audioPosition[0]
|
||||
})
|
||||
}
|
||||
|
||||
function recordStart() {
|
||||
if (sentenceAudioRef.paused) {
|
||||
sentenceAudioRef.play()
|
||||
}
|
||||
editSentence.audioPosition[0] = Number(sentenceAudioRef.currentTime.toFixed(2))
|
||||
if (editSentence.audioPosition[0] > editSentence.audioPosition[1] && editSentence.audioPosition[1] !== 0) {
|
||||
editSentence.audioPosition[1] = editSentence.audioPosition[0]
|
||||
}
|
||||
}
|
||||
|
||||
function recordEnd() {
|
||||
if (!sentenceAudioRef.paused) {
|
||||
sentenceAudioRef.pause()
|
||||
}
|
||||
editSentence.audioPosition[1] = Number(sentenceAudioRef.currentTime.toFixed(2))
|
||||
}
|
||||
|
||||
const { playSentenceAudio } = usePlaySentenceAudio()
|
||||
|
||||
function saveLrcPosition() {
|
||||
// showEditAudioDialog = false
|
||||
currentSentence.audioPosition = cloneDeep(editSentence.audioPosition)
|
||||
editArticle.lrcPosition = editArticle.sections.map((v, i) => v.map((w, j) => w.audioPosition ?? [])).flat()
|
||||
}
|
||||
|
||||
function jumpAudio(time: number) {
|
||||
sentenceAudioRef.currentTime = time
|
||||
}
|
||||
|
||||
function setPreEndTimeToCurrentStartTime() {
|
||||
if (preSentence) {
|
||||
editSentence.audioPosition[0] = preSentence.audioPosition[1]
|
||||
}
|
||||
}
|
||||
|
||||
function setStartTime(val: Sentence, i: number, j: number) {
|
||||
let preSentence = null
|
||||
if (j == 0) {
|
||||
if (i != 0) {
|
||||
preSentence = last(editArticle.sections[i - 1])
|
||||
}
|
||||
} else {
|
||||
preSentence = editArticle.sections[i][j - 1]
|
||||
}
|
||||
if (preSentence) {
|
||||
val.audioPosition[0] = preSentence.audioPosition[1]
|
||||
} else {
|
||||
val.audioPosition[0] = Number(Number(audioRef.currentTime).toFixed(2))
|
||||
}
|
||||
if (val.audioPosition[0] > val.audioPosition[1] && val.audioPosition[1] !== 0) {
|
||||
val.audioPosition[1] = 0
|
||||
}
|
||||
}
|
||||
|
||||
function setEndTime(val: Sentence, i: number, j: number) {
|
||||
val.audioPosition[1] = Number(Number(audioRef.currentTime).toFixed(2))
|
||||
if (val.audioPosition[0] === 0) {
|
||||
setStartTime(val, i, j)
|
||||
}
|
||||
}
|
||||
|
||||
function minusStartTime(val: Sentence) {
|
||||
if (val.audioPosition[0] <= 0) return
|
||||
val.audioPosition[0] = Number((val.audioPosition[0] - 0.3).toFixed(2))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<div class="row flex flex-col gap-2">
|
||||
<div class="title">原文</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="shrink-0">标题:</div>
|
||||
<BaseInput
|
||||
v-model="editArticle.title"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
type="text"
|
||||
placeholder="请填写原文标题"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>正文:<span class="text-sm color-gray">一行一句,段落间空一行</span></span>
|
||||
<Tooltip title="配置人名之后,在练习时自动忽略(可选,默认开启)">
|
||||
<div @click="showNameDialog = true" class="center gap-1 cp">
|
||||
<span>人名配置</span>
|
||||
<IconFluentSettings20Regular />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="editArticle.text"
|
||||
class="h-full"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
placeholder="请复制原文"
|
||||
:autosize="false"
|
||||
/>
|
||||
<div class="justify-end items-center flex">
|
||||
<Tooltip>
|
||||
<IconFluentQuestionCircle20Regular class="mr-3" width="20" />
|
||||
<template #reference>
|
||||
<div>
|
||||
<div class="mb-2">使用方法</div>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-main">
|
||||
<li>复制原文,然后分句</li>
|
||||
<li>
|
||||
点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span class="color-red font-bold">
|
||||
或</span
|
||||
>
|
||||
手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>修改完成后点击 <span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<BaseButton @click="splitText">分句</BaseButton>
|
||||
<BaseButton @click="apply()">应用</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row flex flex-col gap-2">
|
||||
<div class="title">译文</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="shrink-0">标题:</div>
|
||||
<BaseInput
|
||||
v-model="editArticle.titleTranslate"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
type="text"
|
||||
placeholder="请填写翻译标题"
|
||||
/>
|
||||
</div>
|
||||
<div class="">正文:<span class="text-sm color-gray">一行一句,段落间空一行</span></div>
|
||||
<Textarea
|
||||
v-model="editArticle.textTranslate"
|
||||
class="h-full"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
placeholder="请填写翻译"
|
||||
:autosize="false"
|
||||
/>
|
||||
<div class="justify-between items-center flex">
|
||||
<div class="flex gap-space items-center w-50">
|
||||
<BaseButton @click="startNetworkTranslate" :loading="progress !== 0 && progress !== 100">翻译 </BaseButton>
|
||||
<Select v-model="networkTranslateEngine">
|
||||
<Option v-for="item in TranslateEngineOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</Select>
|
||||
{{ progress }}%
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip>
|
||||
<IconFluentQuestionCircle20Regular class="mr-3" width="20" />
|
||||
<template #reference>
|
||||
<div>
|
||||
<div class="mb-2">使用方法</div>
|
||||
<ol class="py-0 pl-5 my-0 text-base color-black/60">
|
||||
<li>复制译文,如果没有请点击 <span class="color-red font-bold">翻译</span> 按钮</li>
|
||||
<li>
|
||||
点击 <span class="color-red font-bold">分句</span> 按钮进行自动分句<span
|
||||
class="color-red font-bold"
|
||||
>
|
||||
或</span
|
||||
>
|
||||
手动编辑分句
|
||||
</li>
|
||||
<li>分句规则:一行一句,段落间空一行</li>
|
||||
<li>
|
||||
修改完成后点击
|
||||
<span class="color-red font-bold">应用</span> 按钮同步到左侧结果栏
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<BaseButton @click="splitTranslateText">分句</BaseButton>
|
||||
<BaseButton @click="apply(true)">应用</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<div class="title">结果</div>
|
||||
<div class="flex gap-2 flex-1 justify-end">
|
||||
<ArticleAudio ref="audioRef" :article="editArticle" :autoplay="false" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="editArticle?.sections?.length">
|
||||
<div class="flex-1 overflow-auto flex flex-col">
|
||||
<div class="flex justify-between bg-black/10 py-2 rounded-lt-md rounded-rt-md">
|
||||
<div class="center flex-[7]">
|
||||
内容(
|
||||
<span class="text-sm color-gray-500">均可编辑,编辑后点击应用按钮会自动同步</span>)
|
||||
</div>
|
||||
<div>|</div>
|
||||
<div class="center flex-[3] gap-2">
|
||||
<span>音频</span>
|
||||
<BaseIcon title="音频管理" @click="showAudioDialog = true">
|
||||
<IconIconParkOutlineAddMusic />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-translate" ref="resultRef">
|
||||
<div class="section rounded-md" v-for="(item, indexI) in editArticle.sections">
|
||||
<div class="section-title text-lg font-bold">第{{ indexI + 1 }}段</div>
|
||||
<div class="sentence" v-for="(sentence, indexJ) in item">
|
||||
<div class="flex-[7]">
|
||||
<EditAbleText
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
:value="sentence.text"
|
||||
@save="(e: string) => saveSentenceText(sentence, e)"
|
||||
/>
|
||||
<EditAbleText
|
||||
class="text-lg!"
|
||||
v-if="sentence.translate"
|
||||
:disabled="![100, 0].includes(progress)"
|
||||
:value="sentence.translate"
|
||||
@save="(e: string) => saveSentenceTranslate(sentence, e)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-[2] flex justify-end gap-1 items-center">
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div>{{ sentence.audioPosition?.[0] ?? 0 }}s</div>
|
||||
<div class="flex gap-1">
|
||||
<BaseIcon
|
||||
@click="setStartTime(sentence, indexI, indexJ)"
|
||||
:title="indexI === 0 && indexJ === 0 ? '设置开始时间' : '使用前一句的结束时间'"
|
||||
>
|
||||
<IconFluentMyLocation20Regular v-if="indexI === 0 && indexJ === 0" />
|
||||
<IconFluentPaddingLeft20Regular v-else />
|
||||
</BaseIcon>
|
||||
<BaseIcon @click="minusStartTime(sentence)" title="减 0.3 秒"> -.3s </BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div>-</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div v-if="sentence.audioPosition?.[1] !== -1">{{ sentence.audioPosition?.[1] ?? 0 }}s</div>
|
||||
<div v-else>结束</div>
|
||||
<BaseIcon @click="setEndTime(sentence, indexI, indexJ)" title="设置结束时间">
|
||||
<IconFluentMyLocation20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<BaseIcon
|
||||
:icon="sentence.audioPosition?.length ? 'basil:edit-outline' : 'basil:add-outline'"
|
||||
title="编辑音频对齐"
|
||||
@click="handleShowEditAudioDialog(sentence, indexI, indexJ)"
|
||||
>
|
||||
<IconFluentSpeakerEdit20Regular
|
||||
v-if="sentence.audioPosition?.length && sentence.audioPosition[1]"
|
||||
/>
|
||||
<IconFluentAddSquare20Regular v-else />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
title="播放"
|
||||
v-if="sentence.audioPosition?.length"
|
||||
@click="playSentenceAudio(sentence, audioRef)"
|
||||
>
|
||||
<IconFluentPlay20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options" v-if="editArticle.text.trim()">
|
||||
<div class="status">
|
||||
<span>状态:</span>
|
||||
<div class="warning" v-if="failCount">
|
||||
<IconFluentShieldQuestion20Regular />
|
||||
共有{{ failCount }}句没有翻译!
|
||||
</div>
|
||||
<div class="success" v-else>
|
||||
<IconFluentCheckmarkCircle16Regular />
|
||||
翻译完成!
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseButton @click="save('save')">保存</BaseButton>
|
||||
<BaseButton v-if="type === 'batch'" @click="save('saveAndNext')">保存并添加下一篇</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Empty v-else text="没有译文对照~" />
|
||||
</div>
|
||||
<Dialog
|
||||
title="调整音频时间轴"
|
||||
v-model="showEditAudioDialog"
|
||||
:footer="true"
|
||||
@close="showEditAudioDialog = false"
|
||||
@ok="saveLrcPosition"
|
||||
>
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
|
||||
<div class="">
|
||||
教程:点击音频播放按钮,当播放到句子开始时,点击开始时间的
|
||||
<span class="color-red">记录</span> 按钮;当播放到句子结束时,点击结束时间的
|
||||
<span class="color-red">记录</span> 按钮,最后再试听是否正确
|
||||
</div>
|
||||
<ArticleAudio ref="sentenceAudioRef" :article="editArticle" :autoplay="false" class="w-full" />
|
||||
<div class="flex items-center gap-2 justify-between mb-2" v-if="editSentence.audioPosition?.length">
|
||||
<div>{{ editSentence.text }}</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div>
|
||||
<span>{{ editSentence.audioPosition?.[0] }}s</span>
|
||||
<span v-if="editSentence.audioPosition?.[1] !== -1"> - {{ editSentence.audioPosition?.[1] }}s</span>
|
||||
<span v-else> - 结束</span>
|
||||
</div>
|
||||
<BaseIcon title="播放" @click="playSentenceAudio(editSentence, sentenceAudioRef)">
|
||||
<IconFluentPlay20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>开始时间:</div>
|
||||
<div class="flex justify-between flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1" />
|
||||
<BaseIcon
|
||||
@click="jumpAudio(editSentence.audioPosition[0])"
|
||||
:title="`跳转至${editSentence.audioPosition[0]}秒`"
|
||||
>
|
||||
<IconFluentMyLocation20Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
v-if="preSentence"
|
||||
@click="setPreEndTimeToCurrentStartTime"
|
||||
:title="`使用前一句的结束时间:${preSentence?.audioPosition?.[1] || 0}秒`"
|
||||
>
|
||||
<IconFluentPaddingLeft20Regular />
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="editSentence.audioPosition[0] = Number((editSentence.audioPosition[0] - 0.3).toFixed(2))"
|
||||
title="减少 0.3 秒"
|
||||
>
|
||||
-.3s
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="editSentence.audioPosition[0] = Number((editSentence.audioPosition[0] + 0.3).toFixed(2))"
|
||||
title="增加 0.3 秒"
|
||||
>
|
||||
+.3s
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<BaseButton @click="recordStart">记录</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>结束时间:</div>
|
||||
<div class="flex justify-between flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber v-model="editSentence.audioPosition[1]" :precision="2" :step="0.1" />
|
||||
<span>或</span>
|
||||
<BaseButton size="small" @click="editSentence.audioPosition[1] = -1">结束</BaseButton>
|
||||
</div>
|
||||
<BaseButton @click="recordEnd">记录</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog title="音频管理" v-model="showAudioDialog" :footer="false" @close="showAudioDialog = false">
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
|
||||
<div class="">
|
||||
1、上传的文件保存在本地电脑上,更换电脑数据将丢失,请及时备份数据
|
||||
<br />
|
||||
2、LRC 文件用于解析句子对应音频的位置,不一定准确,后续可自行修改
|
||||
</div>
|
||||
<!-- <ArticleAudio ref="sentenceAudioRef" :article="editArticle" class="w-full"/>-->
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传音频</BaseButton>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
@change="handleAudioChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传 LRC 文件</BaseButton>
|
||||
<input
|
||||
type="file"
|
||||
accept=".lrc"
|
||||
@change="handleChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog title="人名管理" v-model="showNameDialog" :footer="true" @close="showNameDialog = false" @ok="saveNameList">
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-base">配置需要忽略的人名,练习时自动忽略这些名称(可选,默认开启)</div>
|
||||
<BaseButton type="info" @click="addName">添加名称</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2" v-for="(name, i) in nameListRef" :key="i">
|
||||
<BaseInput
|
||||
v-model="nameListRef[i]"
|
||||
placeholder="输入名称"
|
||||
size="large"
|
||||
:autofocus="i === nameListRef.length - 1"
|
||||
/>
|
||||
<BaseButton type="info" @click="removeName(i)">删除</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content {
|
||||
color: var(--color-article);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--space);
|
||||
padding: 0.6rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex: 7;
|
||||
width: 33%;
|
||||
//height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
//opacity: 0;
|
||||
|
||||
&:nth-child(3) {
|
||||
flex: 10;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.article-translate {
|
||||
flex: 1;
|
||||
overflow-y: overlay;
|
||||
|
||||
.section {
|
||||
background: var(--color-textarea-bg);
|
||||
margin-bottom: 1.2rem;
|
||||
|
||||
.section-title {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-item-border);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sentence {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
line-height: 1.2;
|
||||
border-bottom: 1px solid var(--color-item-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.2rem;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.2rem;
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
gap: 1rem;
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
|
||||
&:nth-child(3) {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
// 表单元素优化
|
||||
.base-input,
|
||||
.base-textarea {
|
||||
width: 100%;
|
||||
font-size: 16px; // 防止iOS自动缩放
|
||||
}
|
||||
|
||||
.base-textarea {
|
||||
min-height: 150px;
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
// 按钮组优化
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
.base-button {
|
||||
min-height: 44px;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
// 文章翻译区域优化
|
||||
.article-translate {
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.sentence {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
|
||||
.flex-\[7\] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-\[2\] {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
|
||||
.flex.justify-end.gap-2 {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选项区域优化
|
||||
.options {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
.status {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.success {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content {
|
||||
padding: 0.3rem;
|
||||
|
||||
.row {
|
||||
.base-textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.flex.gap-2 {
|
||||
.base-button {
|
||||
min-width: 100px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
src/components/article/components/EditBook.vue
Normal file
149
src/components/article/components/EditBook.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dict } from '@/types/types.ts'
|
||||
import { cloneDeep } from '@/utils'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { getDefaultDict } from '@/types/func.ts'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import Form from '@/components/base/form/Form.vue'
|
||||
import FormItem from '@/components/base/form/FormItem.vue'
|
||||
import { addDict } from '@/apis'
|
||||
import { AppEnv, DictId } from '@/config/env.ts'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DictType } from '@/types/enum.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
isAdd: boolean
|
||||
isBook: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
submit: []
|
||||
close: []
|
||||
}>()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const store = useBaseStore()
|
||||
const DefaultDictForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
tags: [],
|
||||
translateLanguage: 'zh-CN',
|
||||
language: 'en',
|
||||
type: DictType.article,
|
||||
}
|
||||
let dictForm: any = $ref(cloneDeep(DefaultDictForm))
|
||||
const dictFormRef = $ref()
|
||||
let loading = $ref(false)
|
||||
const dictRules = reactive({
|
||||
name: [
|
||||
{ required: true, message: '请输入名称', trigger: 'blur' },
|
||||
{ max: 20, message: '名称不能超过20个字符', trigger: 'blur' },
|
||||
],
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
await dictFormRef.validate(async valid => {
|
||||
if (valid) {
|
||||
let data: Dict = getDefaultDict(dictForm)
|
||||
data.type = props.isBook ? DictType.article : DictType.word
|
||||
let source = [store.article, store.word][props.isBook ? 0 : 1]
|
||||
//todo 可以检查的更准确些,比如json对比
|
||||
if (props.isAdd) {
|
||||
data.id = 'custom-dict-' + Date.now()
|
||||
data.custom = true
|
||||
if (source.bookList.find(v => v.name === data.name)) {
|
||||
Toast.warning('已有相同名称!')
|
||||
return
|
||||
} else {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
loading = true
|
||||
let res = await addDict(null, data)
|
||||
loading = false
|
||||
if (res.success) {
|
||||
data = getDefaultDict(res.data)
|
||||
} else {
|
||||
return Toast.error(res.msg)
|
||||
}
|
||||
}
|
||||
source.bookList.push(cloneDeep(data))
|
||||
runtimeStore.editDict = data
|
||||
emit('submit')
|
||||
Toast.success('添加成功')
|
||||
}
|
||||
} else {
|
||||
let rIndex = source.bookList.findIndex(v => v.id === data.id)
|
||||
//任意修改,都将其变为自定义词典
|
||||
if (
|
||||
!data.custom &&
|
||||
![DictId.wordKnown, DictId.wordWrong, DictId.wordCollect, DictId.articleCollect].includes(
|
||||
data.en_name || data.id
|
||||
)
|
||||
) {
|
||||
data.custom = true
|
||||
if (!data.id.includes('_custom')) {
|
||||
data.id += '_custom_' + nanoid(6)
|
||||
}
|
||||
}
|
||||
runtimeStore.editDict = data
|
||||
if (rIndex > -1) {
|
||||
source.bookList[rIndex] = getDefaultDict(data)
|
||||
emit('submit')
|
||||
Toast.success('修改成功')
|
||||
} else {
|
||||
source.bookList.push(getDefaultDict(data))
|
||||
Toast.success('修改成功并加入我的词典')
|
||||
}
|
||||
}
|
||||
console.log('submit!', data)
|
||||
} else {
|
||||
Toast.warning('请填写完整')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.isAdd) {
|
||||
dictForm = cloneDeep(runtimeStore.editDict)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-120 mt-4">
|
||||
<Form ref="dictFormRef" :rules="dictRules" :model="dictForm" label-width="8rem">
|
||||
<FormItem label="名称" prop="name">
|
||||
<BaseInput v-model="dictForm.name" />
|
||||
</FormItem>
|
||||
<FormItem label="描述">
|
||||
<BaseInput v-model="dictForm.description" textarea />
|
||||
</FormItem>
|
||||
<FormItem label="原文语言" v-if="false">
|
||||
<Select v-model="dictForm.language" placeholder="请选择选项">
|
||||
<Option label="英语" value="en" />
|
||||
<Option label="德语" value="de" />
|
||||
<Option label="日语" value="ja" />
|
||||
<Option label="代码" value="code" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="译文语言" v-if="false">
|
||||
<Select v-model="dictForm.translateLanguage" placeholder="请选择选项">
|
||||
<Option label="中文" value="zh-CN" />
|
||||
<Option label="英语" value="en" />
|
||||
<Option label="德语" value="de" />
|
||||
<Option label="日语" value="ja" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
<div class="center">
|
||||
<base-button type="info" @click="emit('close')">关闭</base-button>
|
||||
<base-button type="primary" :loading="loading" @click="onSubmit">确定</base-button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
53
src/components/article/components/EditSingleArticleModal.vue
Normal file
53
src/components/article/components/EditSingleArticleModal.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type {Article} from "@/types/types.ts";
|
||||
import {useDisableEventListener} from "@/hooks/event.ts";
|
||||
import EditArticle from "@/components/article/components/EditArticle.vue";
|
||||
import {getDefaultArticle} from "@/types/func.ts";
|
||||
import {defineAsyncComponent} from "vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
interface IProps {
|
||||
article?: Article
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
article: () => getDefaultArticle(),
|
||||
modelValue: false
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
save: [val: Article]
|
||||
'update:modelValue': [val: boolean]
|
||||
}>()
|
||||
|
||||
useDisableEventListener(() => props.modelValue)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:header="false"
|
||||
:model-value="props.modelValue"
|
||||
@close="emit('update:modelValue',false)"
|
||||
:full-screen="true"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<EditArticle
|
||||
:article="article"
|
||||
@save="val => emit('save',val)"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
100
src/components/article/components/QuestionForm.vue
Normal file
100
src/components/article/components/QuestionForm.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="question-form en-article-family">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-bold">Multiple choice questions 选择题</div>
|
||||
<div v-if="false">
|
||||
<button
|
||||
v-if="!started"
|
||||
class="bg-blue-600 text-white px-4 py-1 rounded"
|
||||
@click="startExam"
|
||||
>开始
|
||||
</button>
|
||||
<span v-if="started" class="text-red-600 font-semibold font-family">
|
||||
倒计时:{{ timeLeft }} 秒
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent>
|
||||
<QuestionItem
|
||||
v-for="(q, i) in questions"
|
||||
:key="i"
|
||||
ref="questionRefs1"
|
||||
:question-index="i + 1"
|
||||
:stem="q.stem"
|
||||
:options="q.options"
|
||||
:correct-answer="q.correctAnswer"
|
||||
:explanation="q.explanation"
|
||||
:immediate-feedback="props.immediateFeedback"
|
||||
:randomize="props.randomize"
|
||||
@answered="onAnswered"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="center items-center gap-2 mt-10">
|
||||
<button
|
||||
class="bg-green-600 text-white px-6 py-2 rounded"
|
||||
@click="submitAll"
|
||||
>提交试卷
|
||||
</button>
|
||||
<span class="text-xl">浅红:错误 深红:未选 绿:正确</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, useTemplateRef} from 'vue'
|
||||
import QuestionItem from './QuestionItem.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
|
||||
interface IProps {
|
||||
questions: Array,
|
||||
duration: Number,
|
||||
immediateFeedback: Boolean,
|
||||
randomize: Boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
questions: [],
|
||||
duration: 300,
|
||||
immediateFeedback: false,
|
||||
randomize: false
|
||||
})
|
||||
|
||||
const questionRefs = useTemplateRef('questionRefs1')
|
||||
const started = ref(false)
|
||||
const timeLeft = ref(props.duration || 300)
|
||||
let timer = null
|
||||
|
||||
const startExam = () => {
|
||||
started.value = true
|
||||
timeLeft.value = props.duration || 300
|
||||
timer = setInterval(() => {
|
||||
timeLeft.value--
|
||||
if (timeLeft.value <= 0) {
|
||||
clearInterval(timer)
|
||||
submitAll()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onAnswered = (res) => {
|
||||
console.log('Answered:', res)
|
||||
// 可收集中间过程(非必须)
|
||||
}
|
||||
|
||||
const submitAll = () => {
|
||||
console.log(questionRefs)
|
||||
questionRefs.value.forEach((q) => q.submit())
|
||||
const results = questionRefs.value.map((q) => q.getResult())
|
||||
const correctCount = results.filter(r => r.isCorrect).length
|
||||
const wrongCount = results.length - correctCount
|
||||
|
||||
console.log('最终结果:', results)
|
||||
Toast.success(`共 ${results.length} 题,答对 ${correctCount},答错 ${wrongCount}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
194
src/components/article/components/QuestionItem.vue
Normal file
194
src/components/article/components/QuestionItem.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div ref="container" class="question-item mb-4">
|
||||
<div class="mb-1 "
|
||||
:class="noChoseClass"
|
||||
><span class="font-family">{{ questionIndex }}</span>. {{ stem }}
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-1"
|
||||
:class="layoutClass"
|
||||
>
|
||||
<label
|
||||
v-for="(opt, i) in shuffledOptions"
|
||||
:key="i"
|
||||
class="option border rounded cursor-pointer hover:bg-gray-300"
|
||||
:class="feedbackClass(i)"
|
||||
>
|
||||
<input
|
||||
:type="isMultiple ? 'checkbox' : 'radio'"
|
||||
:name="`question-${questionIndex}`"
|
||||
class="mr-2"
|
||||
:value="opt"
|
||||
v-model="userSelection"
|
||||
@change="onSelect"
|
||||
/>
|
||||
<span ref="optionRefs">(<span class="italic">{{ ['a', 'b', 'c', 'd'][i] }}</span>) {{ opt }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="explanation && isSubmitted" class="mt-2 text-xl text-gray-600">
|
||||
解析:{{ explanation }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue'
|
||||
import {shuffle} from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
stem: String,
|
||||
options: Array,
|
||||
correctAnswer: Array, // ['a', 'b']
|
||||
explanation: String,
|
||||
immediateFeedback: Boolean,
|
||||
questionIndex: Number,
|
||||
randomize: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['answered'])
|
||||
|
||||
// 将选项打乱并映射回原始下标
|
||||
const originalOptions = props.options
|
||||
const shuffledOptions = ref([])
|
||||
const answerMap = ref([]) // 映射 shuffled[i] 对应原始 index(用于判分)
|
||||
|
||||
const isMultiple = computed(() => props.correctAnswer.length > 1)
|
||||
const userSelection = ref(isMultiple.value ? [] : '')
|
||||
const isSubmitted = ref(false)
|
||||
const isCorrect = ref(null)
|
||||
|
||||
// 初始化打乱选项
|
||||
const initOptions = () => {
|
||||
const indices = originalOptions.map((_, i) => i)
|
||||
const shuffledIndices = props.randomize ? shuffle(indices) : indices
|
||||
shuffledOptions.value = shuffledIndices.map(i => originalOptions[i])
|
||||
answerMap.value = shuffledIndices
|
||||
}
|
||||
|
||||
initOptions()
|
||||
|
||||
const getLetter = (index) => ['a', 'b', 'c', 'd'][index]
|
||||
const getOriginalLetter = (shuffledIndex) => getLetter(answerMap.value[shuffledIndex])
|
||||
|
||||
const onSelect = () => {
|
||||
if (props.immediateFeedback) submit()
|
||||
emitAnswer()
|
||||
}
|
||||
|
||||
const emitAnswer = () => {
|
||||
const selectedLetters = isMultiple.value
|
||||
? userSelection.value.map(val => getOriginalLetter(shuffledOptions.value.indexOf(val)))
|
||||
: [getOriginalLetter(shuffledOptions.value.indexOf(userSelection.value))]
|
||||
|
||||
const isAnswerCorrect =
|
||||
selectedLetters.sort().join() === props.correctAnswer.sort().join()
|
||||
|
||||
emit('answered', {
|
||||
index: props.questionIndex,
|
||||
selected: selectedLetters,
|
||||
isCorrect: isAnswerCorrect
|
||||
})
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
isSubmitted.value = true
|
||||
const selectedLetters = isMultiple.value
|
||||
? userSelection.value.map(val => getOriginalLetter(shuffledOptions.value.indexOf(val)))
|
||||
: [getOriginalLetter(shuffledOptions.value.indexOf(userSelection.value))]
|
||||
isCorrect.value =
|
||||
selectedLetters.sort().join() === props.correctAnswer.sort().join()
|
||||
}
|
||||
|
||||
const feedbackClass = (i) => {
|
||||
if (!isSubmitted.value) return ''
|
||||
const selected = isMultiple.value
|
||||
? userSelection.value.includes(shuffledOptions.value[i])
|
||||
: userSelection.value === shuffledOptions.value[i]
|
||||
const correct = props.correctAnswer.includes(getOriginalLetter(i))
|
||||
if (correct) return 'bg-green-200'
|
||||
if (selected && !correct) return 'bg-red-200'
|
||||
return ''
|
||||
}
|
||||
const noChoseClass = computed(() => {
|
||||
if (!isSubmitted.value) return ''
|
||||
const selected = isMultiple.value
|
||||
? userSelection.value.length
|
||||
: userSelection.value
|
||||
return !selected && 'bg-red-400'
|
||||
})
|
||||
|
||||
// 父组件调用此方法统一评分
|
||||
defineExpose({
|
||||
submit,
|
||||
getResult: () => {
|
||||
const selectedLetters = isMultiple.value
|
||||
? userSelection.value.map(val => getOriginalLetter(shuffledOptions.value.indexOf(val)))
|
||||
: [getOriginalLetter(shuffledOptions.value.indexOf(userSelection.value))]
|
||||
return {
|
||||
index: props.questionIndex,
|
||||
selected: selectedLetters,
|
||||
isCorrect: selectedLetters.sort().join() === props.correctAnswer.sort().join()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const optionRefs = ref([])
|
||||
const container = ref(null)
|
||||
const layoutClass = ref('')
|
||||
|
||||
const calculateLayout = () => {
|
||||
if (!container.value || optionRefs.value.length === 0) return
|
||||
|
||||
const containerWidth = container.value.clientWidth
|
||||
const widths = optionRefs.value.map(el => el.getBoundingClientRect().width)
|
||||
|
||||
const totalWidth = widths.reduce((sum, w) => sum + w, 0)
|
||||
// console.log(widths,totalWidth)
|
||||
|
||||
// 如果任意选项宽度超过容器一半
|
||||
if (widths.some(w => w > containerWidth / 2)) {
|
||||
layoutClass.value = 'grid-cols-1'
|
||||
return
|
||||
}
|
||||
|
||||
// 如果所有选项都可以在一行内放下
|
||||
if (totalWidth + 80 * (widths.length - 1) <= containerWidth) {
|
||||
layoutClass.value = 'grid-cols-4'
|
||||
return
|
||||
}
|
||||
|
||||
// 否则 2 列
|
||||
layoutClass.value = 'grid-cols-2'
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
calculateLayout()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateLayout()
|
||||
})
|
||||
|
||||
resizeObserver.observe(container.value)
|
||||
})
|
||||
|
||||
watch(() => props.options, async () => {
|
||||
await nextTick()
|
||||
calculateLayout()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.option {
|
||||
white-space: normal;
|
||||
text-overflow: unset;
|
||||
overflow: visible;
|
||||
word-break: keep-all;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
94
src/components/article/components/Space.vue
Normal file
94
src/components/article/components/Space.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
isWrong: boolean,
|
||||
isWait?: boolean,
|
||||
isShake?: boolean,
|
||||
}>(), {
|
||||
isWrong: false,
|
||||
isShake: false,
|
||||
})
|
||||
const settingStore = useSettingStore()
|
||||
const isMoveBottom = $computed(() => {
|
||||
return settingStore.dictation && !props.isWrong
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="word-space wrong" v-if="isWrong"></span>
|
||||
<span v-bind="$attrs" v-else>
|
||||
<span class="word-space wait"
|
||||
:class="[
|
||||
isWait ? 'opacity-100':' opacity-0',
|
||||
isShake ? isMoveBottom ? 'shakeBottom' : 'shake' : '',
|
||||
isMoveBottom && 'to-bottom'
|
||||
]"
|
||||
></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.word-space {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
height: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
margin: 0 1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&.wrong {
|
||||
border-bottom: 2px solid red;
|
||||
}
|
||||
|
||||
&.to-bottom {
|
||||
transform: translateY(0.3rem);
|
||||
}
|
||||
|
||||
&.wait {
|
||||
border-bottom: 2px solid var(--color-article);
|
||||
margin-left: 0.125rem;
|
||||
margin-right: 0.125rem;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: .25rem;
|
||||
background: var(--color-article);
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: .26rem;
|
||||
background: var(--color-article);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shake {
|
||||
border-bottom: 2px solid red !important;
|
||||
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
|
||||
&::after {
|
||||
background: red !important;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: red !important;
|
||||
}
|
||||
}
|
||||
|
||||
.shakeBottom {
|
||||
@extend .shake;
|
||||
animation: shakeBottom 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
}
|
||||
|
||||
</style>
|
||||
1049
src/components/article/components/TypingArticle.vue
Normal file
1049
src/components/article/components/TypingArticle.vue
Normal file
File diff suppressed because it is too large
Load Diff
113
src/components/article/components/TypingWord.vue
Normal file
113
src/components/article/components/TypingWord.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="tsx">
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import Space from "@/components/article/components/Space.vue";
|
||||
|
||||
import {PracticeArticleWordType} from "@/types/enum.ts";
|
||||
import type {ArticleWord} from "@/types/types.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
word: ArticleWord,
|
||||
isTyping: boolean,
|
||||
}>()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
function compare(a: string, b: string) {
|
||||
return settingStore.ignoreCase ? a.toLowerCase() === b.toLowerCase() : a === b
|
||||
}
|
||||
|
||||
const isHide = $computed(() => {
|
||||
if (settingStore.dictation && props.word.type === PracticeArticleWordType.Word) return 'hide'
|
||||
return ''
|
||||
})
|
||||
|
||||
const list = $computed(() => {
|
||||
let t = []
|
||||
let right = ''
|
||||
let wrong = ''
|
||||
if (props.word.input.length) {
|
||||
if (props.word.input.length === props.word.word.length) {
|
||||
if (settingStore.ignoreCase ? props.word.input.toLowerCase() === props.word.word.toLowerCase() : props.word.input === props.word.word) {
|
||||
t.push({type: 'word-complete', val: props.word.input})
|
||||
return t
|
||||
}
|
||||
}
|
||||
props.word.input.split('').forEach((k, i) => {
|
||||
if (k === ' ') {
|
||||
right = wrong = ''
|
||||
t.push({type: 'space'})
|
||||
}
|
||||
else {
|
||||
if (compare(k, props.word.word[i])) {
|
||||
right += k
|
||||
wrong = ''
|
||||
if (t.length) {
|
||||
let last = t[t.length - 1]
|
||||
if (last.type === 'input-right') {
|
||||
last.val = right
|
||||
} else {
|
||||
t.push({type: 'input-right', val: right})
|
||||
}
|
||||
} else {
|
||||
t.push({type: 'input-right', val: right})
|
||||
}
|
||||
} else {
|
||||
wrong += k
|
||||
right = ''
|
||||
if (t.length) {
|
||||
let last = t[t.length - 1]
|
||||
if (last.type === 'input-wrong') {
|
||||
last.val = wrong
|
||||
} else {
|
||||
t.push({type: 'input-wrong', val: wrong})
|
||||
}
|
||||
} else {
|
||||
t.push({type: 'input-wrong', val: wrong})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (props.word.input.length < props.word.word.length) {
|
||||
t.push({type: 'word-end', val: props.word.word.slice(props.word.input.length)})
|
||||
}
|
||||
} else {
|
||||
//word-end这个class用于光标定位,光标会定位到第一个word-end的位置
|
||||
t.push({type: 'word-end', val: props.word.word})
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
defineRender(() => {
|
||||
return list.map((item, i) => {
|
||||
if (item.type === 'word-complete') {
|
||||
return <span>{item.val}</span>
|
||||
}
|
||||
if (item.type === 'word-end') {
|
||||
return <span className={'word-end ' + isHide}>{item.val}</span>
|
||||
}
|
||||
if (item.type === 'input-right') {
|
||||
return <span className={props.isTyping ? 'input-right' : ''}>{item.val}</span>
|
||||
}
|
||||
if (item.type === 'input-wrong') {
|
||||
return <span className="input-wrong">{item.val}</span>
|
||||
}
|
||||
if (item.type === 'space') {
|
||||
return <Space isWrong={true}/>
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.input-right {
|
||||
color: var(--color-select-bg);
|
||||
}
|
||||
|
||||
.input-wrong {
|
||||
@apply color-red
|
||||
}
|
||||
|
||||
.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Switch from '@/components/base/Switch.vue'
|
||||
import Slider from '@/components/base/Slider.vue'
|
||||
import SettingItem from '@/pages/setting/SettingItem.vue'
|
||||
import SettingItem from '@/components/setting/SettingItem.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Option, Select } from '@/components/base/select'
|
||||
import Textarea from '@/components/base/Textarea.vue'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import Slider from '@/components/base/Slider.vue'
|
||||
import SettingItem from '@/pages/setting/SettingItem.vue'
|
||||
import SettingItem from '@/components/setting/SettingItem.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import {ShortcutKey} from "@/types/enum.ts";
|
||||
|
||||
153
src/components/setting/Log.vue
Normal file
153
src/components/setting/Log.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
let logList = [
|
||||
{
|
||||
date: '2026/01/06',
|
||||
content: '优化书籍详情页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/30',
|
||||
content: '移除“继续默写”选项',
|
||||
},
|
||||
{
|
||||
date: '2025/12/29',
|
||||
content: '单词练习界面,底部工具栏新增音频设置按钮',
|
||||
},
|
||||
{
|
||||
date: '2025/12/27',
|
||||
content: '优化进度条展示,现可展示当前阶段、所有阶段',
|
||||
},
|
||||
{
|
||||
date: '2025/12/23',
|
||||
content: '新增复习、自测、默写、听写模式',
|
||||
},
|
||||
{
|
||||
date: '2025/12/20',
|
||||
content: '新增资源分享页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/17',
|
||||
content: '新增帮助页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/16',
|
||||
content: '修复弹框内边距太小;单词、文章、通用设置在设置页面、练习界面均可进行设置',
|
||||
},
|
||||
{
|
||||
date: '2025/12/15',
|
||||
content: '修复在黑暗模式下,翻译颜色不正确;支持中文符号输入',
|
||||
},
|
||||
{
|
||||
date: '2025/12/11',
|
||||
content: '修复音标显示错误问题,优化反馈页面',
|
||||
},
|
||||
{
|
||||
date: '2025/12/10',
|
||||
content: '新增选项:复习比(单词练习时,复习词与新词的比例)',
|
||||
},
|
||||
{
|
||||
date: '2025/12/5',
|
||||
content: '解决练习界面无法复制、全选的问题',
|
||||
},
|
||||
{
|
||||
date: '2025/12/3',
|
||||
content: '单词、文章设置修改为弹框,更方便',
|
||||
},
|
||||
{
|
||||
date: '2025/12/3',
|
||||
content: '录入新概念(三、四)部分音频,优化文章相关功能',
|
||||
},
|
||||
{
|
||||
date: '2025/12/2',
|
||||
content: '完成新概念(一)音频,优化文章管理页面',
|
||||
},
|
||||
{
|
||||
date: '2025/11/30',
|
||||
content: '文章里的单词可点击播放',
|
||||
},
|
||||
{
|
||||
date: '2025/11/29',
|
||||
content: '修改 Slider 组件显示bug,新增 IE 浏览器检测提示',
|
||||
},
|
||||
{
|
||||
date: '2025/11/28',
|
||||
content: '新增引导框、 新增词典测试模式(由大佬hebeihang 开发)',
|
||||
},
|
||||
{
|
||||
date: '2025/11/25',
|
||||
content: '文章练习新增人名忽略功能(新概念一已全部适配),上传了新概念(一)1-18 音频',
|
||||
},
|
||||
{
|
||||
date: '2025/11/23',
|
||||
content: '优化练习完成结算界面,新增分享功能',
|
||||
},
|
||||
{
|
||||
date: '2025/11/22',
|
||||
content: '适配移动端',
|
||||
},
|
||||
{
|
||||
date: '2025/11/16',
|
||||
content: '自测单词时,不认识单词可以直接输入,自动标识为错误单词,无需按2',
|
||||
},
|
||||
{
|
||||
date: '2025/11/15',
|
||||
content: '练习单词时,底部工具栏新增“跳到下一阶段”按钮',
|
||||
},
|
||||
{
|
||||
date: '2025/11/14',
|
||||
content:
|
||||
'新增文章练习时可跳过空格:如果在单词的最后一位上,不按空格直接输入下一个字母的话,自动跳下一个单词,按空格也自动跳下一个单词',
|
||||
},
|
||||
{
|
||||
date: '2025/11/13',
|
||||
content: '新增文章练习时“输入时忽略符号/数字”选项',
|
||||
},
|
||||
{
|
||||
date: '2025/11/6',
|
||||
content: '新增随机复习功能',
|
||||
},
|
||||
{
|
||||
date: '2025/10/30',
|
||||
content: '集成PWA基础配置,支持用户以类App形式打开项目',
|
||||
},
|
||||
{
|
||||
date: '2025/10/26',
|
||||
content: '进一步完善单词练习,解决复习数量太多的问题',
|
||||
},
|
||||
{
|
||||
date: '2025/10/8',
|
||||
content: '文章支持自动播放下一篇',
|
||||
},
|
||||
{
|
||||
date: '2025/9/14',
|
||||
content: '完善文章编辑、导入、导出等功能',
|
||||
},
|
||||
{
|
||||
date: '2025/8/10',
|
||||
content: '2.0版本发布,全新UI,全新逻辑,新增短语、例句、近义词等功能',
|
||||
},
|
||||
{
|
||||
date: '2025/7/19',
|
||||
content: '1.0版本发布',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="log-item" v-for="item in logList" :key="item.date">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:{{ item.date }}</div>
|
||||
<div>内容:{{ item.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-item {
|
||||
border-bottom: 1px solid var(--color-input-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
125
src/components/setting/SettingItem.vue
Normal file
125
src/components/setting/SettingItem.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
mainTitle?: string,
|
||||
title?: string,
|
||||
desc?: string,
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting-item" :class="{'has-desc': !!desc}" v-bind="$attrs">
|
||||
<div class="setting-item__main">
|
||||
<div class="setting-item__label">
|
||||
<span v-if="mainTitle" class="setting-item__main-title">{{ mainTitle }}</span>
|
||||
<span v-else-if="title" class="setting-item__title">{{ title }}</span>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desc" class="setting-item__desc">{{ desc }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setting-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-item__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-item__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 12rem;
|
||||
max-width: 18rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-font-1);
|
||||
}
|
||||
|
||||
.setting-item__main-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-item__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-item__control {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setting-item__desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-font-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.setting-item__label {
|
||||
min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.setting-item {
|
||||
margin: 0.75rem 0;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.setting-item__main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.setting-item__label {
|
||||
min-width: auto;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-item__control {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.setting-item__control > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.setting-item__desc {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.setting-item__main-title,
|
||||
.setting-item__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.setting-item__desc {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@ import Switch from "@/components/base/Switch.vue";
|
||||
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
|
||||
import InputNumber from "@/components/base/InputNumber.vue";
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import SettingItem from "@/pages/setting/SettingItem.vue";
|
||||
import SettingItem from "@/components/setting/SettingItem.vue";
|
||||
import Radio from "@/components/base/radio/Radio.vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BaseTable from "@/components/BaseTable.vue";
|
||||
import WordItem from "@/components/WordItem.vue";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import { AppEnv } from "@/config/env.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const model = defineModel()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
|
||||
async function requestList({pageNo, pageSize, searchKey}) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
|
||||
} else {
|
||||
let list = runtimeStore.editDict.words
|
||||
let total = list.length
|
||||
list = list.slice((pageNo - 1) * pageSize, (pageNo - 1) * pageSize + pageSize)
|
||||
return {list, total}
|
||||
}
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
ok: [number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- todo 这里显示的时候可以选中并高亮当前index-->
|
||||
<!-- todo 这个组件的分页器,需要直接可跳转指定页面,并显示一页有多少个-->
|
||||
<Dialog v-model="model"
|
||||
padding
|
||||
title="修改学习进度">
|
||||
<div class="py-4 h-80vh ">
|
||||
<BaseTable
|
||||
class="h-full"
|
||||
:request="requestList"
|
||||
:show-toolbar="false"
|
||||
>
|
||||
<template v-slot="item">
|
||||
<WordItem
|
||||
@click="$emit('ok',item.index-1)"
|
||||
:item="item.item"
|
||||
:show-translate="false"
|
||||
:index="item.index"
|
||||
:show-option="false"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
503
src/components/word/components/Footer.vue
Normal file
503
src/components/word/components/Footer.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, Ref } from 'vue'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import type { PracticeData, TaskWords } from '@/types/types.ts'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import SettingDialog from '@/components/setting/SettingDialog.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import VolumeSettingMiniDialog from '@/components/word/components/VolumeSettingMiniDialog.vue'
|
||||
import StageProgress from '@/components/StageProgress.vue'
|
||||
import { ShortcutKey, WordPracticeMode, WordPracticeStage } from '@/types/enum.ts'
|
||||
import { WordPracticeModeNameMap, WordPracticeModeStageMap, WordPracticeStageNameMap } from '@/config/env.ts'
|
||||
|
||||
const statStore = usePracticeStore()
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
defineProps<{
|
||||
showEdit?: boolean
|
||||
isCollect: boolean
|
||||
isSimple: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleCollect: []
|
||||
toggleSimple: []
|
||||
edit: []
|
||||
skip: []
|
||||
skipStep: []
|
||||
}>()
|
||||
|
||||
let practiceData = inject<PracticeData>('practiceData')
|
||||
let isTypingWrongWord = inject<Ref<boolean>>('isTypingWrongWord')
|
||||
let practiceTaskWords = inject<TaskWords>('practiceTaskWords')
|
||||
|
||||
function format(val: number, suffix: string = '', check: number = -1) {
|
||||
return val === check ? '-' : val + suffix
|
||||
}
|
||||
|
||||
const status = $computed(() => {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) return '自由练习'
|
||||
if (isTypingWrongWord.value) return '复习错词'
|
||||
return statStore.getStageName
|
||||
})
|
||||
|
||||
const progress = $computed(() => {
|
||||
if (!practiceData.words.length) return 0
|
||||
return (practiceData.index / practiceData.words.length) * 100
|
||||
})
|
||||
|
||||
const stages = $computed(() => {
|
||||
let DEFAULT_BAR = {
|
||||
name: '',
|
||||
ratio: 100,
|
||||
percentage: (practiceData.index / practiceData.words.length) * 100,
|
||||
active: true,
|
||||
}
|
||||
if ([WordPracticeMode.Shuffle, WordPracticeMode.Free].includes(settingStore.wordPracticeMode)) {
|
||||
return [DEFAULT_BAR]
|
||||
} else {
|
||||
// 阶段映射:将 WordPracticeStage 映射到 stageIndex 和 childIndex
|
||||
const stageMap: Partial<Record<WordPracticeStage, { stageIndex: number; childIndex: number }>> = {
|
||||
[WordPracticeStage.FollowWriteNewWord]: { stageIndex: 0, childIndex: 0 },
|
||||
[WordPracticeStage.IdentifyNewWord]: { stageIndex: 0, childIndex: 0 },
|
||||
[WordPracticeStage.ListenNewWord]: { stageIndex: 0, childIndex: 1 },
|
||||
[WordPracticeStage.DictationNewWord]: { stageIndex: 0, childIndex: 2 },
|
||||
[WordPracticeStage.IdentifyReview]: { stageIndex: 1, childIndex: 0 },
|
||||
[WordPracticeStage.ListenReview]: { stageIndex: 1, childIndex: 1 },
|
||||
[WordPracticeStage.DictationReview]: { stageIndex: 1, childIndex: 2 },
|
||||
[WordPracticeStage.IdentifyReviewAll]: { stageIndex: 2, childIndex: 0 },
|
||||
[WordPracticeStage.ListenReviewAll]: { stageIndex: 2, childIndex: 1 },
|
||||
[WordPracticeStage.DictationReviewAll]: { stageIndex: 2, childIndex: 2 },
|
||||
}
|
||||
|
||||
// 获取当前阶段的配置
|
||||
const currentStageConfig = stageMap[statStore.stage]
|
||||
if (!currentStageConfig) {
|
||||
return stages
|
||||
}
|
||||
|
||||
const { stageIndex, childIndex } = currentStageConfig
|
||||
const currentProgress = (practiceData.index / practiceData.words.length) * 100
|
||||
|
||||
if (
|
||||
[WordPracticeMode.IdentifyOnly, WordPracticeMode.DictationOnly, WordPracticeMode.ListenOnly].includes(
|
||||
settingStore.wordPracticeMode
|
||||
)
|
||||
) {
|
||||
const stages = [
|
||||
{
|
||||
name: `新词:${WordPracticeModeNameMap[settingStore.wordPracticeMode]}`,
|
||||
ratio: 33,
|
||||
percentage: 0,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: `上次学习:${WordPracticeModeNameMap[settingStore.wordPracticeMode]}`,
|
||||
ratio: 33,
|
||||
percentage: 0,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: `之前学习:${WordPracticeModeNameMap[settingStore.wordPracticeMode]}`,
|
||||
ratio: 33,
|
||||
percentage: 0,
|
||||
active: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 设置已完成阶段的百分比和比例
|
||||
for (let i = 0; i < stageIndex; i++) {
|
||||
stages[i].percentage = 100
|
||||
stages[i].ratio = 33
|
||||
}
|
||||
|
||||
// 设置当前激活的阶段
|
||||
stages[stageIndex].active = true
|
||||
stages[stageIndex].percentage = (practiceData.index / practiceData.words.length) * 100
|
||||
|
||||
return stages
|
||||
} else {
|
||||
// 阶段配置:定义每个阶段组的基础信息
|
||||
const stageConfigs = [
|
||||
{
|
||||
name: '新词',
|
||||
ratio: 70,
|
||||
children: [{ name: '新词:跟写' }, { name: '新词:听写' }, { name: '新词:默写' }],
|
||||
},
|
||||
{
|
||||
name: '上次学习:复习',
|
||||
ratio: 15,
|
||||
children: [{ name: '上次学习:自测' }, { name: '上次学习:听写' }, { name: '上次学习:默写' }],
|
||||
},
|
||||
{
|
||||
name: '之前学习:复习',
|
||||
ratio: 15,
|
||||
children: [{ name: '之前学习:自测' }, { name: '之前学习:听写' }, { name: '之前学习:默写' }],
|
||||
},
|
||||
]
|
||||
|
||||
// 初始化 stages
|
||||
const stages = stageConfigs.map(config => ({
|
||||
name: config.name,
|
||||
percentage: 0,
|
||||
ratio: config.ratio,
|
||||
active: false,
|
||||
children: config.children.map(child => ({
|
||||
name: child.name,
|
||||
percentage: 0,
|
||||
ratio: 33,
|
||||
active: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// 设置已完成阶段的百分比和比例
|
||||
for (let i = 0; i < stageIndex; i++) {
|
||||
stages[i].percentage = 100
|
||||
stages[i].ratio = 15
|
||||
}
|
||||
|
||||
// 设置当前激活的阶段
|
||||
stages[stageIndex].ratio = 70
|
||||
stages[stageIndex].active = true
|
||||
|
||||
// 根据类型设置子阶段的进度
|
||||
const currentStageChildren = stages[stageIndex].children
|
||||
|
||||
if (childIndex === 0) {
|
||||
// 跟写/自测:只激活第一个子阶段
|
||||
currentStageChildren[0].active = true
|
||||
currentStageChildren[0].percentage = currentProgress
|
||||
} else if (childIndex === 1) {
|
||||
// 听写:第一个完成,第三个未开始,第二个进行中
|
||||
currentStageChildren[0].active = false
|
||||
currentStageChildren[1].active = true
|
||||
currentStageChildren[2].active = false
|
||||
currentStageChildren[0].percentage = 100
|
||||
currentStageChildren[1].percentage = currentProgress
|
||||
currentStageChildren[2].percentage = 0
|
||||
} else if (childIndex === 2) {
|
||||
// 默写:前两个完成,第三个进行中
|
||||
currentStageChildren[0].active = false
|
||||
currentStageChildren[1].active = false
|
||||
currentStageChildren[2].active = true
|
||||
currentStageChildren[0].percentage = 100
|
||||
currentStageChildren[1].percentage = 100
|
||||
currentStageChildren[2].percentage = currentProgress
|
||||
}
|
||||
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) {
|
||||
return stages
|
||||
}
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Review) {
|
||||
stages.shift()
|
||||
if (stageIndex === 1) stages[1].ratio = 30
|
||||
if (stageIndex === 2) stages[0].ratio = 30
|
||||
|
||||
console.log('stages', stages, childIndex)
|
||||
|
||||
return stages
|
||||
}
|
||||
}
|
||||
}
|
||||
return [DEFAULT_BAR]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="footer">
|
||||
<Tooltip :title="settingStore.showToolbar ? '收起' : '展开'">
|
||||
<IconFluentChevronLeft20Filled
|
||||
@click="settingStore.showToolbar = !settingStore.showToolbar"
|
||||
class="arrow"
|
||||
:class="!settingStore.showToolbar && 'down'"
|
||||
color="#999"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<div class="bottom">
|
||||
<StageProgress :stages="stages" />
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="stat">
|
||||
<div class="row">
|
||||
<div class="num">{{ `${practiceData.index + 1}/${practiceData.words.length}` }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">{{ status }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<!-- <div class="num">{{ statStore.spend }}分钟</div>-->
|
||||
<div class="num">{{ Math.floor(statStore.spend / 1000 / 60) }}分钟</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">时间</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ statStore.total }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">单词总数</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ format(statStore.wrong, '', 0) }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">错误数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-center items-center" id="toolbar-icons">
|
||||
<SettingDialog type="word" />
|
||||
|
||||
<VolumeSettingMiniDialog />
|
||||
|
||||
<BaseIcon
|
||||
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Free"
|
||||
@click="emit('skipStep')"
|
||||
:title="`跳到下一阶段:${WordPracticeStageNameMap[statStore.nextStage]}`"
|
||||
>
|
||||
<IconFluentArrowRight16Regular />
|
||||
</BaseIcon>
|
||||
|
||||
<div class="relative z-999 group">
|
||||
<div
|
||||
class="space-y-2 btn-no-margin pb-2 left-1/2 -transform-translate-x-1/2 absolute z-999 bottom-full scale-95 opacity-0 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<BaseButton size="normal" type="info" class="w-full" @click="$emit('toggleSimple')">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentCheckmarkCircle16Regular v-if="!isSimple" />
|
||||
<IconFluentCheckmarkCircle16Filled v-else />
|
||||
<span>
|
||||
{{
|
||||
(!isSimple ? '标记已掌握' : '取消已掌握') +
|
||||
`(${settingStore.shortcutKeyMap[ShortcutKey.ToggleSimple]})`
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton size="normal" type="info" class="w-full" @click="$emit('toggleCollect')">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentStarAdd16Regular v-if="!isCollect" />
|
||||
<IconFluentStar16Filled v-else />
|
||||
<span>
|
||||
{{
|
||||
(!isCollect ? '收藏' : '取消收藏') + `(${settingStore.shortcutKeyMap[ShortcutKey.ToggleCollect]})`
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton size="normal" type="info" class="w-full" @click="$emit('skip')">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentArrowBounce20Regular class="transform-rotate-180" />
|
||||
<span> 跳过单词({{ settingStore.shortcutKeyMap[ShortcutKey.Next] }})</span>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<BaseIcon>
|
||||
<IconPhMicrosoftWordLogoLight />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
|
||||
<BaseIcon
|
||||
@click="settingStore.dictation = !settingStore.dictation"
|
||||
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
|
||||
>
|
||||
<IconFluentEyeOff16Regular v-if="settingStore.dictation" />
|
||||
<IconFluentEye16Regular v-else />
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
|
||||
@click="settingStore.translate = !settingStore.translate"
|
||||
>
|
||||
<IconFluentTranslate16Regular v-if="settingStore.translate" />
|
||||
<IconFluentTranslateOff16Regular v-else />
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
@click="settingStore.showPanel = !settingStore.showPanel"
|
||||
:title="`单词本(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`"
|
||||
>
|
||||
<IconFluentTextListAbcUppercaseLtr20Regular />
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-wrap flex gap-3 items-center color-gray">
|
||||
<span class="shrink-0">{{ status }}</span>
|
||||
<StageProgress :stages="stages" />
|
||||
<div class="num">{{ `${practiceData.index + 1}/${practiceData.words.length}` }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
width: var(--toolbar-width);
|
||||
position: relative;
|
||||
z-index: 20; // 提高z-index确保在最上方
|
||||
|
||||
&.hide {
|
||||
margin-bottom: -6rem;
|
||||
margin-top: 3rem;
|
||||
|
||||
.progress-wrap {
|
||||
bottom: calc(100% + 1.8rem);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
@apply relative w-full box-border rounded-xl bg-second shadow-lg z-10;
|
||||
padding: 0.2rem var(--space) calc(0.4rem + env(safe-area-inset-bottom, 0px)) var(--space);
|
||||
|
||||
.stat {
|
||||
@apply flex justify-around gap-[var(--stat-gap)] mt-2;
|
||||
|
||||
.row {
|
||||
@apply flex flex-col items-center gap-1 text-gray;
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--color-sub-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
width: var(--toolbar-width);
|
||||
transition: all 0.3s;
|
||||
padding: 0 0.6rem;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
z-index: 1; // 确保进度条也在最上方
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
left: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.5s;
|
||||
transform: rotate(-90deg);
|
||||
padding: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
|
||||
&.down {
|
||||
top: -90%;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
width: 100%;
|
||||
|
||||
.bottom {
|
||||
padding: 0.3rem 0.5rem 0.5rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
|
||||
.stat {
|
||||
margin-top: 0.3rem;
|
||||
gap: 0.2rem;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
|
||||
.row {
|
||||
min-width: 3.5rem;
|
||||
gap: 0.2rem;
|
||||
|
||||
.num {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端按钮组调整 - 改为网格布局
|
||||
.flex.gap-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
|
||||
.base-icon {
|
||||
padding: 0.3rem;
|
||||
font-size: 1rem;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超小屏幕适配
|
||||
@media (max-width: 480px) {
|
||||
.footer {
|
||||
.bottom {
|
||||
padding: 0.2rem 0.3rem 0.3rem 0.3rem;
|
||||
|
||||
.stat {
|
||||
margin-top: 0.2rem;
|
||||
gap: 0.1rem;
|
||||
|
||||
.row {
|
||||
min-width: 3rem;
|
||||
gap: 0.1rem;
|
||||
|
||||
.num {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
// 隐藏部分统计信息,只保留关键数据
|
||||
&:nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flex.gap-2 {
|
||||
gap: 0.2rem;
|
||||
|
||||
.base-icon {
|
||||
padding: 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
padding: 0 0.3rem;
|
||||
bottom: 0.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
src/components/word/components/GroupList.vue
Normal file
116
src/components/word/components/GroupList.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import RadioGroup from '@/components/base/radio/RadioGroup.vue'
|
||||
import Radio from '@/components/base/radio/Radio.vue'
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
const store = useBaseStore()
|
||||
|
||||
const isVisible = ref(false)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const itemRefs = ref<(HTMLElement | null)[]>([])
|
||||
|
||||
// 计算每个组的词数
|
||||
const getGroupWordCount = (groupIndex: number) => {
|
||||
const totalLength = store.sdict.length
|
||||
const perDay = store.sdict.perDayStudyNumber
|
||||
const totalGroups = store.groupLength
|
||||
|
||||
// 如果是最后一组且不能被整除,则显示余数
|
||||
if (groupIndex === totalGroups && totalLength % perDay !== 0) {
|
||||
return totalLength % perDay
|
||||
}
|
||||
return perDay
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isVisible.value = true
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
// 当弹框显示时,自动滚动到选中的item
|
||||
watch(isVisible, async newVal => {
|
||||
if (newVal) {
|
||||
// 等待DOM更新和过渡动画开始
|
||||
await nextTick()
|
||||
// 再等待一小段时间确保元素已渲染
|
||||
const currentIndex = store.currentGroup - 1 // currentGroup是1-based,数组是0-based
|
||||
const targetItem = itemRefs.value[currentIndex]
|
||||
const container = scrollContainer.value
|
||||
|
||||
if (targetItem && container) {
|
||||
// 计算目标item相对于容器的位置
|
||||
const itemTop = targetItem.offsetTop
|
||||
const itemHeight = targetItem.offsetHeight
|
||||
const containerHeight = container.clientHeight
|
||||
|
||||
// 滚动到目标item,使其居中显示
|
||||
container.scrollTo({
|
||||
top: itemTop - containerHeight / 2 + itemHeight / 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const setItemRef = (el: HTMLElement | null, index: number) => {
|
||||
if (el) {
|
||||
itemRefs.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [value: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-999" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<div
|
||||
class="pt-2 left-1/2 -transform-translate-x-1/2 absolute z-999 top-full transition-all duration-300"
|
||||
:class="{
|
||||
'opacity-0 scale-95 pointer-events-none': !isVisible,
|
||||
'opacity-100 scale-100 pointer-events-auto': isVisible,
|
||||
}"
|
||||
>
|
||||
<RadioGroup :model-value="store.currentGroup">
|
||||
<div class="card-white">
|
||||
<div ref="scrollContainer" class="max-h-70 overflow-y-auto space-y-2">
|
||||
<div
|
||||
:ref="el => setItemRef(el as HTMLElement, value - 1)"
|
||||
class="break-keep flex bg-primary px-3 py-1 rounded-md hover:bg-card-active anim border border-solid border-item"
|
||||
:class="{
|
||||
'bg-card-active!': value === store.currentGroup,
|
||||
}"
|
||||
@click="emit('click', value)"
|
||||
v-for="(value) in store.groupLength"
|
||||
:key="value"
|
||||
>
|
||||
<Radio :value="value" :label="`第${value}组`" />
|
||||
<span class="text-sm ml-2">{{ getGroupWordCount(value) }}词</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="target">第{{ store.currentGroup }}组</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.target {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: underline dashed gray;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.3rem;
|
||||
&:hover {
|
||||
text-decoration: underline dashed transparent;
|
||||
color: white;
|
||||
background: var(--color-icon-hightlight);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
src/components/word/components/PracticeSettingDialog.vue
Normal file
251
src/components/word/components/PracticeSettingDialog.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { _getAccomplishDays } from '@/utils'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import Checkbox from '@/components/base/checkbox/Checkbox.vue'
|
||||
import Slider from '@/components/base/Slider.vue'
|
||||
import { defineAsyncComponent, watch } from 'vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import ChangeLastPracticeIndexDialog from '@/components/word/components/ChangeLastPracticeIndexDialog.vue'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import { useRuntimeStore } from '@/stores/runtime.ts'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import InputNumber from '@/components/base/InputNumber.vue'
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const settings = useSettingStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
defineProps<{
|
||||
showLeftOption: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ok: []
|
||||
}>()
|
||||
|
||||
let show = $ref(false)
|
||||
let tempPerDayStudyNumber = $ref(0)
|
||||
let tempWordReviewRatio = $ref(0)
|
||||
let tempLastLearnIndex = $ref(0)
|
||||
let tempDisableShowPracticeSettingDialog = $ref(false)
|
||||
|
||||
function changePerDayStudyNumber() {
|
||||
runtimeStore.editDict.perDayStudyNumber = tempPerDayStudyNumber
|
||||
runtimeStore.editDict.lastLearnIndex = tempLastLearnIndex
|
||||
settings.wordReviewRatio = tempWordReviewRatio
|
||||
settings.disableShowPracticeSettingDialog = tempDisableShowPracticeSettingDialog
|
||||
emit('ok')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
n => {
|
||||
if (n) {
|
||||
if (runtimeStore.editDict.id) {
|
||||
tempPerDayStudyNumber = runtimeStore.editDict.perDayStudyNumber
|
||||
tempLastLearnIndex = runtimeStore.editDict.lastLearnIndex
|
||||
tempWordReviewRatio = settings.wordReviewRatio
|
||||
tempDisableShowPracticeSettingDialog = settings.disableShowPracticeSettingDialog
|
||||
} else {
|
||||
Toast.warning('请先选择一本词典')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="model" title="学习设置" padding :footer="true" @ok="changePerDayStudyNumber">
|
||||
<div class="target-modal color-main" id="mode">
|
||||
<div class="text-center mt-4">
|
||||
<span
|
||||
>共<span class="target-number mx-2">{{ runtimeStore.editDict.length }}</span
|
||||
>个单词,</span
|
||||
>
|
||||
<span
|
||||
>预计<span class="target-number mx-2">{{
|
||||
_getAccomplishDays(
|
||||
runtimeStore.editDict.length - tempLastLearnIndex,
|
||||
tempPerDayStudyNumber
|
||||
)
|
||||
}}</span
|
||||
>天完成</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 mb-8 flex gap-1 items-end justify-center">
|
||||
<span>从第</span>
|
||||
<div class="w-20">
|
||||
<BaseInput class="target-number" v-model="tempLastLearnIndex" />
|
||||
</div>
|
||||
<span>个开始,每日</span>
|
||||
<div class="w-16">
|
||||
<BaseInput class="target-number" v-model="tempPerDayStudyNumber" />
|
||||
</div>
|
||||
<span>个新词</span>
|
||||
<span>,复习</span>
|
||||
<div class="target-number mx-2">
|
||||
{{ tempPerDayStudyNumber * tempWordReviewRatio }}
|
||||
</div>
|
||||
<span>个</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center gap-space">
|
||||
<Tooltip title="复习词与新词的比例">
|
||||
<div class="flex items-center gap-1 w-20 break-keep">
|
||||
<span>复习比</span>
|
||||
<IconFluentQuestionCircle20Regular />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<InputNumber :min="0" :max="10" v-model="tempWordReviewRatio" />
|
||||
</div>
|
||||
<div class="flex" v-if="!tempWordReviewRatio">
|
||||
<div class="w-23 flex-shrink-0"></div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<div>未完成学习时,复习数量按照设置的复习比生成,为0则不复习</div>
|
||||
<div>完成学习后,新词数量固定为0,复习数量按照比例生成(若复习比小于1,以 1 计算)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mb-4 gap-space">
|
||||
<span class="shrink-0 w-20">每日学习</span>
|
||||
<Slider
|
||||
:min="10"
|
||||
:step="10"
|
||||
show-text
|
||||
class="mt-1"
|
||||
:max="200"
|
||||
v-model="tempPerDayStudyNumber"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-space">
|
||||
<span class="shrink-0 w-20">学习进度</span>
|
||||
<div class="flex-1">
|
||||
<Slider
|
||||
:min="0"
|
||||
:step="10"
|
||||
show-text
|
||||
class="my-1"
|
||||
:max="runtimeStore.editDict.words.length"
|
||||
v-model="tempLastLearnIndex"
|
||||
/>
|
||||
<BaseButton @click="show = true">从词典选起始位置</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-slot:footer-left v-if="showLeftOption">
|
||||
<div class="flex items-center">
|
||||
<Checkbox v-model="tempDisableShowPracticeSettingDialog" />
|
||||
<Tooltip title="可在设置页面更改">
|
||||
<span class="text-sm">保持默认,不再显示</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<ChangeLastPracticeIndexDialog
|
||||
v-model="show"
|
||||
@ok="
|
||||
e => {
|
||||
tempLastLearnIndex = e
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.target-modal {
|
||||
width: 35rem;
|
||||
|
||||
.mode-item {
|
||||
@apply w-50% border border-blue border-solid p-2 rounded-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.active {
|
||||
@apply bg-blue color-white;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.target-modal {
|
||||
width: 90vw !important;
|
||||
max-width: 400px;
|
||||
padding: 0 1rem;
|
||||
|
||||
// 模式选择
|
||||
.center .flex.gap-4 {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
gap: 0.8rem;
|
||||
|
||||
.mode-item {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计显示
|
||||
.text-center {
|
||||
font-size: 0.9rem;
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 滑块控件
|
||||
.flex.mb-4,
|
||||
.flex.mb-6 {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮
|
||||
.base-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.target-modal {
|
||||
width: 95vw !important;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
.text-center {
|
||||
font-size: 0.8rem;
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
src/components/word/components/PracticeWordListDialog.vue
Normal file
93
src/components/word/components/PracticeWordListDialog.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BaseTable from "@/components/BaseTable.vue";
|
||||
import WordItem from "@/components/WordItem.vue";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import type { TaskWords } from "@/types/types.ts";
|
||||
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const model = defineModel()
|
||||
defineProps<{
|
||||
data: TaskWords
|
||||
}>()
|
||||
|
||||
let showTranslate = $ref(false)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="model" padding title="任务">
|
||||
<div class="pb-4 h-80vh flex gap-4">
|
||||
<div class="h-full flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="title">新词 {{data.new.length}} 个</span>
|
||||
<Checkbox v-model="showTranslate">翻译</Checkbox>
|
||||
</div>
|
||||
<BaseTable
|
||||
class="overflow-auto flex-1 w-85"
|
||||
:list='data.new'
|
||||
:loading='false'
|
||||
:show-toolbar="false"
|
||||
:showPagination="false"
|
||||
>
|
||||
<template v-slot="item">
|
||||
<WordItem
|
||||
:item="item.item"
|
||||
:show-translate="showTranslate"
|
||||
:index="item.index"
|
||||
:show-option="false"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<div class="h-full flex flex-col gap-2" v-if="data.review.length">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="title">复习上次 {{data.review.length}} 个</span>
|
||||
</div>
|
||||
<BaseTable
|
||||
class="overflow-auto flex-1 w-85"
|
||||
:list='data.review'
|
||||
:loading='false'
|
||||
:show-toolbar="false"
|
||||
:showPagination="false"
|
||||
>
|
||||
<template v-slot="item">
|
||||
<WordItem
|
||||
:item="item.item"
|
||||
:show-translate="showTranslate"
|
||||
:index="item.index"
|
||||
:show-option="false"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
<div class="h-full flex flex-col gap-2" v-if="data.write.length">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="title">复习之前 {{data.write.length}} 个</span>
|
||||
</div>
|
||||
<BaseTable
|
||||
class="overflow-auto flex-1 w-85"
|
||||
:list='data.write'
|
||||
:loading='false'
|
||||
:show-toolbar="false"
|
||||
:showPagination="false"
|
||||
>
|
||||
<template v-slot="item">
|
||||
<WordItem
|
||||
:item="item.item"
|
||||
:show-translate="showTranslate"
|
||||
:index="item.index"
|
||||
:show-option="false"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
47
src/components/word/components/SentenceHightLightWord.vue
Normal file
47
src/components/word/components/SentenceHightLightWord.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
interface IProps {
|
||||
text: string
|
||||
dictation: boolean
|
||||
highLight?: boolean
|
||||
word: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
text: '',
|
||||
dictation: false,
|
||||
highLight: true,
|
||||
word: ''
|
||||
})
|
||||
|
||||
// 计算属性:将句子中的目标单词高亮显示
|
||||
const highlightedText = computed(() => {
|
||||
if (!props.text || !props.word) {
|
||||
return props.text
|
||||
}
|
||||
|
||||
// 创建正则表达式,不区分大小写,匹配整个单词
|
||||
const regex = new RegExp(`\\b${escapeRegExp(props.word)}\\b`, 'gi')
|
||||
|
||||
// 将匹配的单词用span标签包裹
|
||||
return props.text.replace(regex, (match) => {
|
||||
return `<span class="${props.highLight && 'highlight-word'} ${props.dictation && 'word-shadow'}">${match}</span>`
|
||||
})
|
||||
})
|
||||
|
||||
// 转义正则表达式特殊字符
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-html="highlightedText"></div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.highlight-word) {
|
||||
color: var(--color-icon-hightlight);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import {defineAsyncComponent, watch} from "vue";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const store = useBaseStore()
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ok: [val: number];
|
||||
}>()
|
||||
|
||||
let num = $ref(0)
|
||||
let min = $ref(0)
|
||||
|
||||
watch(() => model.value, (n) => {
|
||||
if (n) {
|
||||
num = Math.floor(store.sdict.lastLearnIndex / 3)
|
||||
num = num > 50 ? 50 : num
|
||||
min = num < 10 ? num : 10
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="model" title="随机复习设置"
|
||||
:footer="true"
|
||||
:padding="true"
|
||||
@ok="emit('ok',num)">
|
||||
<div class="w-120 color-main">
|
||||
<div class="flex gap-4 items-end mb-2">
|
||||
<span>随机复习:<span class="font-bold">{{ store.sdict.name }}</span></span>
|
||||
<span class="target-number">{{ num }}</span>个单词
|
||||
</div>
|
||||
<div class="flex gap-space">
|
||||
<span class="shrink-0">随机数量:</span>
|
||||
<Slider :min="min"
|
||||
:step="10"
|
||||
show-text
|
||||
class="mt-1"
|
||||
:max="store.sdict.lastLearnIndex"
|
||||
v-model="num"/>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm text-gray-500">只能复习已学习过的单词</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
357
src/components/word/components/Statistics.vue
Normal file
357
src/components/word/components/Statistics.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { useBaseStore } from '@/stores/base.ts'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { Statistics, TaskWords } from '@/types/types.ts'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import dayjs from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import { defineAsyncComponent, inject, watch } from 'vue'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { msToHourMinute } from '@/utils'
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
import ChannelIcons from '@/components/ChannelIcons/ChannelIcons.vue'
|
||||
import { AppEnv } from '@/config/env.ts'
|
||||
import { addStat } from '@/apis'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { ShortcutKey, WordPracticeMode } from '@/types/enum.ts'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
const statStore = usePracticeStore()
|
||||
const model = defineModel({ default: false })
|
||||
let list = $ref([])
|
||||
let dictIsEnd = $ref(false)
|
||||
let practiceTaskWords = inject<TaskWords>('practiceTaskWords')
|
||||
|
||||
function calcWeekList() {
|
||||
// 获取本周的起止时间
|
||||
const startOfWeek = dayjs().startOf('isoWeek') // 周一
|
||||
const endOfWeek = dayjs().endOf('isoWeek') // 周日
|
||||
// 初始化 7 天的数组,默认 false
|
||||
const weekList = Array(7).fill(false)
|
||||
|
||||
store.sdict.statistics.forEach(item => {
|
||||
const date = dayjs(item.startDate)
|
||||
if (date.isBetween(startOfWeek, endOfWeek, null, '[]')) {
|
||||
let idx = date.day()
|
||||
// dayjs().day() 0=周日, 1=周一, ..., 6=周六
|
||||
// 需要转换为 0=周一, ..., 6=周日
|
||||
if (idx === 0) {
|
||||
idx = 6 // 周日放到最后
|
||||
} else {
|
||||
idx = idx - 1 // 其余前移一位
|
||||
}
|
||||
weekList[idx] = true
|
||||
}
|
||||
})
|
||||
list = weekList
|
||||
}
|
||||
|
||||
// 监听 model 弹窗打开时重新计算
|
||||
watch(model, async newVal => {
|
||||
if (newVal) {
|
||||
dictIsEnd = false
|
||||
let data: Statistics = {
|
||||
spend: statStore.spend,
|
||||
startDate: statStore.startDate,
|
||||
total: statStore.total,
|
||||
wrong: statStore.wrong,
|
||||
new: statStore.newWordNumber,
|
||||
review: statStore.reviewWordNumber + statStore.writeWordNumber,
|
||||
}
|
||||
window.umami?.track('endStudyWord', {
|
||||
name: store.sdict.name,
|
||||
spend: Number(statStore.spend / 1000 / 60).toFixed(1),
|
||||
index: store.sdict.lastLearnIndex,
|
||||
perDayStudyNumber: store.sdict.perDayStudyNumber,
|
||||
custom: store.sdict.custom,
|
||||
complete: store.sdict.complete,
|
||||
str: `name:${store.sdict.name},per:${store.sdict.perDayStudyNumber},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)},index:${store.sdict.lastLearnIndex}`,
|
||||
})
|
||||
|
||||
//如果 shuffle 数组不为空,就说明是复习,不用修改 lastLearnIndex
|
||||
if (settingStore.wordPracticeMode !== WordPracticeMode.Shuffle) {
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
// 检查已忽略的单词数量,是否全部完成
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
// 忽略单词数
|
||||
const ignoreCount = ignoreList.filter(word => store.sdict.words.some(w => w.word.toLowerCase() === word)).length
|
||||
// 如果lastLearnIndex已经超过可学单词数,则判定完成
|
||||
if (store.sdict.lastLearnIndex + ignoreCount >= store.sdict.length) {
|
||||
dictIsEnd = true
|
||||
store.sdict.complete = true
|
||||
store.sdict.lastLearnIndex = store.sdict.length
|
||||
}
|
||||
}
|
||||
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await addStat({
|
||||
...data,
|
||||
type: 'word',
|
||||
perDayStudyNumber: store.sdict.perDayStudyNumber,
|
||||
lastLearnIndex: store.sdict.lastLearnIndex,
|
||||
complete: store.sdict.complete,
|
||||
})
|
||||
if (!res.success) {
|
||||
Toast.error(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
store.sdict.statistics.push(data as any)
|
||||
calcWeekList() // 新增:计算本周学习记录
|
||||
}
|
||||
})
|
||||
|
||||
const close = () => (model.value = false)
|
||||
|
||||
useEvents([
|
||||
//特意注释掉,因为在练习界面用快捷键下一组时,需要判断是否在结算界面
|
||||
// [ShortcutKey.NextChapter, close],
|
||||
[ShortcutKey.RepeatChapter, close],
|
||||
[ShortcutKey.DictationChapter, close],
|
||||
])
|
||||
|
||||
function options(emitType: string) {
|
||||
emitter.emit(EventKey[emitType])
|
||||
close()
|
||||
}
|
||||
|
||||
// 计算学习进度百分比
|
||||
const studyProgress = $computed(() => {
|
||||
if (!store.sdict.length) return 0
|
||||
return Math.round((store.sdict.lastLearnIndex / store.sdict.length) * 100)
|
||||
})
|
||||
|
||||
// 计算正确率
|
||||
const accuracyRate = $computed(() => {
|
||||
if (statStore.total === 0) return 100
|
||||
return Math.round(((statStore.total - statStore.wrong) / statStore.total) * 100)
|
||||
})
|
||||
|
||||
// 获取鼓励文案
|
||||
const encouragementText = $computed(() => {
|
||||
const rate = accuracyRate
|
||||
if (rate >= 95) return '🎉 太棒了!继续保持!'
|
||||
if (rate >= 85) return '👍 表现很好,再接再厉!'
|
||||
if (rate >= 70) return '💪 不错的成绩,继续加油!'
|
||||
return '🌟 每次练习都是进步,坚持下去!'
|
||||
})
|
||||
|
||||
// 格式化学习时间
|
||||
const formattedStudyTime = $computed(() => {
|
||||
const time = msToHourMinute(statStore.spend)
|
||||
return time.replace('小时', 'h ').replace('分钟', 'm')
|
||||
})
|
||||
|
||||
calcWeekList() // 新增:计算本周学习记录
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="model" :close-on-click-bg="false" :header="false" :keyboard="false" :show-close="false">
|
||||
<div class="p-8 pr-3 bg-[var(--bg-card-primary)] rounded-2xl space-y-6">
|
||||
<!-- Header Section -->
|
||||
<div class="text-center relative">
|
||||
<div
|
||||
class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-500 to-purple-700 bg-clip-text text-transparent"
|
||||
>
|
||||
<template v-if="practiceTaskWords.shuffle.length"> 🎯 复习完成 </template>
|
||||
<template v-else> 🎉 今日任务完成 </template>
|
||||
</div>
|
||||
<p class="font-medium text-lg">{{ encouragementText }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="item">
|
||||
<IconFluentClock20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">学习时长</div>
|
||||
<div class="text-xl font-bold">{{ formattedStudyTime }}</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<IconFluentTarget20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">正确率</div>
|
||||
<div class="text-xl font-bold">{{ accuracyRate }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<IconFluentSparkle20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">新词</div>
|
||||
<div class="text-xl font-bold">{{ statStore.newWordNumber }}</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<IconFluentBook20Regular class="text-purple-500" />
|
||||
<div class="text-sm mb-1 font-medium">复习</div>
|
||||
<div class="text-xl font-bold">
|
||||
{{ statStore.reviewWordNumber + statStore.writeWordNumber }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full gap-3 flex">
|
||||
<div class="space-y-6 flex-1">
|
||||
<!-- Weekly Progress -->
|
||||
<div class="bg-[--bg-card-secend] rounded-xl p-2">
|
||||
<div class="text-center mb-4">
|
||||
<div class="text-xl font-semibold mb-1">本周学习记录</div>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<div
|
||||
v-for="(item, i) in list"
|
||||
:key="i"
|
||||
class="flex-1 text-center px-2 py-3 rounded-lg"
|
||||
:class="item ? 'bg-green-500 text-white shadow-lg' : 'bg-white text-gray-700'"
|
||||
>
|
||||
<div class="font-semibold mb-1">{{ i + 1 }}</div>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mx-auto mb-1"
|
||||
:class="item ? 'bg-white bg-opacity-30' : 'bg-gray-300'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Overview -->
|
||||
<div class="bg-[var(--bg-card-secend)] rounded-xl py-2 px-6">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="text-xl font-semibold">学习进度</div>
|
||||
<div class="text-2xl font-bold text-purple-600">{{ studyProgress }}%</div>
|
||||
</div>
|
||||
<Progress :percentage="studyProgress" size="large" :show-text="false" />
|
||||
<div class="flex justify-between text-sm font-medium mt-4">
|
||||
<span>已学习: {{ store.sdict.lastLearnIndex }}</span>
|
||||
<span>总词数: {{ store.sdict.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChannelIcons />
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex min-w-130 justify-center">
|
||||
<BaseButton
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
|
||||
@click="options(EventKey.repeatStudy)"
|
||||
>
|
||||
<div class="center gap-2">
|
||||
<IconFluentArrowClockwise20Regular />
|
||||
重学一遍
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Review"
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
|
||||
@click="options(EventKey.continueStudy)"
|
||||
>
|
||||
<div class="center gap-2">
|
||||
<IconFluentPlay20Regular />
|
||||
{{ dictIsEnd ? '从头开始练习' : '再来一组' }}
|
||||
</div>
|
||||
</BaseButton>
|
||||
<BaseButton @click="$router.back">
|
||||
<div class="center gap-2">
|
||||
<IconFluentHome20Regular />
|
||||
返回主页
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
// 弹窗容器优化
|
||||
.w-140 {
|
||||
width: 90vw !important;
|
||||
max-width: 500px;
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
|
||||
// 标题优化
|
||||
.center.text-2xl {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
// 统计数据布局
|
||||
.flex .flex-1 {
|
||||
.text-sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 时间显示
|
||||
.text-xl {
|
||||
font-size: 1rem;
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 错词/正确统计卡片
|
||||
.flex.justify-center.gap-10 {
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> div {
|
||||
padding: 0.8rem 2rem;
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 本周学习记录
|
||||
.flex.gap-4 {
|
||||
gap: 0.5rem;
|
||||
|
||||
.w-8.h-8 {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.flex.justify-center.gap-4 {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.base-button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.w-140 {
|
||||
width: 95vw !important;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.flex .flex-1 {
|
||||
.text-4xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
@apply bg-[var(--bg-card-secend)] rounded-xl p-2 text-center border border-gray-100;
|
||||
}
|
||||
</style>
|
||||
885
src/components/word/components/TypeWord.vue
Normal file
885
src/components/word/components/TypeWord.vue
Normal file
@@ -0,0 +1,885 @@
|
||||
<script setup lang="ts">
|
||||
import type { Word } from '@/types/types.ts'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio } from '@/hooks/sound.ts'
|
||||
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import SentenceHightLightWord from '@/components/word/components/SentenceHightLightWord.vue'
|
||||
import { usePracticeStore } from '@/stores/practice.ts'
|
||||
import { getDefaultWord } from '@/types/func.ts'
|
||||
import { _nextTick, last } from '@/utils'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import Space from '@/components/article/components/Space.vue'
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import Tooltip from '@/components/base/Tooltip.vue'
|
||||
import { ShortcutKey, WordPracticeStage, WordPracticeType } from '@/types/enum.ts'
|
||||
|
||||
interface IProps {
|
||||
word: Word
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
word: () => getDefaultWord(),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
wrong: []
|
||||
know: []
|
||||
}>()
|
||||
|
||||
let input = $ref('')
|
||||
let wrong = $ref('')
|
||||
let showFullWord = $ref(false)
|
||||
//输入锁定,因为跳转到下一个单词有延时,如果重复在延时期间内重复输入,导致会跳转N次
|
||||
let inputLock = false
|
||||
let wordRepeatCount = 0
|
||||
// 记录单词完成的时间戳,用于防止同时按下最后一个字母和空格键时跳过单词
|
||||
let wordCompletedTime = 0
|
||||
let jumpTimer = -1
|
||||
let cursor = $ref({
|
||||
top: 0,
|
||||
left: 0,
|
||||
})
|
||||
const settingStore = useSettingStore()
|
||||
const statStore = usePracticeStore()
|
||||
|
||||
const playBeep = usePlayBeep()
|
||||
const playCorrect = usePlayCorrect()
|
||||
const playKeyboardAudio = usePlayKeyboardAudio()
|
||||
const playWordAudio = usePlayWordAudio()
|
||||
// const ttsPlayAudio = useTTsPlayAudio()
|
||||
const volumeIconRef: any = $ref()
|
||||
const typingWordRef = $ref<HTMLDivElement>()
|
||||
// const volumeTranslateIconRef: any = $ref()
|
||||
|
||||
let displayWord = $computed(() => {
|
||||
return props.word.word.slice(input.length + wrong.length)
|
||||
})
|
||||
|
||||
// 在全局对象中存储当前单词信息,以便其他模块可以访问
|
||||
function updateCurrentWordInfo() {
|
||||
window.__CURRENT_WORD_INFO__ = {
|
||||
word: props.word.word,
|
||||
input: input,
|
||||
inputLock: inputLock,
|
||||
containsSpace: props.word.word.includes(' '),
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.word, reset, { deep: true })
|
||||
|
||||
function reset() {
|
||||
wrong = input = ''
|
||||
wordRepeatCount = 0
|
||||
showWordResult = inputLock = false
|
||||
wordCompletedTime = 0 // 重置时间戳
|
||||
if (settingStore.wordSound) {
|
||||
if (settingStore.wordPracticeType !== WordPracticeType.Dictation) {
|
||||
volumeIconRef?.play(400, true)
|
||||
}
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo()
|
||||
checkCursorPosition()
|
||||
}
|
||||
|
||||
// 监听输入变化,更新当前单词信息
|
||||
watch(
|
||||
() => input,
|
||||
() => {
|
||||
updateCurrentWordInfo()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化当前单词信息
|
||||
updateCurrentWordInfo()
|
||||
|
||||
emitter.on(EventKey.resetWord, reset)
|
||||
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)
|
||||
}
|
||||
|
||||
let showWordResult = $ref(false)
|
||||
let pressNumber = 0
|
||||
|
||||
const right = $computed(() => {
|
||||
if (settingStore.ignoreCase) {
|
||||
return input.toLowerCase() === props.word.word.toLowerCase()
|
||||
} else {
|
||||
return input === props.word.word
|
||||
}
|
||||
})
|
||||
|
||||
let showNotice = false
|
||||
|
||||
function know(e) {
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Identify) {
|
||||
if (!showWordResult) {
|
||||
inputLock = showWordResult = true
|
||||
input = props.word.word
|
||||
emit('know')
|
||||
if (!showNotice) {
|
||||
Toast.info('若误选“我认识”,可按删除键重新选择!', { duration: 5000 })
|
||||
showNotice = true
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
onTyping(e)
|
||||
}
|
||||
|
||||
function unknown(e) {
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Identify) {
|
||||
if (!showWordResult) {
|
||||
showWordResult = true
|
||||
emit('wrong')
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
return
|
||||
}
|
||||
}
|
||||
onTyping(e)
|
||||
}
|
||||
|
||||
async function onTyping(e: KeyboardEvent) {
|
||||
debugger
|
||||
let word = props.word.word
|
||||
// 输入完成会锁死不能再输入
|
||||
if (inputLock) {
|
||||
//判断是否是空格键以便切换到下一个单词
|
||||
if (e.code === 'Space') {
|
||||
//正确时就切换到下一个单词
|
||||
if (right) {
|
||||
clearInterval(jumpTimer)
|
||||
// 如果单词刚完成(300ms内),忽略空格键,避免同时按下最后一个字母和空格键时跳过单词
|
||||
if (wordCompletedTime && Date.now() - wordCompletedTime < 300) {
|
||||
return
|
||||
}
|
||||
showWordResult = inputLock = false
|
||||
emit('complete')
|
||||
} else {
|
||||
if (showWordResult) {
|
||||
// 错误时,提示用户按删除键,仅默写需要提示
|
||||
pressNumber++
|
||||
if (pressNumber >= 3) {
|
||||
Toast.info('请按删除键重新输入', { duration: 2000 })
|
||||
pressNumber = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//当正确时,提醒用户按空格键切下一个
|
||||
if (right) {
|
||||
pressNumber++
|
||||
if (pressNumber >= 3) {
|
||||
Toast.info('请按空格键继续', { duration: 2000 })
|
||||
pressNumber = 0
|
||||
}
|
||||
} else {
|
||||
//当错误时,按任意键重新输入
|
||||
showWordResult = inputLock = false
|
||||
input = wrong = ''
|
||||
onTyping(e)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
inputLock = true
|
||||
let letter = e.key
|
||||
// console.log('letter',letter)
|
||||
//默写特殊逻辑
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Dictation) {
|
||||
if (e.code === 'Space') {
|
||||
//如果输入长度大于单词长度/单词不包含空格,并且输入不为空(开始直接输入空格不行),则显示单词;
|
||||
// 这里inputLock 不设为 false,不能再输入了,只能删除(删除会重置 inputLock)或按空格切下一格
|
||||
if (input.length && (input.length >= word.length || !word.includes(' '))) {
|
||||
//比对是否一致
|
||||
if (input.toLowerCase() === word.toLowerCase()) {
|
||||
//如果已显示单词,则发射完成事件,并 return
|
||||
if (showWordResult) {
|
||||
return emit('complete')
|
||||
} else {
|
||||
//未显示单词,则播放正确音乐,并在后面设置为 showWordResult 为 true 来显示单词
|
||||
playCorrect()
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
}
|
||||
} else {
|
||||
//错误处理
|
||||
playBeep()
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
emit('wrong')
|
||||
}
|
||||
showWordResult = true
|
||||
return
|
||||
}
|
||||
}
|
||||
//默写途中不判断是否正确,在按空格再判断
|
||||
input += letter
|
||||
wrong = ''
|
||||
playKeyboardAudio()
|
||||
updateCurrentWordInfo()
|
||||
inputLock = false
|
||||
} else if (settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult) {
|
||||
//当自测模式下,按1和2会单独处理,如果按其他键则自动默认为不认识
|
||||
showWordResult = true
|
||||
emit('wrong')
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
inputLock = false
|
||||
onTyping(e)
|
||||
} else {
|
||||
let right = false
|
||||
if (settingStore.ignoreCase) {
|
||||
right = letter.toLowerCase() === word[input.length].toLowerCase()
|
||||
} else {
|
||||
right = letter === word[input.length]
|
||||
}
|
||||
//针对中文的特殊判断
|
||||
if (
|
||||
e.shiftKey &&
|
||||
(('!' === word[input.length] && e.code === 'Digit1') ||
|
||||
('¥' === word[input.length] && e.code === 'Digit4') ||
|
||||
('…' === word[input.length] && e.code === 'Digit6') ||
|
||||
('(' === word[input.length] && e.code === 'Digit9') ||
|
||||
('—' === word[input.length] && e.code === 'Minus') ||
|
||||
('?' === word[input.length] && e.code === 'Slash') ||
|
||||
('》' === word[input.length] && e.code === 'Period') ||
|
||||
('《' === word[input.length] && e.code === 'Comma') ||
|
||||
('“' === word[input.length] && e.code === 'Quote') ||
|
||||
(':' === word[input.length] && e.code === 'Semicolon') ||
|
||||
(')' === word[input.length] && e.code === 'Digit0'))
|
||||
) {
|
||||
right = true
|
||||
letter = word[input.length]
|
||||
}
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
(('【' === word[input.length] && e.code === 'BracketLeft') ||
|
||||
('、' === word[input.length] && e.code === 'Slash') ||
|
||||
('。' === word[input.length] && e.code === 'Period') ||
|
||||
(',' === word[input.length] && e.code === 'Comma') ||
|
||||
('‘' === word[input.length] && e.code === 'Quote') ||
|
||||
(';' === word[input.length] && e.code === 'Semicolon') ||
|
||||
('【' === word[input.length] && e.code === 'BracketLeft') ||
|
||||
('】' === word[input.length] && e.code === 'BracketRight'))
|
||||
) {
|
||||
right = true
|
||||
letter = word[input.length]
|
||||
}
|
||||
// console.log('e', e, e.code, e.shiftKey, word[input.length])
|
||||
|
||||
if (right) {
|
||||
input += letter
|
||||
wrong = ''
|
||||
playKeyboardAudio()
|
||||
} else {
|
||||
emit('wrong')
|
||||
wrong = letter
|
||||
playBeep()
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
setTimeout(() => {
|
||||
if (settingStore.inputWrongClear) input = ''
|
||||
wrong = ''
|
||||
}, 500)
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo()
|
||||
//不需要把inputLock设为false,输入完成不能再输入了,只能删除,删除会打开锁
|
||||
if (input.toLowerCase() === word.toLowerCase()) {
|
||||
wordCompletedTime = Date.now() // 记录单词完成的时间戳
|
||||
playCorrect()
|
||||
if (
|
||||
[WordPracticeType.Listen, WordPracticeType.Identify].includes(settingStore.wordPracticeType) &&
|
||||
!showWordResult
|
||||
) {
|
||||
showWordResult = true
|
||||
}
|
||||
if ([WordPracticeType.FollowWrite, WordPracticeType.Spell].includes(settingStore.wordPracticeType)) {
|
||||
if (settingStore.autoNextWord) {
|
||||
if (settingStore.repeatCount == 100) {
|
||||
if (settingStore.repeatCustomCount <= wordRepeatCount + 1) {
|
||||
jumpTimer = setTimeout(() => emit('complete'), settingStore.waitTimeForChangeWord)
|
||||
} else {
|
||||
repeat()
|
||||
}
|
||||
} else {
|
||||
if (settingStore.repeatCount <= wordRepeatCount + 1) {
|
||||
jumpTimer = setTimeout(() => emit('complete'), settingStore.waitTimeForChangeWord)
|
||||
} else {
|
||||
repeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inputLock = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function del() {
|
||||
playKeyboardAudio()
|
||||
inputLock = false
|
||||
if (showWordResult) {
|
||||
input = ''
|
||||
showWordResult = false
|
||||
} else {
|
||||
if (wrong) {
|
||||
wrong = ''
|
||||
} else {
|
||||
input = input.slice(0, -1)
|
||||
}
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo()
|
||||
}
|
||||
|
||||
function showWord() {
|
||||
if (settingStore.allowWordTip) {
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Dictation || settingStore.dictation) {
|
||||
emit('wrong')
|
||||
}
|
||||
showFullWord = true
|
||||
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
|
||||
if (statStore.stage !== WordPracticeStage.FollowWriteNewWord) {
|
||||
emit('wrong')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideWord() {
|
||||
showFullWord = false
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Dictation || settingStore.dictation) {
|
||||
emit('wrong')
|
||||
}
|
||||
volumeIconRef?.play()
|
||||
}
|
||||
|
||||
defineExpose({ del, showWord, hideWord, play })
|
||||
|
||||
function mouseleave() {
|
||||
setTimeout(() => {
|
||||
showFullWord = false
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// 在释义中隐藏单词本身及其变形
|
||||
function hideWordInTranslation(text: string, word: string): string {
|
||||
if (!text || !word) {
|
||||
return text
|
||||
}
|
||||
|
||||
// 创建正则表达式,匹配单词本身及其常见变形(如复数、过去式等)
|
||||
const wordBase = word.toLowerCase()
|
||||
const patterns = [
|
||||
`\\b${escapeRegExp(wordBase)}\\b`, // 单词本身
|
||||
`\\b${escapeRegExp(wordBase)}s\\b`, // 复数形式
|
||||
`\\b${escapeRegExp(wordBase)}es\\b`, // 复数形式
|
||||
`\\b${escapeRegExp(wordBase)}ed\\b`, // 过去式
|
||||
`\\b${escapeRegExp(wordBase)}ing\\b`, // 进行时
|
||||
]
|
||||
|
||||
let result = text
|
||||
patterns.forEach(pattern => {
|
||||
const regex = new RegExp(pattern, 'gi')
|
||||
result = result.replace(regex, match => `<span class="word-shadow">${match}</span>`)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 转义正则表达式特殊字符
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
watch([() => input, () => showFullWord, () => settingStore.dictation], checkCursorPosition)
|
||||
|
||||
//检测光标位置
|
||||
function checkCursorPosition() {
|
||||
_nextTick(() => {
|
||||
// 选中目标元素
|
||||
const cursorEl = document.querySelector(`.cursor`)
|
||||
const inputList = document.querySelectorAll(`.l`)
|
||||
if (!typingWordRef) return
|
||||
const typingWordRect = typingWordRef.getBoundingClientRect()
|
||||
|
||||
if (inputList.length) {
|
||||
let inputRect = last(Array.from(inputList)).getBoundingClientRect()
|
||||
cursor = {
|
||||
top: inputRect.top + inputRect.height - cursorEl.clientHeight - typingWordRect.top,
|
||||
left: inputRect.right - typingWordRect.left - 3,
|
||||
}
|
||||
} else {
|
||||
const dictation = document.querySelector(`.dictation`)
|
||||
let elRect
|
||||
if (dictation) {
|
||||
elRect = dictation.getBoundingClientRect()
|
||||
} else {
|
||||
const letter = document.querySelector(`.letter`)
|
||||
elRect = letter.getBoundingClientRect()
|
||||
}
|
||||
cursor = {
|
||||
top: elRect.top + elRect.height - cursorEl.clientHeight - typingWordRect.top,
|
||||
left: elRect.left - typingWordRect.left - 3,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useEvents([
|
||||
[ShortcutKey.KnowWord, know],
|
||||
[ShortcutKey.UnknownWord, unknown],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="typing-word" ref="typingWordRef" v-if="word.word.length">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="flex gap-1 mt-30">
|
||||
<div
|
||||
class="phonetic"
|
||||
:class="!(!settingStore.dictation || showFullWord || showWordResult) && 'word-shadow'"
|
||||
v-if="settingStore.soundType === 'uk' && word.phonetic0"
|
||||
>
|
||||
[{{ word.phonetic0 }}]
|
||||
</div>
|
||||
<div
|
||||
class="phonetic"
|
||||
:class="
|
||||
(settingStore.dictation ||
|
||||
[WordPracticeType.Spell, WordPracticeType.Listen, WordPracticeType.Dictation].includes(
|
||||
settingStore.wordPracticeType
|
||||
)) &&
|
||||
!showFullWord &&
|
||||
!showWordResult &&
|
||||
'word-shadow'
|
||||
"
|
||||
v-if="settingStore.soundType === 'us' && word.phonetic1"
|
||||
>
|
||||
[{{ word.phonetic1 }}]
|
||||
</div>
|
||||
<VolumeIcon
|
||||
:title="`发音(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
|
||||
ref="volumeIconRef"
|
||||
:simple="true"
|
||||
:cb="() => playWordAudio(word.word)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
:title="
|
||||
settingStore.dictation ? `可以按快捷键 ${settingStore.shortcutKeyMap[ShortcutKey.ShowWord]} 显示正确答案` : ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
id="word"
|
||||
class="word my-1"
|
||||
:class="wrong && 'is-wrong'"
|
||||
:style="{ fontSize: settingStore.fontSize.wordForeignFontSize + 'px' }"
|
||||
@mouseenter="showWord"
|
||||
@mouseleave="mouseleave"
|
||||
>
|
||||
<div v-if="settingStore.wordPracticeType === WordPracticeType.Dictation">
|
||||
<div
|
||||
class="letter text-align-center w-full inline-block"
|
||||
v-opacity="!settingStore.dictation || showWordResult || showFullWord"
|
||||
>
|
||||
{{ word.word }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 w-120 dictation"
|
||||
:style="{ minHeight: settingStore.fontSize.wordForeignFontSize + 'px' }"
|
||||
:class="showWordResult ? (right ? 'right' : 'wrong') : ''"
|
||||
>
|
||||
<template v-for="i in input">
|
||||
<span class="l" v-if="i !== ' '">{{ i }}</span>
|
||||
<Space class="l" v-else :is-wrong="showWordResult ? !right : false" :is-wait="!showWordResult" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="input" v-if="input">{{ input }}</span>
|
||||
<span class="wrong" v-if="wrong">{{ wrong }}</span>
|
||||
<span class="letter" v-if="settingStore.dictation && !showFullWord">
|
||||
{{
|
||||
displayWord
|
||||
.split('')
|
||||
.map(v => (v === ' ' ? ' ' : '_'))
|
||||
.join('')
|
||||
}}
|
||||
</span>
|
||||
<span class="letter" v-else>{{ displayWord }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
class="mt-4 flex gap-4"
|
||||
v-if="settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult"
|
||||
>
|
||||
<BaseButton
|
||||
:keyboard="`快捷键(${settingStore.shortcutKeyMap[ShortcutKey.KnowWord]})`"
|
||||
size="large"
|
||||
@click="know"
|
||||
>我认识
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:keyboard="`快捷键(${settingStore.shortcutKeyMap[ShortcutKey.UnknownWord]})`"
|
||||
size="large"
|
||||
@click="unknown"
|
||||
>不认识
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="translate flex flex-col gap-2 my-3"
|
||||
v-opacity="settingStore.translate || showWordResult || showFullWord"
|
||||
:style="{
|
||||
fontSize: settingStore.fontSize.wordTranslateFontSize + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="flex" v-for="v in word.trans">
|
||||
<div class="shrink-0" :class="v.pos ? 'w-12 en-article-family' : '-ml-3'">
|
||||
{{ v.pos }}
|
||||
</div>
|
||||
<span v-if="!settingStore.dictation || showWordResult || showFullWord">{{ v.cn }}</span>
|
||||
<span v-else v-html="hideWordInTranslation(v.cn, word.word)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="other anim"
|
||||
v-opacity="
|
||||
![WordPracticeType.Listen, WordPracticeType.Dictation, WordPracticeType.Identify].includes(
|
||||
settingStore.wordPracticeType
|
||||
) ||
|
||||
showFullWord ||
|
||||
showWordResult
|
||||
"
|
||||
>
|
||||
<div class="line-white my-3"></div>
|
||||
<template v-if="word?.sentences?.length">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="sentence" v-for="item in word.sentences">
|
||||
<SentenceHightLightWord
|
||||
class="text-xl"
|
||||
:text="item.c"
|
||||
:word="word.word"
|
||||
:dictation="!(!settingStore.dictation || showFullWord || showWordResult)"
|
||||
/>
|
||||
<div class="text-base anim" v-opacity="settingStore.translate || showFullWord || showWordResult">
|
||||
{{ item.cn }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="word?.phrases?.length">
|
||||
<div class="line-white my-3"></div>
|
||||
<div class="flex">
|
||||
<div class="label">短语</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-4" v-for="item in word.phrases">
|
||||
<SentenceHightLightWord
|
||||
class="en"
|
||||
:text="item.c"
|
||||
:word="word.word"
|
||||
:dictation="!(!settingStore.dictation || showFullWord || showWordResult)"
|
||||
/>
|
||||
<div class="cn anim" v-opacity="settingStore.translate || showFullWord || showWordResult">
|
||||
{{ item.cn }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="settingStore.translate || !settingStore.dictation">
|
||||
<template v-if="word?.synos?.length">
|
||||
<div class="line-white my-3"></div>
|
||||
<div class="flex">
|
||||
<div class="label">同近义词</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex" v-for="item in word.synos">
|
||||
<div class="pos line-height-1.4rem!">{{ item.pos }}</div>
|
||||
<div>
|
||||
<div class="cn anim" v-opacity="settingStore.translate || showFullWord || showWordResult">
|
||||
{{ item.cn }}
|
||||
</div>
|
||||
<div class="anim" v-opacity="!settingStore.dictation || showFullWord || showWordResult">
|
||||
<span class="en" v-for="(i, j) in item.ws">
|
||||
{{ i }} {{ j !== item.ws.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="anim"
|
||||
v-opacity="(settingStore.translate && !settingStore.dictation) || showFullWord || showWordResult"
|
||||
>
|
||||
<template v-if="word?.etymology?.length">
|
||||
<div class="line-white my-3"></div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="label">词源</div>
|
||||
<div class="text-base">
|
||||
<div class="mb-2" v-for="item in word.etymology">
|
||||
<div class="">{{ item.t }}</div>
|
||||
<div class="">{{ item.d }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="line-white my-2"></div>-->
|
||||
</template>
|
||||
|
||||
<template v-if="word?.relWords?.root && false">
|
||||
<div class="flex">
|
||||
<div class="label">同根词</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="word.relWords.root" class=" ">
|
||||
词根:<span class="en">{{ word.relWords.root }}</span>
|
||||
</div>
|
||||
<div class="flex" v-for="item in word.relWords.rels">
|
||||
<div class="pos">{{ item.pos }}</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4" v-for="itemj in item.words">
|
||||
<div class="en">{{ itemj.c }}</div>
|
||||
<div class="cn">{{ itemj.cn }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="cursor"
|
||||
:style="{
|
||||
top: cursor.top + 'px',
|
||||
left: cursor.left + 'px',
|
||||
height: settingStore.fontSize.wordForeignFontSize + 'px',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dictation {
|
||||
border-bottom: 2px solid gray;
|
||||
}
|
||||
|
||||
.typing-word {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
//overflow: auto;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
color: var(--color-font-2);
|
||||
|
||||
.phonetic,
|
||||
.translate {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.phonetic {
|
||||
color: var(--color-font-1);
|
||||
font-family: var(--word-font-family);
|
||||
}
|
||||
|
||||
.word {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
font-family: var(--en-article-family);
|
||||
letter-spacing: 0.3rem;
|
||||
|
||||
.input,
|
||||
.right {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@apply: text-lg font-medium;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
|
||||
.tab {
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-bottom: 2px solid var(--color-font-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 6rem;
|
||||
padding-top: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cn {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.en {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.pos {
|
||||
font-family: var(--en-article-family);
|
||||
@apply text-lg w-12;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.typing-word {
|
||||
padding: 0 0.5rem 12rem;
|
||||
|
||||
.word {
|
||||
font-size: 2rem !important;
|
||||
letter-spacing: 0.1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.phonetic,
|
||||
.translate {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cn {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.en {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pos {
|
||||
font-size: 0.9rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
// 移动端按钮组调整
|
||||
.flex.gap-4 {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
z-index: 10; // 确保按钮不被其他元素遮挡
|
||||
|
||||
.base-button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0.8rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保短语和例句区域保持默认层级
|
||||
.phrase-section,
|
||||
.sentence {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
// 移动端例句和短语调整
|
||||
.sentence,
|
||||
.phrase {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.5rem;
|
||||
pointer-events: auto; // 允许点击但不调起输入法
|
||||
}
|
||||
|
||||
// 移动端短语调整
|
||||
.flex.items-center.gap-4 {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超小屏幕适配
|
||||
@media (max-width: 480px) {
|
||||
.typing-word {
|
||||
padding: 0 0.3rem 12rem;
|
||||
|
||||
.word {
|
||||
font-size: 1.5rem !important;
|
||||
letter-spacing: 0.05rem;
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.phonetic,
|
||||
.translate {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 3rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cn {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.en {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pos {
|
||||
font-size: 0.8rem;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.sentence {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
128
src/components/word/components/VolumeSettingMiniDialog.vue
Normal file
128
src/components/word/components/VolumeSettingMiniDialog.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import Switch from '@/components/base/Switch.vue'
|
||||
import { Option, Select } from '@/components/base/select'
|
||||
import MiniDialog from '@/components/dialog/MiniDialog.vue'
|
||||
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
|
||||
import { SoundFileOptions } from '@/config/env.ts'
|
||||
import { useWindowClick } from '@/hooks/event.ts'
|
||||
import { getAudioFileUrl, usePlayAudio } from '@/hooks/sound.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { emitter, EventKey } from '@/utils/eventBus.ts'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let timer = 0
|
||||
//停止切换事件,因为hover到select时会跳出mini-dialog
|
||||
let selectIsOpen = false
|
||||
let show = $ref(false)
|
||||
|
||||
useWindowClick(() => {
|
||||
if (selectIsOpen) {
|
||||
selectIsOpen = false
|
||||
} else {
|
||||
show = false
|
||||
}
|
||||
})
|
||||
|
||||
function toggle(val: boolean) {
|
||||
if (selectIsOpen) return
|
||||
clearTimeout(timer)
|
||||
if (val) {
|
||||
emitter.emit(EventKey.closeOther)
|
||||
show = val
|
||||
} else {
|
||||
timer = setTimeout(() => {
|
||||
show = val
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function selectToggle(e: boolean) {
|
||||
//这里要延时设置,因为关闭的时候,如果太早设置了false了,useWindowClick的事件就会把弹框关闭
|
||||
setTimeout(() => (selectIsOpen = e))
|
||||
}
|
||||
|
||||
function eventCheck(e) {
|
||||
const isSelfOrChild = e.currentTarget.contains(e.target)
|
||||
if (isSelfOrChild) {
|
||||
//如果下拉框打开的情况就不拦截
|
||||
if (selectIsOpen) return
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting" @click="eventCheck">
|
||||
<BaseIcon @mouseenter="toggle(true)" @mouseleave="toggle(false)">
|
||||
<IconClarityVolumeUpLine />
|
||||
</BaseIcon>
|
||||
<MiniDialog width="18rem" @mouseenter="toggle(true)" @mouseleave="toggle(false)" v-model="show">
|
||||
<div class="mini-row-title">音效设置</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">单词自动发音</label>
|
||||
<div class="wrapper">
|
||||
<Switch v-model="settingStore.wordSound" inline-prompt active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">单词发音口音</label>
|
||||
<div class="wrapper">
|
||||
<Select v-model="settingStore.soundType" @toggle="selectToggle" placeholder="请选择" size="small">
|
||||
<Option label="美音" value="us" />
|
||||
<Option label="英音" value="uk" />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">按键音</label>
|
||||
<div class="wrapper">
|
||||
<Switch v-model="settingStore.keyboardSound" inline-prompt active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">按键音效</label>
|
||||
<div class="wrapper">
|
||||
<Select v-model="settingStore.keyboardSoundFile" @toggle="selectToggle" placeholder="请选择" size="small">
|
||||
<Option v-for="item in SoundFileOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||
<div class="el-option-row">
|
||||
<span>{{ item.label }}</span>
|
||||
<VolumeIcon :time="100" @click="usePlayAudio(getAudioFileUrl(item.value)[0])" />
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<label class="item-title">效果音</label>
|
||||
<div class="wrapper">
|
||||
<Switch v-model="settingStore.effectSound" inline-prompt active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
</MiniDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.wrapper {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.setting {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.el-option-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.icon-wrapper {
|
||||
transform: translateX(10rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user