feat: add AI training evaluation frontend

dev-QQq
chenyuan 1 month ago
parent 6fb30dcb6b
commit d731aa1614

@ -0,0 +1,15 @@
# Task 4: AI training evaluation frontend
## Delivered
- Added the AI training evaluation API client for status, help, and assessment operations.
- Added `TrainingAiEvaluation.vue` with distinct AI助学 and AI助评 controls, backend status display, confirmation before assessment, retryable errors, safe interpolated report rendering, and score criteria.
- Integrated the component into `GenericTrainingPage.vue` before the legacy AI sidebar and derived `hasAnyAnswer` from form fields, work rows, and process rows.
- Added a static contract test for this integration.
## Verification
- `node tests/ai-training-evaluation.static.test.cjs`
- `npm run build:prod`
The production build emits pre-existing warnings for deprecated `::v-deep` syntax, a runtime-resolved navbar image, and oversized chunks; it exits successfully.

@ -0,0 +1,24 @@
import request from "@/utils/request";
export function getAiTrainingEvaluation(taskKey) {
return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation`,
method: "get",
});
}
export function requestAiTrainingHelp(taskKey) {
return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation/help`,
method: "post",
headers: { repeatSubmit: false },
});
}
export function requestAiTrainingAssessment(taskKey) {
return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation/assessment`,
method: "post",
headers: { repeatSubmit: false },
});
}

@ -0,0 +1,234 @@
<template>
<aside class="training-ai-evaluation" aria-label="AI">
<section class="evaluation-panel">
<div class="evaluation-panel__header">
<div>
<p class="evaluation-panel__eyebrow">AI TRAINING SUPPORT</p>
<h3>AI助学</h3>
</div>
<span class="evaluation-status" :class="`evaluation-status--${helpStatus.toLowerCase()}`">{{ statusLabel(helpStatus) }}</span>
</div>
<p class="evaluation-panel__hint">根据当前已填写的实训内容生成针对性的完善建议</p>
<el-button :loading="helpLoading" :disabled="isHelpDisabled" type="primary" @click="requestHelp">
{{ helpActionLabel }}
</el-button>
<p v-if="helpError" class="evaluation-error">
{{ helpError }}
<button type="button" class="retry-link" @click="retry('help')"></button>
</p>
<div v-if="helpReport" class="evaluation-report">
<h4>助学建议</h4>
<pre>{{ helpReport }}</pre>
</div>
</section>
<section class="evaluation-panel">
<div class="evaluation-panel__header">
<div>
<p class="evaluation-panel__eyebrow">AI TRAINING ASSESSMENT</p>
<h3>AI助评</h3>
</div>
<span class="evaluation-status" :class="`evaluation-status--${assessmentStatus.toLowerCase()}`">{{ statusLabel(assessmentStatus) }}</span>
</div>
<p class="evaluation-panel__hint">AI将基于已填写的答案给出综合评分和改进方向</p>
<el-button :loading="assessmentLoading" :disabled="isAssessmentDisabled" type="primary" @click="confirmAssessment">
{{ assessmentActionLabel }}
</el-button>
<p v-if="assessmentError" class="evaluation-error">
{{ assessmentError }}
<button type="button" class="retry-link" @click="retry('assessment')"></button>
</p>
<div v-if="assessmentReport" class="evaluation-report">
<h4>评价报告</h4>
<p v-if="score !== null" class="evaluation-score"><strong>{{ score }}</strong></p>
<pre>{{ assessmentReport }}</pre>
</div>
<div v-if="scoreCriteria.length" class="score-criteria">
<h4>评分标准</h4>
<ul>
<li v-for="(criterion, index) in scoreCriteria" :key="`${criterion.name}-${index}`">
<strong>{{ criterion.name }}</strong>
<span v-if="criterion.score !== ''">{{ criterion.score }}</span>
<span v-if="criterion.description">{{ criterion.description }}</span>
</li>
</ul>
</div>
</section>
</aside>
</template>
<script setup>
import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation";
const props = defineProps({
taskKey: { type: String, default: "" },
hasAnyAnswer: { type: Boolean, default: false },
});
const { proxy } = getCurrentInstance();
const evaluation = ref({});
const helpLoading = ref(false);
const assessmentLoading = ref(false);
const helpError = ref("");
const assessmentError = ref("");
const help = computed(() => normalizeOperation(evaluation.value.help || evaluation.value.helpEvaluation, evaluation.value, "help"));
const assessment = computed(() => normalizeOperation(evaluation.value.assessment || evaluation.value.assessmentEvaluation, evaluation.value, "assessment"));
const helpStatus = computed(() => help.value.status);
const assessmentStatus = computed(() => assessment.value.status);
const helpReport = computed(() => asText(help.value.report || help.value.content || help.value.suggestion));
const assessmentReport = computed(() => asText(assessment.value.report || assessment.value.content || assessment.value.feedback));
const score = computed(() => assessment.value.score ?? evaluation.value.score ?? null);
const scoreCriteria = computed(() => normalizeCriteria(assessment.value.scoreCriteria || evaluation.value.scoreCriteria));
const isHelpDisabled = computed(() => !props.hasAnyAnswer || helpLoading.value || isTerminal(helpStatus.value));
const isAssessmentDisabled = computed(() => !props.hasAnyAnswer || assessmentLoading.value || isTerminal(assessmentStatus.value));
const helpActionLabel = computed(() => actionLabel(helpStatus.value, "生成助学建议", helpLoading.value));
const assessmentActionLabel = computed(() => actionLabel(assessmentStatus.value, "请求AI助评", assessmentLoading.value));
onMounted(() => loadEvaluation());
watch(
() => props.taskKey,
() => loadEvaluation()
);
async function loadEvaluation() {
evaluation.value = {};
helpError.value = "";
assessmentError.value = "";
if (!props.taskKey) return;
try {
const res = await getAiTrainingEvaluation(props.taskKey);
evaluation.value = res?.data || {};
} catch (error) {
const message = "AI评价状态加载失败请重试。";
helpError.value = message;
assessmentError.value = message;
}
}
async function requestHelp() {
if (isHelpDisabled.value || !props.taskKey) return;
helpLoading.value = true;
helpError.value = "";
setLocalStatus("help", "PROCESSING");
try {
const res = await requestAiTrainingHelp(props.taskKey);
applyOperationResult("help", res?.data);
await loadEvaluation();
} catch (error) {
setLocalStatus("help", "FAILED");
helpError.value = "AI助学请求失败可重试。";
} finally {
helpLoading.value = false;
}
}
async function confirmAssessment() {
if (isAssessmentDisabled.value || !props.taskKey) return;
try {
await proxy?.$modal?.confirm("AI将根据当前已填写的内容进行评估是否继续", "提示");
} catch (error) {
return;
}
await requestAssessment();
}
async function requestAssessment() {
assessmentLoading.value = true;
assessmentError.value = "";
setLocalStatus("assessment", "PROCESSING");
try {
const res = await requestAiTrainingAssessment(props.taskKey);
applyOperationResult("assessment", res?.data);
await loadEvaluation();
} catch (error) {
setLocalStatus("assessment", "FAILED");
assessmentError.value = "AI助评请求失败可重试。";
} finally {
assessmentLoading.value = false;
}
}
function retry(type) {
if (type === "help") return requestHelp();
return confirmAssessment();
}
function applyOperationResult(type, result) {
if (!result || typeof result !== "object") return;
evaluation.value = {
...evaluation.value,
[type]: result[type] || result,
};
}
function setLocalStatus(type, status) {
evaluation.value = {
...evaluation.value,
[type]: { ...normalizeOperation(evaluation.value[type], evaluation.value, type), status },
};
}
function normalizeOperation(operation, root, type) {
const value = operation && typeof operation === "object" ? operation : {};
const prefix = type === "help" ? "help" : "assessment";
return {
...value,
status: String(value.status || root?.[`${prefix}Status`] || "PENDING").toUpperCase(),
report: value.report || root?.[`${prefix}Report`] || "",
scoreCriteria: value.scoreCriteria || root?.scoreCriteria || [],
};
}
function normalizeCriteria(value) {
if (!Array.isArray(value)) return [];
return value.map((item) => {
if (typeof item === "string") return { name: item, score: "", description: "" };
return {
name: String(item?.name || item?.criterion || item?.title || "评分项"),
score: item?.score ?? item?.weight ?? "",
description: String(item?.description || item?.detail || ""),
};
});
}
function asText(value) {
if (value == null) return "";
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function isTerminal(status) {
return ["PROCESSING", "SUCCEEDED"].includes(status);
}
function statusLabel(status) {
return ({ PENDING: "待生成", PROCESSING: "生成中", SUCCEEDED: "已完成", FAILED: "生成失败" }[status] || status);
}
function actionLabel(status, fallback, loading) {
if (loading || status === "PROCESSING") return "生成中…";
if (status === "SUCCEEDED") return "已生成";
return fallback;
}
</script>
<style lang="scss" scoped>
.training-ai-evaluation { display: grid; gap: 16px; align-self: start; }
.evaluation-panel { padding: 18px; border: 1px solid rgba(72, 207, 255, .34); border-radius: 20px; color: #d9efff; background: rgba(6, 19, 31, .94); }
.evaluation-panel__header { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
.evaluation-panel__header h3, .evaluation-report h4, .score-criteria h4 { margin: 0; color: #fff; }
.evaluation-panel__eyebrow { margin: 0 0 5px; color: #77eaff; font-size: 11px; font-weight: 800; }
.evaluation-panel__hint { margin: 14px 0; line-height: 1.7; }
.evaluation-status { padding: 4px 8px; border-radius: 999px; color: #a9ecff; background: rgba(29, 162, 255, .16); font-size: 12px; white-space: nowrap; }
.evaluation-status--failed { color: #ffb5b5; background: rgba(255, 96, 96, .14); }
.evaluation-status--succeeded { color: #9df4c2; background: rgba(84, 213, 139, .14); }
.evaluation-error { margin: 10px 0 0; color: #ffb5b5; }
.retry-link { margin-left: 8px; border: 0; color: #77eaff; background: transparent; cursor: pointer; text-decoration: underline; }
.evaluation-report, .score-criteria { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(129, 211, 255, .18); }
.evaluation-report pre { margin: 10px 0 0; white-space: pre-wrap; word-break: break-word; font: inherit; line-height: 1.7; }
.evaluation-score { margin: 10px 0 0; }.evaluation-score strong { color: #7ee9ff; font-size: 22px; }
.score-criteria ul { margin: 10px 0 0; padding-left: 20px; line-height: 1.7; }.score-criteria span { margin-left: 4px; }
</style>

@ -146,10 +146,14 @@
</section> </section>
</main> </main>
<TrainingAiSidebar <div class="training-ai-tools">
:study-tip="`当前任务建议:围绕“${page.title}”先明确产品对象,再按任务流程逐步补全分析内容。`" <TrainingAiEvaluation :task-key="pageKey" :has-any-answer="hasAnyAnswer" />
:progress-items="progressItems"
/> <TrainingAiSidebar
:study-tip="`当前任务建议:围绕“${page.title}”先明确产品对象,再按任务流程逐步补全分析内容。`"
:progress-items="progressItems"
/>
</div>
</div> </div>
</template> </template>
@ -158,6 +162,7 @@ import { Download, Upload } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getStudentTrainingPage } from "./studentTrainingPages"; import { getStudentTrainingPage } from "./studentTrainingPages";
import TrainingAiEvaluation from "@/views/components/TrainingAiEvaluation.vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
@ -188,6 +193,11 @@ const buildInitialProcessRows = () =>
const form = ref(buildInitialForm()); const form = ref(buildInitialForm());
const workRows = ref(buildInitialRows()); const workRows = ref(buildInitialRows());
const processRows = ref(buildInitialProcessRows()); const processRows = ref(buildInitialProcessRows());
const hasAnyAnswer = computed(() =>
Object.values(form.value).some(hasMeaningfulAnswer) ||
workRows.value.some((row) => hasMeaningfulAnswer(row.answer)) ||
processRows.value.some((row) => hasMeaningfulAnswer(row.workContent))
);
const uploadInputRef = ref(null); const uploadInputRef = ref(null);
const saving = ref(false); const saving = ref(false);
const uploading = ref(false); const uploading = ref(false);
@ -272,6 +282,11 @@ function parseArray(value) {
} }
} }
function hasMeaningfulAnswer(value) {
if (typeof value === "string") return value.trim().length > 0;
return value !== null && value !== undefined && value !== "";
}
function buildOutcome() { function buildOutcome() {
return { return {
title: page.value.title, title: page.value.title,
@ -466,6 +481,13 @@ function exportOutcome() {
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.training-ai-tools {
display: flex;
flex-direction: column;
gap: 22px;
min-width: 0;
}
.training-core { .training-core {
padding: 28px; padding: 28px;
border-radius: 36px; border-radius: 36px;

@ -0,0 +1,23 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const api = fs.readFileSync(path.join(root, "src/api/aiTrainingEvaluation.js"), "utf8");
const component = fs.readFileSync(path.join(root, "src/views/components/TrainingAiEvaluation.vue"), "utf8");
const page = fs.readFileSync(path.join(root, "src/views/training/GenericTrainingPage.vue"), "utf8");
assert(/`\/api\/student\/training-tasks\/\$\{taskKey\}\/ai-evaluation`/.test(api), "evaluation API should use the student task endpoint");
assert(/\/help/.test(api) && /\/assessment/.test(api), "evaluation API should expose help and assessment operations");
assert(/TrainingAiEvaluation/.test(page) && /:has-any-answer="hasAnyAnswer"/.test(page), "generic training page should pass meaningful answer state to AI evaluation");
assert(/form\.value/.test(page) && /workRows\.value/.test(page) && /processRows\.value/.test(page), "hasAnyAnswer should inspect all supported answer shapes");
assert(/<TrainingAiEvaluation[\s\S]*<TrainingAiSidebar/.test(page), "AI evaluation should render before the existing AI sidebar");
assert(/onMounted\(/.test(component) && /watch\(\s*\(\)\s*=>\s*props\.taskKey/.test(component), "AI evaluation should load on mount and task changes");
assert(/AI助学/.test(component) && /AI助评/.test(component), "AI evaluation should offer separate help and assessment controls");
assert(/PROCESSING/.test(component) && /SUCCEEDED/.test(component), "AI evaluation should surface backend statuses");
assert(/!props\.hasAnyAnswer/.test(component), "AI actions should disable when no answer is available");
assert(/proxy\?\.\$modal\?\.confirm/.test(component), "assessment should request confirmation through the modal proxy");
assert(!/v-html/.test(component), "AI reports must be rendered as interpolated text, never v-html");
assert(/scoreCriteria/.test(component), "assessment should display score criteria");
assert(/retry/.test(component) || /重试/.test(component), "failed AI operations should expose a retry action");
Loading…
Cancel
Save