diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
new file mode 100644
index 0000000..0022f65
--- /dev/null
+++ b/.superpowers/sdd/task-4-report.md
@@ -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.
diff --git a/src/api/aiTrainingEvaluation.js b/src/api/aiTrainingEvaluation.js
new file mode 100644
index 0000000..413cae0
--- /dev/null
+++ b/src/api/aiTrainingEvaluation.js
@@ -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 },
+ });
+}
diff --git a/src/views/components/TrainingAiEvaluation.vue b/src/views/components/TrainingAiEvaluation.vue
new file mode 100644
index 0000000..ee21315
--- /dev/null
+++ b/src/views/components/TrainingAiEvaluation.vue
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
diff --git a/src/views/training/GenericTrainingPage.vue b/src/views/training/GenericTrainingPage.vue
index e1cb499..bb148fa 100644
--- a/src/views/training/GenericTrainingPage.vue
+++ b/src/views/training/GenericTrainingPage.vue
@@ -146,10 +146,14 @@
-
+
+
+
+
+
@@ -158,6 +162,7 @@ import { Download, Upload } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getStudentTrainingPage } from "./studentTrainingPages";
+import TrainingAiEvaluation from "@/views/components/TrainingAiEvaluation.vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
@@ -188,6 +193,11 @@ const buildInitialProcessRows = () =>
const form = ref(buildInitialForm());
const workRows = ref(buildInitialRows());
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 saving = 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() {
return {
title: page.value.title,
@@ -466,6 +481,13 @@ function exportOutcome() {
backdrop-filter: blur(8px);
}
+.training-ai-tools {
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+ min-width: 0;
+}
+
.training-core {
padding: 28px;
border-radius: 36px;
diff --git a/tests/ai-training-evaluation.static.test.cjs b/tests/ai-training-evaluation.static.test.cjs
new file mode 100644
index 0000000..50712bb
--- /dev/null
+++ b/tests/ai-training-evaluation.static.test.cjs
@@ -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(/\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");