feat:add seo,learning page can be directly studied through the url

This commit is contained in:
zyronon
2025-08-18 02:13:41 +08:00
parent d1443fc6e9
commit 1ce13a7cbb
20 changed files with 238 additions and 110 deletions

3
components.d.ts vendored
View File

@@ -19,6 +19,9 @@ declare module 'vue' {
IconBiArrowRight: typeof import('~icons/bi/arrow-right')['default']
IconBiKeyboard: typeof import('~icons/bi/keyboard')['default']
IconBxHeadphone: typeof import('~icons/bx/headphone')['default']
IconBxVolume: typeof import('~icons/bx/volume')['default']
IconBxVolumeFull: typeof import('~icons/bx/volume-full')['default']
IconBxVolumeLow: typeof import('~icons/bx/volume-low')['default']
IconCarbonCloseOutline: typeof import('~icons/carbon/close-outline')['default']
IconCarbonMove: typeof import('~icons/carbon/move')['default']
IconEosIconsLoading: typeof import('~icons/eos-icons/loading')['default']

View File

@@ -6,8 +6,8 @@ import {useSettingStore} from "@/stores/setting.ts";
import useTheme from "@/hooks/theme.ts";
import CollectNotice from "@/pages/pc/components/CollectNotice.vue";
import {SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/utils/const.ts";
import {isMobile, shakeCommonDict} from "@/utils";
import router, {routes} from "@/router.ts";
import {shakeCommonDict} from "@/utils";
import {routes} from "@/router.ts";
import {set} from 'idb-keyval'
import {useRoute} from "vue-router";
@@ -22,29 +22,18 @@ watch(store.$state, (n: BaseState) => {
})
watch(settingStore.$state, (n) => {
console.log('watch',settingStore.$state)
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
})
async function init() {
// console.time()
store.init().then(() => {
store.load = true
// console.timeEnd()
})
await store.init()
await settingStore.init()
store.load = true
setTheme(settingStore.theme)
}
onMounted(() => {
init()
onMounted(init)
if (isMobile()) {
// 当前设备是移动设备
console.log('当前设备是移动设备')
router.replace('/mobile')
}
})
let transitionName = $ref('go')
const route = useRoute()
watch(() => route.path, (to, from) => {

View File

@@ -49,11 +49,23 @@ defineExpose({play})
</script>
<template>
<BaseIcon @click.stop="click"
v-if="props.simple"
no-bg
:icon="iconList[step]"/>
<BaseIcon @click.stop="click" v-else :icon="iconList[step]"/>
<template v-if="props.simple">
<BaseIcon @click.stop="click"
no-bg
>
<IconBxVolume v-if="step === 0"/>
<IconBxVolumeLow v-if="step === 1"/>
<IconBxVolumeFull v-if="step === 2"/>
</BaseIcon>
</template>
<template v-else>
<BaseIcon @click.stop="click"
>
<IconBxVolume v-if="step === 0"/>
<IconBxVolumeLow v-if="step === 1"/>
<IconBxVolumeFull v-if="step === 2"/>
</BaseIcon>
</template>
</template>
<style scoped lang="scss">

View File

@@ -0,0 +1,61 @@
// src/directives/loading.js
import {createApp, h} from 'vue'
import IconEosIconsLoading from '~icons/eos-icons/loading'
// 创建一个 Loading 组件
const LoadingComponent = {
name: 'LoadingComponent',
render() {
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'rgba(255, 255, 255, 0.7)',
zIndex: 9999
}}
>
<IconEosIconsLoading class="text-3xl"/>
</div>
)
}
}
// 自定义指令
export default {
mounted(el, binding) {
const position = getComputedStyle(el).position
if (position === 'static' || !position) {
el.style.position = 'relative' // 保证 loading 居中
}
const app = createApp(LoadingComponent)
const instance = app.mount(document.createElement('div'))
el.__loadingInstance = instance
if (binding.value) {
el.appendChild(instance.$el)
}
},
updated(el, binding) {
const instance = el.__loadingInstance
if (binding.value && !el.contains(instance.$el)) {
el.appendChild(instance.$el)
} else if (!binding.value && el.contains(instance.$el)) {
el.removeChild(instance.$el)
}
},
unmounted(el) {
const instance = el.__loadingInstance
if (instance && instance.$el.parentNode) {
instance.$el.parentNode.removeChild(instance.$el)
}
delete el.__loadingInstance
}
}

View File

@@ -546,7 +546,7 @@ export function usePlaySentenceAudio() {
ref.currentTime = start
ref.play()
let end = sentence.audioPosition?.[1]
console.log(sentence.audioPosition,(end - start) * 1000)
// console.log(sentence.audioPosition,(end - start) * 1000)
if (end && end !== -1) {
timer = setTimeout(() => {

View File

@@ -112,20 +112,10 @@ export function useOnKeyboardEventListener(onKeyDown: (e: KeyboardEvent) => void
})
}
//因为如果用useStartKeyboardEventListener局部变量控制当出现多个hooks时就不行了所以用全局变量来控制
export function useDisableEventListener(watchVal: any) {
const runtimeStore = useRuntimeStore()
watch(watchVal, (n: any) => {
if (n === true) runtimeStore.disableEventListener = true
if (n === false) runtimeStore.disableEventListener = false
})
onMounted(() => {
if (watchVal() === undefined) {
runtimeStore.disableEventListener = true
}
})
onUnmounted(() => {
if (watchVal() === undefined) {
runtimeStore.disableEventListener = false
}
runtimeStore.disableEventListener = n
})
}

View File

@@ -26,7 +26,6 @@ export function useSound(audioSrcList?: string[], audioFileLength?: number) {
}
function play(volume: number = 100) {
console.log('play')
index++
if (audioList.length > 1 && audioList.length !== audioLength) {
audioList[index % audioList.length].volume = volume / 100

View File

@@ -7,6 +7,7 @@ import router from "@/router.ts";
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import './types/global.d.ts'
import loadingDirective from './directives/loading.tsx'
const pinia = createPinia()
const app = createApp(App)
@@ -18,5 +19,6 @@ app.use(router)
app.directive('opacity', (el, binding) => {
el.style.opacity = binding.value ? 1 : 0
})
app.directive('loading', loadingDirective)
app.mount('#app')

View File

@@ -2,7 +2,7 @@
import {ref, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import {getAudioFileUrl, useChangeAllSound, usePlayAudio, useWatchAllSound} from "@/hooks/sound.ts";
import {getShortcutKey, useDisableEventListener, useEventListener} from "@/hooks/event.ts";
import {getShortcutKey, useEventListener} from "@/hooks/event.ts";
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, shakeCommonDict} from "@/utils";
import {DefaultShortcutKeyMap, ShortcutKey} from "@/types/types.ts";
import BaseButton from "@/components/BaseButton.vue";
@@ -31,7 +31,6 @@ const store = useBaseStore()
//@ts-ignore
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
useDisableEventListener(() => undefined)
useWatchAllSound()
let editShortcutKey = $ref('')

View File

@@ -43,7 +43,7 @@ function startStudy() {
custom: base.sbook.custom,
complete: base.sbook.complete,
})
nav('/study-article')
nav('/study-article', {name: store.sbook.name, id: store.sbook.id})
} else {
window.umami?.track('no-book')
Toast.warning('请先选择一本书籍')
@@ -120,7 +120,7 @@ async function goBookDetail(val: DictResource) {
<div class="title">我的书籍</div>
<div class="flex gap-4 items-center">
<PopConfirm title="确认删除所有选中书籍?" @confirm="handleBatchDel" v-if="selectIds.length">
<BaseIcon class="del" title="删除" >
<BaseIcon class="del" title="删除">
<DeleteIcon/>
</BaseIcon>
</PopConfirm>

View File

@@ -7,7 +7,7 @@ import {useBaseStore} from "@/stores/base.ts";
import List from "@/pages/pc/components/list/List.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
import {useWindowClick} from "@/hooks/event.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {nanoid} from "nanoid";
@@ -42,8 +42,6 @@ onUnmounted(() => {
emitter.off(EventKey.openArticleListModal)
})
useDisableEventListener(() => show)
async function selectArticle(item: Article) {
let r = await checkDataChange()
if (r) {

View File

@@ -1,19 +1,18 @@
<script setup lang="ts">
import {onMounted, onUnmounted} from "vue";
import {onMounted, onUnmounted, watch} from "vue";
import {useBaseStore} from "@/stores/base.ts";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {Article, ArticleItem, ArticleWord, ShortcutKey, Word} from "@/types/types.ts";
import {useOnKeyboardEventListener, useStartKeyboardEventListener} from "@/hooks/event.ts";
import {Article, ArticleItem, ArticleWord, Dict, DictType, ShortcutKey, Word} from "@/types/types.ts";
import {useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener} from "@/hooks/event.ts";
import useTheme from "@/hooks/theme.ts";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import {cloneDeep} from "@/utils";
import {_getDictDataByUrl, cloneDeep} from "@/utils";
import {usePracticeStore} from "@/stores/practice.ts";
import {useArticleOptions} from "@/hooks/dict.ts";
import {genArticleSectionData, usePlaySentenceAudio} from "@/hooks/article.ts";
import router from "@/router.ts";
import {getDefaultArticle} from "@/types/func.ts";
import {getDefaultArticle, getDefaultDict} from "@/types/func.ts";
import TypingArticle from "@/pages/pc/article/components/TypingArticle.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import Panel from "@/pages/pc/components/Panel.vue";
@@ -21,6 +20,9 @@ import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import EditSingleArticleModal from "@/pages/pc/article/components/EditSingleArticleModal.vue";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import ConflictNotice from "@/pages/pc/components/ConflictNotice.vue";
import {enArticle} from "@/assets/dictionary.ts";
import {useRoute, useRouter} from "vue-router";
import {useRuntimeStore} from "@/stores/runtime.ts";
const store = useBaseStore()
const settingStore = useSettingStore()
@@ -37,10 +39,9 @@ let articleData = $ref({
})
let showEditArticle = $ref(false)
let typingArticleRef = $ref<any>()
let loading = $ref<boolean>(true)
let editArticle = $ref<Article>(getDefaultArticle())
useStartKeyboardEventListener()
function write() {
// console.log('write')
settingStore.dictation = true
@@ -84,14 +85,61 @@ function next() {
getCurrentPractice()
}
function init() {
if (!store.sbook?.articles?.length) {
router.push('/article')
return
const router = useRouter()
const route = useRoute()
const runtimeStore = useRuntimeStore()
watch(() => store.load, (n) => {
if (n && loading && runtimeStore.editDict.id) {
console.log('load好了开始加载')
store.changeBook(runtimeStore.editDict)
articleData.list = cloneDeep(store.sbook.articles)
getCurrentPractice()
loading = false
}
},{immediate: true})
useStartKeyboardEventListener()
useDisableEventListener(() => loading)
function init() {
if (store.sbook?.articles?.length) {
articleData.list = cloneDeep(store.sbook.articles)
getCurrentPractice()
loading = false
} else {
let dictName = route.query.name
let dictId = route.query.id
//如果url里有词典id或name那么直接请求词典数据并加到bookList里面进行学习
//todo 这里要处理自定义词典的问题
if (dictName || dictId) {
let dictResource = getDefaultDict()
if (dictId) dictResource = enArticle.find(v => v.id === dictId) as Dict
else if (dictName) dictResource = enArticle.find(v => v.name === dictName) as Dict
if (dictResource.id) {
loading = true
_getDictDataByUrl(dictResource, DictType.article).then(r => {
if (!r.articles.length) {
router.push('/article')
return Toast.warning('没有文章可学习!')
}
runtimeStore.editDict = r
if (store.load) {
console.log('直接加载')
store.changeBook(r)
articleData.list = cloneDeep(store.sbook.articles)
getCurrentPractice()
loading = false
}
})
} else {
router.push('/article')
}
} else {
router.push('/article')
}
}
articleData.list = cloneDeep(store.sbook.articles)
getCurrentPractice()
console.log('init', articleData.article)
}
function setArticle(val: Article) {
@@ -183,7 +231,6 @@ function show() {
typingArticleRef?.showSentence()
}
function onKeyUp() {
typingArticleRef.hideSentence()
}
@@ -199,7 +246,6 @@ async function onKeyDown(e: KeyboardEvent) {
useOnKeyboardEventListener(onKeyDown, onKeyUp)
onMounted(init)
useEvents([
@@ -240,7 +286,7 @@ const {playSentenceAudio} = usePlaySentenceAudio()
</script>
<template>
<div class="practice-wrapper">
<div class="practice-wrapper" v-loading="loading">
<div class="practice-article">
<TypingArticle
ref="typingArticleRef"
@@ -290,11 +336,11 @@ const {playSentenceAudio} = usePlaySentenceAudio()
<div class="footer" :class="!settingStore.showToolbar && 'hide'">
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
<IconIconParkOutlineDown
@click="settingStore.showToolbar = !settingStore.showToolbar"
class="arrow"
:class="!settingStore.showToolbar && 'down'"
width="24"
color="#999"/>
@click="settingStore.showToolbar = !settingStore.showToolbar"
class="arrow"
:class="!settingStore.showToolbar && 'down'"
width="24"
color="#999"/>
</Tooltip>
<div class="bottom">

View File

@@ -5,6 +5,7 @@ import {useDisableEventListener} from "@/hooks/event.ts";
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
import {getDefaultArticle} from "@/types/func.ts";
import {defineAsyncComponent} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
interface IProps {

View File

@@ -41,7 +41,6 @@ const emit = defineEmits<{
edit: [val: Article]
}>()
let isPlay = $ref(false)
let typeArticleRef = $ref<HTMLInputElement>(null)
let articleWrapperRef = $ref<HTMLInputElement>(null)
let sectionIndex = $ref(0)
@@ -93,7 +92,6 @@ watch(() => settingStore.translate, () => {
checkTranslateLocation().then(() => checkCursorPosition())
})
function checkCursorPosition(a = sectionIndex, b = sentenceIndex, c = wordIndex) {
// console.log('checkCursorPosition')
_nextTick(() => {
@@ -143,7 +141,6 @@ function checkTranslateLocation() {
})
}
let lockNextSentence = false
function nextSentence() {

View File

@@ -2,23 +2,24 @@
import {defineAsyncComponent, onMounted, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
let settingStore = useSettingStore()
let show = $ref(false)
watch(() => settingStore.load, (n) => {
show = settingStore.conflictNotice
})
onMounted(() => {
if (settingStore.load) {
show = settingStore.conflictNotice
if (n && settingStore.conflictNotice) {
setTimeout(() => {
show = true
}, 300)
}
})
}, {immediate: true})
</script>
<template>
<Dialog :modelValue="show"
<Dialog v-model="show"
title="提示"
footer
cancel-button-text="不再提醒"
@@ -39,7 +40,7 @@ onMounted(() => {
</div>
<div class="pl-4">
<div>请打开浏览器无痕模式尝试</div>
<div>无痕模式下无法正常使用请给<a href="https://github.com/zyronon/TypeWords/issues">作者提一个 BUG</a>
<div>无痕模式下无法正常使用请给<a href="https://github.com/zyronon/TypeWords/issues">作者提 BUG</a>
</div>
</div>
</div>

View File

@@ -6,8 +6,8 @@ import Statistics from "@/pages/pc/word/Statistics.vue";
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {ShortcutKey, StudyData, Word} from "@/types/types.ts";
import {useOnKeyboardEventListener, useStartKeyboardEventListener} from "@/hooks/event.ts";
import {Dict, ShortcutKey, StudyData, Word} from "@/types/types.ts";
import {useDisableEventListener, useOnKeyboardEventListener, useStartKeyboardEventListener} from "@/hooks/event.ts";
import useTheme from "@/hooks/theme.ts";
import {getCurrentStudyWord, useWordOptions} from "@/hooks/dict.ts";
import {_getDictDataByUrl, cloneDeep, shuffle} from "@/utils";
@@ -23,7 +23,7 @@ import {useBaseStore} from "@/stores/base.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import {dictionaryResources} from "@/assets/dictionary.ts";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import {getDefaultWord} from "@/types/func.ts";
import {getDefaultDict, getDefaultWord} from "@/types/func.ts";
import ConflictNotice from "@/pages/pc/components/ConflictNotice.vue";
interface IProps {
@@ -61,26 +61,48 @@ let data = $ref<StudyData>({
wrongWords: [],
})
watch(() => store.load, (n) => {
if (n && loading && runtimeStore.editDict.id) {
console.log('load好了开始加载')
store.changeDict(runtimeStore.editDict)
studyData = getCurrentStudyWord()
loading = false
}
}, {immediate: true})
useStartKeyboardEventListener()
useDisableEventListener(() => loading)
onMounted(() => {
let dictId = route.query.q
//如果url里有词典id那么直接请求词典数据并加到bookList里面进行学习
if (dictId) {
let dictResource = dictionaryResources.find(v => v.id === dictId)
if (dictResource) {
loading = true
requestIdleCallback(() => {
_getDictDataByUrl(dictResource).then(r => {
store.changeDict(r)
studyData = getCurrentStudyWord()
loading = false
})
})
} else {
router.push('/word')
}
if (runtimeStore.routeData) {
studyData = runtimeStore.routeData
} else {
if (runtimeStore.routeData) {
studyData = runtimeStore.routeData
let dictName = route.query.name
let dictId = route.query.id
//如果url里有词典id或name那么直接请求词典数据并加到bookList里面进行学习
//todo 这里要处理自定义词典的问题
if (dictName || dictId) {
let dictResource = getDefaultDict()
if (dictId) dictResource = dictionaryResources.find(v => v.id === dictId) as Dict
else if (dictName) dictResource = dictionaryResources.find(v => v.name === dictName) as Dict
if (dictResource.id) {
loading = true
_getDictDataByUrl(dictResource).then(r => {
if (!r.words.length) {
router.push('/word')
return Toast.warning('没有单词可学习!')
}
runtimeStore.editDict = r
if (store.load) {
console.log('直接加载')
store.changeDict(r)
studyData = getCurrentStudyWord()
loading = false
}
})
} else {
router.push('/word')
}
} else {
router.push('/word')
}
@@ -124,9 +146,6 @@ watch(() => studyData, () => {
provide('studyData', data)
const dictIsEnd = $computed(() => {
return store.sdict.lastLearnIndex === store.sdict.length
})
const word = $computed(() => {
return data.words[data.index] ?? getDefaultWord()
})
@@ -248,7 +267,6 @@ async function onKeyDown(e: KeyboardEvent) {
}
}
useStartKeyboardEventListener()
useOnKeyboardEventListener(onKeyDown, onKeyUp)
@@ -354,7 +372,7 @@ useEvents([
</script>
<template>
<div class="practice-wrapper">
<div class="practice-wrapper" v-loading="loading">
<div class="practice-word">
<div class="absolute z-1 top-4 w-full" v-if="settingStore.showNearWord">
<div class="center gap-2 cursor-pointer float-left"
@@ -375,7 +393,7 @@ useEvents([
>
<div class="word" :class="settingStore.dictation && 'word-shadow'">{{ nextWord.word }}</div>
</Tooltip>
<IconBiArrowRight class="arrow" width="22"/>
<IconBiArrowRight class="arrow" width="22"/>
</div>
</div>
<TypeWord

View File

@@ -24,7 +24,7 @@ const store = useBaseStore()
const router = useRouter()
const {nav} = useNav()
const runtimeStore = useRuntimeStore()
let loading = $ref(true)
let currentStudy = $ref({
new: [],
review: [],
@@ -45,6 +45,7 @@ async function init() {
if (!currentStudy.new.length && store.sdict.words.length) {
currentStudy = getCurrentStudyWord()
}
loading = false
}
function startStudy() {
@@ -59,7 +60,7 @@ function startStudy() {
custom: store.sdict.custom,
complete: store.sdict.complete,
})
nav('study-word', {}, currentStudy)
nav('study-word', {name: store.sdict.name, id: store.sdict.id}, currentStudy)
} else {
window.umami?.track('no-dict')
Toast.warning('请先选择一本词典')
@@ -188,7 +189,9 @@ const progressTextRight = $computed(() => {
</div>
个单词 <span class="color-blue cursor-pointer" @click="setPerDayStudyNumber">更改</span>
</div>
<BaseButton size="large" :disabled="!store.sdict.name" @click="startStudy">
<BaseButton size="large" :disabled="!store.sdict.name"
:loading="loading"
@click="startStudy">
<!-- <BaseButton size="large" @click="startStudy">-->
<div class="flex items-center gap-2">
<span>开始学习</span>

View File

@@ -99,13 +99,16 @@ export const useSettingStore = defineStore('setting', {
},
init() {
return new Promise(async resolve => {
//TODO 后面记得删除了
let configStr = localStorage.getItem(SAVE_SETTING_KEY.key)
if (!configStr) {
configStr = await get(SAVE_SETTING_KEY.key)
let configStr2 = await get(SAVE_SETTING_KEY.key)
if (configStr2) {
//兼容localStorage.getItem
configStr = configStr2
}
let data = checkAndUpgradeSaveSetting(configStr)
this.setState(data)
this.load = true
this.setState({...data, load: true})
// this.load = true
resolve(true)
})
}

View File

@@ -17,6 +17,7 @@ export const EventKey = {
write: 'write',
editDict: 'editDict',
openMyDictDialog: 'openMyDictDialog',
stateInitEnd: 'stateInitEnd',
}
export function useEvent(key: string, func: any) {

View File

@@ -408,7 +408,12 @@ export function _parseLRC(lrc: string): { start: number, end: number, text: stri
return parsed;
}
export async function sleep(time: number) {
return new Promise(resolve => setTimeout(resolve, time));
}
export async function _getDictDataByUrl(val: DictResource, type: DictType = DictType.word): Promise<Dict> {
// await sleep(2000);
let dictResourceUrl = `./dicts/${val.language}/word/${val.url}`
if (type === DictType.article) {
dictResourceUrl = `./dicts/${val.language}/${val.type}/${val.url}`;