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

<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>