You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/teacherEnd/exam/ai-generate.vue

485 lines
19 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<main class="ai-gen-page">
<!-- ===== 顶部工具条(扁平,与教师端筛选条同风格)===== -->
<section class="ai-gen-toolbar">
<el-button text :icon="ArrowLeft" @click="goBack">返回题库</el-button>
<h2 class="ai-gen-title">AI 智能出题</h2>
<span class="ai-gen-sub">按要求生成,勾选后加入题库</span>
<div class="ai-gen-spacer" />
<el-button v-if="aiLoading" type="danger" :icon="VideoPause" @click="stopAiStream">停止生成</el-button>
<el-button v-else type="primary" :icon="MagicStick" @click="generateAi">生成试题</el-button>
<!-- 加入题库为 ghost 次级:白底 + 灰边 + 深字(skill 规范:主按钮唯一,次级一律幽灵化)-->
<el-button :icon="Plus" :disabled="!aiSelected.size || aiImporting" :loading="aiImporting" @click="importAiQuestions">加入题库{{ aiSelected.size ? `(${aiSelected.size})` : "" }}</el-button>
</section>
<!-- ===== 主体:左表单 + 右识别区 ===== -->
<section class="ai-gen-main">
<!-- 左侧:生成要求表单(白卡,左栏) -->
<aside class="ai-gen-form-card">
<el-form label-position="top" class="ai-gen-form">
<el-form-item label="题目标题">
<el-input v-model="aiForm.title" placeholder="请输入题目标题(如:网络营销基础测试)" maxlength="20" show-word-limit />
</el-form-item>
<el-form-item label="生成要求描述">
<el-input v-model="aiForm.description" type="textarea" :rows="7" placeholder="请描述试题主题、考察知识点、难度侧重等要求" />
</el-form-item>
<el-form-item label="题型设置">
<div class="ai-gen-type-grid">
<div class="ai-gen-type-cell">
<span class="ai-gen-type-name">单选题</span>
<el-input-number v-model="aiForm.single" :min="0" :max="20" size="small" controls-position="right" />
</div>
<div class="ai-gen-type-cell">
<span class="ai-gen-type-name">多选题</span>
<el-input-number v-model="aiForm.multiple" :min="0" :max="20" size="small" controls-position="right" />
</div>
<div class="ai-gen-type-cell">
<span class="ai-gen-type-name">判断题</span>
<el-input-number v-model="aiForm.judgment" :min="0" :max="20" size="small" controls-position="right" />
</div>
<div class="ai-gen-type-cell">
<span class="ai-gen-type-name">简答题</span>
<el-input-number v-model="aiForm.shortAnswer" :min="0" :max="20" size="small" controls-position="right" />
</div>
</div>
<div class="ai-gen-type-tip">每类最多 20 道</div>
</el-form-item>
<el-form-item label="试题难度">
<el-radio-group v-model="aiForm.difficulty">
<el-radio label="简单">简单</el-radio>
<el-radio label="一般">一般</el-radio>
<el-radio label="困难">困难</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</aside>
<!-- 右侧:识别区结果(白卡,右栏) -->
<section class="ai-gen-preview-card">
<div class="ai-gen-preview-head">
<span class="ai-gen-preview-title">识别区</span>
<span class="ai-gen-count" v-if="aiQuestions.length">已识别 {{ aiQuestions.length }} 题</span>
<div class="ai-gen-preview-actions" v-if="aiQuestions.length">
<el-button size="small" :icon="Check" @click="toggleAllAi">全选</el-button>
<el-button size="small" :icon="Delete" @click="clearAi">清空</el-button>
</div>
</div>
<div v-loading="aiLoading" class="ai-gen-preview-body">
<div v-if="aiLoading && !aiRawResult" class="ai-thinking">
<span class="ai-thinking-text">AI 正在思考</span>
<span class="ai-dot" /><span class="ai-dot" /><span class="ai-dot" />
</div>
<pre v-else-if="aiLoading && aiRawResult" class="ai-raw-stream">{{ aiRawResult }}</pre>
<div v-else-if="aiQuestions.length" class="ai-question-list">
<div v-for="(q, i) in aiQuestions" :key="i" class="ai-question-card" :class="{ 'is-selected': aiSelected.has(i) }" @click="toggleAiQuestion(i)">
<div class="ai-question-head">
<el-checkbox :model-value="aiSelected.has(i)" />
<span class="ai-question-type">{{ aiTypeName(q.type) }}</span>
</div>
<div class="ai-question-stem">{{ q.question }}</div>
<div v-if="q.items && q.items.length" class="ai-question-options">
<div v-for="(opt, idx) in q.items" :key="idx" class="ai-question-option">{{ optionLabel(idx) }}. {{ opt }}</div>
</div>
<div class="ai-question-meta"><span class="ai-meta-label">答案:</span><span class="ai-meta-value">{{ q.type === 'true_false' ? (q.answers === 'A' ? '正确' : '错误') : q.answers }}</span></div>
<div v-if="q.difficulty" class="ai-question-meta"><span class="ai-meta-label">难度:</span><span class="ai-meta-value">{{ q.difficulty }}</span></div>
<div v-if="q.knowledges" class="ai-question-meta"><span class="ai-meta-label">知识点:</span><span class="ai-meta-value">{{ q.knowledges }}</span></div>
<div v-if="q.explan" class="ai-question-meta"><span class="ai-meta-label">解析:</span><span class="ai-meta-value">{{ q.explan }}</span></div>
</div>
</div>
<el-empty v-else description="尚未生成试题,请在左侧填写要求后点击「生成试题」" :image-size="80" />
</div>
</section>
</section>
</main>
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft, Check, Delete, MagicStick, Plus, VideoPause } from "@element-plus/icons-vue";
import * as examApi from "@/api/exam";
const router = useRouter();
const aiLoading = ref(false);
const aiImporting = ref(false);
const aiRawResult = ref("");
const aiQuestions = ref([]);
const aiSelected = reactive(new Set());
let aiAbort = null;
const aiForm = reactive({ title: "", description: "", single: 0, multiple: 0, judgment: 0, shortAnswer: 0, difficulty: "简单" });
const aiTypeName = (t) => ({ single_choice: "单选题", multiple_choice: "多选题", true_false: "判断题", short_answer: "简答题" }[t] || "问答题");
const optionLabel = (i) => String.fromCharCode(65 + i);
function goBack() { stopAiStream(); router.push("/exam/index"); }
function clearAi() { aiQuestions.value = []; aiSelected.clear(); }
function toggleAiQuestion(i) { if (aiSelected.has(i)) aiSelected.delete(i); else aiSelected.add(i); }
function toggleAllAi() {
if (aiSelected.size === aiQuestions.value.length) aiSelected.clear();
else { aiSelected.clear(); aiQuestions.value.forEach((_, i) => aiSelected.add(i)); }
}
function stopAiStream() { if (aiAbort) { aiAbort.abort(); aiAbort = null; } aiLoading.value = false; }
function buildAiPrompt() {
const types = [];
if (aiForm.single > 0) types.push(`${aiForm.single}道单选题`);
if (aiForm.multiple > 0) types.push(`${aiForm.multiple}道多选题`);
if (aiForm.judgment > 0) types.push(`${aiForm.judgment}道判断题`);
if (aiForm.shortAnswer > 0) types.push(`${aiForm.shortAnswer}道简答题`);
return `请帮我生成一套${aiForm.title},具体要求如下:
1. 测试包含${types.join("、")}
2. 题目难度为${aiForm.difficulty}级别
3. 主题围绕"${aiForm.description}"
题目模版参考这个:
{题号}. {题干内容}(单选题)
A. 选项A内容
B. 选项B内容
C. 选项C内容
D. 选项D内容
答案: {A}
难度:简单 / 中等 / 难 / 较难
知识点:知识点名称
解析:{解释为什么是正确答案}
{题号}. {题干内容}(多选题)
A. 选项A内容
B. 选项B内容
C. 选项C内容
D. 选项D内容
答案: {A,C}
难度:简单 / 中等 / 难 / 较难
知识点:知识点名称
解析:{解释每个正确选项的理由,指出错误选项的问题}
{题号}. {题干内容}(判断题)
A. 正确
B. 错误
答案: {A 或 B}
难度:简单 / 中等 / 难 / 较难
知识点:知识点名称
解析:{说明判断依据或背景知识}
{题号}. {问题内容}(问答题)
答案: {参考答案或标准答案要点}
难度:简单 / 中等 / 难 / 较难
知识点:知识点名称
解析:{答题思路、评分标准、重点提示等}
必须根据题型和数量返回!不使用Markdown语法,开启严格模式严格按照我提供的题目数量和题目类型生成试题。`;
}
async function generateAi() {
if (!aiForm.title.trim()) return ElMessage.warning("请输入题目标题");
if (!aiForm.description.trim()) return ElMessage.warning("请输入生成要求描述");
const total = aiForm.single + aiForm.multiple + aiForm.judgment + aiForm.shortAnswer;
if (!total) return ElMessage.warning("请至少设置一种题型的数量");
aiLoading.value = true;
aiRawResult.value = "";
aiQuestions.value = [];
aiSelected.clear();
const messages = [
{ role: "system", content: "我是深圳市天择教育科技AI大模型,可以进行实时搜索和推理" },
{ role: "user", content: buildAiPrompt() }
];
try {
aiAbort = new AbortController();
const aiBase = (import.meta.env.VITE_APP_AI_API || "/").replace(/\/?$/, "/");
const resp = await fetch(`${aiBase}api/stream/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: 1, enableSearch: true, enableThinking: false, messages }),
signal: aiAbort.signal
});
if (!resp.ok) throw new Error(`请求失败,状态码: ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let jsonParts = [];
let jsonDepth = 0;
let inString = false;
let escapeChar = false;
let complete = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (let i = 0; i < buffer.length; i++) {
const ch = buffer[i];
if (!inString && ch === "{") {
if (jsonDepth === 0) jsonParts = [];
jsonDepth++;
jsonParts.push(ch);
} else if (!inString && ch === "}") {
jsonDepth--;
jsonParts.push(ch);
if (jsonDepth === 0) {
try {
const data = JSON.parse(jsonParts.join(""));
if (data.content) { complete += data.content; aiRawResult.value = complete; }
} catch (e) { /* 不完整 JSON 片段,跳过 */ }
}
} else if (jsonDepth > 0) {
if (ch === '"' && !escapeChar) inString = !inString;
jsonParts.push(ch);
escapeChar = ch === "\\" && !escapeChar;
}
}
}
aiQuestions.value = parseAiQuestions(complete);
aiRawResult.value = "";
ElMessage.success(`成功识别 ${aiQuestions.value.length} 道题,勾选后可加入题库`);
} catch (e) {
if (e.name !== "AbortError") ElMessage.error("AI 生成失败:" + e.message);
} finally {
stopAiStream();
}
}
/** 解析 AI 文本输出为结构化题目数组(迁移自 AI-assistant parseQuestionsToJSON)*/
function parseAiQuestions(text) {
const cleaned = text.replace(/\n\s*\n/g, "\n");
const blocks = cleaned.trim().split(/(?=^\d)/gm);
return blocks.map((block) => {
const lines = block.split("\n");
const qLine = lines[0];
const items = [];
let answer = "", difficulty = "", knowledge = "", explan = "";
lines.forEach((line) => {
if (/^[A-E]\.\s*/.test(line)) items.push(line.replace(/^[A-E]\.\s*/, ""));
else if (line.startsWith("答案:") || line.startsWith("答案:")) answer = line.replace(/^答案[::]/, "").trim();
else if (line.startsWith("难度:")) difficulty = line.replace("难度:", "").trim();
else if (line.startsWith("知识点:")) knowledge = line.replace("知识点:", "").trim();
else if (line.startsWith("解析:")) explan = line.replace("解析:", "").trim();
});
return {
question: qLine.replace(/^\d+\.\s*/, "").replace(/[(\(].*?[)\)]/, "").replace(/".*?"/, ""),
type: aiDetectType(qLine),
difficulty, items, answers: answer, explan, knowledges: knowledge
};
}).filter((q) => q.question);
}
function aiDetectType(q) {
if (q.includes("单选题")) return "single_choice";
if (q.includes("多选题")) return "multiple_choice";
if (q.includes("判断题")) return "true_false";
if (q.includes("问答题") || q.includes("简答题")) return "short_answer";
return "short_answer";
}
/** 将 AI 题目转换为教师题库 schema 并批量入库 */
function convertAiQuestion(q) {
const typeMap = { single_choice: "SINGLE", multiple_choice: "MULTIPLE", true_false: "TRUE_FALSE", short_answer: "SHORT_ANSWER" };
const type = typeMap[q.type];
if (!type) return null;
const item = { id: null, taskKey: "", type, stem: q.question || "", options: [], answer: null, analysis: q.explan || "" };
if (type === "SINGLE" || type === "MULTIPLE") {
item.options = (q.items || []).map((c, i) => ({ label: String.fromCharCode(65 + i), content: c }));
const ans = String(q.answers || "").split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
item.answer = type === "SINGLE" ? ans.slice(0, 1) : ans;
} else if (type === "TRUE_FALSE") {
item.answer = q.answers === "A" || q.answers === "正确";
} else {
item.answer = q.answers || "";
}
return item;
}
async function importAiQuestions() {
const targets = [...aiSelected].map((i) => convertAiQuestion(aiQuestions.value[i])).filter(Boolean);
if (!targets.length) return ElMessage.warning("请选择要加入的题目");
aiImporting.value = true;
let ok = 0;
try {
for (const q of targets) {
try { await examApi.createQuestion(q); ok++; } catch (e) { console.error("导入失败", e); }
}
if (ok) {
ElMessage.success(`成功加入 ${ok} 道题到题库`);
goBack();
} else {
ElMessage.error("加入题库失败,请重试");
}
} finally { aiImporting.value = false; }
}
</script>
<style scoped lang="scss">
/* 按 teacher-edu-style skill 规范:token 全来自 teacher-light.scss 第 0 节,scoped 只写布局 */
.ai-gen-page {
display: flex;
flex-direction: column;
min-height: 0;
.ai-gen-toolbar {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
/* 扁平:skill 禁止页头大卡背景 */
}
.ai-gen-title {
font-size: var(--fs-page);
font-weight: 700;
color: var(--text-strong);
margin: 0;
}
.ai-gen-sub {
color: var(--text-muted);
font-size: var(--fs-body);
}
.ai-gen-spacer {
flex: 1;
}
.ai-gen-main {
display: grid;
grid-template-columns: 340px 1fr;
gap: var(--sp-4);
min-width: 0;
}
.ai-gen-form-card,
.ai-gen-preview-card {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-card);
overflow: hidden;
}
.ai-gen-form {
padding: var(--sp-4);
}
/* 关键:题型 2x2 grid 紧凑布局 */
.ai-gen-type-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.ai-gen-type-cell {
display: flex;
align-items: center;
gap: var(--sp-1);
.ai-gen-type-name {
flex: 0 0 auto;
font-size: var(--fs-body);
color: var(--text-strong);
}
.el-input-number--small {
width: 96px;
}
}
.ai-gen-type-tip {
margin-top: var(--sp-1);
color: var(--text-muted);
font-size: var(--fs-body);
}
.ai-gen-preview-card {
display: flex;
flex-direction: column;
min-height: 540px;
max-height: calc(100vh - 200px);
}
.ai-gen-preview-head {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-color);
/* skill 规范:白卡纯白,不用蓝底 */
background: #ffffff;
}
.ai-gen-preview-title {
font-size: var(--fs-card);
font-weight: 600;
color: var(--text-strong);
}
.ai-gen-count {
color: var(--edu-primary);
font-size: var(--fs-body);
font-weight: 600;
}
.ai-gen-preview-actions {
margin-left: auto;
display: flex;
gap: var(--sp-1);
}
.ai-gen-preview-body {
flex: 1;
overflow-y: auto;
padding: var(--sp-4);
}
.ai-thinking {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 24px 0;
color: var(--edu-primary);
font-size: var(--fs-body);
.ai-thinking-text { font-weight: 600; }
.ai-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--edu-primary);
animation: ai-bounce 1.2s infinite ease-in-out;
&:nth-child(3) { animation-delay: 0.2s; }
&:nth-child(4) { animation-delay: 0.4s; }
}
}
.ai-raw-stream {
margin: 0; padding: 8px 10px;
background: #f9fafb; border-radius: var(--r-control);
color: var(--text-strong); font-size: var(--fs-body); line-height: 1.6;
white-space: pre-wrap; word-break: break-word;
}
.ai-question-list {
display: flex; flex-direction: column; gap: var(--sp-3);
}
.ai-question-card {
padding: var(--sp-3);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #fff; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover { border-color: var(--edu-primary); }
&.is-selected { border-color: var(--edu-primary); background: var(--edu-primary-soft); }
.ai-question-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-1); }
.ai-question-type {
padding: 1px 8px; border: 1px solid var(--edu-primary); border-radius: 4px;
background: var(--edu-primary-soft); color: var(--edu-primary); font-size: var(--fs-tag);
}
.ai-question-stem { margin-bottom: var(--sp-1); color: var(--text-strong); font-size: var(--fs-card); line-height: 1.6; }
.ai-question-options { margin-bottom: var(--sp-1); }
.ai-question-option { padding: 2px 0; color: var(--text-body); font-size: var(--fs-body); line-height: 1.6; }
.ai-question-meta { display: flex; font-size: var(--fs-body); line-height: 1.6; }
.ai-meta-label { flex: 0 0 auto; color: var(--text-muted); }
.ai-meta-value { color: var(--text-body); word-break: break-all; }
}
}
@keyframes ai-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-6px); opacity: 1; }
}
</style>