Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/components/setting/WordSetting.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ const settingStore = useSettingStore()
<Switch v-model="settingStore.showUsageTips" />
</SettingItem>

<div class="line"></div>
<SettingItem mainTitle="自由练习总结" />
<SettingItem
title="默写时整词核对"
desc="仅作用于自由练习中临时开启的默写。开启后可完整输入单词,按空格时再统一核对;关闭时保持逐字即时纠错。"
>
<Switch v-model="settingStore.freePracticeWholeWordCheck" />
</SettingItem>
<SettingItem
title="总结错误次数门槛"
desc="练习总结会列出拼写错误次数大于或等于该数值的单词"
>
<InputNumber v-model="settingStore.freePracticeSummaryWrongThreshold" :min="1" :max="20" />
<span>次</span>
</SettingItem>

<div class="line"></div>
<SettingItem :mainTitle="`例句设置`" />
<SettingItem :title="$t('practice_sentence')">
Expand Down
99 changes: 97 additions & 2 deletions app/components/word/Statistics.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { useBaseStore } from '@/core/stores/base.ts'
import { BaseButton, Progress } from '@/base'
import type { PracticeData } from '@/core/composables/practice-words/practice-word-session.ts'
import { ShortcutKey } from '@/core/types/enum.ts'
import { ShortcutKey, WordPracticeMode } from '@/core/types/enum.ts'
import { emitter, useEvents } from '@/core/utils/eventBus.ts'
import { useSettingStore } from '@/core/stores/setting.ts'
import { usePracticeStore } from '@/core/stores/practice.ts'
Expand All @@ -13,6 +13,12 @@ import isoWeek from 'dayjs/plugin/isoWeek'
import { msToHourMinute } from '@/core/utils'
import ChannelIcons from '@/components/channel-icons/ChannelIcons.vue'
import { useI18n } from 'vue-i18n'
import saveAs from 'file-saver'
import {
buildFreePracticeSummary,
renderFreePracticeSummaryMarkdown,
renderFreePracticeSummaryText,
} from '@/core/composables/practice-words/practice-summary.ts'

dayjs.extend(isoWeek)
dayjs.extend(isBetween)
Expand All @@ -28,6 +34,33 @@ const statStore = usePracticeStore()
const model = defineModel({ default: false })
let list = $ref<boolean[]>([])
const practiceData = inject<PracticeData>('practiceData')!
const isFreePractice = $computed(() => settingStore.wordPracticeMode === WordPracticeMode.Free)
const practiceSummary = $computed(() =>
buildFreePracticeSummary(practiceData.spellingMistakes, settingStore.freePracticeSummaryWrongThreshold)
)

const summaryDocumentInfo = $computed(() => ({
dictName: store.sdict.name,
startedAt: dayjs(statStore.startDate).format('YYYY-MM-DD HH:mm:ss'),
duration: msToHourMinute(statStore.spend, true),
totalWords: statStore.total,
newWords: statStore.newWordNumber,
reviewWords: statStore.reviewWordNumber,
}))

function sanitizeFileName(value: string): string {
return value.replace(/[\\/:*?"<>|]/g, '-').trim() || '未命名词典'
}

function exportPracticeSummary(format: 'txt' | 'md') {
const content =
format === 'md'
? renderFreePracticeSummaryMarkdown(practiceSummary, summaryDocumentInfo)
: renderFreePracticeSummaryText(practiceSummary, summaryDocumentInfo)
const type = format === 'md' ? 'text/markdown;charset=utf-8' : 'text/plain;charset=utf-8'
const fileName = `自由练习总结-${sanitizeFileName(store.sdict.name)}-${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.${format}`
saveAs(new Blob(['\uFEFF', content], { type }), fileName)
}

function calcWeekList() {
// 获取本周的起止时间
Expand Down Expand Up @@ -99,7 +132,7 @@ const encouragementText = $computed(() => {

<template>
<Dialog v-model="model" :close-on-click-bg="false" :header="false" :keyboard="false" :show-close="false">
<div class="p-8 pr-3 bg-[var(--bg-card-primary)] min-w-130 rounded-2xl">
<div class="p-8 pr-3 bg-[var(--bg-card-primary)] min-w-130 max-h-[90vh] overflow-y-auto rounded-2xl">
<!-- Header Section -->
<div class="text-center relative">
<div
Expand Down Expand Up @@ -155,6 +188,68 @@ const encouragementText = $computed(() => {
</div>
</div>

<div v-if="isFreePractice" class="bg-[var(--bg-card-secend)] rounded-xl p-4 space-y-4">
<div class="text-center">
<div class="font-semibold text-xl">自由练习总结</div>
<div class="text-sm text-[var(--color-font-3)] mt-1">
本次共记录 {{ practiceSummary.totalMistakes }} 次拼写错误;以下错词门槛为
{{ practiceSummary.threshold }} 次
</div>
</div>

<div>
<div class="font-medium mb-2">错误达到 {{ practiceSummary.threshold }} 次的单词</div>
<div v-if="practiceSummary.wrongWords.length" class="flex gap-space flex-wrap">
<span
v-for="item in practiceSummary.wrongWords"
:key="item.word"
class="bg-[var(--bg-card-primary)] py-1 px-2 rounded-md"
>
{{ item.word }} {{ item.count }}次
</span>
</div>
<div v-else class="text-sm text-[var(--color-font-3)]">本次没有达到门槛的单词</div>
</div>

<div>
<div class="font-medium mb-2">常见拼写错误</div>
<div v-if="practiceSummary.spellingWordGroups.length" class="space-y-3">
<div
v-for="group in practiceSummary.spellingWordGroups.slice(0, 10)"
:key="group.word"
class="bg-[var(--bg-card-primary)] rounded-lg px-3 py-3 text-sm"
>
<div class="flex justify-between gap-4 mb-2">
<span class="font-semibold text-base">{{ group.word }}</span>
<span class="whitespace-nowrap font-medium">共 {{ group.totalCount }}次</span>
</div>
<div class="space-y-1.5">
<div
v-for="item in group.patterns"
:key="item.key"
class="flex justify-between gap-4 border-t border-[var(--color-input-border)] pt-1.5"
>
<span>{{ item.detail }}</span>
<span class="whitespace-nowrap">{{ item.count }}次</span>
</div>
</div>
</div>
<div
v-if="practiceSummary.spellingWordGroups.length > 10"
class="text-sm text-center text-[var(--color-font-3)]"
>
网页仅展示错误次数最高的 10 个单词,导出文件包含全部内容
</div>
</div>
<div v-else class="text-sm text-[var(--color-font-3)]">本次没有记录到拼写错误</div>
</div>

<div class="flex justify-end flex-wrap gap-2">
<BaseButton :disabled="loading" @click="exportPracticeSummary('txt')">导出 TXT</BaseButton>
<BaseButton :disabled="loading" @click="exportPracticeSummary('md')">导出 Markdown</BaseButton>
</div>
</div>

<div class="summary-main w-full gap-3 flex">
<div class="space-y-6 flex-1">
<!-- Weekly Progress -->
Expand Down
15 changes: 13 additions & 2 deletions app/components/word/TypeWord.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
* - WordIdentifyPanel:自测 / WordTest UI
* - WordMetaPanel:音标 / 翻译 / 例句 / 短语 / 词源
*/
import type { Question, Word } from '@/core/types/types.ts'
import type { PracticeSpellingMistake, Question, Word } from '@/core/types/types.ts'
import { getDefaultWord } from '@/core/types/func.ts'
import { ShortcutKey, WordPlayTrigger, WordPracticeType } from '@/core/types/enum.ts'
import { ShortcutKey, WordPlayTrigger, WordPracticeMode, WordPracticeType } from '@/core/types/enum.ts'
import { useBaseStore } from '@/core/stores/base.ts'
import { useSettingStore } from '@/core/stores/setting.ts'
import { usePlayWordAudio } from '@/core/hooks/sound.ts'
Expand Down Expand Up @@ -52,6 +52,7 @@ const props = withDefaults(defineProps<IProps>(), {
const emit = defineEmits<{
complete: []
wrong: [source?: 'identifyTyping']
mistake: [mistake: PracticeSpellingMistake]
know: []
mastered: []
skip: []
Expand Down Expand Up @@ -90,7 +91,15 @@ const localReveal = computed(() => ({
showFullWord,
showWordResult,
}))
const baseEffective = useInjectedDisplayPolicy()
const effective = useInjectedDisplayPolicy(localReveal)
const checkWholeWordOnSpace = computed(
() =>
settingStore.wordPracticeMode === WordPracticeMode.Free &&
props.practiceType === WordPracticeType.FollowWrite &&
baseEffective.value.isWordMasked &&
settingStore.freePracticeWholeWordCheck
)

function playWord(trigger: WordPlayTrigger) {
if (trigger === WordPlayTrigger.Manual || settingStore.wordSound) typeWordController.playWord(trigger)
Expand Down Expand Up @@ -349,10 +358,12 @@ useEvents([
:isWordMasked="effective.isWordMasked"
v-model:showWordResult="showWordResult"
:showFullWord="showFullWord"
:checkWholeWordOnSpace="checkWholeWordOnSpace"
:wordFontSize="settingStore.fontSize.wordForeignFontSize"
@play="playWord"
@complete="onTypingCoreComplete"
@wrong="onTypingCoreWrong"
@mistake="mistake => emit('mistake', mistake)"
/>
</div>
</Tooltip>
Expand Down
25 changes: 24 additions & 1 deletion app/components/word/WordTypingCore.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Word } from '@/core/types/types.ts'
import type { PracticeSpellingMistake, Word } from '@/core/types/types.ts'
import { getDefaultWord } from '@/core/types/func.ts'
import { WordPlayTrigger, WordPracticeType } from '@/core/types/enum.ts'
import { useSettingStore } from '@/core/stores/setting.ts'
Expand All @@ -19,6 +19,7 @@ interface IProps {
showWordResult: boolean
showFullWord: boolean
wordFontSize: number
checkWholeWordOnSpace?: boolean
active?: boolean
}

Expand All @@ -35,6 +36,7 @@ const emit = defineEmits<{
'update:showWordResult': [value: boolean]
complete: []
wrong: []
mistake: [mistake: PracticeSpellingMistake]
play: [trigger: WordPlayTrigger]
}>()

Expand All @@ -49,9 +51,11 @@ const typing = usePracticeWordTyping({
getIsWordMasked: () => props.isWordMasked,
getShowWordResult: () => props.showWordResult,
getSettings: () => settingStore,
getCheckWholeWordOnSpace: () => !!props.checkWholeWordOnSpace,
setShowWordResult: value => emit('update:showWordResult', value),
onComplete: () => emit('complete'),
onWrong: () => emit('wrong'),
onMistake: mistake => emit('mistake', mistake),
onPlay: trigger => emit('play', trigger),
onNotice: notice =>
Toast.info($t(notice.type === 'delete-reinput' ? 'press_delete_reinput' : 'press_space_continue'), {
Expand Down Expand Up @@ -187,6 +191,25 @@ defineExpose({
</div>
</div>

<!-- 自由练习临时默写:先完整输入,按空格后再统一显示结果 -->
<template v-else-if="checkWholeWordOnSpace">
<div>
<div class="letter text-align-center w-full inline-block" v-opacity:noAnim="showWordResult || showFullWord">
{{ word.word }}
</div>
<div
class="mt-2 min-w-120 pb-1 dictation"
:style="{ minHeight: wordFontSize + 'px' }"
:class="showWordResult ? (isWordCorrect ? 'right' : 'wrong') : ''"
>
<template v-for="i in input">
<span class="l" v-if="i !== ' '">{{ i }}</span>
<Space class="l" v-else :is-wrong="showWordResult ? !isWordCorrect : false" :is-wait="!showWordResult" />
</template>
</div>
</div>
</template>

<!-- 非默写模式 -->
<template v-else>
<template v-if="wholeInputAttempt">
Expand Down
Loading