feat: 添加选项题

This commit is contained in:
zyronon
2025-05-22 03:02:23 +08:00
parent 4146a6e5eb
commit 444c11e717
30 changed files with 2718 additions and 2205 deletions

View File

@@ -3,19 +3,16 @@ import {onMounted, watch} from "vue";
import {BaseState, useBaseStore} from "@/stores/base.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useSettingStore} from "@/stores/setting.ts";
import {cloneDeep} from "lodash-es";
import Backgorund from "@/pages/pc/components/Backgorund.vue";
import useTheme from "@/hooks/theme.ts";
import * as localforage from "localforage";
import SettingDialog from "@/pages/pc/components/dialog/SettingDialog.vue";
import ArticleContentDialog from "@/pages/pc/components/dialog/ArticleContentDialog.vue";
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 {useRoute} from "vue-router";
import {splitEnArticle} from "@/hooks/article.ts";
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
@@ -81,7 +78,6 @@ watch(() => route.path, (to, from) => {
</transition>
</router-view>
<CollectNotice/>
<ArticleContentDialog/>
<SettingDialog/>
</template>

View File

@@ -143,6 +143,12 @@ html.dark {
.anim {
transition: background var(--anim-time), color var(--anim-time), border var(--anim-time);
}
.en-article-family {
font-family: var(--en-article-family);
}
.font-family {
font-family: var(--font-family);
}
html, body {
//font-size: 1px;
@@ -412,25 +418,6 @@ footer {
}
}
&.border {
&.active {
.item-title {
border-bottom: 2px solid gray !important;
}
}
.item-title {
transition: all .3s;
cursor: pointer;
border-bottom: 2px solid transparent;
}
&:hover {
.item-title {
border-bottom: 2px solid gray !important;
}
}
}
.item-title {
display: flex;

View File

@@ -0,0 +1,99 @@
<template>
<div class="question-form en-article-family">
<div class="flex items-center justify-between">
<div class="font-bold">Multiple choice questions 选择题</div>
<div v-if="false">
<button
v-if="!started"
class="bg-blue-600 text-white px-4 py-1 rounded"
@click="startExam"
>开始
</button>
<span v-if="started" class="text-red-600 font-semibold font-family">
倒计时{{ timeLeft }}
</span>
</div>
</div>
<form @submit.prevent>
<QuestionItem
v-for="(q, i) in questions"
:key="i"
ref="questionRefs1"
:question-index="i + 1"
:stem="q.stem"
:options="q.options"
:correct-answer="q.correctAnswer"
:explanation="q.explanation"
:immediate-feedback="props.immediateFeedback"
:randomize="props.randomize"
@answered="onAnswered"
/>
</form>
<div class="flex-center items-center gap-2 mt-10">
<button
class="bg-green-600 text-white px-6 py-2 rounded"
@click="submitAll"
>提交试卷
</button>
<span class="text-xl">浅红错误 深红未选 绿正确</span>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, useTemplateRef} from 'vue'
import QuestionItem from './QuestionItem.vue'
interface IProps {
questions: Array,
duration: Number,
immediateFeedback: Boolean,
randomize: Boolean
}
const props = withDefaults(defineProps<IProps>(), {
questions: [],
duration: 300,
immediateFeedback: false,
randomize: false
})
const questionRefs = useTemplateRef('questionRefs1')
const started = ref(false)
const timeLeft = ref(props.duration || 300)
let timer = null
const startExam = () => {
started.value = true
timeLeft.value = props.duration || 300
timer = setInterval(() => {
timeLeft.value--
if (timeLeft.value <= 0) {
clearInterval(timer)
submitAll()
}
}, 1000)
}
const onAnswered = (res) => {
console.log('Answered:', res)
// 可收集中间过程(非必须)
}
const submitAll = () => {
console.log(questionRefs)
questionRefs.value.forEach((q) => q.submit())
const results = questionRefs.value.map((q) => q.getResult())
const correctCount = results.filter(r => r.isCorrect).length
const wrongCount = results.length - correctCount
console.log('最终结果:', results)
ElMessage({message: `${results.length} 题,答对 ${correctCount},答错 ${wrongCount}`})
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,194 @@
<template>
<div ref="container" class="question-item mb-4">
<div class="mb-1 "
:class="noChoseClass"
><span class="font-family">{{ questionIndex }}</span>. {{ stem }}
</div>
<div
class="grid gap-1"
:class="layoutClass"
>
<label
v-for="(opt, i) in shuffledOptions"
:key="i"
class="option border rounded cursor-pointer hover:bg-gray-300"
:class="feedbackClass(i)"
>
<input
:type="isMultiple ? 'checkbox' : 'radio'"
:name="`question-${questionIndex}`"
class="mr-2"
:value="opt"
v-model="userSelection"
@change="onSelect"
/>
<span ref="optionRefs">(<span class="italic">{{ ['a', 'b', 'c', 'd'][i] }}</span>) {{ opt }}</span>
</label>
</div>
<div v-if="explanation && isSubmitted" class="mt-2 text-xl text-gray-600">
解析{{ explanation }}
</div>
</div>
</template>
<script setup>
import {ref, computed, watch, onMounted, nextTick} from 'vue'
import {shuffle} from "lodash-es";
const props = defineProps({
stem: String,
options: Array,
correctAnswer: Array, // ['a', 'b']
explanation: String,
immediateFeedback: Boolean,
questionIndex: Number,
randomize: Boolean
})
const emit = defineEmits(['answered'])
// 将选项打乱并映射回原始下标
const originalOptions = props.options
const shuffledOptions = ref([])
const answerMap = ref([]) // 映射 shuffled[i] 对应原始 index用于判分
const isMultiple = computed(() => props.correctAnswer.length > 1)
const userSelection = ref(isMultiple.value ? [] : '')
const isSubmitted = ref(false)
const isCorrect = ref(null)
// 初始化打乱选项
const initOptions = () => {
const indices = originalOptions.map((_, i) => i)
const shuffledIndices = props.randomize ? shuffle(indices) : indices
shuffledOptions.value = shuffledIndices.map(i => originalOptions[i])
answerMap.value = shuffledIndices
}
initOptions()
const getLetter = (index) => ['a', 'b', 'c', 'd'][index]
const getOriginalLetter = (shuffledIndex) => getLetter(answerMap.value[shuffledIndex])
const onSelect = () => {
if (props.immediateFeedback) submit()
emitAnswer()
}
const emitAnswer = () => {
const selectedLetters = isMultiple.value
? userSelection.value.map(val => getOriginalLetter(shuffledOptions.value.indexOf(val)))
: [getOriginalLetter(shuffledOptions.value.indexOf(userSelection.value))]
const isAnswerCorrect =
selectedLetters.sort().join() === props.correctAnswer.sort().join()
emit('answered', {
index: props.questionIndex,
selected: selectedLetters,
isCorrect: isAnswerCorrect
})
}
const submit = () => {
isSubmitted.value = true
const selectedLetters = isMultiple.value
? userSelection.value.map(val => getOriginalLetter(shuffledOptions.value.indexOf(val)))
: [getOriginalLetter(shuffledOptions.value.indexOf(userSelection.value))]
isCorrect.value =
selectedLetters.sort().join() === props.correctAnswer.sort().join()
}
const feedbackClass = (i) => {
if (!isSubmitted.value) return ''
const selected = isMultiple.value
? userSelection.value.includes(shuffledOptions.value[i])
: userSelection.value === shuffledOptions.value[i]
const correct = props.correctAnswer.includes(getOriginalLetter(i))
if (correct) return 'bg-green-200'
if (selected && !correct) return 'bg-red-200'
return ''
}
const noChoseClass = computed(() => {
if (!isSubmitted.value) return ''
const selected = isMultiple.value
? userSelection.value.length
: userSelection.value
return !selected && 'bg-red-400'
})
// 父组件调用此方法统一评分
defineExpose({
submit,
getResult: () => {
const selectedLetters = isMultiple.value
? userSelection.value.map(val => getOriginalLetter(shuffledOptions.value.indexOf(val)))
: [getOriginalLetter(shuffledOptions.value.indexOf(userSelection.value))]
return {
index: props.questionIndex,
selected: selectedLetters,
isCorrect: selectedLetters.sort().join() === props.correctAnswer.sort().join()
}
}
})
const optionRefs = ref([])
const container = ref(null)
const layoutClass = ref('')
const calculateLayout = () => {
if (!container.value || optionRefs.value.length === 0) return
const containerWidth = container.value.clientWidth
const widths = optionRefs.value.map(el => el.getBoundingClientRect().width)
const totalWidth = widths.reduce((sum, w) => sum + w, 0)
// console.log(widths,totalWidth)
// 如果任意选项宽度超过容器一半
if (widths.some(w => w > containerWidth / 2)) {
layoutClass.value = 'grid-cols-1'
return
}
// 如果所有选项都可以在一行内放下
if (totalWidth + 80 * (widths.length - 1) <= containerWidth) {
layoutClass.value = 'grid-cols-4'
return
}
// 否则 2 列
layoutClass.value = 'grid-cols-2'
}
onMounted(async () => {
await nextTick()
calculateLayout()
const resizeObserver = new ResizeObserver(() => {
calculateLayout()
})
resizeObserver.observe(container.value)
})
watch(() => props.options, async () => {
await nextTick()
calculateLayout()
})
</script>
<style scoped>
.option {
white-space: normal;
text-overflow: unset;
overflow: visible;
word-break: keep-all;
padding: 5px;
border-radius: 6px;
transition: all 0.2s ease;
}
</style>

View File

@@ -1,97 +0,0 @@
<script setup lang="ts">
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {onMounted, onUnmounted, watch} from "vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import WordList from "@/pages/pc/components/list/WordList.vue";
import {Article, DefaultArticle} from "@/types.ts";
import {cloneDeep} from "lodash-es";
import Empty from "@/components/Empty.vue";
import {getTranslateText} from "@/hooks/article.ts";
let show = $ref(false)
let loading = $ref(false)
const runtimeStore = useRuntimeStore()
let article: Article = $ref(cloneDeep(DefaultArticle))
onMounted(() => {
emitter.on(EventKey.openArticleContentModal, (val: any) => {
show = true
article = cloneDeep(val)
})
})
onUnmounted(() => {
emitter.off(EventKey.openArticleContentModal)
})
</script>
<template>
<Dialog
:header="false"
v-model="show">
<div class="content">
<div class="article-content">
<div class="title">
<div>{{ article.title }}</div>
</div>
<div class="text" v-if="article.text">
<div class="sentence" v-for="t in article.text.split('\n\n')">{{ t }}</div>
</div>
<Empty v-else/>
</div>
<div class="article-content">
<div class="title">
<div>{{ article.titleTranslate }}</div>
</div>
<div class="text" v-if="getTranslateText(article).length">
<div class="sentence" v-for="t in getTranslateText(article)">{{ t }}</div>
</div>
<Empty v-else/>
</div>
</div>
</Dialog>
</template>
<style lang="scss" scoped>
@import "@/assets/css/style";
.content {
width: 70vw;
height: 75vh;
display: flex;
gap: var(--space);
padding: var(--space);
color: var(--color-font-1);
.article-content {
flex: 1;
overflow: hidden;
font-size: 1.2rem;
display: flex;
flex-direction: column;
.title {
text-align: center;
margin-bottom: var(--space);
font-size: 1.4rem;
}
.text {
text-indent: 1.5em;
line-height: 2rem;
overflow: auto;
padding-right: .6rem;
padding-bottom: 3rem;
.sentence {
margin-bottom: 1rem;
}
}
}
}
</style>

View File

@@ -59,7 +59,7 @@ defineExpose({scrollToBottom, scrollToItem})
<slot name="prefix" :item="item" :index="index"></slot>
</template>
<template v-slot="{ item, index }">
<div class="item-title" @click.stop="emit('title',{item,index})">
<div class="item-title" >
<div class="name"> {{ `${searchKey ? '' : (index + 1) + '. '}${item.title}` }}</div>
</div>
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">

View File

@@ -7,14 +7,12 @@ const props = withDefaults(defineProps<{
activeIndex?: number,
activeId?: string,
isActive?: boolean
showBorder?: boolean
static?: boolean
}>(), {
list: [],
activeIndex: -1,
activeId: '',
isActive: false,
showBorder: false,
static: true
})
@@ -124,7 +122,6 @@ defineExpose({scrollToBottom, scrollToItem})
<div class="common-list-item"
:class="{
active:itemIsActive(item,index),
border:showBorder
}"
@click="emit('click',{item,index})"
>
@@ -154,7 +151,6 @@ defineExpose({scrollToBottom, scrollToItem})
<div class="common-list-item"
:class="{
active:itemIsActive(item,index),
border:showBorder
}"
@click="emit('click',{item,index})"
>

View File

@@ -362,7 +362,6 @@ defineExpose({getDictDetail, add, editDict})
<BaseIcon
style="position:absolute;right: 0;"
title="大屏显示"
@click="emitter.emit(EventKey.openArticleContentModal,article)"
icon="iconoir:expand"/>
</div>
<div class="text" v-if="article.text">
@@ -376,7 +375,6 @@ defineExpose({getDictDetail, add, editDict})
<BaseIcon
style="position:absolute;right: 0;"
title="大屏显示"
@click="emitter.emit(EventKey.openArticleContentModal,article)"
icon="iconoir:expand"/>
</div>
<div class="text" v-if="getTranslateText(article).length">

View File

@@ -15,6 +15,7 @@ import {useToast} from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-sugar.css';
import {getTranslateText} from "@/hooks/article.ts";
import BaseButton from "@/components/BaseButton.vue";
import QuestionForm from "@/pages/pc/components/QuestionForm.vue";
interface IProps {
article: Article,
@@ -348,7 +349,6 @@ onMounted(() => {
wrong = input = ''
})
emitter.on(EventKey.onTyping, onTyping)
})
onUnmounted(() => {
@@ -358,15 +358,106 @@ onUnmounted(() => {
defineExpose({showSentence, play, del, hideSentence, nextSentence})
let list = $ref([
{
stem: "The writer thought_____?",
options: [
"he had lost his money",
"someone had stolen his money",
"the manager had the money",
"the girl had stolen the money"
],
correctAnswer: ["b"],
explanation: "根据课文第23行'I left the money in my room,'I said, and it's not there now',只有(b)someone had stolen his money 符合作者的推测。其他3个选择都不正确。"
},
{
stem: "What had really happened?",
options: [
"The writer had lost the money.",
"The girl had stolen the money.",
"The manager had taken the money.",
"Someone had stolen the money."
],
correctAnswer: ["a"],
explanation: "根据课文的情节,只有(a)The writer had lost the money是正确的符合课文的原义。(b)The girl had stolen themoney不符合课文的情况因为是这个女孩捡到了钱而不是她偷了钱(c)The manager had taken the money 更与事实不符;(d)Someone had stolen the money与实际情况不符。"
},
{
stem: "The money____ in his room.",
options: ["was", "were", "are", "has"],
correctAnswer: ["a"],
explanation: "(b)were不符合语法因为The money是单数不可数名词故不能用were作谓语动词(c)are 也不合乎语法因为它也不能作money的谓语动词(d)has不符合题义若选(d)此句意思讲不通;只有(a)was合乎语法。"
},
{
stem: "He could do nothing. He couldn't do____.",
options: ["something", "nothing", "anything", "everything"],
correctAnswer: ["c"],
explanation: "只有(c)anything可以用在否定句中。(a)something不能用于否定句(b)nothing若用在否定句中双重否定会变成肯定意义的句子不合题义(d)everything 一般不用于这种否定句中。"
},
{
stem: "A knock at the door____ him.",
options: ["interrupted", "was interrupted", "interrupting", "was interrupting"],
correctAnswer: ["a"],
explanation: "只有(a)interrupted最合乎语法。(b)was interrupted是被动语态这个句子不应该是被动语态。(c)interrupting是现在分词不能作谓语。(d)was interrupting是过去进行时interrupt(打断)是表示一个瞬间动作,即敲门声是一下子打断了他的话,而不是正在打断。故应该用一般过去时,不应该用过去进行时。"
},
{
stem: "Where did she find the money? ____ the room.",
options: ["Outside", "Out of", "Out", "Without"],
correctAnswer: ["a"],
explanation: "(a)Outside(prep.在……外)最符合逻辑,因为只有(a)能回答地点where。(b)Out of(prep.从……里面……)强调从里面向外,不合乎题义;(c)out不是介词因此不能同 the room 构成表示地点的短语;(d)without(prep.没有)不表示地点,更不符合题义。"
},
{
stem: "____ room was it? — This gentleman's.",
options: ["To whom", "Who", "Whose", "Of whom"],
correctAnswer: ["c"],
explanation: "这是一个对定语(所有格)提问的疑问句。(a)To whom是对宾语提问(b)Who是对主语提问(d)Of whom 也是对宾语提问;只有(c)Whose(谁的)是对定语提问,所以应该选(c)。"
},
{
stem: "The writer had lost his money. He felt upset. He must have been____.",
options: ["sick", "ill", "worried", "tired"],
correctAnswer: ["c"],
explanation: "只有(c)worried(着急,忧虑)同前一句中的upset(不安)意思相近。(a)sick(有病的,恶心的)、(b)ill(有病的)与(d)tired(疲劳的,厌倦的)这3个选择都不合乎题义。"
},
{
stem: "The manager was sympathetic. ____.",
options: [
"Everyone liked him",
"He liked everyone",
"He was sorry for the writer",
"He liked the writer"
],
correctAnswer: ["c"],
explanation: "只有(c)He was sorry for the writer(他为作者感到难过或惋惜)才能解释前面的句子The manager was sympathetic(表示同情的)。而其他3个选择(a)Everyone liked him(每个人都喜欢他)、(b)He liked everyone(他喜欢每个人)与(d)He liked the writer(他喜欢作者)都与前句意思不符。"
},
{
stem: "He lost his money. His money was____.",
options: ["losing", "missing", "going away", "disappearing"],
correctAnswer: ["b"],
explanation: "(a)losing(丢失)不正确。若选(a)主语应该是人而不应该是money;(c)going away(走开,离开)词义不对;(d)disappearing(消失,失踪)词义不够恰当;只有(b)missing(丢掉的,失去的)词义最准确,而且可以作表语。"
},
{
stem: "You can't post this letter without____.",
options: ["an envelope", "a packet", "some string", "a pen"],
correctAnswer: ["a"],
explanation: "只有选(a)an envelope(信封)最合乎逻辑和事实。(b)apacket(一包)、(c)some string(一些细绳)和(d)a pen(一枝钢笔)这3个选择都不符合实际情况。"
},
{
stem: "The girl returned the money. She was very____.",
options: ["honourable", "honest", "honoured", "trusting"],
correctAnswer: ["b"],
explanation: "只有选(b)honest(诚实的)最合乎逻辑。(a)honourable(光荣的,体面的)、(c)honoured(感到荣幸的,受到尊敬的)与(d)trusting(信任的)这3个词都不如honest合乎逻辑。"
}
])
let show = $ref(false)
</script>
<template>
<div class="typing-article" ref="typeArticleRef">
<header class="mb-4">
<div class="title word">{{ props.article.title }}</div>
<div class="titleTranslate" v-if="settingStore.translate">{{ props.article.titleTranslate }}</div>
</header>
<div class="article-content" ref="articleWrapperRef">
<article :class="[
settingStore.translate && 'tall',
@@ -449,12 +540,15 @@ defineExpose({showSentence, play, del, hideSentence, nextSentence})
</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
v-if="store.currentArticleDict.lastLearnIndex < store.currentArticleDict.articles.length - 1"
@click="emitter.emit(EventKey.next)">下一章</BaseButton>
@click="emitter.emit(EventKey.next)">下一章
</BaseButton>
</div>
<div class="translate-bottom" v-if="settingStore.translate">
<div class="translate-bottom mb-10" v-if="settingStore.translate">
<header class="mb-4">
<div class="text-2xl center">{{ props.article.titleTranslate }}</div>
</header>
@@ -462,6 +556,18 @@ defineExpose({showSentence, play, del, hideSentence, nextSentence})
<div class="text-xl mb-4 indent-8" v-for="t in getTranslateText(article)">{{ t }}</div>
</template>
</div>
<div class="flex-center">
<BaseButton @click="show =! show">显示题目</BaseButton>
</div>
<div class="toggle" v-if="show">
<QuestionForm :questions="list"
:duration="300"
:immediateFeedback="false"
:randomize="true"
/>
</div>
</div>
</template>
@@ -478,6 +584,7 @@ defineExpose({showSentence, play, del, hideSentence, nextSentence})
width: 100%;
overflow: auto;
color: var(--color-article);
font-size: 1.6rem;
header {
word-wrap: break-word;
@@ -504,7 +611,6 @@ defineExpose({showSentence, play, del, hideSentence, nextSentence})
}
article {
font-size: 1.6rem;
line-height: 1.3;
word-break: keep-all;
word-wrap: break-word;

View File

@@ -115,6 +115,7 @@ function getCurrentPractice() {
function saveArticle(val: Article) {
console.log('saveArticle', val, JSON.stringify(val.lrcPosition))
console.log('saveArticle', val.textTranslate)
showEditArticle = false
let rIndex = store.currentArticleDict.articles.findIndex(v => v.id === val.id)
if (rIndex > -1) {
@@ -347,9 +348,7 @@ const {playSentenceAudio} = usePlaySentenceAudio()
<ArticleList
:isActive="active"
:static="false"
:show-border="true"
:show-translate="settingStore.translate"
@title="e => emitter.emit(EventKey.openArticleContentModal,e.item)"
@click="handleChangeChapterIndex"
:active-id="articleData.article.id"
:list="articleData.articles ">

View File

@@ -132,7 +132,7 @@ export const DefaultBaseState = (): BaseState => ({
type: DictType.article,
resourceId: 'article_nce2',
length: 96,
lastLearnIndex:10
lastLearnIndex:23
},
],
wordDictList: [

View File

@@ -7,7 +7,6 @@ export const EventKey = {
changeDict: 'changeDict',
openStatModal: 'openStatModal',
openWordListModal: 'openWordListModal',
openArticleContentModal: 'openArticleContentModal',
openDictModal: 'openDictModal',
openArticleListModal: 'openArticleListModal',
closeOther: 'closeOther',