feat: 优化学生端 AI 助学体验

master
陈沅 13 hours ago
parent ffbc5d567b
commit e8c1f08640

@ -7,11 +7,11 @@ export function getAiTrainingEvaluation(taskKey) {
}); });
} }
export function requestAiTrainingHelp(taskKey, question) { export function requestAiTrainingHelp(taskKey, question, stepContext = "") {
return request({ return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation/help`, url: `/api/student/training-tasks/${taskKey}/ai-evaluation/help`,
method: "post", method: "post",
data: { question }, data: { question, stepContext },
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
}); });
} }

@ -1,5 +1,5 @@
.student-training-shell { .student-training-shell {
--student-step-height: 46px; --student-step-height: 42px;
--student-step-font: 14px; --student-step-font: 14px;
--student-action-height: 42px; --student-action-height: 42px;
--student-action-font: 16px; --student-action-font: 16px;
@ -30,12 +30,12 @@
display: grid !important; display: grid !important;
grid-template-columns: minmax(660px, 1fr) 300px !important; grid-template-columns: minmax(660px, 1fr) 300px !important;
align-items: start !important; align-items: start !important;
gap: 14px !important; gap: 10px !important;
width: 100% !important; width: 100% !important;
max-width: none !important; max-width: none !important;
min-height: calc(100vh - 90px) !important; min-height: calc(100vh - 90px) !important;
margin: 0 !important; margin: 0 !important;
padding: 14px 14px 20px !important; padding: 10px 10px 16px !important;
background: #06111d !important; background: #06111d !important;
} }
@ -46,27 +46,66 @@
*/ */
.student-training-shell > main { .student-training-shell > main {
min-width: 0 !important; min-width: 0 !important;
padding: 20px !important; padding: 12px !important;
border-radius: 24px !important; border-radius: 20px !important;
} }
.student-training-shell > main > .task-card, .student-training-shell > main > .task-card,
.student-training-shell > main > .process-workbench, .student-training-shell > main > .process-workbench,
.student-training-shell > main > .factors-workbench, .student-training-shell > main > .factors-workbench,
.student-training-shell > main > .workbench { .student-training-shell > main > .workbench {
padding: 14px 16px !important; padding: 10px 12px !important;
border-radius: 16px !important;
}
/* 产品开发主要流程的信息层级较多,收紧装饰性边距,为甘特图等实操区域留出横向空间。 */
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process {
gap: 10px !important;
padding: 10px 10px 16px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process > main {
padding: 12px !important;
border-radius: 20px !important; border-radius: 20px !important;
} }
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .process-workbench {
padding: 10px 12px !important;
border-radius: 16px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .process-step-card {
padding: 12px !important;
border-radius: 16px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .task-header {
margin-bottom: 12px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .workbench-head {
margin-bottom: 12px !important;
padding-bottom: 10px !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .workbench-head--actions-only {
margin-bottom: 6px !important;
padding-bottom: 0 !important;
}
#app .studentLayout .app-main:has(.student-training-shell) .student-training-shell.product-process .step-indicator {
margin-bottom: 12px !important;
}
.student-training-shell .step-card, .student-training-shell .step-card,
.student-training-shell .process-step-card, .student-training-shell .process-step-card,
.student-training-shell .factor-step-card { .student-training-shell .factor-step-card {
padding: 16px !important; padding: 12px !important;
border-radius: 20px !important; border-radius: 16px !important;
} }
.student-training-shell .task-header { .student-training-shell .task-header {
margin-bottom: 16px !important; margin-bottom: 12px !important;
} }
.student-training-shell .task-header .task-title { .student-training-shell .task-header .task-title {
@ -106,7 +145,7 @@
justify-content: space-between !important; justify-content: space-between !important;
gap: 6px !important; gap: 6px !important;
width: 100% !important; width: 100% !important;
margin: 0 0 18px !important; margin: 0 0 12px !important;
padding: 6px !important; padding: 6px !important;
border: 1px solid var(--student-panel-border) !important; border: 1px solid var(--student-panel-border) !important;
border-radius: var(--student-radius-pill) !important; border-radius: var(--student-radius-pill) !important;
@ -156,7 +195,7 @@
justify-content: space-between !important; justify-content: space-between !important;
gap: 6px !important; gap: 6px !important;
width: 100% !important; width: 100% !important;
margin: 0 0 24px !important; margin: 0 0 12px !important;
padding: 6px !important; padding: 6px !important;
border: 1px solid var(--student-panel-border) !important; border: 1px solid var(--student-panel-border) !important;
border-radius: 60px !important; border-radius: 60px !important;

@ -9,7 +9,13 @@
<p class="ai-instruction">点击参考提示词可自动带入问题AI 将结合当前实训内容给出建议</p> <p class="ai-instruction">点击参考提示词可自动带入问题AI 将结合当前实训内容给出建议</p>
<div class="ai-output"> <div class="ai-output">
<div class="output-glow"></div> <div class="output-glow"></div>
<p>{{ displayAssistMessage }}</p> <div class="study-messages" aria-live="polite">
<p v-if="!assistMessages.length" class="study-message study-message--assistant">{{ displayAssistMessage }}</p>
<div v-for="message in assistMessages" :key="message.id" class="study-message" :class="`study-message--${message.role}`">
<strong>{{ message.role === "student" ? "我" : "AI助学" }}</strong>
<span>{{ message.content }}</span>
</div>
</div>
<button v-if="referenceText" type="button" class="reference-block" @click="useReferencePrompt"> <button v-if="referenceText" type="button" class="reference-block" @click="useReferencePrompt">
<strong>{{ referenceTitle }}</strong> <strong>{{ referenceTitle }}</strong>
<span v-html="referenceText"></span> <span v-html="referenceText"></span>
@ -98,9 +104,11 @@ const internalScore = ref("--");
const internalScoreVisible = ref(false); const internalScoreVisible = ref(false);
const assessmentLoading = ref(false); const assessmentLoading = ref(false);
const evaluation = ref({}); const evaluation = ref({});
const assistMessages = ref([]);
const route = useRoute(); const route = useRoute();
const currentTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey)); const currentTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
const reports = computed(() => normalizeEvaluationReports(evaluation.value)); const reports = computed(() => normalizeEvaluationReports(evaluation.value));
const currentStepContext = computed(() => [props.studyTip, props.referenceTitle, plainText(props.referenceText)].filter(Boolean).join("\n"));
const displayAssistMessage = computed(() => assistMessage.value || props.studyTip); const displayAssistMessage = computed(() => assistMessage.value || props.studyTip);
const displayScoreVisible = computed(() => props.scoreVisible || internalScoreVisible.value || reports.value.score !== null); const displayScoreVisible = computed(() => props.scoreVisible || internalScoreVisible.value || reports.value.score !== null);
@ -111,7 +119,10 @@ const displayEvalMessage = computed(() => {
}); });
onMounted(loadEvaluation); onMounted(loadEvaluation);
watch(currentTaskKey, loadEvaluation); watch(currentTaskKey, (nextTaskKey, previousTaskKey) => {
if (nextTaskKey !== previousTaskKey) assistMessages.value = [];
loadEvaluation();
});
async function loadEvaluation() { async function loadEvaluation() {
if (!currentTaskKey.value) return; if (!currentTaskKey.value) return;
@ -129,15 +140,22 @@ async function requestAssist() {
assistMessage.value = "请输入问题,或点击上方参考提示词后发送。"; assistMessage.value = "请输入问题,或点击上方参考提示词后发送。";
return; return;
} }
assistMessages.value.push({ id: `student-${Date.now()}`, role: "student", content: question });
assistQuestion.value = "";
if (currentTaskKey.value) { if (currentTaskKey.value) {
try { try {
await requestAiTrainingHelp(currentTaskKey.value, question); await requestAiTrainingHelp(currentTaskKey.value, question, currentStepContext.value);
await loadEvaluation(); await loadEvaluation();
assistMessage.value = reports.value.helpReport || props.assistText; assistMessage.value = reports.value.helpReport || props.assistText;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
return; return;
} catch (error) {} } catch (error) {
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: "AI 助学暂时不可用,请稍后重试。" });
return;
}
} }
assistMessage.value = `围绕“${question}”,建议先对照任务目标检查信息是否完整,再补充数据依据和分析结论。`; assistMessage.value = `围绕“${question}”,建议先对照任务目标检查信息是否完整,再补充数据依据和分析结论。`;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
} }
async function requestEvaluation() { async function requestEvaluation() {
@ -157,6 +175,14 @@ async function requestEvaluation() {
internalEvalMessage.value = "AI 助评暂时不可用,请稍后重试。"; internalEvalMessage.value = "AI 助评暂时不可用,请稍后重试。";
} finally { assessmentLoading.value = false; } } finally { assessmentLoading.value = false; }
} }
function plainText(value) {
return String(value || "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -236,6 +262,31 @@ async function requestEvaluation() {
margin: 0; margin: 0;
} }
} }
.study-messages {
position: relative;
z-index: 1;
display: flex;
max-height: 310px;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
.study-message {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 9px 10px;
border: 1px solid rgba(129, 211, 255, 0.2);
border-radius: 10px;
white-space: pre-wrap;
strong { font-size: 12px; color: #75e9ff; }
span { color: #d9efff; }
&--student { align-self: flex-end; background: rgba(16, 105, 151, 0.26); }
&--assistant { background: rgba(6, 23, 36, 0.6); }
}
.ai-instruction { margin: -4px 0 12px; color: #9fcbe2; font-size: 13px; line-height: 1.6; } .ai-instruction { margin: -4px 0 12px; color: #9fcbe2; font-size: 13px; line-height: 1.6; }
.reference-block { width: 100%; border: 0; background: transparent; padding: 14px 0 0; text-align: left; cursor: pointer; } .reference-block { width: 100%; border: 0; background: transparent; padding: 14px 0 0; text-align: left; cursor: pointer; }
.reference-block:hover strong { color: #77eaff; } .reference-block:hover strong { color: #77eaff; }

@ -13,11 +13,11 @@ assert(/`\/api\/student\/training-tasks\/\$\{taskKey\}\/ai-evaluation`/.test(api
assert(/\/help/.test(api) && /\/assessment/.test(api), "evaluation API should expose help and assessment operations"); assert(/\/help/.test(api) && /\/assessment/.test(api), "evaluation API should expose help and assessment operations");
assert(/TrainingAiSidebar/.test(page) && /:has-saved-answer="hasSavedAnswer"/.test(page), "generic training page should pass persisted answer state to the AI sidebar"); assert(/TrainingAiSidebar/.test(page) && /:has-saved-answer="hasSavedAnswer"/.test(page), "generic training page should pass persisted answer state to the AI sidebar");
assert(/form\.value/.test(page) && /workRows\.value/.test(page) && /processRows\.value/.test(page), "hasAnyAnswer should inspect all supported answer shapes"); assert(/form\.value/.test(page) && /workRows\.value/.test(page) && /processRows\.value/.test(page), "hasAnyAnswer should inspect all supported answer shapes");
assert(/requestAiTrainingHelp\(taskKey, question\)/.test(api), "AI help API should accept the student's question"); assert(/requestAiTrainingHelp\(taskKey, question, stepContext = ""\)/.test(api), "AI help API should accept the student's question and current step context");
assert(/data:\s*\{\s*question\s*\}/.test(api), "AI help API should submit the student's question as JSON"); assert(/data:\s*\{\s*question\s*,\s*stepContext\s*\}/.test(api), "AI help API should submit the student's question and current step context as JSON");
assert(/const question = assistQuestion\.value\.trim\(\)/.test(sidebar), "AI sidebar should trim the student's question before sending it"); assert(/const question = assistQuestion\.value\.trim\(\)/.test(sidebar), "AI sidebar should trim the student's question before sending it");
assert(/if \(!question\)/.test(sidebar), "AI sidebar should block an empty AI help question"); assert(/if \(!question\)/.test(sidebar), "AI sidebar should block an empty AI help question");
assert(/requestAiTrainingHelp\(currentTaskKey\.value, question\)/.test(sidebar), "AI sidebar should send the trimmed question to AI help"); assert(/requestAiTrainingHelp\(currentTaskKey\.value, question, currentStepContext\.value\)/.test(sidebar), "AI sidebar should send the trimmed question and current step context to AI help");
assert(/onMounted\(/.test(component) && /watch\(\s*\(\)\s*=>\s*props\.taskKey/.test(component), "AI evaluation should load on mount and task changes"); 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(/AI助学/.test(component) && /AI助评/.test(component), "AI evaluation should offer separate help and assessment controls");

@ -0,0 +1,24 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const styles = fs.readFileSync(path.join(root, "src/assets/styles/student-training.scss"), "utf8");
assert.match(
styles,
/\.student-training-shell\.product-process\s*\{[\s\S]*?gap:\s*10px\s*!important;[\s\S]*?padding:\s*10px\s+10px\s+16px\s*!important;/,
"产品开发主要流程页应压缩外层边距与双栏间隔"
);
assert.match(
styles,
/\.student-training-shell\.product-process\s*>\s*main\s*\{[\s\S]*?padding:\s*12px\s*!important;/,
"产品开发主要流程主卡应减少装饰性内边距"
);
assert.match(
styles,
/\.student-training-shell\.product-process\s+\.process-workbench\s*\{[\s\S]*?padding:\s*10px\s+12px\s*!important;/,
"产品开发主要流程工作台应为实操内容释放空间"
);
console.log("Product development process uses the compact training layout.");

@ -0,0 +1,29 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const styles = fs.readFileSync(path.join(root, "src/assets/styles/student-training.scss"), "utf8");
assert.match(
styles,
/\.student-training-shell\s*\{[\s\S]*?--student-step-height:\s*42px;/,
"所有学生端实训页应使用更紧凑的步骤条高度"
);
assert.match(
styles,
/\.student-training-shell\s*>\s*main\s*\{[\s\S]*?padding:\s*12px\s*!important;/,
"所有学生端实训主卡应减少装饰性内边距"
);
assert.match(
styles,
/\.student-training-shell\s*>\s*main\s*>\s*\.task-card,[\s\S]*?padding:\s*10px\s+12px\s*!important;/,
"所有学生端工作台应为实操内容保留更多空间"
);
assert.match(
styles,
/\.student-training-shell\s+\.step-card,[\s\S]*?padding:\s*12px\s*!important;/,
"所有学生端步骤卡应使用紧凑内边距"
);
console.log("All student training pages use the compact layout density.");
Loading…
Cancel
Save