206 lines
6.1 KiB
Vue
206 lines
6.1 KiB
Vue
<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>
|