Compare commits
No commits in common. "88c479f730e49242f0d164949cbbf147c40b0c05" and "6d8f23d0374b1ab592ef0ac1c4aea141fa834545" have entirely different histories.
88c479f730
...
6d8f23d037
@ -158,7 +158,6 @@ export const aiApi = {
|
|||||||
explain: (body: BodyOf<'/api/ai/explain-question', 'post'>) =>
|
explain: (body: BodyOf<'/api/ai/explain-question', 'post'>) =>
|
||||||
unwrap(apiClient.POST('/api/ai/explain-question', { body }))
|
unwrap(apiClient.POST('/api/ai/explain-question', { body }))
|
||||||
}
|
}
|
||||||
export type AiExplain = SuccessBody<paths['/api/ai/explain-question']['post']>
|
|
||||||
|
|
||||||
// ---- 个人档案 / 设置 ----
|
// ---- 个人档案 / 设置 ----
|
||||||
export const profileApi = {
|
export const profileApi = {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import PracticeView from '../views/practice/PracticeView.vue'
|
|||||||
import AnswerView from '../views/practice/AnswerView.vue'
|
import AnswerView from '../views/practice/AnswerView.vue'
|
||||||
import CustomView from '../views/practice/CustomView.vue'
|
import CustomView from '../views/practice/CustomView.vue'
|
||||||
import WrongDetailView from '../views/practice/WrongDetailView.vue'
|
import WrongDetailView from '../views/practice/WrongDetailView.vue'
|
||||||
import AiExplainView from '../views/practice/AiExplainView.vue'
|
|
||||||
import MockView from '../views/mock/MockView.vue'
|
import MockView from '../views/mock/MockView.vue'
|
||||||
import MockFormView from '../views/mock/MockFormView.vue'
|
import MockFormView from '../views/mock/MockFormView.vue'
|
||||||
import PlanView from '../views/plan/PlanView.vue'
|
import PlanView from '../views/plan/PlanView.vue'
|
||||||
@ -23,7 +22,6 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
{ path: '/practice/custom', name: 'practice-custom', component: CustomView, meta: { title: '自定义组卷' } },
|
{ path: '/practice/custom', name: 'practice-custom', component: CustomView, meta: { title: '自定义组卷' } },
|
||||||
{ path: '/practice/wrong', name: 'practice-wrong', component: PracticeView, meta: { title: '错题本' } },
|
{ path: '/practice/wrong', name: 'practice-wrong', component: PracticeView, meta: { title: '错题本' } },
|
||||||
{ path: '/practice/wrong/:id', name: 'practice-wrong-detail', component: WrongDetailView, meta: { title: '错题详情' } },
|
{ path: '/practice/wrong/:id', name: 'practice-wrong-detail', component: WrongDetailView, meta: { title: '错题详情' } },
|
||||||
{ path: '/practice/explain', name: 'practice-explain', component: AiExplainView, meta: { title: 'AI 讲解' } },
|
|
||||||
{ path: '/practice/essay', name: 'practice-essay', component: PracticeView, meta: { title: '申论' } },
|
{ path: '/practice/essay', name: 'practice-essay', component: PracticeView, meta: { title: '申论' } },
|
||||||
{ path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } },
|
{ path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } },
|
||||||
{ path: '/mock/new', name: 'mock-new', component: MockFormView, meta: { title: '录入模考成绩' } },
|
{ path: '/mock/new', name: 'mock-new', component: MockFormView, meta: { title: '录入模考成绩' } },
|
||||||
|
|||||||
@ -1,257 +0,0 @@
|
|||||||
<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 AppLoading from '../../components/feedback/AppLoading.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>
|
|
||||||
<div 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">
|
|
||||||
<!-- 顶栏 -->
|
|
||||||
<header class="ai-explain__top">
|
|
||||||
<button type="button" class="ai-explain__back" @click="back">
|
|
||||||
<AppIcon name="chevron-left" :size="20" />
|
|
||||||
</button>
|
|
||||||
<h1 class="ai-explain__title">{{ title }}</h1>
|
|
||||||
<AppBadge :variant="isAi ? 'primary' : 'soft'">
|
|
||||||
{{ isAi ? 'AI 生成' : '题库解析回退' }}
|
|
||||||
</AppBadge>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 结论 -->
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ai-explain {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
.ai-explain__top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.ai-explain__back {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.ai-explain__title {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--fs-h2);
|
|
||||||
font-weight: var(--fw-h2);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.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>
|
|
||||||
@ -138,13 +138,6 @@ async function finishSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goAiExplain(questionId: string, userAnswer: string) {
|
|
||||||
router.push({
|
|
||||||
name: 'practice-explain',
|
|
||||||
query: { questionId, userAnswer, requestType: 'standard' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function backToPractice() {
|
function backToPractice() {
|
||||||
router.push('/practice')
|
router.push('/practice')
|
||||||
}
|
}
|
||||||
@ -190,14 +183,6 @@ const finishAccuracy = computed(() => finishResult.value?.accuracy ?? 0)
|
|||||||
<span>正确答案 <b class="is-right">{{ q.correctAnswer }}</b></span>
|
<span>正确答案 <b class="is-right">{{ q.correctAnswer }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="wrong-list__analysis">{{ q.analysis }}</p>
|
<p class="wrong-list__analysis">{{ q.analysis }}</p>
|
||||||
<AppButton
|
|
||||||
variant="ghost"
|
|
||||||
size="md"
|
|
||||||
class="wrong-list__ai"
|
|
||||||
@click="goAiExplain(q.questionId, q.userAnswer)"
|
|
||||||
>
|
|
||||||
<AppIcon name="sparkles" :size="14" />AI 讲解
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -279,15 +264,7 @@ const finishAccuracy = computed(() => finishResult.value?.accuracy ?? 0)
|
|||||||
|
|
||||||
<AppCard v-if="result.analysis" title="解析" icon="book-open">
|
<AppCard v-if="result.analysis" title="解析" icon="book-open">
|
||||||
<p class="single-result__analysis">{{ result.analysis }}</p>
|
<p class="single-result__analysis">{{ result.analysis }}</p>
|
||||||
<AppButton
|
<AppButton block variant="secondary" icon="sparkles" class="single-result__ai">没看懂?让 AI 讲一讲</AppButton>
|
||||||
block
|
|
||||||
variant="secondary"
|
|
||||||
icon="sparkles"
|
|
||||||
class="single-result__ai"
|
|
||||||
@click="goAiExplain(question.id, selected)"
|
|
||||||
>
|
|
||||||
没看懂?让 AI 讲一讲
|
|
||||||
</AppButton>
|
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<div v-if="!result.correct" class="single-result__reason">
|
<div v-if="!result.correct" class="single-result__reason">
|
||||||
@ -648,9 +625,6 @@ const finishAccuracy = computed(() => finishResult.value?.accuracy ?? 0)
|
|||||||
line-height: var(--lh-body);
|
line-height: var(--lh-body);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.wrong-list__ai {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.result-note {
|
.result-note {
|
||||||
margin: var(--space-4) 0 0;
|
margin: var(--space-4) 0 0;
|
||||||
font-size: var(--fs-label);
|
font-size: var(--fs-label);
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppCard from '../../components/base/AppCard.vue'
|
|
||||||
import AppBadge from '../../components/base/AppBadge.vue'
|
|
||||||
import AppIcon from '../../components/base/AppIcon.vue'
|
|
||||||
|
|
||||||
const QUESTION_TYPES = [
|
|
||||||
{ name: '归纳概括', desc: '概括给定资料的主要内容或问题' },
|
|
||||||
{ name: '综合分析', desc: '对观点、现象进行多角度分析' },
|
|
||||||
{ name: '提出对策', desc: '针对问题提出可行解决措施' },
|
|
||||||
{ name: '贯彻执行', desc: '撰写公文、讲话稿等应用文' },
|
|
||||||
{ name: '文章写作', desc: '围绕主题撰写议论文' }
|
|
||||||
] as const
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="essay">
|
|
||||||
<!-- 功能说明 -->
|
|
||||||
<AppCard title="申论功能说明" icon="book-open">
|
|
||||||
<p class="essay__intro">
|
|
||||||
申论是公务员考试笔试科目之一,重点考查阅读理解、综合分析、解决问题与文字表达能力。
|
|
||||||
备考通当前聚焦行测闭环,申论功能正在规划中。
|
|
||||||
</p>
|
|
||||||
<div class="essay__types">
|
|
||||||
<div v-for="t in QUESTION_TYPES" :key="t.name" class="essay__type">
|
|
||||||
<AppBadge variant="primary">{{ t.name }}</AppBadge>
|
|
||||||
<span class="essay__type-desc">{{ t.desc }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<!-- 后续入口 -->
|
|
||||||
<AppCard title="后续能力" icon="sparkles">
|
|
||||||
<ul class="essay__future">
|
|
||||||
<li>
|
|
||||||
<AppIcon name="pen" :size="16" />
|
|
||||||
<span>在线写作:按题型练习,支持计时作答</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<AppIcon name="sparkles" :size="16" />
|
|
||||||
<span>AI 批改:结合 AI 给出分项评分与改进建议</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<AppIcon name="chart" :size="16" />
|
|
||||||
<span>范文库:历年真题范文与素材积累</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<!-- 说明提示 -->
|
|
||||||
<AppCard :padded="true">
|
|
||||||
<div class="essay__notice">
|
|
||||||
<AppIcon name="alert" :size="18" />
|
|
||||||
<p>当前为占位页面,不保存任何写作内容。建议先完成行测「刷题 → 错题复习 → 模考分析」闭环。</p>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.essay {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
.essay__intro {
|
|
||||||
margin: 0 0 var(--space-4);
|
|
||||||
font-size: var(--fs-body);
|
|
||||||
line-height: var(--lh-body);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.essay__types {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.essay__type {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-soft);
|
|
||||||
}
|
|
||||||
.essay__type-desc {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: var(--fs-label);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.essay__future {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.essay__future li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
font-size: var(--fs-subtitle);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.essay__future li svg {
|
|
||||||
color: var(--primary);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.essay__notice {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.essay__notice svg {
|
|
||||||
color: var(--warning);
|
|
||||||
flex: none;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.essay__notice p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--fs-subtitle);
|
|
||||||
line-height: var(--lh-subtitle);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.essay__types {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -13,7 +13,7 @@ import AppIcon from '../../components/base/AppIcon.vue'
|
|||||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||||||
import EssayView from './EssayView.vue'
|
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||||
import { formatPercent, formatDateShort } from '../../utils/format'
|
import { formatPercent, formatDateShort } from '../../utils/format'
|
||||||
import type { IconName } from '../../components/base/icons'
|
import type { IconName } from '../../components/base/icons'
|
||||||
|
|
||||||
@ -387,7 +387,7 @@ const subtitle = computed(() => {
|
|||||||
|
|
||||||
<!-- ============ 申论 tab ============ -->
|
<!-- ============ 申论 tab ============ -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<EssayView />
|
<ComingSoon />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -96,20 +96,6 @@ function toggleReason(reason: string) {
|
|||||||
function backToList() {
|
function backToList() {
|
||||||
router.push('/practice/wrong')
|
router.push('/practice/wrong')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AI 讲解:优先携带最近一次作答(历史最新一条),无历史则省略用户答案 */
|
|
||||||
function goAiExplain() {
|
|
||||||
const questionId = detail.value?.questionId
|
|
||||||
if (!questionId) return
|
|
||||||
router.push({
|
|
||||||
name: 'practice-explain',
|
|
||||||
query: {
|
|
||||||
questionId,
|
|
||||||
userAnswer: detail.value?.history[0]?.userAnswer ?? '',
|
|
||||||
requestType: 'standard'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -163,7 +149,7 @@ function goAiExplain() {
|
|||||||
<AppButton block size="lg" icon="refresh" @click="router.push(`/practice/session`)" disabled>
|
<AppButton block size="lg" icon="refresh" @click="router.push(`/practice/session`)" disabled>
|
||||||
重做此题
|
重做此题
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton block size="lg" variant="secondary" icon="sparkles" @click="goAiExplain">
|
<AppButton block size="lg" variant="secondary" icon="sparkles" @click="router.push(`/questions`)" disabled>
|
||||||
AI 讲解
|
AI 讲解
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
# 任务 11 检查记录:AI 讲解与申论占位
|
|
||||||
|
|
||||||
日期:2026-09-01
|
|
||||||
|
|
||||||
## 交付内容
|
|
||||||
|
|
||||||
### 后端
|
|
||||||
|
|
||||||
- `server/src/ai.ts`(新增):
|
|
||||||
- `readAiConfig()`:从环境变量读取 `AI_BASE_URL` / `AI_API_KEY` / `AI_MODEL`(默认 gpt-4o-mini)/ `AI_TIMEOUT_MS`(默认 20000),未配置返回 null。
|
|
||||||
- `buildMessages()`:组装模块/题干/选项/标准答案/题库解析/标签/用户作答,要求模型输出严格 JSON(summary / steps / knowledgePoints / commonMistakes / answer)。
|
|
||||||
- `parseExplainJson()`:兼容 markdown 代码块等杂文的稳健 JSON 提取与字段校验。
|
|
||||||
- `explainWithAi()`:OpenAI 兼容 Chat Completions 调用(`${baseUrl}/chat/completions` + Bearer),AbortController 超时;未配置/超时/网络/HTTP/解析失败分别抛可识别错误;任何错误信息不含 API Key。
|
|
||||||
- `server/src/handlers/ai.ts`(重写):配置有效 → 调用 AI 并返回 `source:'ai'`;未配置 → `source:'fallback'`(「AI 尚未配置」);超时 → `source:'fallback'`(「AI 请求超时」);其余失败 → `source:'fallback'`(「AI 讲解暂时不可用」)。回退内容为题库解析 + 标签 + 答案,前端可识别。
|
|
||||||
- `server/src/schemas/api.ts`:`AiExplainBodySchema.userAnswer` 改为可选(从错题详情进入且无作答历史时省略)。
|
|
||||||
- `server/.env.example`(新增):记录 PORT / DATA_DIR / AI 配置项说明。
|
|
||||||
- `server/src/env.ts` + `server.ts`(补充):引入 dotenv,启动时最先加载 `server/.env`(进程内环境变量优先),确保 `PORT` / `DATA_DIR` / `AI_*` 在模块读取前就位;实测 `server/.env` 中配置 AI 变量后 `aiConfigured=true` 且讲解走 AI,删除 `.env` 后自动回退。
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
|
|
||||||
- `client/src/views/practice/AiExplainView.vue`(新增):
|
|
||||||
- 路由 `/practice/explain?questionId=&userAnswer=&requestType=`,加载/错误/重试三态,缺少题目参数给出明确提示。
|
|
||||||
- 展示:来源徽章(AI 生成 / 题库解析回退)、讲解结论、分步解析(编号步骤)、考点(chip)、常见错误、正确答案(绿色高亮);回退时显示说明提示条。
|
|
||||||
- `requestType=deep` 时标题为「AI 深度讲解」。
|
|
||||||
- `client/src/views/practice/AnswerView.vue`:
|
|
||||||
- 单题结果「没看懂?让 AI 讲一讲」接入跳转(携带题目 ID + 用户答案)。
|
|
||||||
- 整卷结果页错题回顾每项新增「AI 讲解」入口。
|
|
||||||
- `client/src/views/practice/WrongDetailView.vue`:AI 讲解按钮由占位禁用改为可用,携带题目 ID 与最近一次作答(无历史则省略)。
|
|
||||||
- `client/src/views/practice/EssayView.vue`(新增):申论占位页——功能说明(五大题型)、后续能力(在线写作/AI 批改/范文库)、不保存写作内容的提示。
|
|
||||||
- `client/src/views/practice/PracticeView.vue`:申论 tab 由通用 ComingSoon 替换为 `EssayView`。
|
|
||||||
- `client/src/router/index.ts`:新增 `/practice/explain`;`client/src/api/index.ts` 导出 `AiExplain` 类型。
|
|
||||||
|
|
||||||
## 接口实测(本地 OpenAI 兼容 mock + curl)
|
|
||||||
|
|
||||||
| 场景 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| 未配置 AI | `source:fallback`,「AI 尚未配置,已回退到题库标准解析(用户答案:A)」,steps=题库解析、answer=C |
|
|
||||||
| 配置有效 Token(mock) | `source:ai`,summary/steps/knowledgePoints/commonMistakes/answer 结构化解析正确 |
|
|
||||||
| deep 模式且无 userAnswer | 正常返回 `source:ai`(prompt 中用户作答为「未作答」) |
|
|
||||||
| 超时(AI_TIMEOUT_MS=1200 + 8s 慢响应) | 1.2s 内返回 `source:fallback`,「AI 请求超时」 |
|
|
||||||
| AI 返回不可解析内容 | `source:fallback`,「AI 讲解暂时不可用」 |
|
|
||||||
| 非法输入 | 缺 questionId → VALIDATION_ERROR;不存在题目 → NOT_FOUND;非法 requestType → VALIDATION_ERROR |
|
|
||||||
| Token 不泄漏 | 后端日志仅含请求方法/URL/状态码,无 API Key;OpenAPI 无 AI Token(仅有的 `apiKey` 字段是要闻 API 导入占位请求结构,任务06 既有);前端源码无密钥 |
|
|
||||||
|
|
||||||
## 浏览器检查(Chrome headless + CDP)
|
|
||||||
|
|
||||||
| 场景 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| AI 讲解页(配置) | AI 生成徽章、结论、分步解析、考点、常见错误、答案 C 全部渲染 |
|
|
||||||
| deep 模式 | 标题「AI 深度讲解」 |
|
|
||||||
| 错题详情 AI 按钮 | 可用;点击跳转 `/practice/explain?questionId=demo-q-004&userAnswer=A` |
|
|
||||||
| AI 未配置回退 UI | 「题库解析回退」徽章 + 回退说明 + 题库解析步骤 + 正确答案 |
|
|
||||||
| 申论占位页 | 功能说明 + 五大题型 + 后续能力 + 不保存写作内容提示 |
|
|
||||||
| 移动 390 | AI 讲解页与申论页均无横向溢出(390/390) |
|
|
||||||
| 类型检查与构建 | vue-tsc / tsc 通过;生产构建 139 模块通过;`api:generate` 与 OpenAPI 一致 |
|
|
||||||
|
|
||||||
截图:`deliverables/checks/task11-ai-desktop.png`、`task11-ai-mobile.png`、`task11-ai-fallback-desktop.png`、`task11-essay-desktop.png`、`task11-essay-mobile.png`。
|
|
||||||
|
|
||||||
## 完成标准核对(开发计划任务11)
|
|
||||||
|
|
||||||
- ✅ OpenAI 兼容客户端(Chat Completions + Bearer Token,环境变量配置)。
|
|
||||||
- ✅ Token 配置读取(`AI_BASE_URL` / `AI_API_KEY` / `AI_MODEL` / `AI_TIMEOUT_MS`,`server/.env.example` 示例)。
|
|
||||||
- ✅ 结构化讲解(summary / steps / knowledgePoints / commonMistakes / answer)。
|
|
||||||
- ✅ 超时处理(AbortController + `AI_TIMEOUT_MS`,超时回退题库解析)。
|
|
||||||
- ✅ 题库解析回退(未配置/超时/解析失败均回退,`source:fallback` 可识别,不阻塞刷题)。
|
|
||||||
- ✅ 前端结果展示(AI 讲解页 + 刷题结果/错题详情入口 + 未配置与失败提示)。
|
|
||||||
- ✅ 申论占位页面(功能说明与后续入口,不保存写作内容)。
|
|
||||||
- ✅ Token 不出现在前端、OpenAPI 与日志。
|
|
||||||
|
|
||||||
## 边界说明
|
|
||||||
|
|
||||||
- 测试使用本地 OpenAI 兼容 mock 服务(127.0.0.1:3199/3198/3197)验证「配置有效」「超时」「不可解析」三条链路,未使用真实外部 Token。
|
|
||||||
- 未配置 AI 时不影响刷题、错题复习、模考等任何流程;设置页 `aiConfigured` 同步反映配置状态。
|
|
||||||
|
|
||||||
## 当前结论
|
|
||||||
|
|
||||||
任务 11 完成:AI 讲解接口与前端结果页、未配置/超时/失败回退、申论占位页全部实测通过;桌面与移动双端可操作;Token 仅存于后端环境变量且未进入日志/文档/前端。
|
|
||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -50,9 +50,6 @@ importers:
|
|||||||
'@fastify/swagger-ui':
|
'@fastify/swagger-ui':
|
||||||
specifier: ^6.1.1
|
specifier: ^6.1.1
|
||||||
version: 6.1.1
|
version: 6.1.1
|
||||||
dotenv:
|
|
||||||
specifier: ^17.4.2
|
|
||||||
version: 17.4.2
|
|
||||||
fastify:
|
fastify:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 5.12.1
|
version: 5.12.1
|
||||||
@ -720,10 +717,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
dotenv@17.4.2:
|
|
||||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
|
|
||||||
entities@7.0.1:
|
entities@7.0.1:
|
||||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@ -1815,8 +1808,6 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
dotenv@17.4.2: {}
|
|
||||||
|
|
||||||
entities@7.0.1: {}
|
entities@7.0.1: {}
|
||||||
|
|
||||||
esbuild@0.28.2:
|
esbuild@0.28.2:
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
# 服务端口与数据目录(可选,默认 3000 与 server/data)
|
|
||||||
PORT=3000
|
|
||||||
# DATA_DIR=./data
|
|
||||||
|
|
||||||
# AI 讲解(OpenAI 兼容 Chat Completions;不配置时讲解自动回退题库解析)
|
|
||||||
AI_BASE_URL=https://api.deepseek.com
|
|
||||||
AI_API_KEY=sk-xxxxxxxx
|
|
||||||
AI_MODEL=deepseek-v4-flash
|
|
||||||
# AI_TIMEOUT_MS=20000
|
|
||||||
@ -11,7 +11,6 @@
|
|||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "latest",
|
||||||
"@fastify/swagger": "^9.8.1",
|
"@fastify/swagger": "^9.8.1",
|
||||||
"@fastify/swagger-ui": "^6.1.1",
|
"@fastify/swagger-ui": "^6.1.1",
|
||||||
"dotenv": "^17.4.2",
|
|
||||||
"fastify": "latest",
|
"fastify": "latest",
|
||||||
"zod": "latest"
|
"zod": "latest"
|
||||||
},
|
},
|
||||||
|
|||||||
159
server/src/ai.ts
159
server/src/ai.ts
@ -1,159 +0,0 @@
|
|||||||
import type { Question } from './schemas/entities.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenAI 兼容 Chat Completions 客户端(Token 仅存在于服务端环境变量,不进入日志/文档/前端)。
|
|
||||||
* 配置:AI_BASE_URL / AI_API_KEY / AI_MODEL(可选,默认 gpt-4o-mini)/ AI_TIMEOUT_MS(可选,默认 20000)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface AiConfig {
|
|
||||||
baseUrl: string
|
|
||||||
apiKey: string
|
|
||||||
model: string
|
|
||||||
timeoutMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RequestType = 'standard' | 'deep'
|
|
||||||
|
|
||||||
export interface AiExplainResult {
|
|
||||||
source: 'ai'
|
|
||||||
summary: string
|
|
||||||
steps: string[]
|
|
||||||
knowledgePoints: string[]
|
|
||||||
commonMistakes: string[]
|
|
||||||
answer: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取 AI 配置;未配置(缺 baseUrl 或 apiKey)返回 null,不抛错 */
|
|
||||||
export function readAiConfig(): AiConfig | null {
|
|
||||||
const baseUrl = process.env.AI_BASE_URL?.trim()
|
|
||||||
const apiKey = process.env.AI_API_KEY?.trim()
|
|
||||||
if (!baseUrl || !apiKey) return null
|
|
||||||
const timeoutMs = Number(process.env.AI_TIMEOUT_MS ?? 20000)
|
|
||||||
return {
|
|
||||||
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
||||||
apiKey,
|
|
||||||
model: process.env.AI_MODEL?.trim() || 'gpt-4o-mini',
|
|
||||||
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 20000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AiTimeoutError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('AI 请求超时')
|
|
||||||
this.name = 'AiTimeoutError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPTION_TEXT: Record<string, string> = { A: 'A', B: 'B', C: 'C', D: 'D' }
|
|
||||||
|
|
||||||
/** 组装题目上下文与用户作答,要求模型输出严格 JSON */
|
|
||||||
function buildMessages(
|
|
||||||
question: Question,
|
|
||||||
userAnswer: string | undefined,
|
|
||||||
requestType: RequestType
|
|
||||||
): Array<{ role: 'system' | 'user'; content: string }> {
|
|
||||||
const options = question.options
|
|
||||||
.map((o) => `${o.key}. ${o.text}`)
|
|
||||||
.join('\n')
|
|
||||||
const depth =
|
|
||||||
requestType === 'deep'
|
|
||||||
? '请深入剖析命题思路、干扰项设计与易错点,步骤更细致。'
|
|
||||||
: '请用简洁清晰的语言讲清楚解题思路。'
|
|
||||||
|
|
||||||
const system =
|
|
||||||
'你是一位经验丰富的公务员考试行测讲师。请基于题目与用户作答输出 JSON,字段必须为:' +
|
|
||||||
'"summary"(一句话结论,字符串)、"steps"(解题步骤,字符串数组)、' +
|
|
||||||
'"knowledgePoints"(考点,字符串数组)、"commonMistakes"(常见错误,字符串数组)、' +
|
|
||||||
'"answer"(正确答案,只能是 A/B/C/D 之一)。不要输出 JSON 以外的内容。'
|
|
||||||
|
|
||||||
const user = [
|
|
||||||
`模块:${question.module}${question.subModule ? `(${question.subModule})` : ''}`,
|
|
||||||
`难度:${question.difficulty ?? '未标注'}`,
|
|
||||||
`题干:${question.stem}`,
|
|
||||||
`选项:\n${options}`,
|
|
||||||
`标准答案:${question.answer}`,
|
|
||||||
`题库解析:${question.analysis ?? '(无)'}`,
|
|
||||||
`标签:${(question.tags ?? []).join('、') || '(无)'}`,
|
|
||||||
`用户作答:${userAnswer ? (OPTION_TEXT[userAnswer] ?? userAnswer) : '(未作答)'}`,
|
|
||||||
`讲解要求:${depth}`
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ role: 'system', content: system },
|
|
||||||
{ role: 'user', content: user }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从模型回复中稳健提取 JSON(兼容 markdown 代码块与前后杂文) */
|
|
||||||
export function parseExplainJson(content: string): AiExplainResult | null {
|
|
||||||
const start = content.indexOf('{')
|
|
||||||
const end = content.lastIndexOf('}')
|
|
||||||
if (start === -1 || end <= start) return null
|
|
||||||
let data: unknown
|
|
||||||
try {
|
|
||||||
data = JSON.parse(content.slice(start, end + 1))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (typeof data !== 'object' || data === null) return null
|
|
||||||
const obj = data as Record<string, unknown>
|
|
||||||
const summary = typeof obj.summary === 'string' ? obj.summary.trim() : ''
|
|
||||||
const answer = typeof obj.answer === 'string' ? obj.answer.trim().toUpperCase() : ''
|
|
||||||
if (!summary || !['A', 'B', 'C', 'D'].includes(answer)) return null
|
|
||||||
const asStrings = (v: unknown): string[] =>
|
|
||||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map((x) => x.trim()) : []
|
|
||||||
return {
|
|
||||||
source: 'ai',
|
|
||||||
summary,
|
|
||||||
steps: asStrings(obj.steps),
|
|
||||||
knowledgePoints: asStrings(obj.knowledgePoints),
|
|
||||||
commonMistakes: asStrings(obj.commonMistakes),
|
|
||||||
answer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用 OpenAI 兼容接口完成一次讲解。
|
|
||||||
* - 未配置:抛错(由 handler 回退题库解析);
|
|
||||||
* - 超时:抛 AiTimeoutError;
|
|
||||||
* - 网络/HTTP/解析失败:抛普通 Error。
|
|
||||||
* 任何错误信息都不包含 API Key。
|
|
||||||
*/
|
|
||||||
export async function explainWithAi(
|
|
||||||
question: Question,
|
|
||||||
userAnswer: string | undefined,
|
|
||||||
requestType: RequestType
|
|
||||||
): Promise<AiExplainResult> {
|
|
||||||
const config = readAiConfig()
|
|
||||||
if (!config) throw new Error('AI 未配置')
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${config.apiKey}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: config.model,
|
|
||||||
messages: buildMessages(question, userAnswer, requestType),
|
|
||||||
temperature: 0.4
|
|
||||||
}),
|
|
||||||
signal: controller.signal
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`AI 服务响应异常(HTTP ${res.status})`)
|
|
||||||
const data = (await res.json()) as { choices?: Array<{ message?: { content?: unknown } }> }
|
|
||||||
const content = data.choices?.[0]?.message?.content
|
|
||||||
if (typeof content !== 'string' || !content.trim()) throw new Error('AI 响应缺少内容')
|
|
||||||
const parsed = parseExplainJson(content)
|
|
||||||
if (!parsed) throw new Error('AI 响应解析失败')
|
|
||||||
return parsed
|
|
||||||
} catch (err) {
|
|
||||||
if (controller.signal.aborted) throw new AiTimeoutError()
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { config } from 'dotenv'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动时最先加载 server/.env(进程内已有环境变量优先,dotenv 默认不覆盖)。
|
|
||||||
* 必须在任何读取 process.env 的模块(如 data/files.ts)之前导入,
|
|
||||||
* 否则 DATA_DIR / AI_* 等配置会读到空值。
|
|
||||||
*/
|
|
||||||
const serverRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
config({ path: join(serverRoot, '.env') })
|
|
||||||
@ -2,28 +2,14 @@ import type { FastifyRequest } from 'fastify'
|
|||||||
import { readData } from '../data/store.js'
|
import { readData } from '../data/store.js'
|
||||||
import { dataFiles } from '../data/files.js'
|
import { dataFiles } from '../data/files.js'
|
||||||
import { ApiError } from '../errors.js'
|
import { ApiError } from '../errors.js'
|
||||||
import { AiTimeoutError, explainWithAi, readAiConfig } from '../ai.js'
|
|
||||||
import type { Question } from '../schemas/entities.js'
|
import type { Question } from '../schemas/entities.js'
|
||||||
import type { z } from 'zod'
|
import type { z } from 'zod'
|
||||||
import { AiExplainBodySchema } from '../schemas/api.js'
|
import { AiExplainBodySchema } from '../schemas/api.js'
|
||||||
|
|
||||||
/** 题库解析回退结果(AI 未配置 / 超时 / 失败时返回,source=fallback 便于前端识别) */
|
|
||||||
function fallbackExplain(question: Question, userAnswer: string | undefined, reason: string) {
|
|
||||||
return {
|
|
||||||
source: 'fallback' as const,
|
|
||||||
summary: `${reason},已回退到题库标准解析(用户答案:${userAnswer ?? '未作答'})`,
|
|
||||||
steps: [question.analysis],
|
|
||||||
knowledgePoints: question.tags ?? [],
|
|
||||||
commonMistakes: [],
|
|
||||||
answer: question.answer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 题目讲解:
|
* AI 讲解:返回结构化的讲解结果。
|
||||||
* 1. 配置了 AI(AI_BASE_URL + AI_API_KEY)时调用 OpenAI 兼容接口,解析结构化讲解;
|
* TODO 任务11:组装题目上下文调用 OpenAI 兼容接口,超时或未配置 Token 时回退到题库解析。
|
||||||
* 2. 未配置 / 超时 / 失败均回退题库解析,返回可识别提示(source=fallback),不阻塞刷题流程;
|
* 当前固定返回题库解析回退结果,保证接口契约与前端可以先联调。
|
||||||
* 3. Token 只存在于环境变量,不进入日志、OpenAPI 与前端。
|
|
||||||
*/
|
*/
|
||||||
export async function explainQuestion(request: FastifyRequest) {
|
export async function explainQuestion(request: FastifyRequest) {
|
||||||
const body = request.body as z.infer<typeof AiExplainBodySchema>
|
const body = request.body as z.infer<typeof AiExplainBodySchema>
|
||||||
@ -31,14 +17,12 @@ export async function explainQuestion(request: FastifyRequest) {
|
|||||||
const question = questions.find((q) => q.id === body.questionId)
|
const question = questions.find((q) => q.id === body.questionId)
|
||||||
if (!question) throw ApiError.notFound('题目不存在')
|
if (!question) throw ApiError.notFound('题目不存在')
|
||||||
|
|
||||||
if (!readAiConfig()) {
|
return {
|
||||||
return fallbackExplain(question, body.userAnswer, 'AI 尚未配置')
|
source: 'fallback' as const,
|
||||||
}
|
summary: `AI 讲解尚未接入,已回退到题库标准解析(用户答案:${body.userAnswer})`,
|
||||||
|
steps: [question.analysis],
|
||||||
try {
|
knowledgePoints: question.tags,
|
||||||
return await explainWithAi(question, body.userAnswer, body.requestType)
|
commonMistakes: [],
|
||||||
} catch (err) {
|
answer: question.answer
|
||||||
const reason = err instanceof AiTimeoutError ? 'AI 请求超时' : 'AI 讲解暂时不可用'
|
|
||||||
return fallbackExplain(question, body.userAnswer, reason)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -436,8 +436,7 @@ export const QuestionsRefsResponseSchema = z.object({
|
|||||||
// ---------- AI 讲解 ----------
|
// ---------- AI 讲解 ----------
|
||||||
export const AiExplainBodySchema = z.object({
|
export const AiExplainBodySchema = z.object({
|
||||||
questionId: z.string().min(1),
|
questionId: z.string().min(1),
|
||||||
/** 用户答案;从错题详情进入且无作答历史时可省略 */
|
userAnswer: z.enum(OPTION_KEYS),
|
||||||
userAnswer: z.enum(OPTION_KEYS).optional(),
|
|
||||||
requestType: z.enum(['standard', 'deep']).default('standard')
|
requestType: z.enum(['standard', 'deep']).default('standard')
|
||||||
})
|
})
|
||||||
export const AiExplainResponseSchema = z.object({
|
export const AiExplainResponseSchema = z.object({
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
// 最先加载 server/.env(dotenv),确保 DATA_DIR / PORT / AI_* 在模块读取前就位
|
|
||||||
import './env.js'
|
|
||||||
|
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user