npm package: @kevinchang1207/cx-ui4
Description: Cx UI components library
Latest version: 0.1.1

# Cx UI（基于 Element Plus 的业务组件库）

轻量封装 Element Plus，提供统一风格与常用增强组件：`CxInput`、`CxButton`、`CxForm`、`CxTable`（列筛选）、`CxDialog`、`CxDrawer`、`CxPagination`、`CxSelect`、`CxDateRange`。

## 安装
```bash
npm i @kevinchang1207/cx-ui element-plus
# 或者
pnpm add @kevinchang1207/cx-ui element-plus
```

要求：Vue 3.2+，Element Plus 2.x。

## 快速开始（全局注册）
```ts
// 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')
```

## 按需使用（局部注册）
```ts
import { CxButton, CxInput } from '@kevinchang1207/cx-ui'
export default { components: { CxButton, CxInput } }
```

## 组件示例

### CxButton
```vue
<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
```vue
<cx-input v-model="keyword" placeholder="请输入关键词" clearable />
```
支持 `prefix`、`suffix` 插槽。

### CxForm（基于 schema）
```vue
<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（列筛选/排序/选择）
```vue
<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
```vue
<cx-dialog v-model="visible" title="提示" @confirm="ok">内容</cx-dialog>
<cx-drawer v-model="drawer" title="侧边">内容</cx-drawer>
```

### CxSelect / CxDateRange
```vue
<cx-select v-model="val" :options="opts" />
<cx-date-range v-model="range" />
```

### 分页
```vue
<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
```vue question-create
<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>
```

```vue question-edit
<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>
```
```vue
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);
  }
}
```