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

227 lines
6.4 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { aiApi, type AiExplain } from '../../api'
import { useResponsive } from '../../composables/useResponsive'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppStack from '../../components/base/AppStack.vue'
import AppError from '../../components/feedback/AppError.vue'
const route = useRoute()
const router = useRouter()
const { isMobile } = useResponsive()
const questionId = computed(() => String(route.query.questionId ?? ''))
const userAnswer = computed(() => String(route.query.userAnswer ?? '').toUpperCase())
const requestType = computed<'standard' | 'deep'>(() =>
route.query.requestType === 'deep' ? 'deep' : 'standard'
)
const loading = ref(true)
const error = ref('')
const explain = ref<AiExplain | null>(null)
onMounted(() => void load())
async function load() {
if (!questionId.value) {
error.value = '缺少题目参数,无法生成讲解'
loading.value = false
return
}
loading.value = true
error.value = ''
try {
explain.value = await aiApi.explain({
questionId: questionId.value,
userAnswer: ['A', 'B', 'C', 'D'].includes(userAnswer.value)
? (userAnswer.value as 'A' | 'B' | 'C' | 'D')
: undefined,
requestType: requestType.value
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'AI 讲解加载失败'
} finally {
loading.value = false
}
}
function back() {
router.back()
}
const title = computed(() => (requestType.value === 'deep' ? 'AI 深度讲解' : 'AI 讲解'))
const isAi = computed(() => explain.value?.source === 'ai')
const mistakeText = computed(() => {
const list = explain.value?.commonMistakes ?? []
return list.length > 0 ? list : ['无明显高频错误']
})
</script>
<template>
<AppStack class="ai-explain">
<AppLoading v-if="loading" fullscreen text="正在生成讲解…" />
<AppError v-else-if="error" fullscreen title="讲解加载失败" :message="error" retry @retry="load" />
<template v-else-if="explain">
<!-- 顶栏 -->
<AppPageHeader :title="title" size="detail" back>
<AppBadge :variant="isAi ? 'primary' : 'soft'">
{{ isAi ? 'AI 生成' : '题库解析回退' }}
</AppBadge>
</AppPageHeader>
<!-- 结论 -->
<AppCard title="讲解结论" icon="sparkles">
<p class="ai-explain__summary">{{ explain.summary }}</p>
<p v-if="!isAi" class="ai-explain__fallback-tip">
AI 未配置或暂时不可用时自动回退到题库标准解析,不会阻塞你的刷题流程。
</p>
</AppCard>
<!-- 分步解析 -->
<AppCard title="分步解析" icon="book-open" v-if="explain.steps.length">
<ol class="ai-explain__steps">
<li v-for="(step, i) in explain.steps" :key="i" class="ai-explain__step">
<span class="ai-explain__step-index">{{ i + 1 }}</span>
<span class="ai-explain__step-text">{{ step }}</span>
</li>
</ol>
</AppCard>
<!-- 考点 -->
<AppCard title="考点" icon="target" v-if="explain.knowledgePoints.length">
<div class="ai-explain__tags">
<AppBadge v-for="(k, i) in explain.knowledgePoints" :key="i" variant="soft">{{ k }}</AppBadge>
</div>
</AppCard>
<!-- 常见错误 -->
<AppCard title="常见错误" icon="alert">
<ul class="ai-explain__mistakes">
<li v-for="(m, i) in mistakeText" :key="i">{{ m }}</li>
</ul>
</AppCard>
<!-- 答案 -->
<AppCard title="正确答案" icon="check">
<div class="ai-explain__answer">
<strong class="ai-explain__answer-key">{{ explain.answer }}</strong>
<span class="ai-explain__answer-label">本题答案为 {{ explain.answer }}</span>
</div>
</AppCard>
<AppButton v-if="!isMobile" variant="ghost" @click="back">
<AppIcon name="chevron-left" :size="16" />返回
</AppButton>
</template>
</AppStack>
</template>
<style scoped>
.ai-explain__summary {
margin: 0;
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--text-primary);
white-space: pre-wrap;
}
.ai-explain__fallback-tip {
margin: var(--space-3) 0 0;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--bg-soft);
font-size: var(--fs-label);
color: var(--text-secondary);
}
.ai-explain__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ai-explain__step {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.ai-explain__step-index {
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: none;
border-radius: 50%;
background: var(--primary-soft, var(--bg-soft));
color: var(--primary);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
}
.ai-explain__step-text {
font-size: var(--fs-subtitle);
line-height: var(--lh-subtitle);
color: var(--text-primary);
white-space: pre-wrap;
}
.ai-explain__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.ai-explain__mistakes {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ai-explain__mistakes li {
position: relative;
padding-left: var(--space-4);
font-size: var(--fs-subtitle);
line-height: var(--lh-subtitle);
color: var(--text-primary);
}
.ai-explain__mistakes li::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warning);
}
.ai-explain__answer {
display: flex;
align-items: center;
gap: var(--space-3);
}
.ai-explain__answer-key {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: none;
border-radius: var(--radius-md);
background: var(--success);
color: var(--on-accent);
font-family: var(--font-num);
font-size: 20px;
font-weight: 700;
}
.ai-explain__answer-label {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
</style>