增加考题管理模块

This commit is contained in:
2025-11-17 17:38:34 +08:00
parent e98b30edd3
commit 0fff276e59
13 changed files with 1951 additions and 45 deletions
+140
View File
@@ -0,0 +1,140 @@
import request from '@/utils/request'
// 获取考试列表
export function getExams(params) {
return request({
url: '/api/exams',
method: 'get',
params
})
}
// 根据ID获取考试
export function getExamById(id) {
return request({
url: `/api/exams/${id}`,
method: 'get'
})
}
// 添加考试
export function addExam(data) {
return request({
url: '/api/exams',
method: 'post',
data: {
...data,
is_global: data.is_global !== undefined ? data.is_global : 0
}
})
}
// 更新考试
export function updateExam(id, data) {
return request({
url: `/api/exams/${id}`,
method: 'put',
data: {
...data,
is_global: data.is_global !== undefined ? data.is_global : 0
}
})
}
// 删除考试
export function deleteExam(id) {
return request({
url: `/api/exams/${id}`,
method: 'delete'
})
}
/** 题库相关接口 */
//题库列表
export function getExamQuestionBanks(params) {
return request({
url: '/api/exam-question-banks',
method: 'get',
params
})
}
//题库详情
export function getExamQuestionBankDetail(id) {
return request({
url: `/api/exam-question-banks/${id}`,
method: 'get'
})
}
//新增题库
export function createExamQuestionBank(data) {
return request({
url: '/api/exam-question-banks',
method: 'post',
data
})
}
//更新题库
export function updateExamQuestionBank(id, data) {
return request({
url: `/api/exam-question-banks/${id}`,
method: 'put',
data
})
}
//删除题库
export function deleteExamQuestionBank(id) {
return request({
url: `/api/exam-question-banks/${id}`,
method: 'delete'
})
}
/** 题目相关接口 */
// 题目列表
export function getExamQuestions(params) {
return request({
url: '/api/exam-questions',
method: 'get',
params
})
}
// 题目详情
export function getExamQuestionDetail(id) {
return request({
url: `/api/exam-questions/${id}`,
method: 'get'
})
}
// 新增题目
export function createExamQuestion(data) {
return request({
url: '/api/exam-questions',
method: 'post',
data
})
}
// 更新题目
export function updateExamQuestion(id, data) {
return request({
url: `/api/exam-questions/${id}`,
method: 'put',
data
})
}
// 删除题目
export function deleteExamQuestion(id) {
return request({
url: `/api/exam-questions/${id}`,
method: 'delete'
})
}
+64 -38
View File
@@ -1,40 +1,46 @@
<template>
<div class="exam-container">
<div class="filter-bar">
<el-form :inline="true" :model="filters">
<div class="form-fields">
<el-form-item label="考试名称">
<el-input v-model="filters.keyword" placeholder="请输入考试名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable>
<el-option label="进行中" :value="1" />
<el-option label="已结束" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="时间范围" class="col-span-2">
<el-date-picker v-model="filters.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" value-format="YYYY-MM-DD" unlink-panels clearable :teleported="false"
placement="bottom-start" :popper-options="{
strategy: 'fixed',
modifiers: [
{ name: 'flip', options: { fallbackPlacements: [] } },
{ name: 'offset', options: { offset: [0, 8] } },
{ name: 'preventOverflow', options: { boundary: 'viewport' } }
]
}" style="width: 100%" />
</el-form-item>
</div>
<el-divider />
<div class="form-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</el-form>
</div>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="handleCreateExam">
创建考试
</el-button>
</div>
<div class="toolright">
<div class="filter-bar">
<el-form :inline="true" :model="filters">
<el-form-item label="考试名称">
<el-input v-model="filters.keyword" placeholder="请输入考试名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 160px">
<el-option label="进行中" :value="1" />
<el-option label="已结束" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
unlink-panels
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 考试列表 -->
@@ -63,14 +69,8 @@
<!-- 分页 -->
<div class="pagination-wrapper" v-if="examList.length > 0">
<el-pagination
background
layout="prev, pager, next, total"
:total="total"
:page-size="pageSize"
:current-page="currentPage"
@current-change="handlePageChange"
/>
<el-pagination background layout="prev, pager, next, total" :total="total" :page-size="pageSize"
:current-page="currentPage" @current-change="handlePageChange" />
</div>
</div>
</template>
@@ -154,8 +154,11 @@ onMounted(() => {
.toolbar {
margin-bottom: 20px;
padding: 15px 20px;
background: #fff;
border-radius: 4px;
display: flex;
justify-content: flex-start;
justify-content: space-between;
}
.filter-bar {
@@ -165,6 +168,29 @@ onMounted(() => {
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
position: relative;
overflow: visible;
.form-fields {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
column-gap: 16px;
row-gap: 12px;
align-items: center;
}
.col-span-2 {
grid-column: span 2;
}
:deep(.el-divider) {
margin: 12px 0 16px;
}
.form-actions {
display: flex;
gap: 12px;
}
}
.exam-list {
@@ -0,0 +1,92 @@
<template>
<el-dialog :model-value="visible" title="题目详情" width="720px" @close="handleClose">
<el-descriptions :column="1" border>
<el-descriptions-item label="题目">{{ q?.question_title || '-' }}</el-descriptions-item>
<el-descriptions-item label="题型">{{ displayType }}</el-descriptions-item>
<el-descriptions-item label="分值">{{ q?.score ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="答案">
<span v-if="Array.isArray(answerDisplay)">{{ (answerDisplay || []).join(', ') }}</span>
<span v-else>{{ answerDisplay || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="解析">{{ q?.question_analysis || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(q?.create_time || q?.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(q?.update_time || q?.updated_at) }}</el-descriptions-item>
</el-descriptions>
<div v-if="Array.isArray(optionsDisplay) && optionsDisplay.length" style="margin-top: 16px;">
<el-table :data="optionsDisplay" size="small" style="width: 100%">
<el-table-column prop="label" label="选项" width="80" />
<el-table-column prop="content" label="内容" />
</el-table>
</div>
<template #footer>
<el-button type="primary" @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import dayjs from 'dayjs'
import { getDictItemsByCode } from '@/api/dict'
const props = defineProps({
visible: { type: Boolean, default: false },
model: { type: Object, default: null }
})
const emit = defineEmits(['close'])
const typeOptions = ref([])
const q = computed(() => (props.model && (props.model.Question || props.model)) || null)
const displayType = computed(() => {
const typeValue = String(q.value?.question_type ?? '')
const found = typeOptions.value.find(opt => String(opt.dict_value) === typeValue)
return found ? found.dict_label : typeValue
})
const optionsDisplay = computed(() => {
if (Array.isArray(props.model?.Options)) {
return props.model.Options.map(o => ({
label: o.option_label || o.label,
content: o.option_content || o.content
}))
}
if (Array.isArray(q.value?.options)) return q.value.options
return []
})
const answerDisplay = computed(() => {
const a = (props.model && props.model.Answer && props.model.Answer.answer_content) ?? q.value?.answer
if (Array.isArray(a)) return a
if (typeof a === 'string' && a.indexOf(',') !== -1) return a.split(',').map(s => s.trim()).filter(Boolean)
return a || ''
})
watch(() => props.visible, async (v) => {
if (v && typeOptions.value.length === 0) {
try {
const res = await getDictItemsByCode('exam_question_type')
if (res && res.success && Array.isArray(res.data)) {
typeOptions.value = res.data
} else if (Array.isArray(res)) {
typeOptions.value = res
}
} catch (e) {
typeOptions.value = []
}
}
})
function formatTime(t) {
if (!t) return ''
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
}
function handleClose() {
emit('close')
}
</script>
<style scoped>
</style>
@@ -0,0 +1,276 @@
<template>
<el-dialog :title="dialogTitle" :model-value="visible" width="720px" @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<div class="form-flex">
<el-form-item label="题型" prop="question_type">
<el-select v-model="form.question_type" placeholder="请选择题型" style="width: 100%">
<el-option v-for="opt in typeOptions" :key="opt.dict_value" :label="opt.dict_label" :value="opt.dict_value" />
</el-select>
</el-form-item>
<el-form-item label="分值" prop="score">
<el-input-number v-model="form.score" :step="1" :min="0" :max="100" :precision="0" />
</el-form-item>
<el-form-item label="题目" prop="question_title">
<el-input v-model="form.question_title" placeholder="请输入题目" type="textarea" :rows="3" />
</el-form-item>
</div>
<template v-if="showOptions">
<el-divider />
<div class="options-header">
<span>选项</span>
<div class="op">
<el-button v-if="canCustomizeOptions" size="small" @click="addOption">新增选项</el-button>
</div>
</div>
<div class="option-list">
<div v-for="(opt, idx) in form.options" :key="opt.key" class="option-item">
<div class="label">{{ opt.label }}</div>
<el-input v-model="opt.content" placeholder="请输入选项内容" />
<el-button v-if="canCustomizeOptions" link type="danger" @click="removeOption(idx)">删除</el-button>
</div>
</div>
</template>
<template v-if="showAnswer">
<el-divider />
<el-form-item label="正确答案" prop="answer">
<template v-if="isSingleType">
<el-select v-model="form.answer" placeholder="请选择">
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
</el-select>
</template>
<template v-else-if="isMultipleType">
<el-select v-model="multiAnswer" multiple placeholder="请选择">
<el-option v-for="opt in form.options" :key="opt.key" :label="opt.label" :value="opt.label" />
</el-select>
</template>
<template v-else>
<el-input v-model="form.answer" type="textarea" :rows="2" placeholder="请输入答案" />
</template>
</el-form-item>
</template>
<el-form-item label="解析">
<el-input v-model="form.question_analysis" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<div class="dlg-actions">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { getDictItemsByCode } from '@/api/dict'
const props = defineProps({
visible: { type: Boolean, default: false },
mode: { type: String, default: 'create' },
model: { type: Object, default: null }
})
const emit = defineEmits(['close', 'saved'])
const formRef = ref()
const saving = ref(false)
const typeOptions = ref([])
const dialogTitle = computed(() => (props.mode === 'edit' ? '编辑题目' : '新建题目'))
const defaultOptions = () => ([
{ key: 'A', label: 'A', content: '' },
{ key: 'B', label: 'B', content: '' },
{ key: 'C', label: 'C', content: '' },
{ key: 'D', label: 'D', content: '' }
])
const form = reactive({
question_title: '',
question_type: '',
score: 5,
question_analysis: '',
options: defaultOptions(),
answer: ''
})
const rules = {
question_title: [{ required: true, message: '请输入题目', trigger: 'blur' }],
question_type: [{ required: true, message: '请选择题型', trigger: 'change' }],
score: [{ required: true, message: '请输入分值', trigger: 'change' }],
answer: [{ validator: validateAnswer, trigger: 'change' }]
}
function validateAnswer(rule, value, callback) {
if (!showAnswer.value) return callback()
if (isMultipleType.value) {
if (!multiAnswer.value || multiAnswer.value.length === 0) return callback(new Error('请选择答案'))
return callback()
}
if (!form.answer) return callback(new Error('请输入/选择答案'))
callback()
}
const isSingleType = computed(() => ['single', 'judge', '1', '3'].includes(String(form.question_type)))
const isMultipleType = computed(() => ['multiple', '2'].includes(String(form.question_type)))
const isObjective = computed(() => isSingleType.value || isMultipleType.value)
const showOptions = computed(() => isObjective.value)
const showAnswer = computed(() => true)
const canCustomizeOptions = computed(() => ['single', 'multiple', '1', '2'].includes(String(form.question_type)))
const multiAnswer = ref([])
watch(() => props.visible, async (v) => {
if (v) {
await ensureTypeOptions()
initForm()
await nextTick()
}
})
watch(() => props.model, (/* newVal */) => {
if (props.visible) {
initForm()
}
})
watch(() => form.question_type, (t) => {
const s = String(t)
if (['judge', '3'].includes(s)) {
form.options = [
{ key: 'T', label: '对', content: '对' },
{ key: 'F', label: '错', content: '错' }
]
form.answer = ''
multiAnswer.value = []
} else if (['single', 'multiple', '1', '2'].includes(s)) {
if (!form.options || form.options.length === 0 || form.options[0].label === '对') {
form.options = defaultOptions()
}
form.answer = ''
multiAnswer.value = []
} else {
form.options = []
}
})
function initForm() {
if (props.model) {
form.question_title = props.model.question_title || ''
form.question_type = props.model.question_type || ''
form.score = props.model.score ?? 5
form.question_analysis = props.model.question_analysis || ''
form.options = Array.isArray(props.model.options) && props.model.options.length > 0 ? props.model.options.map((o, i) => ({ key: o.key || String.fromCharCode(65 + i), label: o.label || String.fromCharCode(65 + i), content: o.content || '' })) : (isObjective.value ? defaultOptions() : [])
if (Array.isArray(props.model.answer)) {
multiAnswer.value = props.model.answer
form.answer = ''
} else {
form.answer = props.model.answer || ''
multiAnswer.value = []
}
} else {
form.question_title = ''
form.question_type = ''
form.score = 5
form.question_analysis = ''
form.options = defaultOptions()
form.answer = ''
multiAnswer.value = []
}
}
async function ensureTypeOptions() {
if (typeOptions.value && typeOptions.value.length > 0) return
try {
const res = await getDictItemsByCode('exam_question_type')
if (res && res.success && Array.isArray(res.data)) typeOptions.value = res.data
else if (Array.isArray(res)) typeOptions.value = res
else typeOptions.value = []
} catch {
typeOptions.value = []
}
}
function addOption() {
const nextIndex = form.options.length
const label = String.fromCharCode(65 + nextIndex)
form.options.push({ key: label, label, content: '' })
}
function removeOption(idx) {
form.options.splice(idx, 1)
form.options.forEach((o, i) => {
o.key = String.fromCharCode(65 + i)
o.label = o.key
})
if (isMultipleType.value) {
multiAnswer.value = multiAnswer.value.filter(l => form.options.some(o => o.label === l))
} else if (isSingleType.value) {
if (!form.options.some(o => o.label === form.answer)) form.answer = ''
}
}
function handleClose() {
emit('close')
}
function handleSubmit() {
formRef.value.validate(async (ok) => {
if (!ok) return
saving.value = true
try {
const payload = {
question_title: form.question_title,
question_type: form.question_type,
score: form.score,
question_analysis: form.question_analysis,
options: showOptions.value ? form.options.map(o => ({ key: o.key, label: o.label, content: o.content })) : [],
answer: isMultipleType.value ? [...multiAnswer.value] : form.answer
}
emit('saved', payload)
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
})
}
</script>
<style lang="scss" scoped>
.form-flex {
display: flex;
flex-direction: column;
gap:15px;
}
.options-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.option-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: grid;
grid-template-columns: 48px 1fr auto;
gap: 8px;
align-items: center;
}
.label {
width: 48px;
text-align: center;
}
.dlg-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
@@ -0,0 +1,86 @@
<template>
<el-dialog :title="dialogTitle" :model-value="visible" width="560px" @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="题库名称" prop="bank_name">
<el-input v-model="form.bank_name" placeholder="请输入题库名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="题库描述" prop="bank_desc">
<el-input v-model="form.bank_desc" type="textarea" :rows="4" placeholder="可选" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<div style="display:flex;justify-content:flex-end;gap:12px;">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
mode: { type: String, default: 'create' },
model: { type: Object, default: null }
})
const emit = defineEmits(['close', 'saved'])
const formRef = ref()
const saving = ref(false)
const dialogTitle = computed(() => (props.mode === 'edit' ? '编辑题库' : '新建题库'))
const form = reactive({
bank_name: '',
bank_desc: ''
})
const rules = {
bank_name: [{ required: true, message: '请输入题库名称', trigger: 'blur' }]
}
function initForm() {
if (props.model) {
form.bank_name = props.model.bank_name || ''
form.bank_desc = props.model.bank_desc || ''
} else {
form.bank_name = ''
form.bank_desc = ''
}
}
watch(() => props.visible, async (v) => {
if (v) {
initForm()
await nextTick()
}
})
watch(() => props.model, () => {
if (props.visible) initForm()
})
function handleClose() {
emit('close')
}
function handleSubmit() {
formRef.value.validate(async (ok) => {
if (!ok) return
saving.value = true
try {
const payload = { bank_name: form.bank_name, bank_desc: form.bank_desc }
emit('saved', payload)
} finally {
saving.value = false
}
})
}
</script>
<style scoped>
</style>
@@ -0,0 +1,285 @@
<template>
<div class="exam-list-page">
<div class="header">
<!-- 顶部工具栏返回 + 标题 -->
<div class="topbar">
<el-button :icon="Back" @click="emit('back')">返回</el-button>
<div class="title">题目列表</div>
</div>
<el-divider />
<!-- 搜索栏 -->
<div class="search-bar">
<el-form :inline="true" :model="filters">
<el-form-item label="关键词">
<el-input v-model="filters.keyword" placeholder="请输入题干关键词" clearable
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="题型">
<el-select v-model="filters.type" placeholder="请选择题型" clearable style="width: 200px;">
<el-option v-for="opt in typeOptions" :key="opt.dict_value" :label="opt.dict_label"
:value="opt.dict_value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<div class="header">
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="handleCreate">新建题目</el-button>
<div style="flex:1" />
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
</div>
</div>
<!-- 列表 -->
<el-table v-loading="loading" :data="list" stripe border>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="question_title" label="题目" min-width="260" show-overflow-tooltip />
<el-table-column prop="question_type" label="题型" width="120" align="center">
<template #default="{ row }">
<el-tag>{{ getTypeLabel(row.question_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="score" label="分值" width="100" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleView(row)">查看</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
<!-- 弹窗仅用于创建/编辑题目与查看详情 -->
<EditDialog :visible="editVisible" :mode="editMode" :model="editModel" @close="editVisible = false"
@saved="handleSaved" />
<DetailDialog :visible="viewVisible" :model="viewModel" @close="viewVisible = false" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Refresh, Back } from '@element-plus/icons-vue'
import { getDictItemsByCode } from '@/api/dict'
import { useAuthStore } from '@/stores/auth'
import { getExamQuestions, getExamQuestionDetail, createExamQuestion, updateExamQuestion, deleteExamQuestion } from '@/api/exam'
import EditDialog from './edit.vue'
import DetailDialog from './detail.vue'
const props = defineProps({
bankId: { type: [Number, String], default: null },
})
const emit = defineEmits(['back', 'saved'])
const auth = useAuthStore()
const loading = ref(false)
const list = ref([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const filters = ref({ keyword: '', type: null })
const typeOptions = ref([])
const typeDictMap = computed(() => {
const m = {}
for (const it of typeOptions.value || []) m[String(it.dict_value)] = it.dict_label
return m
})
const getTypeLabel = (type) => typeDictMap.value[String(type)] || String(type || '')
const editVisible = ref(false)
const editMode = ref('create')
const editModel = ref(null)
const viewVisible = ref(false)
const viewModel = ref(null)
function normalizeType(val) {
if (val === null || val === undefined || val === '') return undefined
const n = Number(val)
if (!Number.isNaN(n)) return n
// fallback by dict value is usually numeric already
return val
}
function formatTime(t) {
if (!t) return ''
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
}
async function fetchList() {
loading.value = true
try {
const params = {
page: currentPage.value,
pageSize: pageSize.value,
keyword: filters.value.keyword || undefined,
type: normalizeType(filters.value.type),
bank_id: props.bankId || undefined,
tenant_id: auth.user?.tenant_id || undefined,
}
const res = await getExamQuestions(params)
let rows = [], sum = 0
if (res && res.code === 0 && res.data) {
rows = res.data.list || []
sum = res.data.total || 0
} else if (res && res.success && Array.isArray(res.data)) {
rows = res.data
sum = res.total || 0
}
list.value = rows
total.value = sum
} catch (e) {
console.error('获取题目列表失败', e)
ElMessage.error('获取题目列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => { currentPage.value = 1; fetchList() }
const handleReset = () => { filters.value = { keyword: '', type: null }; currentPage.value = 1; fetchList() }
const handlePageChange = (p) => { currentPage.value = p; fetchList() }
const handleSizeChange = (s) => { pageSize.value = s; currentPage.value = 1; fetchList() }
// 改为顶部返回按钮 emit('back')
function handleCreate() {
editMode.value = 'create'
editModel.value = null
editVisible.value = true
}
async function handleView(row) {
try {
const res = await getExamQuestionDetail(row.id)
if (res && res.code === 0 && res.data) {
viewModel.value = res.data
} else if (res && res.success && res.data) {
viewModel.value = res.data
} else {
viewModel.value = row
}
} catch (e) {
viewModel.value = row
}
viewVisible.value = true
}
function handleEdit(row) {
editMode.value = 'edit'
editModel.value = {
id: row.id,
question_title: row.question_title,
question_type: row.question_type,
score: row.score,
question_analysis: row.question_analysis,
options: row.options || [],
answer: row.answer,
}
editVisible.value = true
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm('确定要删除该题目吗?', '提示', { type: 'warning' })
await deleteExamQuestion(row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// cancel
}
}
async function handleSaved(payload) {
try {
const submit = { ...payload, question_type: normalizeType(payload.question_type) }
if (props.bankId) submit.bank_id = props.bankId
if (editMode.value === 'edit' && editModel.value && editModel.value.id) {
await updateExamQuestion(editModel.value.id, submit)
} else {
await createExamQuestion(submit)
}
ElMessage.success('保存成功')
editVisible.value = false
fetchList()
emit('saved')
} catch (e) {
ElMessage.error('保存失败')
}
}
// 父组件切换到本页时请自行触发 fetchList,或在此自动加载一次
onMounted(() => { fetchList() })
onMounted(async () => {
try {
const res = await getDictItemsByCode('exam_question_type')
if (res && res.success && Array.isArray(res.data)) typeOptions.value = res.data
else if (Array.isArray(res)) typeOptions.value = res
else typeOptions.value = []
} catch (e) {
typeOptions.value = []
}
})
</script>
<style scoped>
.exam-list-page {
padding: 0;
.header {
margin-bottom: 20px;
padding: 20px;
background: #fff;
border-radius: 4px;
}
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.topbar .title {
font-weight: 600;
font-size: 16px;
}
.search-bar {
/* margin-bottom: 12px; */
}
.toolbar {
display: flex;
align-items: center;
/* margin-bottom: 12px; */
}
.pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,322 @@
<template>
<div class="examination-questions">
<template v-if="!examListVisible">
<!-- 搜索栏 -->
<div class="search-bar">
<el-form :inline="true" :model="filters">
<div class="form-fields">
<el-form-item label="关键词">
<el-input v-model="filters.keyword" placeholder="请输入题目关键词" clearable
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="题型">
<el-select v-model="filters.type" placeholder="请选择题型" clearable>
<el-option v-for="opt in dictTypeOptions" :key="opt.dict_value" :label="opt.dict_label"
:value="opt.dict_value" />
</el-select>
</el-form-item>
</div>
<el-divider />
<div class="form-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</el-form>
</div>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="handleCreate">新建题库</el-button>
<div class="toolright">
<el-button @click="handleBatchImport" :icon="Upload">批量导入</el-button>
<el-button @click="handleRefresh" :icon="Refresh">刷新</el-button>
</div>
</div>
<!-- 题库列表 -->
<el-table v-loading="loading" :data="bankList" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="bank_name" label="题库名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="bank_desc" label="描述" min-width="200" show-overflow-tooltip />
<!-- <el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="String(row.status)==='0' ? 'success' : 'info'">{{ String(row.status)==='0' ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column> -->
<!-- <el-table-column prop="create_time" label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column> -->
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="handleAdd(row)">题目管理</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
<EditDialog :visible="editVisible" :mode="editMode" :model="editModel" @close="editVisible = false"
@saved="handleSaved" />
<EditBanks :visible="bankVisible" :mode="bankMode" :model="bankModel" @close="bankVisible = false" @saved="handleBankSaved" />
<DetailDialog :visible="viewVisible" :model="viewModel" @close="viewVisible = false" />
</template>
<template v-else>
<ExamList :bank-id="examListBankId" @back="examListVisible = false" />
</template>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Plus, Refresh, Upload } from '@element-plus/icons-vue';
import { getDictItemsByCode } from '@/api/dict';
import { useAuthStore } from '@/stores/auth';
import { getExamQuestionBanks, createExamQuestionBank, updateExamQuestionBank, deleteExamQuestionBank } from '@/api/exam';
import EditDialog from './components/edit.vue';
import DetailDialog from './components/detail.vue';
import EditBanks from './components/editBanks.vue';
import ExamList from './components/examlist.vue';
import dayjs from 'dayjs';
const router = useRouter();
const loading = ref(false);
const bankList = ref([]);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const dictTypeOptions = ref([]);
const editVisible = ref(false);
const editMode = ref('create');
const editModel = ref(null);
const viewVisible = ref(false);
const viewModel = ref(null);
const bankVisible = ref(false);
const bankMode = ref('create');
const bankModel = ref(null);
const examListVisible = ref(false);
const examListBankId = ref(null);
const auth = useAuthStore();
const filters = ref({
keyword: '',
type: null,
difficulty: null
});
const normalizeType = (val) => undefined;
const typeDictMap = computed(() => ({}));
const getTypeLabel = (type) => '';
const getTypeTagType = (type) => '';
// 难度字段后端未实现,先移除对应逻辑
const handleSearch = () => {
currentPage.value = 1;
fetchBankList();
};
//格式化时间
const formatTime = (time) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
};
const handleReset = () => {
filters.value = { keyword: '', type: null, difficulty: null };
currentPage.value = 1;
fetchBankList();
};
const handleCreate = () => {
bankMode.value = 'create';
bankModel.value = null;
bankVisible.value = true;
};
const handleBatchImport = () => {
ElMessage.info('批量导入功能开发中');
};
const handleView = async (row) => {};
const handleRefresh = () => {
fetchBankList();
};
const handleBankSaved = async (payload) => {
try {
if (bankMode.value === 'edit' && bankModel.value && bankModel.value.id) {
await updateExamQuestionBank(bankModel.value.id, payload);
} else {
await createExamQuestionBank(payload);
}
ElMessage.success('保存成功');
bankVisible.value = false;
} catch (e) {
ElMessage.error('保存失败');
}
};
const handleEdit = (row) => {
bankMode.value = 'edit';
bankModel.value = { id: row.id, bank_name: row.bank_name, bank_desc: row.bank_desc };
bankVisible.value = true;
};
const handleAdd = (row) => {
examListBankId.value = row?.id || null;
examListVisible.value = true;
};
const handleSaved = async (payload) => {
try {
const submit = { ...payload, question_type: normalizeType(payload.question_type) };
if (editMode.value === 'edit' && editModel.value && editModel.value.id) {
await updateExamQuestion(editModel.value.id, submit);
} else {
await createExamQuestion(submit);
}
ElMessage.success('保存成功');
editVisible.value = false;
await fetchBankList();
} catch (e) {
ElMessage.error('保存失败');
}
};
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该题库吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await deleteExamQuestionBank(row.id);
ElMessage.success('删除成功');
fetchBankList();
} catch (error) {
}
};
const handlePageChange = (page) => {
currentPage.value = page;
fetchBankList();
};
const handleSizeChange = (size) => {
pageSize.value = size;
currentPage.value = 1;
fetchBankList();
};
const fetchBankList = async () => {
loading.value = true;
try {
const params = {
page: currentPage.value,
pageSize: pageSize.value,
keyword: filters.value.keyword || undefined,
tenant_id: auth.user?.tenant_id || undefined,
};
const res = await getExamQuestionBanks(params);
let list = [], sum = 0;
if (res && res.code === 0 && res.data) {
list = res.data.list || [];
sum = res.data.total || 0;
} else if (res && res.success && Array.isArray(res.data)) {
list = res.data;
sum = res.total || 0;
}
bankList.value = list;
total.value = sum;
} catch (error) {
console.error('获取题库列表失败', error);
ElMessage.error('获取题库列表失败');
} finally {
loading.value = false;
}
};
onMounted(() => {
(async () => {
try {
const res = await getDictItemsByCode('exam_question_type');
if (res && res.success && Array.isArray(res.data)) {
dictTypeOptions.value = res.data;
} else if (Array.isArray(res)) {
dictTypeOptions.value = res;
} else {
dictTypeOptions.value = [];
}
} catch (e) {
dictTypeOptions.value = [];
}
})();
fetchBankList();
});
</script>
<style lang="scss" scoped>
.examination-questions {
padding: 20px;
.search-bar {
margin-bottom: 20px;
padding: 20px;
background: #fff;
border-radius: 4px;
.form-fields {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
column-gap: 16px;
row-gap: 12px;
align-items: center;
}
:deep(.el-divider) {
margin: 12px 0 16px;
}
.form-actions {
display: flex;
gap: 12px;
}
}
.toolbar {
margin-bottom: 20px;
padding: 15px 20px;
background: #fff;
border-radius: 4px;
display: flex;
justify-content: space-between;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>
+5 -1
View File
@@ -4,4 +4,8 @@
<script setup></script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
:deep(.el-form-item) {
margin-bottom: 4px !important;
}
</style>