增加考题管理模块

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
pc/src/api/exam.js Normal file
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'
})
}

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 {

View File

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

View File

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

View File

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

View File

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

View File

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

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>

340
server/controllers/exam.go Normal file
View File

@ -0,0 +1,340 @@
package controllers
import (
"encoding/json"
"strconv"
"server/models"
"server/services"
beego "github.com/beego/beego/v2/server/web"
)
type ExamQuestionController struct {
beego.Controller
}
type ExamQuestionBankController struct {
beego.Controller
}
// Get list
// @router /exam-questions [get]
func (c *ExamQuestionController) GetList() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
keyword := c.GetString("keyword")
typeStr := c.GetString("type")
var qtype *int8
if typeStr != "" {
if iv, err := strconv.Atoi(typeStr); err == nil {
v := int8(iv)
qtype = &v
}
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
list, total, err := services.GetExamQuestions(services.QuestionListParams{
TenantId: tenantId,
Keyword: keyword,
QuestionType: qtype,
Page: page,
PageSize: pageSize,
})
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "获取试题列表失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
// build minimal DTO to limit fields in response
items := make([]map[string]interface{}, 0, len(list))
for _, q := range list {
if q == nil {
continue
}
items = append(items, map[string]interface{}{
"id": q.Id,
"tenant_id": q.TenantId,
"question_type": q.QuestionType,
"question_title": q.QuestionTitle,
"score": q.Score,
})
}
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "ok",
"data": map[string]interface{}{
"list": items,
"total": total,
},
}
c.ServeJSON()
}
// Get detail
// @router /exam-questions/:id [get]
func (c *ExamQuestionController) GetDetail() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
id, err := c.GetInt64(":id")
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "ID无效", "data": nil}
c.ServeJSON()
return
}
detail, err := services.GetExamQuestionDetail(tenantId, id)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "试题不存在", "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "ok", "data": detail}
c.ServeJSON()
}
// Create
// @router /exam-questions [post]
func (c *ExamQuestionController) Create() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
var payload struct {
QuestionTitle string `json:"question_title"`
QuestionType int8 `json:"question_type"`
Score float64 `json:"score"`
QuestionAnalysis string `json:"question_analysis"`
Options []map[string]string `json:"options"`
Answer interface{} `json:"answer"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "请求参数错误: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
q := &models.ExamQuestion{
TenantId: tenantId,
QuestionType: payload.QuestionType,
QuestionTitle: payload.QuestionTitle,
QuestionAnalysis: payload.QuestionAnalysis,
Score: payload.Score,
Status: 1,
}
var opts []models.ExamQuestionOption
for _, o := range payload.Options {
opts = append(opts, models.ExamQuestionOption{OptionLabel: o["label"], OptionContent: o["content"]})
}
answerContent := ""
switch v := payload.Answer.(type) {
case string:
answerContent = v
case []interface{}:
for i, item := range v {
if s, ok := item.(string); ok {
if i == 0 {
answerContent = s
} else {
answerContent += "," + s
}
}
}
}
id, err := services.CreateExamQuestion(tenantId, q, opts, answerContent)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "创建失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "创建成功", "data": map[string]interface{}{"id": id}}
c.ServeJSON()
}
// Update
// @router /exam-questions/:id [put]
func (c *ExamQuestionController) Update() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
id, err := c.GetInt64(":id")
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "ID无效", "data": nil}
c.ServeJSON()
return
}
var payload struct {
QuestionTitle string `json:"question_title"`
QuestionType int8 `json:"question_type"`
Score float64 `json:"score"`
QuestionAnalysis string `json:"question_analysis"`
Options []map[string]string `json:"options"`
Answer interface{} `json:"answer"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "请求参数错误: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
q := &models.ExamQuestion{QuestionType: payload.QuestionType, QuestionTitle: payload.QuestionTitle, QuestionAnalysis: payload.QuestionAnalysis, Score: payload.Score}
var opts []models.ExamQuestionOption
for _, o := range payload.Options {
opts = append(opts, models.ExamQuestionOption{OptionLabel: o["label"], OptionContent: o["content"]})
}
answerContent := ""
switch v := payload.Answer.(type) {
case string:
answerContent = v
case []interface{}:
for i, item := range v {
if s, ok := item.(string); ok {
if i == 0 {
answerContent = s
} else {
answerContent += "," + s
}
}
}
}
if err := services.UpdateExamQuestion(tenantId, id, q, opts, answerContent); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "更新失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "更新成功", "data": nil}
c.ServeJSON()
}
// Delete
// @router /exam-questions/:id [delete]
func (c *ExamQuestionController) Delete() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
id, err := c.GetInt64(":id")
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "ID无效", "data": nil}
c.ServeJSON()
return
}
if err := services.DeleteExamQuestion(tenantId, id); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "删除失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "删除成功", "data": nil}
c.ServeJSON()
}
// GetBankList
// @router /exam-question-banks [get]
func (c *ExamQuestionBankController) GetBankList() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
keyword := c.GetString("keyword")
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
list, total, err := services.GetExamQuestionBanks(services.BankListParams{
TenantId: tenantId,
Keyword: keyword,
Page: page,
PageSize: pageSize,
})
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "获取试题库列表失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "ok", "data": map[string]interface{}{"list": list, "total": total}}
c.ServeJSON()
}
// GetBankDetail
// @router /exam-question-banks/:id [get]
func (c *ExamQuestionBankController) GetBankDetail() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
id, err := c.GetInt64(":id")
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "ID无效", "data": nil}
c.ServeJSON()
return
}
bank, err := services.GetExamQuestionBankDetail(tenantId, id)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "试题库不存在", "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "ok", "data": bank}
c.ServeJSON()
}
// CreateBank
// @router /exam-question-banks [post]
func (c *ExamQuestionBankController) CreateBank() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
var payload struct {
BankName string `json:"bank_name"`
BankDesc string `json:"bank_desc"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "请求参数错误: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
bank := &models.ExamQuestionBank{
TenantId: tenantId,
BankName: payload.BankName,
BankDesc: payload.BankDesc,
Status: 1,
}
id, err := services.CreateExamQuestionBank(tenantId, bank)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "创建失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "创建成功", "data": map[string]interface{}{"id": id}}
c.ServeJSON()
}
// UpdateBank
// @router /exam-question-banks/:id [put]
func (c *ExamQuestionBankController) UpdateBank() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
id, err := c.GetInt64(":id")
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "ID无效", "data": nil}
c.ServeJSON()
return
}
var payload struct {
BankName string `json:"bank_name"`
BankDesc string `json:"bank_desc"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "请求参数错误: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
bank := &models.ExamQuestionBank{
Id: id,
TenantId: tenantId,
BankName: payload.BankName,
BankDesc: payload.BankDesc,
}
if err := services.UpdateExamQuestionBank(tenantId, id, bank); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "更新失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "更新成功", "data": nil}
c.ServeJSON()
}
// DeleteBank
// @router /exam-question-banks/:id [delete]
func (c *ExamQuestionBankController) DeleteBank() {
tenantId, _ := c.Ctx.Input.GetData("tenantId").(int)
id, err := c.GetInt64(":id")
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "ID无效", "data": nil}
c.ServeJSON()
return
}
if err := services.DeleteExamQuestionBank(tenantId, id); err != nil {
c.Data["json"] = map[string]interface{}{"code": 1, "message": "删除失败: " + err.Error(), "data": nil}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 0, "message": "删除成功", "data": nil}
c.ServeJSON()
}

View File

@ -1,4 +1,4 @@
-- MySQL dump 10.13 Distrib 8.0.41, for Win64 (x86_64)
-- MySQL dump 10.13 Distrib 8.0.41, for Win64 (x86_64)
--
-- Host: 212.64.112.158 Database: gotest
-- ------------------------------------------------------
@ -228,6 +228,30 @@ CREATE TABLE `yz_exam_category` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试分类表(含多租户隔离)';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `yz_exam_question_bank`
--
DROP TABLE IF EXISTS `yz_exam_question_bank`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `yz_exam_question_bank` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '题库ID',
`tenant_id` int(11) NOT NULL COMMENT '租户ID多租户隔离',
`bank_name` varchar(100) NOT NULL COMMENT '题库名称',
`bank_desc` varchar(500) DEFAULT '' COMMENT '题库描述',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态1-启用0-禁用)',
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序序号',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_tenant_status` (`tenant_id`,`status`),
KEY `idx_tenant_name` (`tenant_id`,`bank_name`),
KEY `idx_delete_time` (`delete_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试题库表(支持租户隔离、软删除)';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `yz_exam_users`
--

79
server/models/exam.go Normal file
View File

@ -0,0 +1,79 @@
package models
import (
"time"
"github.com/beego/beego/v2/client/orm"
)
// ExamQuestion 对应表 yz_exam_question
type ExamQuestion struct {
Id int64 `orm:"column(id);auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
QuestionType int8 `orm:"column(question_type)" json:"question_type"`
QuestionTitle string `orm:"column(question_title);size(1000)" json:"question_title"`
QuestionAnalysis string `orm:"column(question_analysis);size(2000);null" json:"question_analysis"`
Score float64 `orm:"column(score);digits(5);decimals(2)" json:"score"`
SortOrder int8 `orm:"column(sort_order);null" json:"sort_order"`
Status int8 `orm:"column(status)" json:"status"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time,omitempty"`
}
func (q *ExamQuestion) TableName() string {
return "yz_exam_question"
}
// ExamQuestionOption 对应表 yz_exam_question_option
type ExamQuestionOption struct {
Id int64 `orm:"column(id);auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
QuestionId int64 `orm:"column(question_id)" json:"question_id"`
OptionLabel string `orm:"column(option_label);size(10)" json:"option_label"`
OptionContent string `orm:"column(option_content);size(500)" json:"option_content"`
SortOrder int8 `orm:"column(sort_order);null" json:"sort_order"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time,omitempty"`
}
func (o *ExamQuestionOption) TableName() string {
return "yz_exam_question_option"
}
// ExamQuestionAnswer 对应表 yz_exam_question_answer
type ExamQuestionAnswer struct {
Id int64 `orm:"column(id);auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
QuestionId int64 `orm:"column(question_id)" json:"question_id"`
AnswerContent string `orm:"column(answer_content);size(1000)" json:"answer_content"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time,omitempty"`
}
func (a *ExamQuestionAnswer) TableName() string {
return "yz_exam_question_answer"
}
// ExamQuestionBank 对应表 yz_exam_question_bank
type ExamQuestionBank struct {
Id int64 `orm:"column(id);auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
BankName string `orm:"column(bank_name);size(100)" json:"bank_name"`
BankDesc string `orm:"column(bank_desc);size(500);null" json:"bank_desc"`
Status int8 `orm:"column(status)" json:"status"`
SortOrder int8 `orm:"column(sort_order);null" json:"sort_order"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time,omitempty"`
}
func (b *ExamQuestionBank) TableName() string {
return "yz_exam_question_bank"
}
func init() {
orm.RegisterModel(new(ExamQuestion), new(ExamQuestionOption), new(ExamQuestionAnswer), new(ExamQuestionBank))
}

View File

@ -283,12 +283,20 @@ func init() {
beego.Router("/api/files/search", &controllers.FileController{}, "get:SearchFiles")
beego.Router("/api/files/statistics", &controllers.FileController{}, "get:GetFileStatistics")
// 考试题目路由
beego.Router("/api/exam-questions", &controllers.ExamQuestionController{}, "get:GetList;post:Create")
beego.Router("/api/exam-questions/:id", &controllers.ExamQuestionController{}, "get:GetDetail;put:Update;delete:Delete")
// 考试题库路由
beego.Router("/api/exam-question-banks", &controllers.ExamQuestionBankController{}, "get:GetBankList;post:CreateBank")
beego.Router("/api/exam-question-banks/:id", &controllers.ExamQuestionBankController{}, "get:GetBankDetail;put:UpdateBank;delete:DeleteBank")
// 知识库路由
beego.Router("/api/knowledge/list", &controllers.KnowledgeController{}, "get:List")
beego.Router("/api/knowledge/count", &controllers.KnowledgeController{}, "get:GetCount")
beego.Router("/api/knowledge/detail", &controllers.KnowledgeController{}, "get:Detail")
beego.Router("/api/knowledge/create", &controllers.KnowledgeController{}, "post:Create")
beego.Router("/api/knowledge/update", &controllers.KnowledgeController{}, "post:Update")
// ...
beego.Router("/api/knowledge/delete", &controllers.KnowledgeController{}, "post:Delete")
beego.Router("/api/knowledge/categories", &controllers.KnowledgeController{}, "get:GetCategories")
beego.Router("/api/knowledge/tags", &controllers.KnowledgeController{}, "get:GetTags")
@ -314,10 +322,10 @@ func init() {
// OA基础数据合并接口一次性获取部门、职位、角色
beego.Router("/api/oa/base-data/:tenantId", &controllers.OAController{}, "get:GetOABaseData")
// OA任务管理路由
beego.Router("/api/oa/tasks", &controllers.TaskController{}, "get:GetTasks;post:CreateTask")
beego.Router("/api/oa/tasks/:id", &controllers.TaskController{}, "get:GetTaskById;put:UpdateTask;delete:DeleteTask")
beego.Router("/api/oa/tasks/todo", &controllers.TaskController{}, "get:GetTodoTasks")
// OA任务管理路由
beego.Router("/api/oa/tasks", &controllers.TaskController{}, "get:GetTasks;post:CreateTask")
beego.Router("/api/oa/tasks/:id", &controllers.TaskController{}, "get:GetTaskById;put:UpdateTask;delete:DeleteTask")
beego.Router("/api/oa/tasks/todo", &controllers.TaskController{}, "get:GetTodoTasks")
// CRM 客户路由
beego.Router("/api/crm/customer/list", &controllers.CustomerController{}, "get:List")

224
server/services/exam.go Normal file
View File

@ -0,0 +1,224 @@
package services
import (
"errors"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
type QuestionListParams struct {
TenantId int
Keyword string
QuestionType *int8
Page int
PageSize int
}
type QuestionWithMeta struct {
Question *models.ExamQuestion
Options []*models.ExamQuestionOption
Answer *models.ExamQuestionAnswer
}
type BankListParams struct {
TenantId int
Keyword string
Page int
PageSize int
}
func GetExamQuestions(params QuestionListParams) ([]*models.ExamQuestion, int64, error) {
o := orm.NewOrm()
qs := o.QueryTable(new(models.ExamQuestion)).Filter("delete_time__isnull", true).Filter("status", 1)
if params.TenantId > 0 {
qs = qs.Filter("tenant_id", params.TenantId)
}
if params.Keyword != "" {
qs = qs.Filter("question_title__icontains", params.Keyword)
}
if params.QuestionType != nil {
qs = qs.Filter("question_type", *params.QuestionType)
}
total, err := qs.Count()
if err != nil {
return nil, 0, err
}
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 10
}
offset := (params.Page - 1) * params.PageSize
var list []*models.ExamQuestion
_, err = qs.OrderBy("-id").Limit(params.PageSize, offset).All(&list, "Id", "TenantId", "QuestionType", "QuestionTitle", "Score")
return list, total, err
}
func GetExamQuestionDetail(tenantId int, id int64) (*QuestionWithMeta, error) {
o := orm.NewOrm()
q := new(models.ExamQuestion)
if err := o.QueryTable(q).Filter("id", id).Filter("delete_time__isnull", true).One(q); err != nil {
return nil, err
}
if tenantId > 0 && q.TenantId != tenantId {
return nil, errors.New("not found")
}
var opts []*models.ExamQuestionOption
_, _ = o.QueryTable(new(models.ExamQuestionOption)).Filter("question_id", q.Id).Filter("delete_time__isnull", true).All(&opts)
a := new(models.ExamQuestionAnswer)
_ = o.QueryTable(a).Filter("question_id", q.Id).Filter("delete_time__isnull", true).One(a)
return &QuestionWithMeta{Question: q, Options: opts, Answer: a}, nil
}
func CreateExamQuestion(tenantId int, q *models.ExamQuestion, options []models.ExamQuestionOption, answerContent string) (int64, error) {
o := orm.NewOrm()
if tenantId > 0 {
q.TenantId = tenantId
}
q.Status = 1
if _, err := o.Insert(q); err != nil {
return 0, err
}
for i := range options {
options[i].TenantId = q.TenantId
options[i].QuestionId = q.Id
_, _ = o.Insert(&options[i])
}
ans := &models.ExamQuestionAnswer{TenantId: q.TenantId, QuestionId: q.Id, AnswerContent: answerContent}
_, _ = o.Insert(ans)
return q.Id, nil
}
func UpdateExamQuestion(tenantId int, id int64, q *models.ExamQuestion, options []models.ExamQuestionOption, answerContent string) error {
o := orm.NewOrm()
existing := models.ExamQuestion{Id: id}
if err := o.Read(&existing); err != nil {
return err
}
if tenantId > 0 && existing.TenantId != tenantId {
return errors.New("not found")
}
existing.QuestionType = q.QuestionType
existing.QuestionTitle = q.QuestionTitle
existing.QuestionAnalysis = q.QuestionAnalysis
existing.Score = q.Score
if _, err := o.Update(&existing, "QuestionType", "QuestionTitle", "QuestionAnalysis", "Score"); err != nil {
return err
}
// soft delete old options and answer
now := time.Now()
o.QueryTable(new(models.ExamQuestionOption)).Filter("question_id", id).Filter("delete_time__isnull", true).Update(orm.Params{"delete_time": now})
o.QueryTable(new(models.ExamQuestionAnswer)).Filter("question_id", id).Filter("delete_time__isnull", true).Update(orm.Params{"delete_time": now})
for i := range options {
options[i].TenantId = existing.TenantId
options[i].QuestionId = id
_, _ = o.Insert(&options[i])
}
ans := &models.ExamQuestionAnswer{TenantId: existing.TenantId, QuestionId: id, AnswerContent: answerContent}
_, _ = o.Insert(ans)
return nil
}
func DeleteExamQuestion(tenantId int, id int64) error {
o := orm.NewOrm()
existing := models.ExamQuestion{Id: id}
if err := o.Read(&existing); err != nil {
return err
}
if tenantId > 0 && existing.TenantId != tenantId {
return errors.New("not found")
}
now := time.Now()
if _, err := o.QueryTable(new(models.ExamQuestion)).Filter("id", id).Update(orm.Params{"delete_time": now}); err != nil {
return err
}
o.QueryTable(new(models.ExamQuestionOption)).Filter("question_id", id).Filter("delete_time__isnull", true).Update(orm.Params{"delete_time": now})
o.QueryTable(new(models.ExamQuestionAnswer)).Filter("question_id", id).Filter("delete_time__isnull", true).Update(orm.Params{"delete_time": now})
return nil
}
func GetExamQuestionBanks(params BankListParams) ([]*models.ExamQuestionBank, int64, error) {
o := orm.NewOrm()
qs := o.QueryTable(new(models.ExamQuestionBank)).Filter("delete_time__isnull", true).Filter("status", 1)
if params.TenantId > 0 {
qs = qs.Filter("tenant_id", params.TenantId)
}
if params.Keyword != "" {
qs = qs.Filter("bank_name__icontains", params.Keyword)
}
total, err := qs.Count()
if err != nil {
return nil, 0, err
}
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 10
}
offset := (params.Page - 1) * params.PageSize
var list []*models.ExamQuestionBank
_, err = qs.OrderBy("-id").Limit(params.PageSize, offset).All(&list, "Id", "TenantId", "BankName", "BankDesc")
return list, total, err
}
func GetExamQuestionBankDetail(tenantId int, id int64) (*models.ExamQuestionBank, error) {
o := orm.NewOrm()
bank := new(models.ExamQuestionBank)
if err := o.QueryTable(bank).Filter("id", id).Filter("delete_time__isnull", true).One(bank); err != nil {
return nil, err
}
if tenantId > 0 && bank.TenantId != tenantId {
return nil, errors.New("not found")
}
return bank, nil
}
func CreateExamQuestionBank(tenantId int, bank *models.ExamQuestionBank) (int64, error) {
o := orm.NewOrm()
if tenantId > 0 {
bank.TenantId = tenantId
}
bank.Status = 1
if _, err := o.Insert(bank); err != nil {
return 0, err
}
return bank.Id, nil
}
func UpdateExamQuestionBank(tenantId int, id int64, bank *models.ExamQuestionBank) error {
o := orm.NewOrm()
existing := models.ExamQuestionBank{Id: id}
if err := o.Read(&existing); err != nil {
return err
}
if tenantId > 0 && existing.TenantId != tenantId {
return errors.New("not found")
}
existing.BankName = bank.BankName
existing.BankDesc = bank.BankDesc
if _, err := o.Update(&existing, "BankName", "BankDesc"); err != nil {
return err
}
return nil
}
func DeleteExamQuestionBank(tenantId int, id int64) error {
o := orm.NewOrm()
existing := models.ExamQuestionBank{Id: id}
if err := o.Read(&existing); err != nil {
return err
}
if tenantId > 0 && existing.TenantId != tenantId {
return errors.New("not found")
}
now := time.Now()
if _, err := o.QueryTable(new(models.ExamQuestionBank)).Filter("id", id).Update(orm.Params{"delete_time": now}); err != nil {
return err
}
return nil
}