gwy-exam/client/src/views/practice/CustomView.vue

206 lines
6.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { practiceApi, questionsApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { usePracticeStore } from '../../stores/practice'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppStack from '../../components/base/AppStack.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
import { useToast } from '../../composables/useToast'
const router = useRouter()
const practice = usePracticeStore()
const toast = useToast()
const list = useRequest(() => questionsApi.list({ page: 1, pageSize: 100 }))
const selected = ref<string[]>([])
const starting = ref(false)
const items = computed(() => list.data.value?.items ?? [])
const maxReached = computed(() => selected.value.length >= 20)
function toggle(id: string) {
const idx = selected.value.indexOf(id)
if (idx >= 0) {
selected.value.splice(idx, 1)
} else {
if (selected.value.length >= 20) {
toast.info('自定义组卷最多选 20 题')
return
}
selected.value.push(id)
}
}
function toggleAll() {
if (selected.value.length === items.value.length) selected.value = []
else selected.value = items.value.slice(0, 20).map((i) => i.id)
}
const selectedCount = computed(() => selected.value.length)
async function startCustom() {
if (selected.value.length === 0) {
toast.info('请至少选择一题')
return
}
starting.value = true
try {
const module = items.value.find((i) => i.id === selected.value[0])?.module
if (!module) throw new Error('无法确定题目模块')
const session = await practiceApi.start({ module, duration: 15, custom: selected.value })
practice.start({
sessionId: session.sessionId,
module: session.module,
durationMinutes: session.durationMinutes,
total: session.total
})
router.push({ name: 'practice-session', params: { id: session.sessionId } })
} catch (err) {
toast.error(err instanceof Error ? err.message : '组卷失败')
} finally {
starting.value = false
}
}
function back() {
router.push('/practice')
}
</script>
<template>
<AppStack class="custom">
<AppPageHeader title="自定义组卷" subtitle="从题库中勾选题目,组成一套专属练习">
<AppButton variant="ghost" icon="chevron-left" @click="back">返回</AppButton>
</AppPageHeader>
<AppLoading v-if="list.loading.value" fullscreen />
<AppError v-else-if="list.error.value" fullscreen title="加载失败" :message="list.error.value" retry @retry="list.refresh" />
<template v-else>
<!-- 工具条 -->
<div class="custom__toolbar">
<span class="custom__count">已选 <b>{{ selectedCount }}</b> / 20 </span>
<AppButton variant="secondary" size="md" @click="toggleAll">
{{ selectedCount === items.length ? '取消全选' : `全选前20题` }}
</AppButton>
</div>
<!-- 题目列表 -->
<div class="custom__list">
<button
v-for="item in items"
:key="item.id"
type="button"
class="qcard"
:class="{ 'qcard--selected': selected.includes(item.id) }"
@click="toggle(item.id)"
>
<span class="qcard__check">
<AppIcon v-if="selected.includes(item.id)" name="check" :size="14" />
</span>
<div class="qcard__body">
<div class="qcard__meta">
<AppBadge variant="primary">{{ item.module }}</AppBadge>
<AppBadge v-if="item.subModule" variant="soft">{{ item.subModule }}</AppBadge>
<AppBadge variant="warning">{{ item.difficulty }}</AppBadge>
</div>
<p class="qcard__stem">{{ item.stem }}</p>
</div>
</button>
<AppEmpty v-if="items.length === 0" title="题库为空" description="请先到题库管理导入题目。" />
</div>
<!-- 底部固定开始 -->
<div class="custom__footer">
<AppButton block size="lg" icon="pen" :loading="starting" :disabled="selectedCount === 0" @click="startCustom">
开始练习{{ selectedCount }}
</AppButton>
</div>
</template>
</AppStack>
</template>
<style scoped>
.custom__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.custom__count {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.custom__count b {
color: var(--primary);
font-family: var(--font-num);
font-size: var(--fs-card-title);
}
.custom__list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.qcard {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--card-padding-desktop);
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
transition: border-color var(--motion-hover-fast) var(--ease-default);
}
.qcard--selected {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, #fff);
}
.qcard__check {
display: grid;
place-items: center;
width: 22px;
height: 22px;
flex: none;
border-radius: 50%;
border: 2px solid var(--border-subtle);
color: var(--on-accent);
margin-top: 2px;
}
.qcard--selected .qcard__check {
background: var(--primary);
border-color: var(--primary);
}
.qcard__body {
flex: 1;
min-width: 0;
}
.qcard__meta {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.qcard__stem {
margin: 0;
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--text-primary);
white-space: pre-wrap;
}
.custom__footer {
position: sticky;
bottom: 0;
padding-block: var(--space-3);
background: var(--bg-canvas);
}
</style>