@kevinchang1207/cx-ui
v0.1.2
Published
Cx UI components library
Maintainers
Readme
Cx UI(基于 Element Plus 的业务组件库)
轻量封装 Element Plus,提供统一风格与常用增强组件:CxInput、CxButton、CxForm、CxTable(列筛选)、CxDialog、CxDrawer、CxPagination、CxSelect、CxDateRange。
安装
npm i @kevinchang1207/cx-ui element-plus
# 或者
pnpm add @kevinchang1207/cx-ui element-plus要求:Vue 3.2+,Element Plus 2.x。
快速开始(全局注册)
// main.ts / main.js
import { createApp } from 'vue'
import App from './App.vue'
import CxUI from '@kevinchang1207/cx-ui'
import 'element-plus/dist/index.css'
createApp(App).use(CxUI).mount('#app')按需使用(局部注册)
import { CxButton, CxInput } from '@kevinchang1207/cx-ui'
export default { components: { CxButton, CxInput } }组件示例
CxButton
<cx-button type="primary" @click="doSomething">提交</cx-button>
<cx-button type="success" plain>成功</cx-button>
<cx-button type="danger" :loading="loading">删除中</cx-button>Props:type(primary/success/warning/danger/info/default)、size(sm/md/lg)、plain、round、circle、loading、disabled。
CxInput
<cx-input v-model="keyword" placeholder="请输入关键词" clearable />支持 prefix、suffix 插槽。
CxForm(基于 schema)
<cx-form :model="form" :rules="rules" :schema="[
{ label: '姓名', prop: 'name', component: 'input', placeholder: '请输入姓名' },
{ label: '状态', prop: 'status', component: 'select', options: [
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' }
]},
{ label: '日期', prop: 'date', component: 'date' }
]" @submit="onSubmit" />事件:submit(values)、reset()、validate({ valid, error })。
CxTable(列筛选/排序/选择)
<cx-table :data="rows" :columns="[
{ type: 'selection' },
{ prop: 'name', label: '姓名', sortable: 'custom' },
{ prop: 'status', label: '状态', filters: [
{ text: '启用', value: 'enabled' },
{ text: '停用', value: 'disabled' }
], filterMethod: (val, row) => row.status === val },
{ prop: 'age', label: '年龄', sortable: 'custom' }
]" @filter-change="onFilter" @sort-change="onSort" />支持插槽列:columns 中定义 slot: 'xxx',并提供 <template #xxx="{ row }">。
CxDialog / CxDrawer
<cx-dialog v-model="visible" title="提示" @confirm="ok">内容</cx-dialog>
<cx-drawer v-model="drawer" title="侧边">内容</cx-drawer>CxSelect / CxDateRange
<cx-select v-model="val" :options="opts" />
<cx-date-range v-model="range" />分页
<cx-pagination :current="page" :page-size="size" :total="total" @change="onPage" />常见问题
- 按钮/输入框尺寸:库内
sm/md/lg自动映射到 Element Plus 的small/default/large。 - 主题:直接使用 Element Plus 主题定制方案(如变量覆盖)。
- Tree-shaking:本库为 UMD/CommonJS 构建,建议在应用层做路由/页面级懒加载。
License
MIT
<template>
<div class="question-create-page">
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-content">
<h1 class="page-title">新建题目</h1>
<el-button text circle class="close-btn" @click="handleClose">
<el-icon><i-ep-close /></el-icon>
</el-button>
</div>
</div>
<!-- 顶部工具栏 -->
<div class="top-toolbar">
<div class="toolbar-content">
<!-- 题目类型选择 -->
<div class="type-selector">
<div
v-for="type in questionTypes"
:key="type.value"
:class="['type-item', { active: formData.type === type.value }]"
@click="formData.type = type.value"
>
<span class="type-name">{{ type.label }}</span>
</div>
</div>
<!-- 基础信息 -->
<div class="info-panel">
<div class="info-item">
<label class="info-label">所属题集</label>
<el-input
v-model="formData.questionSet"
disabled
size="small"
style="width: 180px"
/>
</div>
<div class="info-item">
<label class="info-label">题目分类</label>
<el-select
v-model="formData.category"
placeholder="请选择分类"
clearable
filterable
size="small"
style="width: 160px"
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="info-item">
<label class="info-label">难度</label>
<div class="difficulty-selector">
<div
v-for="level in 5"
:key="level"
:class="[
'difficulty-item',
{ active: formData.difficulty === level },
]"
@click="formData.difficulty = level"
>
{{ level }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 编辑区 -->
<div class="editor-area">
<div class="editor-container">
<el-scrollbar class="editor-scrollbar">
<!-- 题干编辑 -->
<div class="editor-block">
<div class="block-header">
<span class="block-title">题干</span>
<span class="block-required">*</span>
</div>
<div class="block-content">
<div class="editor-wrapper-border">
<WangEditor v-model="formData.content" />
</div>
</div>
</div>
<!-- 题干附件 -->
<div class="editor-block">
<div class="block-header">
<span class="block-title">题干附件</span>
</div>
<div class="block-content">
<el-button
type="primary"
:icon="ElIconUpload"
@click="handleSelectAttachment"
>
选择附件
</el-button>
<div
v-if="formData.attachments.length > 0"
class="attachment-tags"
>
<el-tag
v-for="(att, idx) in formData.attachments"
:key="idx"
closable
@close="removeAttachment(idx)"
>
{{ att.name }}
</el-tag>
</div>
</div>
</div>
<!-- 选项编辑(仅选择题) -->
<div v-if="showOptions" class="editor-block">
<div class="block-header">
<span class="block-title">选项</span>
<span class="block-required">*</span>
</div>
<div class="options-list">
<div
v-for="(option, index) in formData.options"
:key="index"
class="option-card"
>
<div class="option-header">
<div class="option-badge">{{ getOptionLabel(index) }}</div>
<el-button
text
type="danger"
:icon="ElIconDelete"
:disabled="formData.options.length <= 2"
@click="removeOption(index)"
>
删除
</el-button>
</div>
<div class="option-editor">
<WangEditor v-model="option.text" />
</div>
</div>
<el-button
type="primary"
size="small"
:icon="ElIconPlus"
class="add-option-btn"
@click="addOption"
>
添加选项
</el-button>
</div>
</div>
<!-- 正确答案 -->
<div class="editor-block">
<div class="block-header">
<span class="block-title">正确答案</span>
<span class="block-required">*</span>
</div>
<div class="block-content">
<!-- 单选题 -->
<div v-if="formData.type === 'single'" class="answer-selector">
<div
v-for="(option, index) in formData.options"
:key="index"
:class="[
'answer-option',
{ selected: formData.correctAnswer === index },
]"
@click="formData.correctAnswer = index"
>
<div class="check-icon">
<i-ep-check
v-if="formData.correctAnswer === index"
class="icon-checked"
/>
<span v-else class="icon-unchecked"></span>
</div>
<span>{{ getOptionLabel(index) }}</span>
</div>
</div>
<!-- 多选题/不定项 -->
<div
v-else-if="
formData.type === 'multiple' ||
formData.type === 'uncertain'
"
class="answer-selector"
>
<div
v-for="(option, index) in formData.options"
:key="index"
:class="['answer-option', { selected: (formData.correctAnswer as number[])?.includes(index) }]"
@click="toggleAnswer(index)"
>
<div class="check-icon">
<i-ep-check
v-if="(formData.correctAnswer as number[])?.includes(index)"
class="icon-checked"
/>
<span v-else class="icon-unchecked"></span>
</div>
<span>{{ getOptionLabel(index) }}</span>
</div>
</div>
<!-- 判断题 -->
<div
v-else-if="formData.type === 'judge'"
class="answer-selector"
>
<div
:class="[
'answer-option',
{ selected: formData.correctAnswer === true },
]"
@click="formData.correctAnswer = true"
>
<div class="check-icon">
<i-ep-check
v-if="formData.correctAnswer === true"
class="icon-checked"
/>
<span v-else class="icon-unchecked"></span>
</div>
<span>正确</span>
</div>
<div
:class="[
'answer-option',
{ selected: formData.correctAnswer === false },
]"
@click="formData.correctAnswer = false"
>
<div class="check-icon">
<i-ep-check
v-if="formData.correctAnswer === false"
class="icon-checked"
/>
<span v-else class="icon-unchecked"></span>
</div>
<span>错误</span>
</div>
</div>
<!-- 填空题/问答题 -->
<el-input
v-else
v-model="formData.correctAnswer"
type="textarea"
:rows="formData.type === 'essay' ? 6 : 3"
placeholder="请输入答案"
class="answer-textarea"
/>
</div>
</div>
<!-- 题目解析 -->
<div class="editor-block">
<div class="block-header">
<span class="block-title">题目解析</span>
</div>
<div class="block-content">
<div class="editor-wrapper-border">
<WangEditor v-model="formData.analysis" />
</div>
</div>
</div>
</el-scrollbar>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="page-footer">
<div class="footer-content">
<div class="footer-left">
<span class="zoom-text">1:1</span>
</div>
<div class="footer-actions">
<el-button size="small" @click="handleClose">取消</el-button>
<el-button type="primary" size="small" @click="handleSave">
<el-icon><i-ep-check /></el-icon>
保存
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Upload as ElIconUpload,
Plus as ElIconPlus,
Delete as ElIconDelete,
} from "@element-plus/icons-vue";
import WangEditor from "@/components/WangEditor/index.vue";
import type { QuestionType, QuestionOption } from "@/api/kaoshi/types";
const router = useRouter();
// 题目类型选项
const questionTypes = [
{ label: "单选题", value: "single" },
{ label: "多选题", value: "multiple" },
{ label: "不定项", value: "uncertain" },
{ label: "判断题", value: "judge" },
{ label: "填空题", value: "fill" },
{ label: "问答题", value: "essay" },
];
// 表单数据
const formData = reactive({
type: "single" as QuestionType,
questionSet: "花雨精讲(学员端)",
category: "",
difficulty: 1,
content: "",
attachments: [] as Array<{ name: string; url: string }>,
options: [] as QuestionOption[],
correctAnswer: undefined as any,
analysis: "",
});
// 分类选项
const categoryOptions = ref([
{ label: "未分类", value: "uncategorized" },
{ label: "复制章节", value: "copy_chapter" },
{ label: "学习收获", value: "learning_outcomes" },
{ label: "最后讨论了", value: "finally_discussed" },
{ label: "图文", value: "graphics_text" },
{ label: "文档", value: "document" },
{ label: "学党史", value: "party_history" },
{ label: "音频任务测试", value: "audio_task_test" },
{ label: "线上考试抽查", value: "online_exam_check" },
{ label: "随堂小测作业", value: "class_quiz" },
{ label: "练习效果", value: "practice_effect" },
]);
// 是否显示选项
const showOptions = computed(() => {
return ["single", "multiple", "uncertain"].includes(formData.type);
});
// 监听题目类型变化
watch(
() => formData.type,
(newType) => {
if (showOptions.value) {
if (formData.options.length === 0) {
formData.options = [{ text: "" }, { text: "" }];
}
if (newType === "single") {
formData.correctAnswer = undefined;
} else {
formData.correctAnswer = [];
}
} else {
formData.options = [];
formData.correctAnswer = undefined;
}
},
{ immediate: true }
);
/**
* 获取选项标签
*/
function getOptionLabel(index: number): string {
return String.fromCharCode(65 + index);
}
/**
* 添加选项
*/
function addOption() {
formData.options.push({ text: "" });
}
/**
* 删除选项
*/
function removeOption(index: number) {
if (formData.options.length <= 2) {
ElMessage.warning("至少需要保留2个选项");
return;
}
formData.options.splice(index, 1);
if (formData.type === "single") {
if (formData.correctAnswer === index) {
formData.correctAnswer = undefined;
} else if (formData.correctAnswer > index) {
formData.correctAnswer = (formData.correctAnswer as number) - 1;
}
} else {
const answerArray = formData.correctAnswer as number[];
if (answerArray) {
formData.correctAnswer = answerArray
.filter((ans) => ans !== index)
.map((ans) => (ans > index ? ans - 1 : ans));
}
}
}
/**
* 切换多选题答案
*/
function toggleAnswer(index: number) {
const answerArray = (formData.correctAnswer as number[]) || [];
const idx = answerArray.indexOf(index);
if (idx > -1) {
answerArray.splice(idx, 1);
} else {
answerArray.push(index);
}
formData.correctAnswer = [...answerArray];
}
/**
* 选择附件
*/
function handleSelectAttachment() {
ElMessage.info("附件选择功能待实现");
}
/**
* 删除附件
*/
function removeAttachment(index: number) {
formData.attachments.splice(index, 1);
}
/**
* 关闭页面
*/
function handleClose() {
ElMessageBox.confirm("确定要关闭吗?未保存的内容将丢失", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
router.back();
})
.catch(() => {});
}
/**
* 保存题目
*/
function handleSave() {
if (!formData.content || formData.content.trim() === "") {
ElMessage.warning("请输入题干内容");
return;
}
if (showOptions.value) {
if (formData.options.length < 2) {
ElMessage.warning("至少需要2个选项");
return;
}
const emptyOptions = formData.options.some(
(opt) => !opt.text || opt.text.trim() === ""
);
if (emptyOptions) {
ElMessage.warning("请填写所有选项内容");
return;
}
if (formData.type === "single") {
if (
formData.correctAnswer === undefined ||
formData.correctAnswer === null
) {
ElMessage.warning("请选择正确答案");
return;
}
} else {
const answerArray = formData.correctAnswer as number[];
if (!answerArray || answerArray.length === 0) {
ElMessage.warning("请选择至少一个正确答案");
return;
}
}
} else {
if (
formData.correctAnswer === undefined ||
formData.correctAnswer === null ||
formData.correctAnswer === ""
) {
ElMessage.warning("请输入正确答案");
return;
}
}
console.log("保存题目数据:", formData);
ElMessage.success("保存成功");
setTimeout(() => {
router.back();
}, 1000);
}
</script>
<style lang="scss" scoped>
.question-create-page {
display: flex;
flex-direction: column;
height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
overflow: hidden;
}
.page-header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
z-index: 10;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
max-width: 1600px;
margin: 0 auto;
.page-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.close-btn {
width: 32px;
height: 32px;
color: #6b7280;
transition: all 0.2s;
&:hover {
color: #ef4444;
background-color: #fef2f2;
}
}
}
}
.top-toolbar {
background: #fff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
.toolbar-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 20px;
max-width: 1600px;
margin: 0 auto;
flex-wrap: wrap;
}
.type-selector {
display: flex;
gap: 6px;
flex: 1;
min-width: 0;
.type-item {
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
background: #f9fafb;
border: 1.5px solid transparent;
white-space: nowrap;
user-select: none;
.type-name {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
&:hover {
background: #f3f4f6;
border-color: #e5e7eb;
}
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
.type-name {
color: #fff;
font-weight: 600;
}
}
}
}
.info-panel {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.info-item {
display: flex;
align-items: center;
gap: 6px;
.info-label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
white-space: nowrap;
}
.difficulty-selector {
display: flex;
gap: 4px;
.difficulty-item {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: #f3f4f6;
color: #6b7280;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #e5e7eb;
transform: translateY(-1px);
}
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);
}
}
}
}
}
}
.main-content {
flex: 1;
padding: 16px 20px;
overflow: hidden;
max-width: 1600px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.editor-area {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
.editor-container {
flex: 1;
min-height: 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
overflow: hidden;
display: flex;
flex-direction: column;
.editor-scrollbar {
flex: 1;
min-height: 0;
}
}
}
.editor-block {
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
&:last-child {
border-bottom: none;
}
.editor-wrapper-border {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
transition: all 0.2s;
min-height: 180px;
&:focus-within {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
:deep(.editor-wrapper) {
border: none;
min-height: 180px;
display: flex;
flex-direction: column;
}
:deep(#toolbar-container) {
flex-shrink: 0;
border-bottom: 1px solid #e4e7ed;
}
:deep(#editor-container) {
flex: 1;
min-height: 130px;
}
:deep(.w-e-text-container) {
min-height: 130px !important;
padding: 6px !important;
}
}
.block-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
.block-title {
font-size: 13px;
font-weight: 600;
color: #1f2937;
}
.block-required {
color: #ef4444;
font-size: 12px;
}
.add-option-btn {
margin-top: 8px;
width: 100%;
}
}
.block-content {
.attachment-tags {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.answer-selector {
display: flex;
flex-direction: column;
gap: 8px;
.answer-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1.5px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
background: #f9fafb;
.check-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
.icon-checked {
font-size: 16px;
color: #667eea;
}
.icon-unchecked {
width: 14px;
height: 14px;
border: 2px solid #9ca3af;
border-radius: 50%;
background: transparent;
display: inline-block;
}
}
span {
font-size: 13px;
font-weight: 500;
color: #374151;
}
&:hover {
border-color: #667eea;
background: #f3f4f6;
transform: translateX(2px);
}
&.selected {
border-color: #667eea;
background: linear-gradient(135deg, #f0f4ff 0%, #e0e7ff 100%);
.check-icon {
.icon-unchecked {
border-color: #667eea;
}
}
}
}
}
.answer-textarea {
:deep(.el-textarea__inner) {
border-radius: 6px;
border-color: #e5e7eb;
font-size: 13px;
line-height: 1.5;
padding: 8px 12px;
&:focus {
border-color: #667eea;
}
}
}
}
.options-list {
display: flex;
flex-direction: column;
gap: 10px;
.option-card {
border: 1.5px solid #e5e7eb;
border-radius: 6px;
padding: 12px;
background: #fafbfc;
transition: all 0.2s;
&:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.option-badge {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-weight: 700;
font-size: 13px;
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
}
}
.option-editor {
margin-top: 8px;
min-height: 150px;
position: relative;
overflow: hidden;
border: 1px solid #dcdfe6;
border-radius: 4px;
transition: all 0.2s;
:deep(.editor-wrapper) {
min-height: 150px;
display: flex;
flex-direction: column;
border: none;
}
:deep(#toolbar-container) {
flex-shrink: 0;
border-bottom: 1px solid #e4e7ed;
}
:deep(#editor-container) {
flex: 1;
min-height: 100px;
}
:deep(.w-e-text-container) {
min-height: 100px !important;
padding: 6px !important;
}
&:focus-within {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
}
}
}
}
.page-footer {
background: #fff;
border-top: 1px solid #e4e7ed;
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.04);
z-index: 10;
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
max-width: 1600px;
margin: 0 auto;
.footer-left {
.zoom-text {
color: #9ca3af;
font-size: 12px;
font-weight: 500;
}
}
.footer-actions {
display: flex;
gap: 8px;
.el-button {
padding: 8px 20px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
transition: all 0.2s;
&.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
}
}
}
}
}
</style><template>
<div class="question-edit-container">
<el-card shadow="never" class="header-card">
<template #header>
<div class="flex justify-between items-center">
<h3>试题编辑</h3>
<div class="flex gap-2">
<el-button @click="handleBack">返 回</el-button>
<el-button type="primary" @click="handleSave">保 存</el-button>
</div>
</div>
</template>
</el-card>
<div class="content-wrapper">
<!-- 左侧题号导航 -->
<div class="left-nav">
<el-card shadow="never" class="nav-card">
<template #header>
<div class="nav-header">
<span>试题列表</span>
<span class="text-sm text-gray-500"
>(共{{ questionList.length }}题)</span
>
</div>
</template>
<el-scrollbar height="calc(100vh - 200px)">
<div class="answer-sheet">
<div
v-for="(item, index) in questionList"
:key="item.id || index"
:class="[
'answer-item',
{
active: currentIndex === index,
error: item.hasError,
},
]"
@click="scrollToQuestion(index)"
>
<div class="answer-number">{{ index + 1 }}</div>
<div class="answer-type-badge">
<el-tag
:type="getTypeTagType(item.type)"
size="small"
effect="plain"
>
{{ getTypeName(item.type) }}
</el-tag>
</div>
<el-icon v-if="item.hasError" class="error-icon">
<i-ep-warning-filled />
</el-icon>
</div>
</div>
</el-scrollbar>
</el-card>
</div>
<!-- 右侧试题编辑区 -->
<div class="right-content">
<el-card shadow="never" class="content-card">
<el-scrollbar height="calc(100vh - 200px)">
<div class="question-list">
<div
v-for="(question, index) in questionList"
:key="question.id || index"
:id="`question-${index}`"
:ref="(el) => setQuestionRef(el, index)"
class="question-item"
>
<div class="question-header">
<div class="flex items-center gap-2">
<span class="question-title">第{{ index + 1 }}题</span>
<el-tag :type="getTypeTagType(question.type)" size="small">
{{ getTypeName(question.type) }}
</el-tag>
<el-tag v-if="question.hasError" type="danger" size="small">
需要修改
</el-tag>
</div>
<el-button
v-if="index > 0"
link
size="small"
@click="scrollToQuestion(index - 1)"
>
<i-ep-arrow-up />上一题
</el-button>
<el-button
v-if="index < questionList.length - 1"
link
size="small"
@click="scrollToQuestion(index + 1)"
>
下一题<i-ep-arrow-down />
</el-button>
</div>
<!-- 题目内容编辑 -->
<el-form
:model="question"
label-width="100px"
class="question-form"
>
<el-form-item label="题目内容">
<el-input
v-model="question.content"
type="textarea"
:rows="3"
placeholder="请输入题目内容"
/>
</el-form-item>
<!-- 选择题 -->
<template
v-if="
question.type === 'single' || question.type === 'multiple'
"
>
<el-form-item
v-for="(option, optIndex) in question.options"
:key="optIndex"
:label="`选项${String.fromCharCode(65 + optIndex)}`"
>
<div class="flex items-center gap-2">
<el-input
v-model="option.text"
placeholder="请输入选项内容"
class="flex-1"
/>
<el-checkbox
v-if="question.type === 'multiple'"
v-model="option.isCorrect"
>
正确答案
</el-checkbox>
<el-radio
v-else
:model-value="question.correctAnswer"
:label="optIndex"
@change="question.correctAnswer = optIndex"
>
正确答案
</el-radio>
<el-button
link
type="danger"
size="small"
@click="removeOption(question, optIndex)"
>
<i-ep-delete />
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button
link
type="primary"
@click="addOption(question)"
>
<i-ep-plus />添加选项
</el-button>
</el-form-item>
</template>
<!-- 判断题 -->
<template v-if="question.type === 'judge'">
<el-form-item label="正确答案">
<el-radio-group v-model="question.correctAnswer">
<el-radio :label="true">正确</el-radio>
<el-radio :label="false">错误</el-radio>
</el-radio-group>
</el-form-item>
</template>
<!-- 填空题 -->
<template v-if="question.type === 'fill'">
<el-form-item label="参考答案">
<el-input
v-model="question.correctAnswer"
type="textarea"
:rows="2"
placeholder="请输入参考答案(多个答案用逗号分隔)"
/>
</el-form-item>
</template>
<!-- 简答题 -->
<template v-if="question.type === 'essay'">
<el-form-item label="参考答案">
<el-input
v-model="question.correctAnswer"
type="textarea"
:rows="5"
placeholder="请输入参考答案"
/>
</el-form-item>
</template>
<!-- 解析 -->
<el-form-item label="题目解析">
<el-input
v-model="question.analysis"
type="textarea"
:rows="3"
placeholder="请输入题目解析(可选)"
/>
</el-form-item>
<!-- 难度和分值 -->
<el-form-item label="难度">
<el-rate v-model="question.difficulty" :max="5" />
</el-form-item>
<el-form-item label="分值">
<el-input-number
v-model="question.score"
:min="0"
:max="100"
:precision="1"
/>
</el-form-item>
</el-form>
<!-- 错误提示 -->
<el-alert
v-if="question.hasError && question.errorMessage"
:title="question.errorMessage"
type="warning"
:closable="false"
class="mt-2"
/>
</div>
</div>
</el-scrollbar>
</el-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "QuestionEdit",
});
import { ref, onMounted, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
// import { getQuestionList, saveQuestions } from "@/api/kaoshi";
// import type { Question } from "@/api/kaoshi/types";
/**
* 试题类型定义
* - single: 单选题(只有一个正确答案)
* - multiple: 多选题(可以有多个正确答案)
* - judge: 判断题(正确/错误)
* - fill: 填空题(需要填写答案)
* - essay: 简答题(需要文字描述答案)
*/
type QuestionType = "single" | "multiple" | "judge" | "fill" | "essay";
/**
* 试题选项(用于选择题)
*/
interface QuestionOption {
/** 选项文本内容 */
text: string;
/** 是否为正确答案(多选题使用,单选题使用 correctAnswer 字段) */
isCorrect?: boolean;
}
/**
* 试题实体
*/
interface Question {
/**
* 试题ID
* - 新建题目时可能为空
* - 从后端获取的题目会有ID
*/
id?: string | number;
/**
* 试题类型
* - single: 单选题
* - multiple: 多选题
* - judge: 判断题
* - fill: 填空题
* - essay: 简答题
*/
type: QuestionType;
/**
* 题目内容
* - 题目的题干部分
* - 必填项,不能为空
* - 支持多行文本
*/
content: string;
/**
* 选项列表(仅选择题使用)
* - 单选题和多选题都需要此字段
* - 至少需要2个选项
* - 每个选项包含文本和是否正确答案的标记
*/
options?: QuestionOption[];
/**
* 正确答案
* - 单选题:存储选项的索引(number),如 0、1、2
* - 多选题:存储选项索引数组(number[]),如 [0, 2, 3]
* - 判断题:存储布尔值(boolean),true 表示正确,false 表示错误
* - 填空题:存储答案字符串(string),多个答案用逗号分隔
* - 简答题:存储参考答案字符串(string),支持多行文本
*/
correctAnswer?: any;
/**
* 题目解析
* - 对题目的解释说明
* - 可选字段,用于帮助理解题目
* - 支持多行文本
*/
analysis?: string;
/**
* 难度等级
* - 取值范围:1-5
* - 1: 非常简单
* - 2: 简单
* - 3: 中等
* - 4: 困难
* - 5: 非常困难
* - 用于题目筛选和组卷
*/
difficulty?: number;
/**
* 分值
* - 该题目的分数
* - 取值范围:0-100
* - 用于计算总分
* - 默认值根据题目类型设置
*/
score?: number;
/**
* 是否有错误标记
* - 导入时如果解析出现问题,会标记为 true
* - 需要用户修改后才能保存
* - 界面上会显示错误提示
*/
hasError?: boolean;
/**
* 错误信息
* - 当 hasError 为 true 时,此字段包含具体的错误描述
* - 例如:"题目内容不能为空"、"至少需要2个选项"等
* - 用于提示用户需要修改的内容
*/
errorMessage?: string;
}
const route = useRoute();
const router = useRouter();
const questionList = ref<Question[]>([]);
const currentIndex = ref(0);
const questionRefs = ref<(HTMLElement | null)[]>([]);
// 设置题目引用
function setQuestionRef(el: HTMLElement | null, index: number) {
if (el) {
questionRefs.value[index] = el;
}
}
// 获取类型名称
function getTypeName(type: QuestionType): string {
const typeMap: Record<QuestionType, string> = {
single: "单选题",
multiple: "多选题",
judge: "判断题",
fill: "填空题",
essay: "简答题",
};
return typeMap[type] || "未知类型";
}
// 获取类型标签类型
function getTypeTagType(type: QuestionType): string {
const typeMap: Record<QuestionType, string> = {
single: "primary",
multiple: "success",
judge: "warning",
fill: "info",
essay: "danger",
};
return typeMap[type] || "";
}
// 滚动到指定题目
function scrollToQuestion(index: number) {
if (index < 0 || index >= questionList.value.length) return;
currentIndex.value = index;
const questionId = `question-${index}`;
const element = document.getElementById(questionId);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}
// 添加选项
function addOption(question: Question) {
if (!question.options) {
question.options = [];
}
question.options.push({
text: "",
isCorrect: false,
});
}
// 移除选项
function removeOption(question: Question, index: number) {
if (question.options && question.options.length > 2) {
question.options.splice(index, 1);
} else {
ElMessage.warning("至少需要保留2个选项");
}
}
// 返回
function handleBack() {
router.back();
}
/**
* 保存试题
* 验证所有题目的必填项,如果有错误则提示用户修改
* 验证通过后调用后端接口保存数据
*/
function handleSave() {
// 验证所有题目的必填项
const hasError = questionList.value.some((q) => {
if (!q.content || q.content.trim() === "") {
q.hasError = true;
q.errorMessage = "题目内容不能为空";
return true;
}
if (q.type === "single" || q.type === "multiple") {
if (!q.options || q.options.length < 2) {
q.hasError = true;
q.errorMessage = "至少需要2个选项";
return true;
}
if (q.options.some((opt) => !opt.text || opt.text.trim() === "")) {
q.hasError = true;
q.errorMessage = "选项内容不能为空";
return true;
}
if (q.type === "single" && q.correctAnswer === undefined) {
q.hasError = true;
q.errorMessage = "请选择正确答案";
return true;
}
if (q.type === "multiple" && !q.options.some((opt) => opt.isCorrect)) {
q.hasError = true;
q.errorMessage = "至少需要选择一个正确答案";
return true;
}
}
if (q.type === "judge" && q.correctAnswer === undefined) {
q.hasError = true;
q.errorMessage = "请选择正确答案";
return true;
}
if (
(q.type === "fill" || q.type === "essay") &&
(!q.correctAnswer || q.correctAnswer.trim() === "")
) {
q.hasError = true;
q.errorMessage = "参考答案不能为空";
return true;
}
q.hasError = false;
q.errorMessage = "";
return false;
});
if (hasError) {
ElMessage.warning("请修改标记为错误的题目后再保存");
return;
}
// 调用保存接口
ElMessageBox.confirm("确定要保存所有修改吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "info",
})
.then(async () => {
// 获取导入批次ID
const importId = (route.params.id || route.query.id) as string;
// 调用后端API保存试题数据
// await saveQuestions(importId, questionList.value);
// ElMessage.success("保存成功");
// router.back();
// 模拟保存(实际开发时删除此部分)
ElMessage.success("保存成功");
router.back();
})
.catch(() => {});
}
/**
* 加载试题数据
* 从后端接口获取试题列表,如果接口返回的数据有错误标记,会在界面上显示
*/
async function loadQuestions() {
// 从路由参数获取导入批次ID
const importId = (route.params.id || route.query.id) as string;
if (importId) {
try {
// 调用后端接口获取试题数据
// 后端返回的试题数据可能包含 hasError 和 errorMessage 字段
// 表示哪些题目在解析时出现问题,需要用户修改
// const { data } = await getQuestionList(importId);
// questionList.value = data;
// 模拟数据(实际开发时删除此部分,使用上面的接口调用)
questionList.value = mockQuestions;
} catch (error) {
ElMessage.error("加载试题数据失败");
console.error("加载失败:", error);
}
} else {
// 如果没有ID,可能是直接导入后跳转,使用模拟数据
questionList.value = mockQuestions;
}
}
// 模拟试题数据
const mockQuestions: Question[] = [
{
id: 1,
type: "single",
content: "Vue 3 的 Composition API 中,哪个函数用于创建响应式引用?",
options: [
{ text: "ref()", isCorrect: false },
{ text: "reactive()", isCorrect: false },
{ text: "ref() 或 reactive()", isCorrect: true },
{ text: "createRef()", isCorrect: false },
],
correctAnswer: 2,
analysis: "Vue 3 中可以使用 ref() 或 reactive() 创建响应式数据",
difficulty: 3,
score: 5,
},
{
id: 2,
type: "multiple",
content: "以下哪些是 Vue 3 的新特性?",
options: [
{ text: "Composition API", isCorrect: true },
{ text: "TypeScript 支持", isCorrect: true },
{ text: "Teleport", isCorrect: true },
{ text: "Options API", isCorrect: false },
],
analysis:
"Vue 3 引入了 Composition API、更好的 TypeScript 支持和 Teleport 等新特性",
difficulty: 4,
score: 10,
},
{
id: 3,
type: "judge",
content: "Vue 3 完全兼容 Vue 2 的语法",
correctAnswer: false,
analysis: "Vue 3 虽然保持了大部分 Vue 2 的语法,但有一些破坏性变更",
difficulty: 2,
score: 3,
},
{
id: 4,
type: "fill",
content: "Vue 3 使用 _____ 作为构建工具",
correctAnswer: "Vite",
analysis: "Vue 3 推荐使用 Vite 作为构建工具",
difficulty: 2,
score: 3,
},
{
id: 5,
type: "essay",
content: "请简述 Vue 3 相比 Vue 2 的主要改进",
correctAnswer:
"1. 性能提升:更小的包体积、更快的渲染速度\n2. Composition API:更好的逻辑复用\n3. 更好的 TypeScript 支持\n4. 新的内置组件:Teleport、Suspense",
analysis: "Vue 3 在性能、开发体验和功能方面都有显著提升",
difficulty: 4,
score: 15,
},
];
onMounted(() => {
loadQuestions();
});
</script>
<style lang="scss" scoped>
.question-edit-container {
padding: 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header-card {
margin-bottom: 20px;
}
.content-wrapper {
display: flex;
gap: 20px;
flex: 1;
overflow: hidden;
}
.left-nav {
width: 250px;
flex-shrink: 0;
}
.nav-card {
height: 100%;
.nav-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.answer-sheet {
display: grid;
grid-template-columns: repeat(3, 1fr); // 一行3个
gap: 6px;
padding: 6px;
.answer-item {
position: relative;
aspect-ratio: 1; // 保持正方形
border: 1px solid var(--el-border-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fff;
padding: 2px;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.2);
transform: translateY(-1px);
}
&.active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
border-width: 2px;
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
}
&.error {
border-color: var(--el-color-danger);
background-color: var(--el-color-danger-light-9);
&.active {
border-color: var(--el-color-danger);
background-color: var(--el-color-danger-light-8);
}
}
.answer-number {
font-size: 12px;
font-weight: bold;
color: var(--el-text-color-primary);
margin-bottom: 1px;
line-height: 1.2;
}
.answer-type-badge {
margin-top: 1px;
:deep(.el-tag) {
font-size: 9px;
padding: 0 3px;
height: 16px;
line-height: 16px;
}
}
.error-icon {
position: absolute;
top: 1px;
right: 1px;
color: var(--el-color-danger);
font-size: 10px;
}
}
}
.right-content {
flex: 1;
overflow: hidden;
}
.content-card {
height: 100%;
}
.question-list {
padding: 20px;
}
.question-item {
margin-bottom: 40px;
padding: 20px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background-color: #fff;
&:last-child {
margin-bottom: 0;
}
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.question-title {
font-size: 16px;
font-weight: bold;
color: var(--el-text-color-primary);
}
.question-form {
margin-top: 20px;
}
</style>async function handleQuestionImport(file: File, fileType: "word" | "excel") {
console.log("导入试题文件:", file.name, "文件类型:", fileType);
try {
// 调用后端API上传文件并解析试题
// 后端会解析Word/Excel文件,提取试题内容,返回结构化数据
// const { data } = await importQuestions(file, fileType);
// const importId = data.id; // 导入批次ID
// const questions = data.questions; // 解析后的试题列表
// 模拟接口调用(实际开发时删除此部分)
ElMessage.success("试题导入成功,正在跳转到编辑页面...");
await new Promise((resolve) => setTimeout(resolve, 500));
const questionId = "mock-id-" + Date.now();
// 跳转到试题编辑页面
// 传递导入批次ID,编辑页面会根据ID从后端获取试题数据
router.push({
path: "/kaoshi/question-edit",
query: {
id: questionId, // 导入批次ID
fileName: file.name,
fileType: fileType,
},
});
} catch (error) {
ElMessage.error("试题导入失败,请检查文件格式");
console.error("导入失败:", error);
}
}