This commit is contained in:
Zyronon
2025-12-03 20:05:13 +08:00
committed by GitHub
parent 6a5156ae36
commit 180e759667

View File

@@ -1 +1,508 @@
WordsPage.vue
<script setup lang="ts">
import { useBaseStore } from "@/stores/base.ts";
import { useRouter } from "vue-router";
import BaseIcon from "@/components/BaseIcon.vue";
import {
_getAccomplishDate,
_getDictDataByUrl,
_nextTick,
isMobile,
loadJsLib,
resourceWrap,
shuffle,
useNav
} from "@/utils";
import BasePage from "@/components/BasePage.vue";
import { DictResource, WordPracticeMode } from "@/types/types.ts";
import { watch } from "vue";
import { getCurrentStudyWord } from "@/hooks/dict.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
import Book from "@/components/Book.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import Progress from '@/components/base/Progress.vue';
import Toast from '@/components/base/toast/Toast.ts';
import BaseButton from "@/components/BaseButton.vue";
import { getDefaultDict } from "@/types/func.ts";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
import { useSettingStore } from "@/stores/setting.ts";
import { useFetch } from "@vueuse/core";
import { AppEnv, DICT_LIST, Host, LIB_JS_URL, PracticeSaveWordKey, TourConfig } from "@/config/env.ts";
import { myDictList } from "@/apis";
import PracticeWordListDialog from "@/pages/word/components/PracticeWordListDialog.vue";
import ShufflePracticeSettingDialog from "@/pages/word/components/ShufflePracticeSettingDialog.vue";
import SettingDialog from "@/pages/word/components/SettingDialog.vue";
const store = useBaseStore()
const settingStore = useSettingStore()
const router = useRouter()
const {nav} = useNav()
const runtimeStore = useRuntimeStore()
let loading = $ref(true)
let isSaveData = $ref(false)
let currentStudy = $ref({
new: [],
review: [],
write: [],
shuffle: [],
})
watch(() => store.load, n => {
if (n) {
init()
_nextTick(async () => {
const Shepherd = await loadJsLib('Shepherd', LIB_JS_URL.SHEPHERD);
const tour = new Shepherd.Tour(TourConfig);
tour.on('cancel', () => {
localStorage.setItem('tour-guide', '1');
});
tour.addStep({
id: 'step1',
text: '点击这里选择一本词典开始学习',
attachTo: {
element: '#step1',
on: 'bottom'
},
buttons: [
{
text: `下一步1/${TourConfig.total}`,
action() {
tour.next()
router.push('/dict-list')
}
}
]
});
const r = localStorage.getItem('tour-guide');
if (settingStore.first && !r && !isMobile()) tour.start();
}, 500)
}
}, {immediate: true})
async function init() {
if (AppEnv.CAN_REQUEST) {
let res = await myDictList({type: "word"})
if (res.success) {
store.setState(Object.assign(store.$state, res.data))
}
}
if (store.word.studyIndex >= 3) {
if (!store.sdict.custom && !store.sdict.words.length) {
store.word.bookList[store.word.studyIndex] = await _getDictDataByUrl(store.sdict)
}
}
if (!currentStudy.new.length && store.sdict.words.length) {
let d = localStorage.getItem(PracticeSaveWordKey.key)
if (d) {
try {
let obj = JSON.parse(d)
currentStudy = obj.val.taskWords
isSaveData = true
} catch (e) {
localStorage.removeItem(PracticeSaveWordKey.key)
currentStudy = getCurrentStudyWord()
}
} else {
currentStudy = getCurrentStudyWord()
}
}
loading = false
}
function startPractice() {
if (store.sdict.id) {
if (!store.sdict.words.length) {
return Toast.warning('没有单词可学习!')
}
window.umami?.track('startStudyWord', {
name: store.sdict.name,
index: store.sdict.lastLearnIndex,
perDayStudyNumber: store.sdict.perDayStudyNumber,
custom: store.sdict.custom,
complete: store.sdict.complete,
wordPracticeMode: settingStore.wordPracticeMode
})
//把是否是第一次设置为false
settingStore.first = false
nav('practice-words/' + store.sdict.id, {}, {taskWords: currentStudy})
} else {
window.umami?.track('no-dict')
Toast.warning('请先选择一本词典')
}
}
let showPracticeSettingDialog = $ref(false)
let showShufflePracticeSettingDialog = $ref(false)
let showChangeLastPracticeIndexDialog = $ref(false)
let showPracticeWordListDialog = $ref(false)
async function goDictDetail(val: DictResource) {
if (!val.id) return nav('dict-list')
runtimeStore.editDict = getDefaultDict(val)
nav('dict-detail', {})
}
let isManageDict = $ref(false)
let selectIds = $ref([])
function handleBatchDel() {
selectIds.forEach(id => {
let r = store.word.bookList.findIndex(v => v.id === id)
if (r !== -1) {
if (store.word.studyIndex === r) {
store.word.studyIndex = -1
}
if (store.word.studyIndex > r) {
store.word.studyIndex--
}
store.word.bookList.splice(r, 1)
}
})
selectIds = []
Toast.success("删除成功!")
}
function toggleSelect(item) {
let rIndex = selectIds.findIndex(v => v === item.id)
if (rIndex > -1) {
selectIds.splice(rIndex, 1)
} else {
selectIds.push(item.id)
}
}
const progressTextLeft = $computed(() => {
if (store.sdict.complete) return '已学完,进入总复习阶段'
return '已学习' + store.currentStudyProgress + '%'
})
const progressTextRight = $computed(() => {
// if (store.sdict.complete) return store.sdict?.length
return store.sdict?.lastLearnIndex
})
function check(cb: Function) {
if (!store.sdict.id) {
Toast.warning('请先选择一本词典')
} else {
runtimeStore.editDict = getDefaultDict(store.sdict)
cb()
}
}
async function savePracticeSetting() {
Toast.success('修改成功')
isSaveData = false
localStorage.removeItem(PracticeSaveWordKey.key)
await store.changeDict(runtimeStore.editDict)
currentStudy = getCurrentStudyWord()
}
async function onShufflePracticeSettingOk(total) {
window.umami?.track('startShuffleStudyWord', {
name: store.sdict.name,
index: store.sdict.lastLearnIndex,
perDayStudyNumber: store.sdict.perDayStudyNumber,
total,
custom: store.sdict.custom,
complete: store.sdict.complete,
})
isSaveData = false
localStorage.removeItem(PracticeSaveWordKey.key)
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
currentStudy.shuffle = shuffle(store.sdict.words.slice(0, store.sdict.lastLearnIndex).filter(v => !ignoreList.includes(v.word))).slice(0, total)
nav('practice-words/' + store.sdict.id, {}, {
taskWords: currentStudy,
total //用于再来一组时,随机出正确的长度,因为练习中可能会点击已掌握,导致重学一遍之后长度变少,如果再来一组,此时长度就不正确
})
}
async function saveLastPracticeIndex(e) {
Toast.success('修改成功')
runtimeStore.editDict.lastLearnIndex = e
showChangeLastPracticeIndexDialog = false
isSaveData = false
localStorage.removeItem(PracticeSaveWordKey.key)
await store.changeDict(runtimeStore.editDict)
currentStudy = getCurrentStudyWord()
}
const {
data: recommendDictList,
isFetching
} = useFetch(resourceWrap(DICT_LIST.WORD.RECOMMENDED)).json()
let isNewHost = $ref(window.location.host === Host)
</script>
<template>
<BasePage>
<div class="mb-4" v-if="!isNewHost">
新域名已启用后续请访问 <a href="https://typewords.cc/words?from_old_site=1">https://typewords.cc</a>。当前
2study.top 域名将在不久后停止使用
</div>
<div class="card flex flex-col md:flex-row gap-8">
<div class="flex-1 w-full flex flex-col justify-between">
<div class="flex gap-3">
<div class="p-1 center rounded-full bg-white">
<IconFluentBookNumber20Filled class="text-xl color-link"/>
</div>
<div
@click="goDictDetail(store.sdict)"
class="text-2xl font-bold cursor-pointer">
{{ store.sdict.name || '当前无正在学习的词典' }}
</div>
</div>
<template v-if="store.sdict.id">
<div class="mt-4 flex flex-col gap-2">
<div class="">当前进度{{ progressTextLeft }}</div>
<Progress size="large" :percentage="store.currentStudyProgress" :show-text="false"></Progress>
<div class="text-sm flex justify-between">
<span>已完成 {{ progressTextRight }} / {{ store.sdict.words.length }} </span>
<span v-if="store.sdict.id">
预计完成日期{{ _getAccomplishDate(store.sdict.words.length, store.sdict.perDayStudyNumber) }}
</span>
</div>
</div>
<div class="flex items-center mt-4 gap-4">
<BaseButton type="info"
size="small"
@click="router.push('/dict-list')">
<div class="center gap-1">
<IconFluentArrowSwap20Regular/>
<span>选择词典</span>
</div>
</BaseButton>
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showChangeLastPracticeIndexDialog = true)">
<BaseButton type="info"
size="small"
v-if="store.sdict.id"
>
<div class="center gap-1">
<IconFluentSlideTextTitleEdit20Regular/>
<span>更改进度</span>
</div>
</BaseButton>
</PopConfirm>
</div>
</template>
<div class="flex items-center gap-4 mt-2 flex-1" v-else>
<div class="title">请选择一本词典开始学习</div>
<BaseButton id="step1" type="primary" size="large" @click="router.push('/dict-list')">
<div class="center gap-1">
<IconFluentAdd16Regular/>
<span>选择词典</span>
</div>
</BaseButton>
</div>
</div>
<div class="flex-1 w-full mt-4 md:mt-0" :class="!store.sdict.id && 'opacity-30 cursor-not-allowed'">
<div class="flex justify-between">
<div class="flex items-center gap-2">
<div class="p-2 center rounded-full bg-white ">
<IconFluentStar20Filled class="text-lg color-amber"/>
</div>
<div class="text-xl font-bold">
{{ isSaveData ? '上次任务' : '今日任务' }}
</div>
<span class="color-link cursor-pointer"
v-if="store.sdict.id"
@click="showPracticeWordListDialog = true">词表</span>
</div>
<div class="flex gap-1 items-center"
v-if="store.sdict.id"
>
每日目标
<div style="color:#ac6ed1;"
class="bg-third px-2 h-10 flex center text-2xl rounded">
{{ store.sdict.id ? store.sdict.perDayStudyNumber : 0 }}
</div>
个单词
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showPracticeSettingDialog = true)">
<BaseButton
type="info" size="small">更改
</BaseButton>
</PopConfirm>
</div>
</div>
<div class="flex mt-4 justify-between">
<div class="stat">
<div class="num">{{ currentStudy.new.length }}</div>
<div class="txt">新词数</div>
</div>
<template v-if="settingStore.wordPracticeMode === WordPracticeMode.System">
<div class="stat">
<div class="num">{{ currentStudy.review.length }}</div>
<div class="txt">复习上次</div>
</div>
<div class="stat">
<div class="num">{{ currentStudy.write.length }}</div>
<div class="txt">复习之前</div>
</div>
</template>
</div>
<div class="flex items-end mt-4">
<BaseButton size="large"
class="flex-1"
:disabled="!store.sdict.id"
:loading="loading"
@click="startPractice">
<div class="flex items-center gap-2">
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
<IconFluentArrowCircleRight16Regular class="text-xl"/>
</div>
</BaseButton>
<div
v-if="false"
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)] hover:opacity-50">
<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">
<IconFluentChevronDown20Regular/>
</div>
<div
class="space-y-2 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"
>
<div>
<BaseButton
size="large" type="orange"
: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>
</div>
<div>
<BaseButton
size="large" type="orange"
: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>
</div>
</div>
</div>
</div>
<BaseButton
v-if="store.sdict.id && store.sdict.lastLearnIndex"
size="large" type="orange"
: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>
</div>
</div>
</div>
<div class="card flex flex-col">
<div class="flex justify-between">
<div class="title">我的词典</div>
<div class="flex gap-4 items-center">
<PopConfirm title="确认删除所有选中词典?" @confirm="handleBatchDel" v-if="selectIds.length">
<BaseIcon class="del" title="删除">
<DeleteIcon/>
</BaseIcon>
</PopConfirm>
<div class="color-link cursor-pointer" v-if="store.word.bookList.length > 3"
@click="isManageDict = !isManageDict; selectIds = []">{{ isManageDict ? '取消' : '管理词典' }}
</div>
<div class="color-link cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人词典</div>
</div>
</div>
<div class="flex gap-4 flex-wrap mt-4">
<Book :is-add="false" quantifier="个词" :item="item" :checked="selectIds.includes(item.id)"
@check="() => toggleSelect(item)" :show-checkbox="isManageDict && j >= 3"
v-for="(item, j) in store.word.bookList" @click="goDictDetail(item)"/>
<Book :is-add="true" @click="router.push('/dict-list')"/>
</div>
</div>
<div class="card flex flex-col overflow-hidden" v-loading="isFetching">
<div class="flex justify-between">
<div class="title">推荐</div>
<div class="flex gap-4 items-center">
<div class="color-link cursor-pointer" @click="router.push('/dict-list')">更多</div>
</div>
</div>
<div class="flex gap-4 flex-wrap mt-4 min-h-50">
<Book :is-add="false"
quantifier="个词"
:item="item as any"
v-for="(item, j) in recommendDictList" @click="goDictDetail(item as any)"/>
</div>
</div>
</BasePage>
<PracticeSettingDialog
:show-left-option="false"
v-model="showPracticeSettingDialog"
@ok="savePracticeSetting"/>
<ChangeLastPracticeIndexDialog
v-model="showChangeLastPracticeIndexDialog"
@ok="saveLastPracticeIndex"
/>
<PracticeWordListDialog
:data="currentStudy"
v-model="showPracticeWordListDialog"
/>
<ShufflePracticeSettingDialog
v-model="showShufflePracticeSettingDialog"
@ok="onShufflePracticeSettingOk"/>
</template>
<style scoped lang="scss">
.stat {
@apply w-31% box-border flex flex-col items-center justify-center rounded-xl p-2 bg-[var(--bg-history)];
border: 1px solid gainsboro;
.num {
@apply color-[#409eff] text-4xl font-bold;
}
.txt {
@apply color-gray-500;
}
}
</style>