Skip to content

Commit 5fe297b

Browse files
authored
feat: repeat word playback, reduced playback speed; updated notification feature; one-click lookup when adding/editing words; phrase pronunciation support; example sentences support shortcut playback (Ctrl+1–9); automatically play example sentences after playing a word; words can be saved to other dictionaries.
1 parent aad92e4 commit 5fe297b

26 files changed

Lines changed: 1157 additions & 370 deletions

File tree

apps/nuxt/app/layouts/default.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useInit } from '@typewords/core/composables/useInit.ts'
1515
import { useI18n } from 'vue-i18n'
1616
import { Supabase } from '@typewords/core/utils/supabase.ts'
1717
import MiniProgram from '@/components/MiniProgram.vue'
18+
import WordCollectPopover from '@typewords/core/components/word/WordCollectPopover.vue'
1819
1920
const router = useRouter()
2021
const { toggleTheme, getTheme, setTheme } = useTheme()
@@ -188,6 +189,7 @@ onMounted(() => {
188189
</BaseIcon>
189190
</div>
190191
</div>
192+
<WordCollectPopover />
191193
</div>
192194
</template>
193195

apps/nuxt/app/pages/(words)/dict.vue

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from '@typewords/base'
1515
import { detail } from '@typewords/core/apis'
1616
import { copyOfficialDict } from '@typewords/core/apis/dict.ts'
17-
import { wordDelete } from '@typewords/core/apis/words.ts'
17+
import { wordDelete, queryWord } from '@typewords/core/apis/words.ts'
1818
import EditBook from '@typewords/core/components/article/EditBook.vue'
1919
import BaseTable from '@typewords/core/components/BaseTable.vue'
2020
import PracticeSettingDialog from '@typewords/core/components/word/PracticeSettingDialog.vue'
@@ -27,7 +27,7 @@ import { useRuntimeStore } from '@typewords/core/stores/runtime.ts'
2727
import { useSettingStore } from '@typewords/core/stores/setting.ts'
2828
import { Sort, WordPracticeMode } from '@typewords/core/types/enum.ts'
2929
import { getDefaultDict, getDefaultWord } from '@typewords/core/types/func.ts'
30-
import type { Dict } from '@typewords/core/types/types.ts'
30+
import type { Dict, Word } from '@typewords/core/types/types.ts'
3131
import {
3232
_getDictDataByUrl,
3333
_nextTick,
@@ -81,6 +81,82 @@ const wordRules = reactive({
8181
],
8282
})
8383
let studyLoading = $ref(false)
84+
let officialWordSnapshot = $ref<Word | null>(null)
85+
let wordSearchLoading = $ref(false)
86+
87+
function resetWordForm() {
88+
wordForm = getDefaultFormWord()
89+
officialWordSnapshot = null
90+
}
91+
92+
function normalizeApiWord(apiData: Record<string, any>): Word {
93+
const relWords = apiData.relWords ?? apiData.rel_words
94+
return getDefaultWord({
95+
...apiData,
96+
id: '',
97+
relWords: relWords ?? { root: '', rels: [] },
98+
synos: apiData.synos ?? [],
99+
etymology: apiData.etymology ?? [],
100+
trans: apiData.trans ?? [],
101+
sentences: apiData.sentences ?? [],
102+
phrases: apiData.phrases ?? [],
103+
custom: false,
104+
})
105+
}
106+
107+
function wordContentKey(word: Word) {
108+
return JSON.stringify({
109+
word: word.word,
110+
phonetic0: word.phonetic0 ?? '',
111+
phonetic1: word.phonetic1 ?? '',
112+
trans: word.trans ?? [],
113+
sentences: word.sentences ?? [],
114+
phrases: word.phrases ?? [],
115+
synos: word.synos ?? [],
116+
relWords: word.relWords ?? { root: '', rels: [] },
117+
etymology: word.etymology ?? [],
118+
})
119+
}
120+
121+
function isSameWordContent(a: Word, b: Word) {
122+
return wordContentKey(a) === wordContentKey(b)
123+
}
124+
125+
function onWordFormWordChange(value: string) {
126+
wordForm.word = value
127+
if (officialWordSnapshot && value !== officialWordSnapshot.word) {
128+
officialWordSnapshot = null
129+
}
130+
}
131+
132+
async function searchOfficialWord() {
133+
const word = wordForm.word?.trim()
134+
if (!word) {
135+
Toast.warning('请输入单词')
136+
return
137+
}
138+
if (!AppEnv.IS_OFFICIAL) {
139+
// Toast.warning('查询失败')
140+
// return
141+
}
142+
wordSearchLoading = true
143+
try {
144+
const res = await queryWord({ word })
145+
if (!res.success || !res.data) {
146+
officialWordSnapshot = null
147+
Toast.warning('单词未收录')
148+
return
149+
}
150+
const normalized = normalizeApiWord(res.data)
151+
const note = wordForm.note
152+
const formId = wordForm.id
153+
wordForm = word2Str({ ...normalized, id: formId })
154+
wordForm.note = note
155+
officialWordSnapshot = convertToWord({ ...wordForm })
156+
} finally {
157+
wordSearchLoading = false
158+
}
159+
}
84160
85161
function syncDictInMyStudyList(study = false) {
86162
_nextTick(() => {
@@ -107,6 +183,7 @@ async function onSubmitWord() {
107183
await wordFormRef.validate(valid => {
108184
if (valid) {
109185
let data: any = convertToWord(wordForm)
186+
data.custom = !officialWordSnapshot || !isSameWordContent(data, officialWordSnapshot)
110187
// 笔记集中存储,不保存在 Word 对象内
111188
const noteVal = wordForm.note?.trim()
112189
const wordKey = wordForm.word
@@ -134,7 +211,7 @@ async function onSubmitWord() {
134211
return
135212
} else allList.push(data)
136213
Toast.success('添加成功')
137-
wordForm = getDefaultFormWord()
214+
resetWordForm()
138215
}
139216
syncDictInMyStudyList()
140217
} else {
@@ -149,7 +226,7 @@ async function batchDel(ids: string[]) {
149226
let rIndex2 = allList.findIndex(v => v.id === id)
150227
if (rIndex2 > -1) {
151228
if (id === wordForm.id) {
152-
wordForm = getDefaultFormWord()
229+
resetWordForm()
153230
}
154231
allList.splice(rIndex2, 1)
155232
}
@@ -219,19 +296,20 @@ function word2Str(word) {
219296
function editWord(word) {
220297
isOperate = true
221298
wordForm = word2Str(word)
299+
officialWordSnapshot = word.custom ? null : convertToWord({ ...wordForm, id: word.id })
222300
if (isMob) activeTab = 'edit'
223301
}
224302
225303
function addWord() {
226304
// setTimeout(wordListRef?.scrollToBottom, 100)
227305
isOperate = true
228-
wordForm = getDefaultFormWord()
306+
resetWordForm()
229307
if (isMob) activeTab = 'edit'
230308
}
231309
232310
function closeWordForm() {
233311
isOperate = false
234-
wordForm = getDefaultFormWord()
312+
resetWordForm()
235313
if (isMob) activeTab = 'list'
236314
}
237315
@@ -621,6 +699,7 @@ defineRender(() => {
621699
index={val.index}
622700
showCollectIcon={false}
623701
showMarkIcon={false}
702+
excludeDictId={runtimeStore.editDict.id}
624703
item={val.item}
625704
>
626705
{{
@@ -667,7 +746,15 @@ defineRender(() => {
667746
label-width="7rem"
668747
>
669748
<FormItem label="单词" prop="word">
670-
<BaseInput modelValue={wordForm.word} onUpdate:modelValue={e => (wordForm.word = e)}></BaseInput>
749+
<BaseInput
750+
modelValue={wordForm.word}
751+
onUpdate:modelValue={onWordFormWordChange}
752+
searchable
753+
searchLoading={wordSearchLoading}
754+
clearable
755+
onSearch={searchOfficialWord}
756+
onEnter={searchOfficialWord}
757+
/>
671758
</FormItem>
672759
<FormItem label="英音音标">
673760
<BaseInput modelValue={wordForm.phonetic0} onUpdate:modelValue={e => (wordForm.phonetic0 = e)} />

apps/nuxt/app/pages/(words)/practice-words/[id].vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useStartKeyboardEventListener } from '@typewords/core/hooks/event.ts'
99
import { useDisableEventListener } from '@typewords/utils'
1010
import useTheme from '@typewords/core/hooks/theme.ts'
1111
import { getCurrentStudyWord, useWordOptions } from '@typewords/core/hooks/dict.ts'
12+
import { openWordCollectPicker } from '@typewords/core/hooks/useWordCollectPicker.ts'
1213
import {
1314
_getDictDataByUrl,
1415
_nextTick,
@@ -54,7 +55,7 @@ import WordMarkPickList, { type WordMarkPickResult } from '@typewords/core/compo
5455
import { buildQuestion } from '@typewords/core/utils/word-test.ts'
5556
import CollectNotice from '@typewords/core/components/dialog/CollectNotice.vue'
5657
57-
const { toggleWordCollect, isWordSimple, toggleWordSimple } = useWordOptions()
58+
const { isWordSimple, toggleWordSimple } = useWordOptions()
5859
const settingStore = useSettingStore()
5960
const runtimeStore = useRuntimeStore()
6061
const { toggleTheme } = useTheme()
@@ -810,7 +811,12 @@ function show(e: KeyboardEvent) {
810811
}
811812
812813
function collect(e: KeyboardEvent) {
813-
toggleWordCollect(word)
814+
const anchor = typingRef?.getCollectAnchor?.() as HTMLElement | null | undefined
815+
openWordCollectPicker(
816+
word,
817+
anchor ?? { x: window.innerWidth / 2, y: window.innerHeight / 3 },
818+
{ excludeDictId: store.sdict.id ? String(store.sdict.id) : undefined }
819+
)
814820
}
815821
816822
function play() {
@@ -1084,6 +1090,7 @@ useEvents([
10841090
:list="data.words"
10851091
:activeIndex="data.index"
10861092
:excludeWords="data.excludeWords"
1093+
:exclude-dict-id="store.sdict.id ? String(store.sdict.id) : undefined"
10871094
@click="(val: any) => (data.index = val.index)"
10881095
>
10891096
</WordList>

apps/nuxt/app/pages/setting.vue

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@ function getShortcutKeyName(key: string): string {
196196
ChooseB: t('shortcut_choose_b'),
197197
ChooseC: t('shortcut_choose_c'),
198198
ChooseD: t('shortcut_choose_d'),
199+
PlaySentence1: t('shortcut_play_sentence_1'),
200+
PlaySentence2: t('shortcut_play_sentence_2'),
201+
PlaySentence3: t('shortcut_play_sentence_3'),
202+
PlaySentence4: t('shortcut_play_sentence_4'),
203+
PlaySentence5: t('shortcut_play_sentence_5'),
204+
PlaySentence6: t('shortcut_play_sentence_6'),
205+
PlaySentence7: t('shortcut_play_sentence_7'),
206+
PlaySentence8: t('shortcut_play_sentence_8'),
207+
PlaySentence9: t('shortcut_play_sentence_9'),
199208
}
200209
201210
return shortcutKeyNameMap[key] || key
@@ -590,11 +599,8 @@ function removeSbConfig() {
590599
<IconFluentDatabasePerson20Regular />
591600
<span>{{ $t('data_management') }}</span>
592601
</div>
593-
<div
594-
class="tab"
595-
:class="tabIndex === 6 && 'active'"
596-
@click="tabIndex = 6"
597-
>
602+
603+
<div class="tab" :class="tabIndex === 6 && 'active'" @click="tabIndex = 6">
598604
<IconFluentCloudSync20Regular />
599605
<span>{{ $t('data_sync') }}</span>
600606
<div class="red-point" v-if="runtimeStore.isError"></div>
@@ -603,17 +609,7 @@ function removeSbConfig() {
603609
<IconFluentKeyboardLayoutFloat20Regular />
604610
<span>{{ $t('shortcut_settings') }}</span>
605611
</div>
606-
<div
607-
class="tab"
608-
:class="tabIndex === 8 && 'active'"
609-
@click="
610-
() => {
611-
tabIndex = 8
612-
// runtimeStore.isNew = false
613-
// settingStore.webAppVersion = APP_VERSION.version
614-
}
615-
"
616-
>
612+
<div class="tab" :class="tabIndex === 8 && 'active'" @click="tabIndex = 8">
617613
<IconFluentTextBulletListSquare20Regular />
618614
<span>{{ $t('update_log') }}</span>
619615
<!-- <div class="red-point" v-if="runtimeStore.isNew"></div>-->

apps/nuxt/i18n/i18n.xlsx

862 Bytes
Binary file not shown.

apps/nuxt/i18n/locales/zh.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@
9090
"data_management": "数据管理",
9191
"shortcut_settings": "快捷键设置",
9292
"update_log": "更新日志",
93+
"release_banner_cta": "查看详情",
94+
"release_page_title": "功能更新",
95+
"release_page_desc": "按版本汇总的重要功能与改进",
96+
"release_feature_new": "新功能",
97+
"release_feature_improve": "优化",
98+
"release_feature_fix": "修复",
99+
"release_latest": "当前版本",
100+
"release_view_full_log": "查看完整更新日志",
93101
"function": "功能",
94102
"shortcut_key": "快捷键(点击可修改)",
95103
"no_shortcut_set": "未设置快捷键",
@@ -192,6 +200,11 @@
192200
"apply": "应用",
193201
"add": "添加",
194202
"collect": "收藏",
203+
"collect_to_dict": "收藏到词典",
204+
"already_in_dict": "该单词已在此词典中",
205+
"add_custom_dict": "新增词典",
206+
"please_enter_dict_name": "请输入词典名称",
207+
"add_failed": "添加失败",
195208
"uncollect": "取消收藏",
196209
"mark_mastered": "标记为已掌握",
197210
"unmark_mastered": "取消标记已掌握",
@@ -508,6 +521,17 @@
508521
"qa13_q4": "也可以去 github/issues 提交",
509522
"random_words_test": "随机单词测试",
510523
"practice_sentence": "练习例句",
524+
"auto_play_first_sentence": "自动播放首条例句",
525+
"auto_play_first_sentence_desc": "单词发音结束后自动朗读第一条英文例句(切词、重复、揭示答案等场景),可在「音效设置 → 例句发音」中开启",
526+
"shortcut_play_sentence_1": "播放例句 1",
527+
"shortcut_play_sentence_2": "播放例句 2",
528+
"shortcut_play_sentence_3": "播放例句 3",
529+
"shortcut_play_sentence_4": "播放例句 4",
530+
"shortcut_play_sentence_5": "播放例句 5",
531+
"shortcut_play_sentence_6": "播放例句 6",
532+
"shortcut_play_sentence_7": "播放例句 7",
533+
"shortcut_play_sentence_8": "播放例句 8",
534+
"shortcut_play_sentence_9": "播放例句 9",
511535
"fsrs_desc": "基于“艾宾浩斯遗忘曲线”设计,系统会根据你的记忆情况,自动安排最合适的复习时间。 \n不再机械重复,让你用更少时间记住更多单词。 \n记得越牢,复习间隔越长;容易忘的内容,会更频繁出现。",
512536
"fsrs_settings": "遗忘曲线设置",
513537
"fsrs_limit_desc": "在学习过程中对每个单词错误次数进行评级,共有四个评级,Again,Hard,Good,Easy,遗忘曲线算法会使用评级来计算这个单词下一次需要复习的时间。",
@@ -628,6 +652,12 @@
628652
"supabase_url_required": "请输入 Supabase Url",
629653
"supabase_key_required": "请输入 Supabase Key",
630654
"word_pronunciation": "单词发音",
655+
"master_volume": "总音量",
656+
"master_speed": "总倍速",
657+
"expand_detail_settings": "展开详细设置",
658+
"sentence_pronunciation": "例句发音",
659+
"sentence_volume": "例句音量",
660+
"sentence_speed": "例句倍速",
631661
"tts_voice": "TTS 声色",
632662
"tts_voice_preview_sentence": "试听句子:",
633663
"tts_voice_setting_title": "TTS 声色",

0 commit comments

Comments
 (0)