feat: 新增AI智能出题功能并优化教师端样式

新增AI智能出题页面路由和完整功能实现,支持按要求生成各类试题
移除Element Plus下拉菜单旧暗色样式,统一使用默认主题适配亮色模式
优化教师端考试列表布局、按钮样式及表单控件,提升视觉一致性
调整正确答案列显示方式,新增悬浮tooltip展示完整内容
fix/exam-question-access-material-excel-delete-draft
qinzhenpen 3 weeks ago
parent d6fbe305b6
commit 63f717ece4

@ -279,33 +279,6 @@
margin-left: auto;
}
/* Element Plus 下拉菜单全局样式覆盖 */
.el-dropdown-menu {
background: linear-gradient(160deg, #0a1929 0%, #0d2137 50%, #0a1929 100%) !important;
border: 1px solid rgba(0, 170, 255, 0.35) !important;
border-radius: 14px !important;
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.6), 0 0 30px rgba(0, 170, 255, 0.15) !important;
padding: 10px 12px !important;
.el-dropdown-menu__item {
background: transparent !important;
color: rgba(200, 230, 255, 0.9) !important;
&:hover {
background: rgba(0, 170, 255, 0.15) !important;
color: #ffffff !important;
}
&.is-disabled {
background: transparent !important;
color: rgba(200, 230, 255, 0.4) !important;
}
}
}
.el-dropdown__popper {
background: linear-gradient(160deg, #0a1929 0%, #0d2137 50%, #0a1929 100%) !important;
border: 1px solid rgba(0, 170, 255, 0.35) !important;
border-radius: 14px !important;
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.6), 0 0 30px rgba(0, 170, 255, 0.15) !important;
}
/* Element Plus 下拉菜单:已移除旧暗色渐变皮肤(教师端/学生端亮色主题下不协调)。
弹层 teleport 到 body 无法按端锚定,统一回归 EP 默认白底 + 灰边 + 浅灰 hover,
教育风外观由组件默认样式保证(教师端 teacher-light.scss 无需再覆盖)。 */

@ -152,6 +152,7 @@
.toolbar,
.filter-section,
.df,
.assessment-toolbar,
.query-bar,
.btn-group {
background: #ffffff !important;
@ -287,7 +288,8 @@
.exam-admin .el-button--primary:not(.is-text):not(.is-link),
.exam-editor .el-button--primary:not(.is-text):not(.is-link),
.review-page .el-button--primary:not(.is-text):not(.is-link),
.analysis-page .el-button--primary:not(.is-text):not(.is-link) {
.analysis-page .el-button--primary:not(.is-text):not(.is-link),
.ai-gen-page .el-button--primary:not(.is-text):not(.is-link) {
background-color: #2563eb !important;
background-image: none !important;
border-color: #2563eb !important;
@ -711,15 +713,13 @@
}
// EP 中 input 与 select 各自承载边框的 wrapper 必须同时覆盖
// 注意:不强制 height/min-height/padding,让 Element Plus 的 size 属性(default/large/small)生效
.el-input__wrapper,
.el-select__wrapper {
height: 36px !important;
min-height: 36px !important;
background-color: #ffffff !important;
border: 1px solid #e5e7eb !important;
box-shadow: none !important;
border-radius: 8px !important;
padding: 0 11px !important;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
@ -736,9 +736,6 @@
}
.el-input__inner {
height: 34px !important;
line-height: 34px !important;
font-size: 12px !important;
color: #1f2937 !important;
&::placeholder {
@ -746,10 +743,9 @@
}
}
// select 显示值/占位值(EP 用 -webkit-text-fill-color,需显式覆盖)
// select 显示值/占位值(EP 用 -webkit-text-fill-color,需显式覆盖;字号由 EP size 属性处理)
.el-select__placeholder,
.el-select__selected-item {
font-size: 12px !important;
color: #1f2937 !important;
-webkit-text-fill-color: #1f2937 !important;
}
@ -768,12 +764,11 @@
color: #9ca3af;
}
// 多行文本:与单行同一描边语言
// 多行文本:与单行同一描边语言(字号由 EP size 属性处理)
.el-textarea__inner {
background-color: #ffffff !important;
border: 1px solid #e5e7eb !important;
color: #1f2937 !important;
font-size: 12px !important;
border-radius: 8px !important;
box-shadow: none !important;
@ -1574,74 +1569,227 @@ body.teacher-light-mode {
}
}
// 弹窗(教师端各页面 teacher-dialog / admin-edit-dialog)
// ===== 弹窗(teacher-dialog / admin-edit-dialog:扁平纯白 + 教育风令牌)=====
.teacher-dialog,
.admin-edit-dialog {
// dialog 作用域内重设 EP CSS 变量,防止 html.dark 的 --el-color-primary:#00aaff 污染
--el-color-primary: #3b82f6;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #2563eb;
--el-color-white: #ffffff;
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
border-radius: 16px !important;
box-shadow: 0 20px 56px rgba(30, 60, 100, 0.18) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--r-card) !important;
box-shadow: 0 20px 56px rgba(30, 60, 100, 0.12) !important;
overflow: hidden;
// 弹窗整体:不超出视口,footer 固定底,body 滚动
display: flex !important;
flex-direction: column !important;
max-height: calc(100vh - 120px) !important;
.el-dialog__header {
background: linear-gradient(135deg, #eff6ff 0%, #eff6ff 100%) !important;
padding: 18px 24px;
background: #ffffff !important;
padding: 18px 24px 14px;
margin: 0;
border-bottom: 1px solid rgba(59, 130, 246, 0.15);
border-bottom: 1px solid var(--border-color);
flex: 0 0 auto;
.el-dialog__title {
color: #1f2937 !important;
color: var(--text-strong) !important;
font-weight: 700;
font-size: 17px;
font-size: var(--fs-page);
text-shadow: none !important;
}
.el-dialog__headerbtn .el-dialog__close {
color: #6b7280 !important;
.el-dialog__headerbtn {
top: 18px;
right: 20px;
.el-dialog__close {
color: var(--text-muted) !important;
font-size: 18px;
&:hover {
color: #3b82f6 !important;
color: var(--edu-primary) !important;
}
}
}
}
.el-dialog__body {
background: #ffffff !important;
color: #1f2937;
padding: 24px 28px;
color: var(--text-strong);
padding: 24px;
flex: 1 1 auto;
overflow-y: auto !important;
// ruoyi.scss 有 max-height:70vh 的 dialog.scrollbar 规则,但我们没加 scrollbar 类,
// 直接用 flex:1 + overflow-y:auto 让 body 在 flex 布局内自然滚动
}
.el-dialog__footer {
background: #f8fbfe !important;
border-top: 1px solid #edf2f7 !important;
background: #ffffff !important;
border-top: 1px solid var(--border-color) !important;
padding: 14px 24px;
text-align: right;
flex: 0 0 auto;
}
// form label 颜色(覆盖 index.scss 暗色残留)
.el-form-item__label,
label {
color: #1f2937 !important;
}
.el-input__wrapper,
.el-select .el-input__wrapper,
.el-textarea__inner {
background-color: #ffffff !important;
color: var(--text-strong) !important;
font-weight: 600;
font-size: var(--fs-body);
}
// input / textarea / select 文字
.el-input__inner,
.el-textarea__inner {
color: #1f2937 !important;
color: var(--text-strong) !important;
font-size: 14px !important; // 题干/答案用 14px 更清晰
}
// radio / checkbox 文字
.el-radio__label,
.el-checkbox__label {
color: #1f2937 !important;
color: var(--text-strong) !important;
font-size: var(--fs-body);
}
// 班级成员管理弹窗内的搜索工具条
.member-toolbar {
background: #f8fbfe !important;
border: 1px solid rgba(59, 130, 246, 0.12) !important;
// ===== 按钮颜色全量兜底(防止暗色主题残留污染)=====
// 默认按钮
.el-button {
color: var(--text-strong) !important;
--el-button-bg-color: #ffffff;
--el-button-border-color: var(--border-color);
&:hover {
color: var(--edu-primary) !important;
}
}
// text 按钮(添加选项 / 删除):必须显式给深字 + 深 icon
.el-button.is-text,
.el-button.is-link {
background: transparent !important;
border-color: transparent !important;
color: var(--text-body) !important;
--el-text-color-placeholder: var(--text-body);
&:hover {
background: transparent !important;
color: var(--edu-primary) !important;
}
// 按钮内的 SVG icon(删除 ×)
.el-icon {
color: var(--text-muted) !important;
&:hover {
color: var(--edu-primary) !important;
}
svg {
fill: currentColor !important;
}
}
}
// ghost/outline 按钮(取消)
.el-button:not(.is-text):not(.is-link):not(.el-button--primary):not(.el-button--success):not(.el-button--danger):not(.el-button--warning) {
background: #ffffff !important;
border: 1px solid var(--border-color) !important;
color: var(--text-strong) !important;
&:hover {
background: #f9fafb !important;
border-color: var(--text-muted) !important;
color: var(--text-strong) !important;
}
}
// ===== radio-button(题型选择器):教育风蓝色选中 =====
// 注意:不能用 --el-color-primary CSS 变量,因为 html.dark 可能设成暗色 #00aaff,
// 必须显式写 var(--edu-primary) 让 teacher-light-mode 令牌生效
.el-radio-button__inner {
border-radius: var(--r-control) !important;
border: 1px solid var(--border-color) !important;
background: #ffffff !important;
color: var(--text-body) !important;
font-size: var(--fs-body);
font-weight: 500;
padding: 8px 16px;
box-shadow: none !important;
transition: border-color 0.18s, color 0.18s, background 0.18s;
&:hover {
color: var(--edu-primary) !important;
border-color: var(--edu-primary) !important;
}
}
// EP 选中时会给 .el-radio-button 加 .is-active 类(比 :checked + 兄弟选择器更可靠,
// 因为中间没有 wrapper 断层)
.el-radio-button.is-active .el-radio-button__inner,
.el-radio-button .el-radio-button__orig-radio:checked + .el-radio-button__inner {
background: var(--edu-primary) !important;
background-color: var(--edu-primary) !important;
border-color: var(--edu-primary) !important;
color: #ffffff !important;
font-weight: 600 !important;
box-shadow: none !important;
}
.el-radio-button:first-child .el-radio-button__inner {
border-left: 1px solid var(--border-color) !important;
border-top-left-radius: var(--r-control) !important;
border-bottom-left-radius: var(--r-control) !important;
}
.el-radio-button:last-child .el-radio-button__inner {
border-top-right-radius: var(--r-control) !important;
border-bottom-right-radius: var(--r-control) !important;
}
// 上传按钮(资料上传)
.el-upload {
.el-button {
border: 1px dashed var(--border-color) !important;
background: #f9fafb !important;
color: var(--text-body) !important;
}
}
// 文件展示名
.file-name {
color: var(--edu-primary);
font-size: var(--fs-body);
}
}
// 弹窗遮罩层:中灰半透明、无 blur、不影响内容清晰度
// 注:遮罩层在 body 下(teleport),用 body.teacher-light-mode 命中
.el-overlay {
background-color: rgba(15, 23, 42, 0.45) !important;
backdrop-filter: none !important;
}
// AI 智能出题页面:已迁移至 ai-generate.vue 的 scoped 样式(遵循 teacher-edu-style skill)
// 全局只保留主按钮方案 A 规则(见 L292),页面布局/视觉由 scoped 接管
@keyframes ai-bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}

@ -711,6 +711,13 @@ export const teacherRoutes = [
name: "TeacherExams",
meta: { title: "综合考核", icon: "Finished", affix: true },
},
{
path: "ai-generate",
component: () => import("@/views/teacherEnd/exam/ai-generate.vue"),
name: "TeacherExamAiGenerate",
hidden: true,
meta: { title: "AI 智能出题", affix: false },
},
{
path: "manage/:id",
component: () => import("@/views/teacherEnd/exam/editor.vue"),

@ -0,0 +1,484 @@
<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>

File diff suppressed because it is too large Load Diff

@ -40,7 +40,7 @@
<el-table-column label="题型" min-width="110"><template #default="{ row }"><el-tag :type="typeTone[row.type]">{{ typeName[row.type] }}</el-tag></template></el-table-column>
<el-table-column prop="stem" label="题干" min-width="420" show-overflow-tooltip />
<el-table-column label="题目来源" width="110"><template #default="{ row }"><el-tag :type="row.sourceType === 'PLATFORM' ? 'success' : 'info'">{{ row.sourceType === 'PLATFORM' ? '平台题库' : '教师自建' }}</el-tag></template></el-table-column>
<el-table-column label="正确答案" min-width="150"><template #default="{ row }">{{ displayAnswer(row.answer) }}</template></el-table-column>
<el-table-column label="正确答案" min-width="200"><template #default="{ row }"><el-tooltip :content="displayAnswer(row.answer)" placement="top" :show-after="300" popper-class="answer-tooltip" :disabled="!displayAnswer(row.answer)"><span class="answer-clip">{{ displayAnswer(row.answer) }}</span></el-tooltip></template></el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="180"><template #default="{ row }">{{ formatTime(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }">
<el-button text type="primary" :icon="Edit" @click="openQuestion(row)">{{ row.sourceType === 'PLATFORM' ? '查看' : '编辑' }}</el-button>
@ -60,19 +60,20 @@
<el-tab-pane label="考评管理" name="exams">
<section class="assessment-toolbar" aria-label="考试筛选">
<div class="assessment-search"><el-input v-model="examKeyword" placeholder="请输入考试名称" clearable @keyup.enter="loadExams" /><el-button type="primary" :icon="Search" @click="loadExams">搜索</el-button></div>
<div class="assessment-search"><el-input v-model="examKeyword" placeholder="请输入考试名称" clearable :prefix-icon="Search" @keyup.enter="loadExams" /><el-button :icon="Search" @click="loadExams">搜索</el-button></div>
<div class="toolbar-spacer" />
<el-button class="assessment-create" type="primary" :icon="Plus" @click="$router.push('/exam/manage/new')">新建考试</el-button>
</section>
<section v-loading="loading" class="assessment-list">
<article v-for="row in exams" :key="row.id" class="assessment-card">
<div class="assessment-cover"><el-icon><PictureFilled /></el-icon></div>
<div class="assessment-info"><h3>{{ row.name }}</h3><p>起止时间:{{ formatTime(row.startTime) }} - {{ formatTime(row.endTime) }}</p><p>考试时长:{{ row.durationMinutes || durationBetween(row) }}分钟</p></div>
<div class="assessment-status">状态:<span :class="`status-${row.status.toLowerCase()}`">{{ statusMeta[row.status]?.label }}</span></div>
<div class="assessment-status"><el-tag :type="statusMeta[row.status]?.tone || 'info'" effect="light">{{ statusMeta[row.status]?.label || row.status }}</el-tag></div>
<div class="assessment-actions">
<el-button v-if="row.status === 'NOT_STARTED'" type="primary" @click="withdraw(row)">撤回</el-button>
<el-button v-if="['DRAFT','IN_PROGRESS'].includes(row.status)" type="primary" @click="$router.push(`/exam/manage/${row.id}`)">编辑</el-button>
<el-button v-if="row.status === 'IN_PROGRESS'" type="primary" @click="$router.push(`/exam/review/${row.id}`)">指定人回退</el-button>
<template v-if="row.status === 'ENDED'"><el-button type="primary" @click="$router.push(`/exam/review/${row.id}`)">评阅</el-button><el-button type="primary" @click="$router.push(`/exam/analysis/${row.id}`)">分析</el-button><el-dropdown @command="(command) => handleExport(command, row)"><el-button type="primary">导出<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="paper">空白试卷</el-dropdown-item><el-dropdown-item command="scores">班级成绩</el-dropdown-item><el-dropdown-item command="submissions">全部答卷</el-dropdown-item></el-dropdown-menu></template></el-dropdown><el-button type="primary" @click="removeExam(row)">删除</el-button></template>
<el-button v-if="row.status === 'NOT_STARTED'" text type="primary" :icon="RefreshLeft" @click="withdraw(row)">撤回</el-button>
<el-button v-if="['DRAFT','IN_PROGRESS'].includes(row.status)" text type="primary" :icon="Edit" @click="$router.push(`/exam/manage/${row.id}`)">编辑</el-button>
<el-button v-if="row.status === 'IN_PROGRESS'" text type="primary" :icon="RefreshLeft" @click="$router.push(`/exam/review/${row.id}`)">指定人回退</el-button>
<template v-if="row.status === 'ENDED'"><el-button text type="primary" :icon="EditPen" @click="$router.push(`/exam/review/${row.id}`)">评阅</el-button><el-button text type="primary" :icon="DataAnalysis" @click="$router.push(`/exam/analysis/${row.id}`)">分析</el-button><el-dropdown @command="(command) => handleExport(command, row)"><el-button text type="primary" :icon="Download">导出<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="paper">空白试卷</el-dropdown-item><el-dropdown-item command="scores">班级成绩</el-dropdown-item><el-dropdown-item command="submissions">全部答卷</el-dropdown-item></el-dropdown-menu></template></el-dropdown><el-button text type="danger" :icon="Delete" @click="removeExam(row)">删除</el-button></template>
</div>
</article>
<el-empty v-if="!exams.length" class="table-empty" :image-size="92" description="暂无考试" />
@ -111,9 +112,9 @@
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown, Check, Close, Delete, Download, Edit, FolderOpened, MagicStick, Paperclip, PictureFilled, Plus, Search, Upload } from "@element-plus/icons-vue";
import { useRouter } from "vue-router";
import { ArrowDown, Check, Close, DataAnalysis, Delete, Download, Edit, EditPen, FolderOpened, MagicStick, Paperclip, PictureFilled, Plus, RefreshLeft, Search, Upload } from "@element-plus/icons-vue";
import { saveAs } from "file-saver";
import * as examApi from "@/api/exam";
@ -139,7 +140,8 @@ async function loadProjects() { try { projectOptions.value = (await examApi.getQ
async function loadQuestions() { loading.value = true; try { questions.value = (await examApi.getQuestions(questionQuery)).data || []; } finally { loading.value = false; } }
async function loadExams() { loading.value = true; try { exams.value = (await examApi.getTeacherExams({ keyword: examKeyword.value })).data || []; } finally { loading.value = false; } }
function openQuestion(row) { questionReadonly.value = row?.sourceType === 'PLATFORM'; Object.assign(questionForm, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(questionForm.answer) && isChoice.value) questionForm.answer = []; questionDialog.value = true; }
function openAi() { router.push('/ai'); }
function openAi() { router.push('/exam/ai-generate'); }
function resetAnswer() { questionForm.answer = questionForm.type === "TRUE_FALSE" ? true : questionForm.type === "SHORT_ANSWER" ? "" : []; }
function toggleMultiple(label, checked) { questionForm.answer = checked ? [...new Set([...questionForm.answer, label])] : questionForm.answer.filter((item) => item !== label); }
function addOption() { questionForm.options.push({ label: String.fromCharCode(65 + questionForm.options.length), content: "" }); }
@ -310,116 +312,182 @@ function durationBetween(row) { if (!row.startTime || !row.endTime) return 0; re
}
}
/* ===== 弹窗内选项编辑布局(外观由 teacher-light.scss 全局接管)===== */
/* ===== 弹窗内选项编辑器(外观由 teacher-light.scss 全局接管 + 本地布局)===== */
.option-editor {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--sp-2);
.option-row {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--r-control);
background: #ffffff;
transition: border-color 0.15s;
&:hover {
border-color: var(--edu-primary);
}
.el-radio,
.el-checkbox {
flex: 0 0 auto;
margin-right: 0;
}
.option-label {
flex: 0 0 auto;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--edu-primary-soft);
color: var(--edu-primary);
font-size: 12px;
font-weight: 600;
margin-left: 4px;
}
.el-input {
flex: 1 1 auto;
min-width: 0;
}
.el-button {
flex: 0 0 auto;
}
}
> .el-button.el-button--text {
align-self: flex-start;
margin-top: var(--sp-1);
}
}
/* ===== 弹窗内表单微调 ===== */
:deep(.teacher-dialog) &,
:deep(.admin-edit-dialog) & {
// dialog 内部的 exam-admin 表单间距
}
// dialog 表单整体紧凑化
:deep(.el-form) {
.el-form-item {
margin-bottom: 18px;
}
.el-form-item:last-child {
margin-bottom: 0;
}
}
/* ===== 考评管理卡片列表(布局来自 master,配色按教育风令牌重制)===== */
.assessment-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 34px;
margin: 0 auto 26px;
padding: 14px 0 18px;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
.el-input {
width: 240px;
}
}
.assessment-search {
display: flex;
gap: 24px;
width: min(560px, 70vw);
gap: var(--sp-2);
.el-input {
flex: 1;
width: 260px;
}
}
.assessment-create {
min-width: 148px;
min-width: 110px;
}
.assessment-list {
display: grid;
gap: 18px;
gap: var(--sp-4);
min-height: 220px;
}
.assessment-card {
display: grid;
grid-template-columns: 86px minmax(260px, 1.25fr) minmax(120px, .65fr) minmax(330px, 1.3fr);
grid-template-columns: 56px minmax(220px, 1.4fr) auto auto;
align-items: center;
gap: 20px;
min-height: 126px;
padding: 18px 24px;
gap: var(--sp-4);
padding: 14px 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.18s ease;
&:hover {
border-color: #d1d5db;
}
}
.assessment-cover {
display: grid;
width: 76px;
height: 76px;
width: 56px;
height: 56px;
place-items: center;
border-radius: 12px;
border-radius: 10px;
color: var(--edu-primary);
background: var(--edu-primary-soft);
.el-icon {
font-size: 40px;
font-size: 26px;
}
}
.assessment-info {
min-width: 0;
h3 {
margin: 0 0 8px;
margin: 0 0 6px;
color: var(--text-strong);
font-size: var(--fs-card);
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
margin: 4px 0;
margin: 2px 0;
color: var(--text-body);
font-size: var(--fs-body);
}
}
.assessment-status {
color: var(--text-body);
font-size: var(--fs-body);
white-space: nowrap;
span {
color: var(--edu-primary-deep);
font-weight: 600;
}
.status-ended {
color: #f59e0b;
}
.status-in_progress {
color: #2ba471;
}
}
.assessment-actions {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 10px;
align-items: center;
gap: var(--sp-2);
.el-button {
min-width: 96px;
.el-button + .el-button {
margin-left: 0;
}
}
@media (max-width: 1120px) {
.assessment-card {
grid-template-columns: 76px minmax(240px, 1fr) minmax(100px, .6fr);
grid-template-columns: 56px minmax(220px, 1fr) auto;
}
.assessment-actions {
@ -432,24 +500,30 @@ function durationBetween(row) { if (!row.startTime || !row.endTime) return 0; re
.assessment-toolbar {
align-items: stretch;
flex-direction: column;
gap: 12px;
gap: var(--sp-3);
}
.assessment-search {
width: 100%;
.el-input {
flex: 1;
width: auto;
}
}
.assessment-card {
grid-template-columns: 64px 1fr;
padding: 14px;
grid-template-columns: 48px 1fr;
gap: var(--sp-3);
padding: var(--sp-3);
}
.assessment-cover {
width: 62px;
height: 62px;
width: 48px;
height: 48px;
.el-icon {
font-size: 32px;
font-size: 22px;
}
}
@ -514,4 +588,23 @@ function durationBetween(row) { if (!row.startTime || !row.endTime) return 0; re
}
}
}
/* 正确答案列:两行截断省略,全文悬浮 tooltip 查看 */
.answer-clip {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.45;
word-break: break-all;
}
</style>
<style lang="scss">
/* tooltip 弹层 teleport 到 body,需 non-scoped;限宽避免长答案撑满屏幕 */
.answer-tooltip.el-popper {
max-width: 460px;
line-height: 1.6;
word-break: break-all;
}
</style>

Loading…
Cancel
Save