Merge branch 'refs/heads/dev'

# Conflicts:
#	src/utils/index.ts
This commit is contained in:
Zyronon
2026-01-08 23:39:28 +08:00
69 changed files with 491 additions and 616 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Dict } from "@/types/types.ts";
import type { Dict } from "@/types/types";
import Progress from '@/components/base/Progress.vue'
import Checkbox from "@/components/base/checkbox/Checkbox.vue";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import {computed, provide} from "vue"
import {useSettingStore} from "@/stores/setting.ts";
import {useSettingStore} from "@/stores/setting";
import Close from "@/components/icon/Close.vue";
import Tooltip from "@/components/base/Tooltip.vue";
import {ShortcutKey} from "@/types/enum.ts";
import {ShortcutKey} from "@/types/enum";
const settingStore = useSettingStore()
let tabIndex = $ref(0)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useSettingStore } from '@/stores/setting.ts'
import { useSettingStore } from '@/stores/setting'
const settingStore = useSettingStore()
defineProps<{

View 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>

View 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 />
2LRC 文件用于解析句子对应音频的位置不一定准确后续可自行修改
</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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -1,5 +1,5 @@
import {createVNode, render} from 'vue'
import ToastComponent from '@/components/base/toast/Toast.vue'
import ToastComponent from '@/components/base/toast/ToastComponent.vue'
import type {ToastOptions, ToastInstance, ToastService} from '@/components/base/toast/type.ts'
interface ToastContainer {

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import Tooltip from '@/components/base/Tooltip.vue'
import { useEventListener } from '@/hooks/event.ts'
import { useEventListener } from '@/hooks/event'
import BaseButton from '@/components/BaseButton.vue'
import { useRuntimeStore } from '@/stores/runtime.ts'
import { useRuntimeStore } from '@/stores/runtime'
export interface ModalProps {
modelValue?: boolean

View File

@@ -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()

View File

@@ -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";

View 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>

View 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>

View File

@@ -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()

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import BaseButton from "@/components/BaseButton.vue";
import {sendCode} from "@/apis/user.ts";
import {PHONE_CONFIG} from "@/config/auth.ts";
import Toast from "@/components/base/toast/Toast.ts";
import {CodeType} from "@/types/enum.ts";
let isSendingCode = $ref(false)
let codeCountdown = $ref(0)
interface IProps {
validateField: Function,
type: CodeType
val: any
size?: any
}
const props = withDefaults(defineProps<IProps>(), {
size: 'large',
})
// 发送验证码
async function sendVerificationCode() {
let res = props.validateField()
if (res) {
try {
isSendingCode = true
const res = await sendCode({val: props.val, type: props.type})
if (res.success) {
codeCountdown = PHONE_CONFIG.sendInterval
const timer = setInterval(() => {
codeCountdown--
if (codeCountdown <= 0) {
clearInterval(timer)
}
}, 1000)
} else {
Toast.error(res.msg || '发送失败')
}
} catch (error) {
console.error('Send code error:', error)
Toast.error('发送验证码失败')
} finally {
isSendingCode = false
}
}
}
</script>
<template>
<BaseButton
@click="sendVerificationCode"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
:size="props.size"
style="border: 1px solid var(--color-input-border)"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '发送验证码') }}
</BaseButton>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
</script>
<template>
<div class="h-12 text-xs text-gray-400">
<span>
继续操作即表示你阅读并同意我们的
<a href="/user-agreement.html" target="_blank" class="link">用户协议</a>
<a href="/privacy-policy.html" target="_blank" class="link">隐私政策</a>
</span>
<slot/>
</div>
</template>

View File

@@ -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>

View File

@@ -0,0 +1,503 @@
<script setup lang="ts">
import { inject, Ref } from 'vue'
import { usePracticeStore } from '@/stores/practice'
import { useSettingStore } from '@/stores/setting'
import type { PracticeData, TaskWords } from '@/types/types'
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'
import VolumeSettingMiniDialog from '@/components/word/components/VolumeSettingMiniDialog.vue'
import StageProgress from '@/components/StageProgress.vue'
import { ShortcutKey, WordPracticeMode, WordPracticeStage } from '@/types/enum'
import { WordPracticeModeNameMap, WordPracticeModeStageMap, WordPracticeStageNameMap } from '@/config/env'
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>

View 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>

View 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'
import Toast from '@/components/base/toast/Toast'
import ChangeLastPracticeIndexDialog from '@/components/word/components/ChangeLastPracticeIndexDialog.vue'
import Tooltip from '@/components/base/Tooltip.vue'
import { useRuntimeStore } from '@/stores/runtime'
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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,885 @@
<script setup lang="ts">
import type { Word } from '@/types/types'
import VolumeIcon from '@/components/icon/VolumeIcon.vue'
import { useSettingStore } from '@/stores/setting'
import { usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio } from '@/hooks/sound'
import { emitter, EventKey, useEvents } from '@/utils/eventBus'
import { onMounted, onUnmounted, watch } from 'vue'
import SentenceHightLightWord from '@/components/word/components/SentenceHightLightWord.vue'
import { usePracticeStore } from '@/stores/practice'
import { getDefaultWord } from '@/types/func'
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'
import Tooltip from '@/components/base/Tooltip.vue'
import { ShortcutKey, WordPracticeStage, WordPracticeType } from '@/types/enum'
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 === '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 === 'Quote') ||
('' === word[input.length] && e.code === 'Semicolon') ||
('' === word[input.length] && e.code === 'Digit0') ||
('' === word[input.length] && e.code === 'Digit9'))
) {
right = true
letter = word[input.length]
}
if (
!e.shiftKey &&
(('、' === 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 === '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]
}
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 === ' ' ? '&nbsp;' : '_'))
.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>

View 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>