This commit is contained in:
Zyronon
2026-01-06 19:48:29 +08:00
committed by GitHub
parent 16f07810f8
commit c5720973ee
13 changed files with 389 additions and 480 deletions

View File

@@ -21,15 +21,6 @@
<a href="https://trendshift.io/repositories/15226" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15226" alt="zyronon%2FTypeWords | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
<p align="center">
<br>
<a href="https://skywork.ai/p/GrXQb4"><img src="/public/skywork-ai.png" alt="License" style="width: 650px;"></a>
<br>
赞助: <a href="https://skywork.ai/p/GrXQb4" target="_blank">Skywork.AI: 10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a>
<br/>
<br/>
</p>
<img width="1920" height="1440" alt="295shots_so" src="https://github.com/user-attachments/assets/383ed437-856e-48fe-92b0-9619babb49be" />
<img width="1920" height="1440" alt="922shots_so" src="https://github.com/user-attachments/assets/5b5fa13f-747c-4368-ae21-3c9d7d30fbc7" />

View File

@@ -23,16 +23,7 @@ Practice English, one strike, one step forward
<div align=center>
<a href="https://trendshift.io/repositories/14139" target="_blank" class="trendshift-badge"><img src="https://trendshift.io/api/badge/repositories/14139" alt="TypeWords | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
<p align="center">
<br>
<a href="https://skywork.ai/p/GrXQb4"><img src="/public/skywork-ai.png" alt="License" style="width: 650px;"></a>
<br>
Sponsor: <a href="https://skywork.ai/p/GrXQb4" target="_blank">Skywork.AI: 10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a>
<br/>
<br/>
</p>
<img width="1920" height="1440" alt="295shots_so" src="https://github.com/user-attachments/assets/383ed437-856e-48fe-92b0-9619babb49be" />
<img width="1920" height="1440" alt="922shots_so" src="https://github.com/user-attachments/assets/5b5fa13f-747c-4368-ae21-3c9d7d30fbc7" />

View File

@@ -311,6 +311,7 @@
width: 100%;
margin-bottom: 1rem;
color: var(--color-card-text);
display: none;
}
.container {
@@ -714,9 +715,9 @@
</div>
</a>
</div>
<div><a href="https://beian.mps.gov.cn/#/query/webSearch?code=51015602001426" target="_blank">川公网安备51015602001426号 </a></div>
<div><a href="https://beian.miit.gov.cn/" target="_blank">蜀ICP备2025157466号-2</a></div>
</div>

View File

@@ -71,6 +71,9 @@
--bg-book: rgb(226 232 240);
--color-line: rgb(226, 226, 226);
--color-translate-main: black;
--color-translate-second: #818181;
}
html.dark {

View File

@@ -1,8 +1,13 @@
import { Article, TaskWords, Word, WordPracticeMode } from '@/types/types.ts'
import { Article, Dict, DictId, DictType, TaskWords, Word } from '@/types/types.ts'
import { useBaseStore } from '@/stores/base.ts'
import { useSettingStore } from '@/stores/setting.ts'
import { getDefaultWord } from '@/types/func.ts'
import { cloneDeep, getRandomN, shuffle, splitIntoN } from '@/utils'
import { getDefaultDict, getDefaultWord } from '@/types/func.ts'
import { _getDictDataByUrl, cloneDeep, getRandomN, resourceWrap, shuffle, splitIntoN } from '@/utils'
import { onMounted, watch } from 'vue'
import { AppEnv, DICT_LIST } from '@/config/env.ts'
import { detail } from '@/apis'
import { useRuntimeStore } from '@/stores/runtime.ts'
import { useRoute, useRouter } from 'vue-router'
export function useWordOptions() {
const store = useBaseStore()
@@ -12,9 +17,7 @@ export function useWordOptions() {
}
function toggleWordCollect(val: Word) {
let rIndex = store.collectWord.words.findIndex(
v => v.word.toLowerCase() === val.word.toLowerCase()
)
let rIndex = store.collectWord.words.findIndex(v => v.word.toLowerCase() === val.word.toLowerCase())
if (rIndex > -1) {
store.collectWord.words.splice(rIndex, 1)
} else {
@@ -114,9 +117,7 @@ export function getCurrentStudyWord(): TaskWords {
if (complete && isEnd) {
//复习比最小是1
let ratio = settingStore.wordReviewRatio || 1
let ignoreList = [store.allIgnoreWords, store.knownWords][
settingStore.ignoreSimpleWord ? 0 : 1
]
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
// 先将可用词表全部随机,再按需过滤忽略列表,只取到目标数量为止
let shuffled = shuffle(cloneDeep(dict.words))
let count = 0
@@ -204,7 +205,7 @@ export function getCurrentStudyWord(): TaskWords {
}
//如果已完成,那么合并写词和复习词
if(complete){
if (complete) {
// data.new = []
// data.review = data.review.concat(data.write)
// data.write = []
@@ -215,3 +216,75 @@ export function getCurrentStudyWord(): TaskWords {
// console.log('data-write', data.write.map(v => v.word))
return data
}
export function useGetDict() {
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
let loading = $ref(false)
const route = useRoute()
const router = useRouter()
watch(
[() => store.load, () => loading],
([a, b]) => {
if (a && b) loadDict()
},
{ immediate: true }
)
onMounted(() => {
if (!runtimeStore.editDict?.id) {
let dictId = route.params?.id
if (!dictId) {
return router.push('/articles')
}
loading = true
} else {
loadDict(runtimeStore.editDict)
}
})
async function loadDict(dict?: Dict) {
// console.log('load好了开始加载')
if (!dict) {
dict = getDefaultDict()
let dictId = route.query.id
//先在自己的词典列表里面找,如果没有再在资源列表里面找
dict = store.article.bookList.find(v => v.id === dictId)
let r = await fetch(resourceWrap(DICT_LIST.WORD.ALL))
let dict_list = await r.json()
if (!dict) dict = dict_list.flat().find(v => v.id === dictId) as Dict
}
if (dict && dict.id) {
if (
!dict?.articles?.length &&
!dict?.custom &&
![DictId.articleCollect].includes(dict.en_name || dict.id) &&
!dict?.is_default
) {
loading = true
let r = await _getDictDataByUrl(dict, DictType.article)
runtimeStore.editDict = r
}
if (store.article.bookList.find(book => book.id === runtimeStore.editDict.id)) {
if (AppEnv.CAN_REQUEST) {
let res = await detail({ id: runtimeStore.editDict.id })
if (res.success) {
runtimeStore.editDict.statistics = res.data.statistics
if (res.data.articles.length) {
runtimeStore.editDict.articles = res.data.articles
}
}
}
}
loading = false
} else {
// router.push('/articles')
}
}
return {
dict: runtimeStore.editDict,
loading,
}
}

View File

@@ -163,7 +163,7 @@ function toggleSelect(item) {
async function goBookDetail(val: DictResource) {
runtimeStore.editDict = getDefaultDict(val)
nav('book-detail')
nav('book-detail',{id: val.id})
}
const totalSpend = $computed(() => {

View File

@@ -12,12 +12,12 @@ import { computed, onMounted, onUnmounted, watch } from 'vue'
import {
_dateFormat,
_getDictDataByUrl,
isMobile,
_nextTick,
cloneDeep,
msToHourMinute,
resourceWrap,
total,
useNav,
_nextTick,
} from '@/utils'
import { getDefaultArticle, getDefaultDict } from '@/types/func.ts'
import Toast from '@/components/base/toast/Toast.ts'
@@ -29,31 +29,21 @@ import { AppEnv, DICT_LIST } from '@/config/env.ts'
import { detail } from '@/apis'
import BaseIcon from '@/components/BaseIcon.vue'
import Switch from '@/components/base/Switch.vue'
import { useGetDict } from '@/hooks/dict.ts'
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const base = useBaseStore()
const store = useBaseStore()
const router = useRouter()
const route = useRoute()
const { nav } = useNav()
let isEdit = $ref(false)
let isAdd = $ref(false)
let loading = $ref(false)
let studyLoading = $ref(false)
let selectArticle: Article = $ref(getDefaultArticle({ id: -1 }))
// 计算当前选中文章的索引
const currentArticleIndex = computed(() => {
return runtimeStore.editDict.articles.findIndex(article => article.id === selectArticle.id)
})
// 处理播放下一个音频
const handlePlayNext = (nextArticle: Article) => {
selectArticle = nextArticle
}
function handleCheckedChange(val) {
selectArticle = val.item
}
@@ -64,7 +54,7 @@ async function startPractice() {
return Toast.warning('没有文章可学习!')
}
studyLoading = true
await base.changeBook(sbook)
await store.changeBook(sbook)
studyLoading = false
window.umami?.track('startStudyArticle', {
@@ -80,63 +70,22 @@ const showBookDetail = computed(() => {
return !(isAdd || isEdit)
})
async function init() {
const { dict, loading } = useGetDict()
onMounted(() => {
if (route.query?.isAdd) {
isAdd = true
runtimeStore.editDict = getDefaultDict()
} else {
if (!runtimeStore.editDict.id) {
await router.push('/articles')
} else {
if (
!runtimeStore.editDict?.articles?.length &&
!runtimeStore.editDict?.custom &&
![DictId.articleCollect].includes(runtimeStore.editDict.en_name || runtimeStore.editDict.id) &&
!runtimeStore.editDict?.is_default
) {
loading = true
let r = await _getDictDataByUrl(runtimeStore.editDict, DictType.article)
runtimeStore.editDict = r
}
if (base.article.bookList.find(book => book.id === runtimeStore.editDict.id)) {
if (AppEnv.CAN_REQUEST) {
let res = await detail({ id: runtimeStore.editDict.id })
if (res.success) {
runtimeStore.editDict.statistics = res.data.statistics
if (res.data.articles.length) {
runtimeStore.editDict.articles = res.data.articles
}
}
}
}
selectArticle = runtimeStore.editDict.articles[0]
loading = false
}
}
}
onMounted(() => {
init()
window.addEventListener('resize', handleResize)
})
watch(
() => selectArticle.id,
() => {
if (displayMode === 'typing-style') {
}
positionTranslations()
}
)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
function handleResize() {
if (displayMode === 'typing-style') {
if (displayMode === 'inline') {
positionTranslations()
}
}
@@ -156,9 +105,9 @@ function reset() {
let dict = book_list.value.find(v => v.url === runtimeStore.editDict.url) as Dict
if (dict && dict.id) {
dict = await _getDictDataByUrl(dict, DictType.article)
let rIndex = base.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
let rIndex = store.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
if (rIndex > -1) {
let item = base.article.bookList[rIndex]
let item = store.article.bookList[rIndex]
item.custom = false
item.id = dict.id
item.articles = dict.articles
@@ -211,9 +160,9 @@ const list = $computed(() => {
let showTranslate = $ref(true)
let startPlay = $ref(false)
let displayMode = $ref<'normal' | 'typing-style'>('normal')
let showDisplayMode = $ref(false)
let displayMode = $ref<'card' | 'inline' | 'line'>('inline')
let articleWrapperRef = $ref<HTMLElement>()
const isMob = isMobile()
const handleVolumeUpdate = (volume: number) => {
settingStore.articleSoundVolume = volume
@@ -223,66 +172,31 @@ const handleSpeedUpdate = (speed: number) => {
settingStore.articleSoundSpeed = speed
}
// 解析文本为段落和句子结构
interface ParsedSentence {
text: string
translate: string
}
// 计算段落数量
const paragraphCount = $computed(() => {
if (!selectArticle.text) return 0
return selectArticle.text.split('\n\n').filter(p => p.trim()).length
})
interface ParsedParagraph {
sentences: ParsedSentence[]
}
function parseTextToSections(text: string, textTranslate: string): ParsedParagraph[] {
if (!text) return []
// 按段落分割(双换行)
const textParagraphs = text.split('\n\n').filter(p => p.trim())
const translateParagraphs = textTranslate ? textTranslate.split('\n\n').filter(p => p.trim()) : []
// 句子分割正则:按句号、问号、感叹号分割,但保留标点
const sentenceRegex = /([^.!?]+[.!?]+)/g
return textParagraphs.map((para, paraIndex) => {
// 分割句子
const sentences = para.match(sentenceRegex) || [para]
const translateSentences = translateParagraphs[paraIndex]
? translateParagraphs[paraIndex].match(sentenceRegex) || [translateParagraphs[paraIndex]]
: []
return {
sentences: sentences.map((sent, sentIndex) => ({
text: sent.trim(),
translate: translateSentences[sentIndex]?.trim() || '',
})),
}
})
}
// 计算解析后的文章结构
const parsedArticle = $computed(() => {
if (!selectArticle.text || displayMode !== 'typing-style') return null
return parseTextToSections(selectArticle.text, selectArticle.textTranslate || '')
// 判断是否应该在段落下显示译文card 模式且段落数 > 1
const shouldShowInlineTranslation = $computed(() => {
return displayMode === 'card' && paragraphCount > 1
})
// 定位翻译到原文下方
function positionTranslations() {
// if ( isMob || !articleWrapperRef) return
_nextTick(() => {
const articleRect = articleWrapperRef.getBoundingClientRect()
console.log('articleRect',articleRect)
selectArticle.textTranslate.split('\n\n').forEach((paragraph, paraIndex) => {
paragraph.split('\n').forEach((sentence, sentIndex) => {
debugger
const location = `${paraIndex}-${sentIndex}`
const sentenceClassName = `.sentence-${location}`
const sentenceClassName = `.word-${location}-0`
const sentenceEl = articleWrapperRef?.querySelector(sentenceClassName)
const translateClassName = `.translate-${location}`
const translateEl = articleWrapperRef?.querySelector(translateClassName) as HTMLDivElement
if (sentenceEl && translateEl && sentence) {
const sentenceRect = sentenceEl.getBoundingClientRect()
console.log('sentenceRect',sentenceEl.innerText, sentenceRect)
translateEl.style.opacity = '1'
translateEl.style.top = sentenceRect.top - articleRect.top + 24 + 'px'
const spaceEl = translateEl.firstElementChild as HTMLElement
@@ -292,12 +206,12 @@ function positionTranslations() {
}
})
})
}, 300)
})
}
// 监听显示模式和文章变化,重新定位翻译
watch([() => displayMode, () => selectArticle.id, () => showTranslate], () => {
if (displayMode === 'typing-style') {
if (displayMode !== 'card') {
positionTranslations()
}
})
@@ -305,194 +219,213 @@ watch([() => displayMode, () => selectArticle.id, () => showTranslate], () => {
<template>
<div class="center h-screen">
<div
class="mb-0 flex p-space box-border flex-col bg-second w-full 3xl:w-7/10 2xl:w-8/10 xl:w-full 2xl:card 2xl:h-[97vh] h-full"
v-if="showBookDetail"
>
<div class="dict-header flex justify-between items-center relative">
<div class="flex gap-space">
<BackIcon class="dict-back z-2" />
<div class="dict-title text-2xl text-align-center">{{ runtimeStore.editDict.name }}</div>
<div class="bg-second w-full 3xl:w-7/10 2xl:w-8/10 xl:w-full 2xl:card 2xl:h-[97vh] h-full overflow-hidden mb-0">
<div class="flex p-space box-border flex-col h-full" v-if="showBookDetail">
<div class="dict-header flex justify-between items-center relative">
<div class="flex gap-space">
<BackIcon class="dict-back z-2" />
<div class="dict-title text-2xl text-align-center">{{ runtimeStore.editDict.name }}</div>
</div>
<div class="dict-actions flex">
<BaseButton v-if="runtimeStore.editDict.custom && runtimeStore.editDict.url" type="info" @click="reset">
恢复默认
</BaseButton>
<BaseButton :loading="studyLoading || loading" type="info" @click="isEdit = true">编辑</BaseButton>
<BaseButton type="info" @click="router.push('batch-edit-article')">文章管理</BaseButton>
<BaseButton :loading="studyLoading || loading" @click="startPractice">学习</BaseButton>
</div>
</div>
<div class="dict-actions flex">
<BaseButton v-if="runtimeStore.editDict.custom && runtimeStore.editDict.url" type="info" @click="reset">
恢复默认
</BaseButton>
<BaseButton :loading="studyLoading || loading" type="info" @click="isEdit = true">编辑</BaseButton>
<BaseButton type="info" @click="router.push('batch-edit-article')">文章管理</BaseButton>
<BaseButton :loading="studyLoading || loading" @click="startPractice">学习</BaseButton>
</div>
</div>
<div class="flex flex-1 overflow-hidden mt-3">
<div class="3xl:w-80 2xl:w-60 xl:w-55 lg:w-50 overflow-auto">
<ArticleList
:show-desc="true"
v-if="runtimeStore.editDict.length"
@click="handleCheckedChange"
:list="list"
:active-id="selectArticle.id"
>
</ArticleList>
<Empty v-else />
</div>
<div class="flex-1 shrink-0 pl-4 flex flex-col overflow-hidden">
<template v-if="selectArticle.id">
<template v-if="selectArticle.id === -1">
<div class="flex gap-4 mt-2">
<img
:src="runtimeStore.editDict?.cover"
class="w-30 rounded-md"
v-if="runtimeStore.editDict?.cover"
alt=""
/>
<div class="text-lg">介绍{{ runtimeStore.editDict.description }}</div>
</div>
<div class="text-base" v-if="totalSpend">总学习时长{{ totalSpend }}</div>
</template>
<template v-else>
<div class="flex-1 space-y-10 overflow-auto pb-30">
<div>
<div class="flex justify-between items-center relative">
<span class="text-3xl">
<span class="font-bold">{{ selectArticle.title }}</span>
<span class="ml-6 text-2xl" v-if="showTranslate">{{ selectArticle.titleTranslate }}</span>
</span>
<div class="flex items-center gap-2 mr-4">
<BaseIcon
:title="`切换显示模式`"
@click="displayMode = displayMode === 'normal' ? 'typing-style' : 'normal'"
>
<IconFluentTextParagraph16Regular v-if="displayMode === 'normal'" />
<IconFluentTextAlignLeft16Regular v-else />
<div class="flex flex-1 overflow-hidden mt-3">
<div class="3xl:w-80 2xl:w-60 xl:w-55 lg:w-50 overflow-auto">
<ArticleList
:show-desc="true"
v-if="runtimeStore.editDict.length"
@click="handleCheckedChange"
:list="list"
:active-id="selectArticle.id"
>
</ArticleList>
<Empty v-else />
</div>
<div class="flex-1 shrink-0 pl-4 flex flex-col overflow-hidden">
<template v-if="selectArticle.id">
<template v-if="selectArticle.id === -1">
<div class="flex gap-4 mt-2">
<img
:src="runtimeStore.editDict?.cover"
class="w-30 rounded-md"
v-if="runtimeStore.editDict?.cover"
alt=""
/>
<div class="text-lg">介绍{{ runtimeStore.editDict.description }}</div>
</div>
<div class="text-base" v-if="totalSpend">总学习时长{{ totalSpend }}</div>
</template>
<template v-else>
<div class="flex-1 overflow-auto pb-30">
<div>
<div class="flex justify-between items-center relative">
<span>
<span class="text-4xl">{{ selectArticle.title }}</span>
<span class="ml-6 text-2xl" v-if="showTranslate">{{ selectArticle.titleTranslate }}</span>
</span>
<div class="flex items-center gap-2 mr-4">
<BaseIcon :title="`开关释义显示`" @click="showTranslate = !showTranslate">
<IconFluentTranslate16Regular v-if="showTranslate" />
<IconFluentTranslateOff16Regular v-else />
</BaseIcon>
<BaseIcon
:disabled="!showTranslate"
:title="`切换显示模式`"
@click="showDisplayMode = !showDisplayMode"
>
<IconFluentTextAlignLeft16Regular />
</BaseIcon>
</div>
</div>
<div class="flex gap-1 mr-4 justify-end" v-if="showDisplayMode">
<BaseIcon title="逐行显示" @click="displayMode = 'inline'">
<IconFluentTextPositionThrough20Regular />
</BaseIcon>
<BaseIcon :title="`开关释义显示`" @click="showTranslate = !showTranslate">
<IconFluentTranslate16Regular v-if="showTranslate" />
<IconFluentTranslateOff16Regular v-else />
<BaseIcon title="单行显示" @click="displayMode = 'line'">
<IconFluentTextAlignLeft16Regular />
</BaseIcon>
<BaseIcon title="对照显示" @click="displayMode = 'card'">
<IconFluentAlignSpaceFitVertical20Regular />
</BaseIcon>
</div>
</div>
<div class="mt-2 text-2xl" v-if="selectArticle?.question?.text">
Question: {{ selectArticle?.question?.text }}
</div>
</div>
<template v-if="false">
<!-- 原文-->
<div class="text-2xl en-article-family space-y-5" v-if="selectArticle.text">
<!-- <div class="break-words w-full" v-for="(t, i) in selectArticle.text.split('\n\n')">-->
<!-- <span v-for="(w, j) in t.split('\n')" :class="`sentence-${i}-${j}`" :key="`${i}-${j}`">-->
<!-- &lt;!&ndash; <span v-for="(s,n) in w.split(' ')">{{s}}</span>&ndash;&gt;-->
<!-- {{ w }}-->
<!-- </span>-->
<!-- </div>-->
<div v-for="t in selectArticle.text.split('\n\n')">{{ t }}</div>
<div class="text-right italic">{{ selectArticle?.quote?.text }}</div>
</div>
<!-- 译文-->
<template v-if="showTranslate">
<div class="line"></div>
<div class="text-xl line-height-normal space-y-5" v-if="selectArticle.textTranslate">
<div class="mt-2" v-if="selectArticle?.question?.translate">
<div class="mt-2 text-2xl" v-if="selectArticle?.question?.text">
<div>Question: {{ selectArticle?.question?.text }}</div>
<div
class="text-xl color-translate-second"
v-if="showTranslate && (displayMode !== 'card' || shouldShowInlineTranslation)"
>
问题: {{ selectArticle?.question?.translate }}
</div>
<!-- <div class="break-words w-full" v-for="(t, i) in selectArticle.textTranslate.split('\n\n')">-->
<!-- <span v-for="(w, j) in t.split('\n')" :class="`translate-${i}-${j}`" :key="`${i}-${j}`">-->
<!-- &lt;!&ndash; <span v-for="(s,n) in w.split(' ')">{{s}}</span>&ndash;&gt;-->
<!-- {{ w }}-->
<!-- </span>-->
<!-- </div>-->
<div v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
<div class="text-right italic">{{ selectArticle?.quote?.translate }}</div>
</div>
<Empty v-else />
</template>
</template>
</div>
<!-- 打字式显示模式 -->
<template v-if="true">
<div class="article-content" :class="[showTranslate && 'tall']" ref="articleWrapperRef">
<div
class="article-content mt-6"
:class="[showTranslate && displayMode !== 'card' && 'tall']"
ref="articleWrapperRef"
>
<article>
<div class="break-words w-full section" v-for="(t, i) in selectArticle.text.split('\n\n')">
<span v-for="(w, j) in t.split('\n')" :class="`sentence-${i}-${j}`" :key="`${i}-${j}`"
>{{ w }}
</span>
</div>
<div class="text-right italic" v-if="selectArticle?.quote?.text">
{{ selectArticle?.quote?.text }}
</div>
</article>
<div class="translate" v-show="showTranslate">
<div
class="break-words w-full section"
v-for="(t, i) in selectArticle.textTranslate.split('\n\n')"
>
<div v-for="(w, j) in t.split('\n')" :class="`row translate-${i}-${j}`" :key="`${i}-${j}`">
<span class="space"></span>
<span>{{ w }}</span>
<template v-for="(t, i) in selectArticle.text.split('\n\n')" :key="`para-${i}`">
<div class="article-row w-full mb-10">
<span
:class="displayMode === 'line' && 'block'"
v-for="(w, j) in t.split('\n')"
:key="`${i}-${j}`"
>
<span
v-for="(s, n) in w.split(' ').filter(Boolean)"
:class="`inline-block word-${i}-${j}-${n}`"
:key="`${i}-${j}-${n}`"
><span>{{ s }}</span>
<span class="space"></span>
</span>
</span>
</div>
<!-- card 模式且段落数 > 1 在每个段落下显示对应译文 -->
<div
v-if="shouldShowInlineTranslation && showTranslate && selectArticle.textTranslate"
class="trans-row text-xl color-translate-second -mt-7 mb-10"
>
<div v-if="selectArticle.textTranslate.split('\n\n')[i]">
{{ selectArticle.textTranslate.split('\n\n')[i] }}
</div>
</div>
</template>
<div class="text-right italic">
<div class="text-2xl" v-if="selectArticle?.quote?.text">{{ selectArticle?.quote?.text }}</div>
<div
class="trans-row text-xl color-translate-second"
v-if="
selectArticle?.quote?.translate &&
showTranslate &&
(displayMode !== 'card' || shouldShowInlineTranslation)
"
>
{{ selectArticle?.quote?.translate }}
</div>
</div>
<div class="text-right italic" v-if="selectArticle?.quote?.translate">
{{ selectArticle?.quote?.translate }}
</article>
<template v-if="showTranslate && selectArticle.textTranslate">
<div class="translate color-translate-second" v-if="displayMode !== 'card'">
<div
class="break-words w-full section"
v-for="(t, i) in selectArticle.textTranslate.split('\n\n')"
>
<div v-for="(w, j) in t.split('\n')" :class="`row translate-${i}-${j}`" :key="`${i}-${j}`">
<span class="space"></span>
<span>{{ w }}</span>
</div>
</div>
</div>
</div>
<template v-else>
<!-- 当段落数 <= 1 时,保持原样在文章末尾显示译文 -->
<template v-if="!shouldShowInlineTranslation">
<div class="line my-10"></div>
<div class="text-xl line-height-normal space-y-5">
<div class="mt-2" v-if="selectArticle?.question?.translate">
问题: {{ selectArticle?.question?.translate }}
</div>
<div class="trans-row" v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
<div class="trans-row text-right italic">{{ selectArticle?.quote?.translate }}</div>
</div>
</template>
</template>
</template>
</div>
<!-- 移动端显示翻译 -->
<template v-if="isMob && showTranslate">
<div
class="sentence-translate-mobile"
v-for="(paragraph, paraIndex) in parsedArticle"
:key="`m-${paraIndex}`"
>
<div v-for="(sentence, sentIndex) in paragraph.sentences" :key="`${paraIndex}-${sentIndex}`">
<div v-if="sentence.translate" class="mt-2">{{ sentence.translate }}</div>
<template v-if="currentPractice.length">
<div class="line my-10"></div>
<div class="font-family text-base pr-2">
<div class="text-2xl font-bold">学习记录</div>
<div class="mt-1 mb-3">总学习时长:{{ msToHourMinute(total(currentPractice, 'spend')) }}</div>
<div
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
v-for="i in currentPractice"
>
<span class="color-gray">{{ _dateFormat(i.startDate) }}</span>
<span>{{ msToHourMinute(i.spend) }}</span>
</div>
</div>
</template>
</template>
<template v-if="currentPractice.length">
<div class="line"></div>
<div class="font-family text-base pr-2">
<div class="text-2xl font-bold">学习记录</div>
<div class="mt-1 mb-3">总学习时长{{ msToHourMinute(total(currentPractice, 'spend')) }}</div>
<div
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
v-for="i in currentPractice"
>
<span class="color-gray">{{ _dateFormat(i.startDate) }}</span>
<span>{{ msToHourMinute(i.spend) }}</span>
</div>
</div>
</template>
</div>
<div class="border-t-1 border-t-gray-300 border-solid border-0 center gap-2 pt-4">
<ArticleAudio
:article="selectArticle"
@update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"
:autoplay="settingStore.articleAutoPlayNext && startPlay"
@ended="next"
/>
<div class="flex items-center gap-1">
<span>结束后播放下一篇</span>
<Switch v-model="settingStore.articleAutoPlayNext" />
</div>
</div>
<div class="border-t-1 border-t-gray-300 border-solid border-0 center gap-2 pt-4">
<ArticleAudio
:article="selectArticle"
@update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"
:autoplay="settingStore.articleAutoPlayNext && startPlay"
@ended="next"
/>
<div class="flex items-center gap-1">
<span>结束后播放下一篇</span>
<Switch v-model="settingStore.articleAutoPlayNext" />
</div>
</div>
</template>
</template>
</template>
<Empty v-else />
<Empty v-else />
</div>
</div>
</div>
</div>
<div class="card mb-0 dict-detail-card" v-else>
<div class="dict-header flex justify-between items-center relative">
<BackIcon class="dict-back z-2" @click="isAdd ? $router.back() : (isEdit = false)" />
<div class="dict-title absolute text-2xl text-align-center w-full">
{{ runtimeStore.editDict.id ? '修改' : '创建' }}书籍
<div class="card mb-0 dict-detail-card" v-else>
<div class="dict-header flex justify-between items-center relative">
<BackIcon class="dict-back z-2" @click="isAdd ? $router.back() : (isEdit = false)" />
<div class="dict-title absolute text-2xl text-align-center w-full">
{{ runtimeStore.editDict.id ? '修改' : '创建' }}书籍
</div>
</div>
<div class="center">
<EditBook :is-add="isAdd" :is-book="true" @close="formClose" @submit="isEdit = isAdd = false" />
</div>
</div>
<div class="center">
<EditBook :is-add="isAdd" :is-book="true" @close="formClose" @submit="isEdit = isAdd = false" />
</div>
</div>
</div>
@@ -517,60 +450,44 @@ $article-lh: 2.4;
.article-content {
position: relative;
color: var(--color-article);
font-size: 1.6rem;
&.tall {
article {
line-height: $article-lh;
color: var(--color-article);
}
}
article {
.article-row {
word-break: keep-all;
word-wrap: break-word;
white-space: pre-wrap;
font-family: var(--en-article-family);
}
.section {
margin-bottom: 1.5rem;
.trans-row {
@apply cn-article-family font-bold;
}
.sentence {
transition: all 0.3s;
display: inline;
}
}
article {
@apply en-article-family;
}
.translate {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
font-size: 1.2rem;
@apply absolute top-0 left-0 h-full w-full text-xl pointer-events-none font-bold cn-article-family;
line-height: $translate-lh;
letter-spacing: 0.2rem;
font-family: var(--zh-article-family);
font-weight: bold;
color: #818181;
.row {
position: absolute;
left: 0;
width: 100%;
opacity: 0;
transition: all 0.3s;
.space {
transition: all 0.3s;
display: inline-block;
}
@apply absolute left-0 w-full opacity-0 transition-all duration-300;
}
}
}
.space {
@apply inline-block w-2 transition-all duration-300;
}
.sentence-translate-mobile {
display: none;
margin-top: 0.4rem;

View File

@@ -10,14 +10,7 @@ import { useBaseStore } from '@/stores/base.ts'
import { usePracticeStore } from '@/stores/practice.ts'
import { useSettingStore } from '@/stores/setting.ts'
import { getDefaultArticle, getDefaultWord } from '@/types/func.ts'
import {
Article,
ArticleWord,
PracticeArticleWordType,
Sentence,
ShortcutKey,
Word,
} from '@/types/types.ts'
import { Article, ArticleWord, PracticeArticleWordType, Sentence, ShortcutKey, Word } from '@/types/types.ts'
import { _dateFormat, _nextTick, isMobile, msToHourMinute, total } from '@/utils'
import { emitter, EventKey, useEvents } from '@/utils/eventBus.ts'
import ContextMenu from '@imengyu/vue3-context-menu'
@@ -96,22 +89,19 @@ const settingStore = useSettingStore()
const statStore = usePracticeStore()
const isMob = isMobile()
watch(
[() => sectionIndex, () => sentenceIndex, () => wordIndex, () => stringIndex],
([a, b, c]) => {
if (a !== 0 || b !== 0 || c !== 0) {
setPracticeArticleCache({
practiceData: {
sectionIndex,
sentenceIndex,
wordIndex,
},
statStoreData: statStore.$state,
})
}
checkCursorPosition(a, b, c)
watch([() => sectionIndex, () => sentenceIndex, () => wordIndex, () => stringIndex], ([a, b, c]) => {
if (a !== 0 || b !== 0 || c !== 0) {
setPracticeArticleCache({
practiceData: {
sectionIndex,
sentenceIndex,
wordIndex,
},
statStoreData: statStore.$state,
})
}
)
checkCursorPosition(a, b, c)
})
// watch(() => props.article.id, init, {immediate: true})
@@ -286,11 +276,7 @@ const isNameWord = () => {
let currentSection = props.article.sections[sectionIndex]
let currentSentence = currentSection[sentenceIndex]
let w: ArticleWord = currentSentence.words[wordIndex]
return (
w?.type === PracticeArticleWordType.Word &&
namePatterns.length > 0 &&
namePatterns.includes(normalize(w.word))
)
return w?.type === PracticeArticleWordType.Word && namePatterns.length > 0 && namePatterns.includes(normalize(w.word))
}
let isTyping = false
@@ -497,11 +483,7 @@ function del() {
}
}
function showSentence(
i1: number = sectionIndex,
i2: number = sentenceIndex,
i3: number = wordIndex
) {
function showSentence(i1: number = sectionIndex, i2: number = sentenceIndex, i3: number = wordIndex) {
hoverIndex = { sectionIndex: i1, sentenceIndex: i2, wordIndex: i3 }
}
@@ -690,13 +672,16 @@ const currentPractice = inject('currentPractice', [])
@beforeinput="handleMobileBeforeInput"
@input="handleMobileInput"
/>
<header class="mb-4">
<div class="title">
<span class="font-family text-3xl">{{ store.sbook.lastLearnIndex + 1 }}. </span
>{{ props.article?.title ?? '' }}
<header class="pt-10 pb-6">
<div class="text-center">
<span class="text-3xl">{{ store.sbook.lastLearnIndex + 1 }}. </span>
<span class="text-4xl">{{ props.article?.title??'' }}</span>
<span class="ml-6 text-2xl" v-if="settingStore.translate">{{ props.article?.titleTranslate }}</span>
</div>
<div class="titleTranslate" v-if="settingStore.translate">
{{ props.article.titleTranslate }}
<div class="mt-2 text-2xl" v-if="props.article?.question?.text">
<div>Question: {{ props.article?.question?.text }}</div>
<div class="text-xl color-translate-second" v-if="settingStore.translate">问题: {{ props.article?.question?.translate }}</div>
</div>
</header>
@@ -742,11 +727,7 @@ const currentPractice = inject('currentPractice', [])
]"
@click="playWordAudio(word.word)"
>
<TypingWord
:word="word"
:is-typing="true"
v-if="isCurrent(indexI, indexJ, indexW) && !isSpace"
/>
<TypingWord :word="word" :is-typing="true" v-if="isCurrent(indexI, indexJ, indexW) && !isSpace" />
<TypingWord :word="word" :is-typing="false" v-else />
<span class="border-bottom" v-if="settingStore.dictation"></span>
</span>
@@ -758,10 +739,7 @@ const currentPractice = inject('currentPractice', [])
:is-shake="isCurrent(indexI, indexJ, indexW) && isSpace && wrong !== ''"
/>
</span>
<span
class="sentence-translate-mobile"
v-if="isMob && settingStore.translate && sentence.translate"
>
<span class="sentence-translate-mobile" v-if="isMob && settingStore.translate && sentence.translate">
{{ sentence.translate }}
</span>
</span>
@@ -773,11 +751,7 @@ const currentPractice = inject('currentPractice', [])
class="row"
:class="[
`translate${indexI + '-' + indexJ}`,
sectionIndex > indexI
? 'wrote'
: sectionIndex >= indexI && sentenceIndex > indexJ
? 'wrote'
: '',
sectionIndex > indexI ? 'wrote' : sectionIndex >= indexI && sentenceIndex > indexJ ? 'wrote' : '',
]"
v-for="(item, indexJ) in v"
>
@@ -788,18 +762,12 @@ const currentPractice = inject('currentPractice', [])
</div>
</template>
</div>
<div
class="cursor"
v-if="!isEnd"
:style="{ top: cursor.top + 'px', left: cursor.left + 'px' }"
></div>
<div class="cursor" v-if="!isEnd" :style="{ top: cursor.top + 'px', left: cursor.left + 'px' }"></div>
</div>
<div class="options flex justify-center" v-if="isEnd">
<BaseButton @click="emit('replay')">重新练习 </BaseButton>
<BaseButton
v-if="store.sbook.lastLearnIndex < store.sbook.articles.length - 1"
@click="emit('next')"
<BaseButton v-if="store.sbook.lastLearnIndex < store.sbook.articles.length - 1" @click="emit('next')"
>下一篇
</BaseButton>
</div>
@@ -813,9 +781,7 @@ const currentPractice = inject('currentPractice', [])
v-for="(item, i) in currentPractice"
>
<span :class="i === currentPractice.length - 1 ? 'color-red' : 'color-gray'"
>{{ i === currentPractice.length - 1 ? '当前' : i + 1 }}.&nbsp;&nbsp;{{
_dateFormat(item.startDate)
}}</span
>{{ i === currentPractice.length - 1 ? '当前' : i + 1 }}.&nbsp;&nbsp;{{ _dateFormat(item.startDate) }}</span
>
<span>{{ msToHourMinute(item.spend) }}</span>
</div>
@@ -826,12 +792,7 @@ const currentPractice = inject('currentPractice', [])
<BaseButton @click="showQuestions = !showQuestions">显示题目</BaseButton>
</div>
<div class="toggle" v-if="showQuestions">
<QuestionForm
:questions="article.questions"
:duration="300"
:immediateFeedback="false"
:randomize="true"
/>
<QuestionForm :questions="article.questions" :duration="300" :immediateFeedback="false" :randomize="true" />
</div>
</template>
</div>
@@ -851,26 +812,6 @@ $article-lh: 2.4;
font-size: 1.6rem;
margin-bottom: 10rem;
header {
word-wrap: break-word;
position: relative;
padding-top: 3rem;
.title {
text-align: center;
font-size: 2.2rem;
font-family: var(--en-article-family);
}
.titleTranslate {
@extend .title;
font-size: 1.2rem;
margin-top: 0.5rem;
font-family: var(--zh-article-family);
font-weight: bold;
}
}
.mobile-input {
position: absolute;
opacity: 0;

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
let logList = [
{
date: '2026/01/06',
content: '优化书籍详情页面',
},
{
date: '2025/12/30',
content: '移除“继续默写”选项',

View File

@@ -2,17 +2,11 @@
import { nextTick, ref, watch } from 'vue'
import { useSettingStore } from '@/stores/setting.ts'
import { getShortcutKey, useEventListener } from '@/hooks/event.ts'
import {
checkAndUpgradeSaveDict,
checkAndUpgradeSaveSetting,
cloneDeep,
loadJsLib,
sleep,
} from '@/utils'
import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, sleep } from '@/utils'
import { DefaultShortcutKeyMap } from '@/types/types.ts'
import BaseButton from '@/components/BaseButton.vue'
import { useBaseStore } from '@/stores/base.ts'
import { APP_NAME, APP_VERSION, Host, LIB_JS_URL, LOCAL_FILE_KEY } from '@/config/env.ts'
import { APP_NAME, APP_VERSION, AppEnv, Host, IS_DEV, LIB_JS_URL, LOCAL_FILE_KEY } from '@/config/env.ts'
import BasePage from '@/components/BasePage.vue'
import Toast from '@/components/base/toast/Toast.ts'
import { set } from 'idb-keyval'
@@ -191,10 +185,7 @@ function importJson(str: string, notice: boolean = true) {
try {
let save: any = obj.val[PRACTICE_WORD_CACHE.key] || {}
if (save.val && Object.keys(save.val).length > 0) {
localStorage.setItem(
PRACTICE_WORD_CACHE.key,
JSON.stringify(obj.val[PRACTICE_WORD_CACHE.key])
)
localStorage.setItem(PRACTICE_WORD_CACHE.key, JSON.stringify(obj.val[PRACTICE_WORD_CACHE.key]))
}
} catch (e) {
//todo 上报
@@ -204,10 +195,7 @@ function importJson(str: string, notice: boolean = true) {
try {
let save: any = obj.val[PRACTICE_ARTICLE_CACHE.key] || {}
if (save.val && Object.keys(save.val).length > 0) {
localStorage.setItem(
PRACTICE_ARTICLE_CACHE.key,
JSON.stringify(obj.val[PRACTICE_ARTICLE_CACHE.key])
)
localStorage.setItem(PRACTICE_ARTICLE_CACHE.key, JSON.stringify(obj.val[PRACTICE_ARTICLE_CACHE.key]))
}
} catch (e) {
//todo 上报
@@ -230,9 +218,11 @@ function importJson(str: string, notice: boolean = true) {
let timer = -1
async function beforeImport() {
importLoading = true
await exportData('已自动备份数据', 'TypeWords数据备份.zip')
await sleep(1500)
if (!IS_DEV) {
importLoading = true
await exportData('已自动备份数据', 'TypeWords数据备份.zip')
await sleep(1500)
}
let d: HTMLDivElement = document.querySelector('#import')
d.click()
timer = setTimeout(() => (importLoading = false), 1000)
@@ -398,15 +388,13 @@ function transferOk() {
<div v-if="tabIndex === 4">
<div>
所有用户数据
<b class="text-red">保存在本地浏览器中</b>如果您需要在不同的设备浏览器上使用
{{ APP_NAME }} 您需要手动进行数据导出和导入
<b class="text-red">保存在本地浏览器中</b>如果您需要在不同的设备浏览器上使用 {{ APP_NAME }}
您需要手动进行数据导出和导入
</div>
<BaseButton :loading="exportLoading" size="large" class="mt-3" @click="exportData()"
>导出数据备份(ZIP)</BaseButton
>
<div class="text-gray text-sm mt-2">
💾 导出的ZIP文件包含所有学习数据可在其他设备上导入恢复
</div>
<div class="text-gray text-sm mt-2">💾 导出的ZIP文件包含所有学习数据可在其他设备上导入恢复</div>
<div class="line mt-15 mb-3"></div>
@@ -415,9 +403,7 @@ function transferOk() {
>当前所有数据请谨慎操作执行导入操作时会先自动备份当前数据到您的电脑中供您随时恢复
</div>
<div class="flex gap-space mt-3">
<BaseButton size="large" @click="beforeImport" :loading="importLoading"
>导入数据恢复</BaseButton
>
<BaseButton size="large" @click="beforeImport" :loading="importLoading">导入数据恢复</BaseButton>
<input
type="file"
id="import"
@@ -430,8 +416,7 @@ function transferOk() {
<template v-if="isNewHost">
<div class="line my-3"></div>
<div>
请注意如果本地已有使用记录请先备份当前数据迁移数据后将<b class="text-red">
完全覆盖 </b
请注意如果本地已有使用记录请先备份当前数据迁移数据后将<b class="text-red"> 完全覆盖 </b
>当前所有数据请谨慎操作
</div>
<div class="flex gap-space mt-3">

View File

@@ -6,7 +6,6 @@ import {
_getAccomplishDate,
_getDictDataByUrl,
_nextTick,
cloneDeep,
isMobile,
loadJsLib,
resourceWrap,
@@ -432,27 +431,27 @@ const systemPracticeText = $computed(() => {
随机复习
</BaseButton>
<BaseButton
class="w-full"
v-if="settingStore.wordPracticeMode !== WordPracticeMode.IdentifyOnly"
@click="startPractice(WordPracticeMode.IdentifyOnly, true)"
>
{{ WordPracticeModeNameMap[WordPracticeMode.IdentifyOnly] }}
</BaseButton>
<BaseButton
class="w-full"
v-if="settingStore.wordPracticeMode !== WordPracticeMode.ListenOnly"
@click="startPractice(WordPracticeMode.ListenOnly, true)"
>
{{ WordPracticeModeNameMap[WordPracticeMode.ListenOnly] }}
</BaseButton>
<BaseButton
class="w-full"
v-if="settingStore.wordPracticeMode !== WordPracticeMode.DictationOnly"
@click="startPractice(WordPracticeMode.DictationOnly, true)"
>
{{ WordPracticeModeNameMap[WordPracticeMode.DictationOnly] }}
</BaseButton>
<!-- <BaseButton-->
<!-- class="w-full"-->
<!-- v-if="settingStore.wordPracticeMode !== WordPracticeMode.IdentifyOnly"-->
<!-- @click="startPractice(WordPracticeMode.IdentifyOnly, true)"-->
<!-- >-->
<!-- {{ WordPracticeModeNameMap[WordPracticeMode.IdentifyOnly] }}-->
<!-- </BaseButton>-->
<!-- <BaseButton-->
<!-- class="w-full"-->
<!-- v-if="settingStore.wordPracticeMode !== WordPracticeMode.ListenOnly"-->
<!-- @click="startPractice(WordPracticeMode.ListenOnly, true)"-->
<!-- >-->
<!-- {{ WordPracticeModeNameMap[WordPracticeMode.ListenOnly] }}-->
<!-- </BaseButton>-->
<!-- <BaseButton-->
<!-- class="w-full"-->
<!-- v-if="settingStore.wordPracticeMode !== WordPracticeMode.DictationOnly"-->
<!-- @click="startPractice(WordPracticeMode.DictationOnly, true)"-->
<!-- >-->
<!-- {{ WordPracticeModeNameMap[WordPracticeMode.DictationOnly] }}-->
<!-- </BaseButton>-->
</template>
</OptionButton>

View File

@@ -76,7 +76,7 @@ const emit = defineEmits<{
>
<RadioGroup :model-value="store.currentGroup">
<div class="card-white">
<div ref="scrollContainer" class="h-70 overflow-y-auto space-y-2">
<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"

View File

@@ -21,6 +21,10 @@ export default defineConfig({
'border-item': 'border-[var(--color-item-border)]',
'border-item-solid': 'border-1 border-solid border-[var(--color-item-border)]',
card: 'rounded-xl p-4 mb-8 shadow-lg box-border relative bg-second',
'color-translate-main':'color-[var(--color-translate-main)]',
'color-translate-second':'color-[var(--color-translate-second)]',
'en-article-family':'font-[var(--en-article-family)]',
'cn-article-family':'font-[var(--zh-article-family)]',
},
presets: [presetWind3()],
// 自定义断点