This commit is contained in:
Zyronon
2025-12-25 19:54:35 +08:00
committed by GitHub
parent db4b0ba18f
commit 4134b4b30b
14 changed files with 439 additions and 261 deletions

View File

@@ -59,7 +59,8 @@
--color-third: rgb(226 232 240 / 1);
--color-fourth: rgb(193, 193, 193);
--color-card-active: #FED7AA;
//--color-card-active: #FED7AA;
--color-card-active: rgb(253, 246, 236);
--color-list-item-active: rgb(253, 246, 236);
--color-icon-hightlight: rgb(12, 140, 233);
//--color-icon-hightlight: rgb(12, 140, 233);

View File

@@ -4,7 +4,7 @@
<template>
<div class="flex justify-center">
<div class="page w-[70vw] 2xl:w-[50vw]">
<div class="page 3xl:w-[50vw] 2xl:w-[60vw] xl:w-[70vw] lg:w-[75vw]">
<slot></slot>
</div>
</div>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts"></script>
<template>
<div class="w-full flex box-border cp color-white">
<div class="option-wrap">
<slot></slot>
</div>
<div class="relative group">
<div
class="w-10 rounded-r-lg h-full center bg-[var(--btn-primary)] hover:bg-gray border-solid border-1 border-l-gray/50 border-transparent box-border transition-all duration-300"
>
<IconFluentChevronDown20Regular />
</div>
<div
class="space-y-2 btn-no-margin pt-2 absolute z-2 right-0 border rounded opacity-0 scale-95 group-hover:opacity-100 group-hover:scale-100 transition-all duration-150 pointer-events-none group-hover:pointer-events-auto"
>
<slot name="options"></slot>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.option-wrap {
width: 100%;
display: flex;
:deep(.base-button) {
width: 100%;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
</style>

View File

@@ -10,7 +10,7 @@
:disabled="isDisabled"
/>
<span class="radio__inner"></span>
<span class="radio__label">
<span class="text-sm">
<slot>{{ label }}</slot>
</span>
</label>
@@ -83,11 +83,7 @@ function onClick() {
transition: transform 0.2s ease-in-out;
}
}
.radio__label {
font-size: 14px;
color: #606266;
}
&.is-checked {
.radio__inner {

View File

@@ -23,7 +23,7 @@ provide('radioGroupValue', groupValue)
provide('radioGroupDisabled', props.disabled)
provide('updateRadioGroupValue', (val: string | number | boolean) => {
if (props.disabled) return
groupValue.value = val
// groupValue.value = val
emit('update:modelValue', val)
})

View File

@@ -58,6 +58,11 @@ import {
import { ToastInstance } from '@/components/base/toast/type.ts'
import { watchOnce } from '@vueuse/core'
import { setUserDictProp } from '@/apis'
import BaseButton from '@/components/BaseButton.vue'
import OptionButton from '@/components/base/OptionButton.vue'
import Radio from '@/components/base/radio/Radio.vue'
import RadioGroup from '@/components/base/radio/RadioGroup.vue'
import GroupList from '@/pages/word/components/GroupList.vue'
const { isWordCollect, toggleWordCollect, isWordSimple, toggleWordSimple } = useWordOptions()
const settingStore = useSettingStore()
@@ -455,7 +460,7 @@ async function next(isTyping: boolean = true) {
data.wrongWords = []
} else {
isTypingWrongWord.value = false
console.log('当前学完了,没错词', statStore.total, statStore.step, data.index)
console.log('当前学完了,没错词', statStore.total, statStore.stage, data.index)
const complete = () => {
console.log('全完学完了')
@@ -591,7 +596,6 @@ function repeat() {
if (settingStore.wordPracticeMode === WordPracticeMode.Shuffle) {
temp.shuffle = shuffle(temp.shuffle.filter(v => !ignoreList.includes(v.word)))
} else {
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
if (store.sdict.lastLearnIndex === 0 && store.sdict.complete) {
//如果是刚刚完成那么学习进度要从length减回去因为lastLearnIndex为0了同时改complete为false
store.sdict.lastLearnIndex = store.sdict.length - statStore.newWordNumber
@@ -669,15 +673,14 @@ function togglePanel() {
async function continueStudy() {
let temp = cloneDeep(taskWords)
//随机练习单独处理
if (taskWords.shuffle.length) {
if (settingStore.wordPracticeMode === WordPracticeMode.Shuffle) {
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
temp.shuffle = shuffle(store.sdict.words.filter(v => !ignoreList.includes(v.word))).slice(
0,
runtimeStore.routeData.total
runtimeStore.routeData.total ?? temp.shuffle.length
)
if (showStatDialog) showStatDialog = false
} else {
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
//这里判断是否显示结算弹框,如果显示了结算弹框的话,就不用加进度了
if (!showStatDialog) {
console.log('没学完,强行跳过')
@@ -699,6 +702,20 @@ async function continueStudy() {
}
}
async function jumpToGroup(group: number) {
console.log('没学完,强行跳过',group)
store.sdict.lastLearnIndex = (group - 1) * store.sdict.perDayStudyNumber
emitter.emit(EventKey.resetWord)
initData(getCurrentStudyWord())
if (AppEnv.CAN_REQUEST) {
let res = await setUserDictProp(null, { ...store.sdict, type: 'word' })
if (!res.success) {
Toast.error(res.msg)
}
}
}
function randomWrite() {
console.log('随机默写')
data.words = shuffle(data.words)
@@ -717,13 +734,7 @@ useEvents([
[EventKey.repeatStudy, repeat],
[EventKey.continueStudy, continueStudy],
[EventKey.randomWrite, nextRandomWrite],
[
EventKey.changeDict,
() => {
initData(getCurrentStudyWord())
},
],
[EventKey.changeDict, () => initData(getCurrentStudyWord())],
[ShortcutKey.ShowWord, show],
[ShortcutKey.Previous, prev],
[ShortcutKey.Next, skip],
@@ -776,21 +787,29 @@ useEvents([
/>
</div>
</template>
{{ Math.ceil(store.sdict.length / store.sdict.perDayStudyNumber) }}
<template v-slot:panel>
<Panel>
<template v-slot:title>
<!-- <span>{{ store.sdict.name }} ({{ data.index + 1 }} / {{ data.words.length }})</span>-->
<div class="center gap-space">
<span
>{{ store.sdict.name }} ({{ store.sdict.lastLearnIndex }} /
{{ store.sdict.length }})</span
>
<BaseIcon
@click="continueStudy"
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`"
>
<IconFluentArrowRight16Regular class="arrow" width="22" />
</BaseIcon>
<div class="center gap-1">
<span>{{ store.sdict.name }}</span>
<GroupList
@click="jumpToGroup"
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Shuffle"
/>
<template v-if="taskWords.new.length">
<BaseIcon
@click="continueStudy"
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`"
>
<IconFluentArrowRight16Regular class="arrow" width="22" />
</BaseIcon>
</template>
<BaseIcon
@click="randomWrite"
:title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`"

View File

@@ -1,69 +1,68 @@
<script setup lang="ts">
import { useBaseStore } from "@/stores/base.ts";
import BaseButton from "@/components/BaseButton.vue";
import { ShortcutKey, 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 { useBaseStore } from '@/stores/base.ts'
import BaseButton from '@/components/BaseButton.vue'
import { ShortcutKey, Statistics, TaskWords, WordPracticeMode } 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 { 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'
dayjs.extend(isoWeek)
dayjs.extend(isBetween);
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})
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'); // 周日
const startOfWeek = dayjs().startOf('isoWeek') // 周一
const endOfWeek = dayjs().endOf('isoWeek') // 周日
// 初始化 7 天的数组,默认 false
const weekList = Array(7).fill(false);
const weekList = Array(7).fill(false)
store.sdict.statistics.forEach(item => {
const date = dayjs(item.startDate);
const date = dayjs(item.startDate)
if (date.isBetween(startOfWeek, endOfWeek, null, '[]')) {
let idx = date.day();
let idx = date.day()
// dayjs().day() 0=周日, 1=周一, ..., 6=周六
// 需要转换为 0=周一, ..., 6=周日
if (idx === 0) {
idx = 6; // 周日放到最后
idx = 6 // 周日放到最后
} else {
idx = idx - 1; // 其余前移一位
idx = idx - 1 // 其余前移一位
}
weekList[idx] = true;
weekList[idx] = true
}
});
weekList[2] = true;
list = weekList;
})
list = weekList
}
// 监听 model 弹窗打开时重新计算
watch(model, async (newVal) => {
watch(model, async newVal => {
if (newVal) {
dictIsEnd = false;
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
review: statStore.reviewWordNumber + statStore.writeWordNumber,
}
window.umami?.track('endStudyWord', {
name: store.sdict.name,
@@ -72,15 +71,28 @@ watch(model, async (newVal) => {
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}`
str: `name:${store.sdict.name},per:${store.sdict.perDayStudyNumber},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)},index:${store.sdict.lastLearnIndex}`,
})
debugger
//如果 shuffle 数组不为空,就说明是复习,不用修改 lastLearnIndex
if (!practiceTaskWords.shuffle.length) {
if (settingStore.wordPracticeMode !== WordPracticeMode.Shuffle) {
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
if (store.sdict.lastLearnIndex >= store.sdict.length) {
dictIsEnd = true;
//todo 这里计算不正确,因为有可能有单词被忽略,所以需要计算忽略的单词数
// 检查已忽略的单词数量,是否全部完成
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 = 0
store.sdict.lastLearnIndex = store.sdict.length
}
}
@@ -98,11 +110,11 @@ watch(model, async (newVal) => {
}
store.sdict.statistics.push(data as any)
calcWeekList(); // 新增:计算本周学习记录
calcWeekList() // 新增:计算本周学习记录
}
})
const close = () => model.value = false
const close = () => (model.value = false)
useEvents([
//特意注释掉,因为在练习界面用快捷键下一组时,需要判断是否在结算界面
@@ -143,8 +155,7 @@ const formattedStudyTime = $computed(() => {
return time.replace('小时', 'h ').replace('分钟', 'm')
})
calcWeekList(); // 新增:计算本周学习记录
calcWeekList() // 新增:计算本周学习记录
</script>
<template>
@@ -153,18 +164,16 @@ calcWeekList(); // 新增:计算本周学习记录
:close-on-click-bg="false"
:header="false"
:keyboard="false"
:show-close="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>
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>
@@ -173,36 +182,37 @@ calcWeekList(); // 新增:计算本周学习记录
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Study Time -->
<div class="item">
<IconFluentClock20Regular class="text-purple-500"/>
<IconFluentClock20Regular class="text-purple-500" />
<div class="text-sm mb-1 font-medium">学习时长</div>
<div class="text-xl font-bold">{{ formattedStudyTime }}</div>
</div>
<!-- Accuracy Rate -->
<div class="item">
<IconFluentTarget20Regular class="text-purple-500"/>
<IconFluentTarget20Regular class="text-purple-500" />
<div class="text-sm mb-1 font-medium">正确率</div>
<div class="text-xl font-bold">{{ accuracyRate }}%</div>
</div>
<!-- New Words -->
<div class="item">
<IconFluentSparkle20Regular class="text-purple-500"/>
<IconFluentSparkle20Regular class="text-purple-500" />
<div class="text-sm mb-1 font-medium">新词</div>
<div class="text-xl font-bold ">{{ statStore.newWordNumber }}</div>
<div class="text-xl font-bold">{{ statStore.newWordNumber }}</div>
</div>
<!-- New Words -->
<div class="item">
<IconFluentBook20Regular class="text-purple-500"/>
<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 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">
@@ -216,8 +226,10 @@ calcWeekList(); // 新增:计算本周学习记录
: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
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>
@@ -228,44 +240,48 @@ calcWeekList(); // 新增:计算本周学习记录
<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"/>
<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/>
<ChannelIcons />
</div>
<!-- Action Buttons -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
@click="options(EventKey.repeatStudy)">
@click="options(EventKey.repeatStudy)"
>
<div class="center gap-2">
<IconFluentArrowClockwise20Regular/>
<IconFluentArrowClockwise20Regular />
重学一遍
</div>
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
@click="options(EventKey.continueStudy)">
@click="options(EventKey.continueStudy)"
>
<div class="center gap-2">
<IconFluentPlay20Regular/>
<IconFluentPlay20Regular />
{{ dictIsEnd ? '从头开始练习' : '再来一组' }}
</div>
</BaseButton>
<!-- todo 感觉这里的继续默写有问题应该是当前组而不是下一组-->
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
@click="options(EventKey.randomWrite)">
@click="options(EventKey.randomWrite)"
>
<div class="center gap-2">
<IconFluentPen20Regular/>
<IconFluentPen20Regular />
继续默写
</div>
</BaseButton>
<BaseButton @click="$router.back">
<div class="center gap-2">
<IconFluentHome20Regular/>
<IconFluentHome20Regular />
返回主页
</div>
</BaseButton>
@@ -274,7 +290,6 @@ calcWeekList(); // 新增:计算本周学习记录
</Dialog>
</template>
<style scoped lang="scss">
// 移动端适配
@media (max-width: 768px) {
// 弹窗容器优化
@@ -359,12 +374,10 @@ calcWeekList(); // 新增:计算本周学习记录
}
}
}
</style>
<style scoped>
.item {
@apply bg-[var(--bg-card-secend)] rounded-xl p-2 text-center border border-gray-100;
}
</style>
</style>

View File

@@ -6,6 +6,7 @@ import {
_getAccomplishDate,
_getDictDataByUrl,
_nextTick,
cloneDeep,
isMobile,
loadJsLib,
resourceWrap,
@@ -40,6 +41,7 @@ import { myDictList } from '@/apis'
import PracticeWordListDialog from '@/pages/word/components/PracticeWordListDialog.vue'
import ShufflePracticeSettingDialog from '@/pages/word/components/ShufflePracticeSettingDialog.vue'
import { deleteDict } from '@/apis/dict.ts'
import OptionButton from '@/components/base/OptionButton.vue'
const store = useBaseStore()
const settingStore = useSettingStore()
@@ -128,10 +130,11 @@ function startPractice(practiceMode?: WordPracticeMode): void {
Toast.warning('没有单词可学习!')
return
}
//todo 临时处理
localStorage.removeItem(PracticeSaveWordKey.key)
// 如果传入了独立模式,临时设置 wordPracticeMode
if (practiceMode !== undefined) {
//todo 临时处理
localStorage.removeItem(PracticeSaveWordKey.key)
settingStore.wordPracticeMode = practiceMode
}
window.umami?.track('startStudyWord', {
@@ -145,7 +148,6 @@ function startPractice(practiceMode?: WordPracticeMode): void {
//把是否是第一次设置为false
settingStore.first = false
nav('practice-words/' + store.sdict.id, {}, { taskWords: currentStudy })
// 注意:不恢复 originalMode因为练习过程中需要保持独立模式
} else {
window.umami?.track('no-dict')
Toast.warning('请先选择一本词典')
@@ -202,7 +204,7 @@ function toggleSelect(item) {
}
const progressTextLeft = $computed(() => {
if (store.sdict.complete) return '已学完,进入复习阶段'
if (store.sdict.complete) return '已学完,进入复习阶段'
return '已学习' + store.currentStudyProgress + '%'
})
const progressTextRight = $computed(() => {
@@ -301,7 +303,10 @@ let isNewHost = $ref(window.location.host === Host)
<span>已完成 {{ progressTextRight }} / {{ store.sdict.words.length }} </span>
<span v-if="store.sdict.id">
预计完成日期{{
_getAccomplishDate(store.sdict.words.length - store.sdict.lastLearnIndex, store.sdict.perDayStudyNumber)
_getAccomplishDate(
store.sdict.words.length - store.sdict.lastLearnIndex,
store.sdict.perDayStudyNumber
)
}}
</span>
</div>
@@ -388,109 +393,53 @@ let isNewHost = $ref(window.location.host === Host)
</div>
</div>
<div class="flex items-end mt-4 gap-4 btn-no-margin">
<BaseButton
size="large"
class="flex-1"
:disabled="!store.sdict.id"
:loading="loading"
@click="startPractice"
v-if="false"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl" />
</div>
</BaseButton>
<div class="w-full flex box-border cp color-white">
<div
@click="startPractice()"
class="flex-1 rounded-l-lg center gap-2 py-1 bg-[var(--btn-primary)] transition-all duration-300 hover:opacity-50"
<OptionButton class="flex-2">
<BaseButton
size="large"
:disabled="!store.sdict.id"
:loading="loading"
@click="startPractice(WordPracticeMode.System)"
>
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl" />
</div>
<div class="relative group">
<div
class="w-10 rounded-r-lg h-full center bg-[var(--btn-primary)] hover:bg-gray border-solid border-2 border-l-gray border-transparent box-border transition-all duration-300"
>
<IconFluentChevronDown20Regular />
<div class="flex items-center gap-2">
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl" />
</div>
</BaseButton>
<template #options>
<BaseButton class="w-20" @click="startPractice(WordPracticeMode.System)">
智能
</BaseButton>
<BaseButton class="w-20" @click="startPractice(WordPracticeMode.FollowWriteOnly)">
跟写
</BaseButton>
<BaseButton class="w-20" @click="startPractice(WordPracticeMode.IdentifyOnly)">
自测
</BaseButton>
<BaseButton class="w-20" @click="startPractice(WordPracticeMode.ListenOnly)">
听写
</BaseButton>
<BaseButton class="w-20" @click="startPractice(WordPracticeMode.DictationOnly)">
默写
</BaseButton>
</template>
</OptionButton>
<div
class="space-y-2 btn-no-margin pt-2 absolute z-2 right-0 border rounded opacity-0 scale-95 group-hover:opacity-100 group-hover:scale-100 transition-all duration-150 pointer-events-none group-hover:pointer-events-auto"
>
<BaseButton
size="large"
class="w-30"
type="primary"
@click="startPractice(WordPracticeMode.System)"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">智能</span>
</div>
</BaseButton>
<BaseButton
size="large"
class="w-30"
type="primary"
@click="startPractice(WordPracticeMode.FollowWriteOnly)"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">跟写</span>
</div>
</BaseButton>
<BaseButton
size="large"
class="w-30"
type="primary"
@click="startPractice(WordPracticeMode.IdentifyOnly)"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">自测</span>
</div>
</BaseButton>
<BaseButton
size="large"
class="w-30"
type="primary"
@click="startPractice(WordPracticeMode.ListenOnly)"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">听写</span>
</div>
</BaseButton>
<BaseButton
size="large"
class="w-30"
type="primary"
@click="startPractice(WordPracticeMode.DictationOnly)"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">默写</span>
</div>
</BaseButton>
</div>
</div>
</div>
<OptionButton class="flex-1">
<BaseButton
size="large"
:loading="loading"
@click="startPractice(WordPracticeMode.Review, true)"
>
复习
</BaseButton>
<template #options>
<BaseButton @click="check(() => (showShufflePracticeSettingDialog = true))">
随机复习
</BaseButton>
</template>
</OptionButton>
<BaseButton
size="large"
:loading="loading"
@click="check(() => (showShufflePracticeSettingDialog = true))"
>
<div class="flex items-center gap-2">
<span class="line-height-[2]">随机复习</span>
<IconFluentArrowShuffle20Filled class="text-xl" />
</div>
</BaseButton>
<BaseButton
size="large"
:loading="loading"
@click="startPractice(WordPracticeMode.Free)"
>
<BaseButton size="large" :loading="loading" @click="startPractice(WordPracticeMode.Free)">
<div class="flex items-center gap-2">
<span class="line-height-[2]">自由练习</span>
<IconStreamlineColorPenDrawFlat class="text-xl" />

View File

@@ -2,14 +2,17 @@
import { inject, Ref } from 'vue'
import { usePracticeStore } from '@/stores/practice.ts'
import { useSettingStore } from '@/stores/setting.ts'
import { PracticeData, ShortcutKey, WordPracticeModeStageMap, WordPracticeStage, WordPracticeStageNameMap } from '@/types/types.ts'
import { PracticeData, ShortcutKey,
WordPracticeMode, WordPracticeModeStageMap, WordPracticeStage, WordPracticeStageNameMap } from '@/types/types.ts'
import BaseIcon from '@/components/BaseIcon.vue'
import Tooltip from '@/components/base/Tooltip.vue'
import Progress from '@/components/base/Progress.vue'
import SettingDialog from '@/components/setting/SettingDialog.vue'
import BaseButton from '@/components/BaseButton.vue'
import { useBaseStore } from '@/stores/base.ts'
const statStore = usePracticeStore()
const store = useBaseStore()
const settingStore = useSettingStore()
defineProps<{
@@ -34,6 +37,7 @@ function format(val: number, suffix: string = '', check: number = -1) {
}
const status = $computed(() => {
if (settingStore.wordPracticeMode === WordPracticeMode.Free) return '自由练习'
if (isTypingWrongWord.value) return '复习错词'
return statStore.getStageName
})
@@ -85,9 +89,8 @@ const progress = $computed(() => {
</div>
<div class="flex gap-2 justify-center items-center" id="toolbar-icons">
<SettingDialog type="word" />
<BaseIcon
v-if="statStore.step < 9"
v-if="settingStore.wordPracticeMode !== WordPracticeMode.Free"
@click="emit('skipStep')"
:title="`跳到下一阶段:${WordPracticeStageNameMap[statStore.nextStage]}`"
>

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="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

@@ -31,13 +31,11 @@ let show = $ref(false)
let tempPerDayStudyNumber = $ref(0)
let tempWordReviewRatio = $ref(0)
let tempLastLearnIndex = $ref(0)
let temPracticeMode = $ref(0)
let tempDisableShowPracticeSettingDialog = $ref(false)
function changePerDayStudyNumber() {
runtimeStore.editDict.perDayStudyNumber = tempPerDayStudyNumber
runtimeStore.editDict.lastLearnIndex = tempLastLearnIndex
settings.wordPracticeMode = temPracticeMode
settings.wordReviewRatio = tempWordReviewRatio
settings.disableShowPracticeSettingDialog = tempDisableShowPracticeSettingDialog
emit('ok')
@@ -50,7 +48,6 @@ watch(
if (runtimeStore.editDict.id) {
tempPerDayStudyNumber = runtimeStore.editDict.perDayStudyNumber
tempLastLearnIndex = runtimeStore.editDict.lastLearnIndex
temPracticeMode = settings.wordPracticeMode
tempWordReviewRatio = settings.wordReviewRatio
tempDisableShowPracticeSettingDialog = settings.disableShowPracticeSettingDialog
} else {
@@ -64,27 +61,6 @@ watch(
<template>
<Dialog v-model="model" title="学习设置" padding :footer="true" @ok="changePerDayStudyNumber">
<div class="target-modal color-main" id="mode">
<div class="center">
<div class="flex gap-4 text-center h-30 w-85">
<div
class="mode-item"
:class="temPracticeMode == 0 && 'active'"
@click="temPracticeMode = 0"
>
<div class="title text-align-center">智能模式</div>
<div class="desc mt-2">自动规划学习复习听写默写</div>
</div>
<div
class="mode-item"
:class="temPracticeMode == 1 && 'active'"
@click="temPracticeMode = 1"
>
<div class="title">自由模式</div>
<div class="desc mt-2">自由练习系统不强制复习与默写</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<span
><span class="target-number">{{ runtimeStore.editDict.length }}</span
@@ -179,7 +155,7 @@ watch(
<style scoped lang="scss">
.target-modal {
width: 35rem;
.mode-item {
@apply w-50% border border-blue border-solid p-2 rounded-lg cursor-pointer;

View File

@@ -1,35 +1,71 @@
import { defineStore } from 'pinia'
import { Dict, DictId, Word } from "../types/types.ts"
import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from "@/utils";
import { shallowReactive } from "vue";
import { getDefaultDict } from "@/types/func.ts";
import { Dict, DictId, Word } from '../types/types.ts'
import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from '@/utils'
import { shallowReactive } from 'vue'
import { getDefaultDict } from '@/types/func.ts'
import { get, set } from 'idb-keyval'
import { AppEnv, SAVE_DICT_KEY } from "@/config/env.ts";
import { add2MyDict, dictListVersion, myDictList } from "@/apis";
import Toast from "@/components/base/toast/Toast.ts";
import { AppEnv, SAVE_DICT_KEY } from '@/config/env.ts'
import { add2MyDict, dictListVersion, myDictList } from '@/apis'
import Toast from '@/components/base/toast/Toast.ts'
export interface BaseState {
simpleWords: string[],
simpleWords: string[]
load: boolean
word: {
studyIndex: number,
bookList: Dict[],
},
studyIndex: number
bookList: Dict[]
}
article: {
bookList: Dict[],
studyIndex: number,
},
bookList: Dict[]
studyIndex: number
}
dictListVersion: number
}
export const getDefaultBaseState = (): BaseState => ({
simpleWords: [
'a', 'an',
'i', 'my', 'me', 'you', 'your', 'he', 'his', 'she', 'her', 'it',
'what', 'who', 'where', 'how', 'when', 'which',
'be', 'am', 'is', 'was', 'are', 'were', 'do', 'did', 'can', 'could', 'will', 'would',
'the', 'that', 'this', 'and', 'not', 'no', 'yes',
'to', 'of', 'for', 'at', 'in'
'a',
'an',
'i',
'my',
'me',
'you',
'your',
'he',
'his',
'she',
'her',
'it',
'what',
'who',
'where',
'how',
'when',
'which',
'be',
'am',
'is',
'was',
'are',
'were',
'do',
'did',
'can',
'could',
'will',
'would',
'the',
'that',
'this',
'and',
'not',
'no',
'yes',
'to',
'of',
'for',
'at',
'in',
],
load: false,
word: {
@@ -40,18 +76,18 @@ export const getDefaultBaseState = (): BaseState => ({
id: DictId.wordKnown,
en_name: DictId.wordCollect,
name: '已掌握',
description: '已掌握后的单词不会出现在练习中'
description: '已掌握后的单词不会出现在练习中',
}),
],
studyIndex: -1,
},
article: {
bookList: [
getDefaultDict({ id: DictId.articleCollect, en_name: DictId.articleCollect, name: '收藏' })
getDefaultDict({ id: DictId.articleCollect, en_name: DictId.articleCollect, name: '收藏' }),
],
studyIndex: -1,
},
dictListVersion: 1
dictListVersion: 1,
})
export const useBaseStore = defineStore('base', {
@@ -75,7 +111,9 @@ export const useBaseStore = defineStore('base', {
return this.known.words.map((v: Word) => v.word.toLowerCase())
},
allIgnoreWords() {
return this.known.words.map((v: Word) => v.word.toLowerCase()).concat(this.simpleWords.map((v: string) => v.toLowerCase()))
return this.known.words
.map((v: Word) => v.word.toLowerCase())
.concat(this.simpleWords.map((v: string) => v.toLowerCase()))
},
sdict(): Dict {
if (this.word.studyIndex >= 0) {
@@ -83,6 +121,15 @@ export const useBaseStore = defineStore('base', {
}
return getDefaultDict()
},
groupLength(): number {
return Math.ceil(this.sdict.length / this.sdict.perDayStudyNumber)
},
currentGroup(): number {
//当能除尽时应该加1
let s = this.sdict.lastLearnIndex % this.sdict.perDayStudyNumber
let d = this.sdict.lastLearnIndex / this.sdict.perDayStudyNumber
return Math.floor(s === 0 ?( d + 1) : d)
},
currentStudyProgress(): number {
if (!this.sdict.length) return 0
return _getStudyProgress(this.sdict.lastLearnIndex, this.sdict.length)
@@ -90,7 +137,9 @@ export const useBaseStore = defineStore('base', {
getDictCompleteDate(): number {
if (!this.sdict.length) return 0
if (!this.sdict.perDayStudyNumber) return 0
return Math.ceil((this.sdict.length - this.sdict.lastLearnIndex) / this.sdict.perDayStudyNumber)
return Math.ceil(
(this.sdict.length - this.sdict.lastLearnIndex) / this.sdict.perDayStudyNumber
)
},
sbook(): Dict {
return this.article.bookList[this.article.studyIndex] ?? {}

View File

@@ -232,6 +232,7 @@ export enum WordPracticeMode {
ListenOnly = 4, // 独立听写模式
FollowWriteOnly = 5, // 独立跟写模式(内部会自动切换到 Spell
Shuffle = 6, // 随机复习模式
Review = 7, // 复习模式
}
//练习类型
@@ -321,6 +322,15 @@ export const WordPracticeModeStageMap: Record<WordPracticeMode, WordPracticeStag
WordPracticeStage.Shuffle,
WordPracticeStage.Complete,
],
[WordPracticeMode.Review]: [
WordPracticeStage.IdentifyReview,
WordPracticeStage.ListenReview,
WordPracticeStage.DictationReview,
WordPracticeStage.IdentifyReviewAll,
WordPracticeStage.ListenReviewAll,
WordPracticeStage.DictationReviewAll,
WordPracticeStage.Complete,
],
}
export const WordPracticeStageNameMap: Record<WordPracticeStage, string> = {
@@ -336,6 +346,6 @@ export const WordPracticeStageNameMap: Record<WordPracticeStage, string> = {
[WordPracticeStage.IdentifyReviewAll]: '自测之前学习',
[WordPracticeStage.ListenReviewAll]: '听写之前学习',
[WordPracticeStage.DictationReviewAll]: '默写之前学习',
[WordPracticeStage.Complete]: '学习完成',
[WordPracticeStage.Complete]: '完成学习',
[WordPracticeStage.Shuffle]: '随机复习',
}

View File

@@ -22,4 +22,17 @@ export default defineConfig({
presets: [
presetWind3(),
],
// 自定义断点
theme: {
breakpoints: {
'xs': '480px', // 自定义小断点
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
'3xl': '1920px',
'4k': '2560px',
}
},
})