Преглед на файлове

feat(问卷管理): 新增题目编辑功能及相关API和组件

yz преди 1 месец
родител
ревизия
b703207da0

+ 94 - 0
src/api/survey/option.js

@@ -0,0 +1,94 @@
+/**
+ * 调查问卷选项管理API接口
+ * @fileoverview 提供选项的增删改查功能
+ */
+
+import request from '@/router/axios'
+
+/**
+ * 选项查询参数类型定义
+ * @typedef {Object} OptionQueryParams
+ * @property {string|number} questionId - 题目ID
+ * @property {number} [size=10] - 每页数量
+ * @property {number} [current=1] - 当前页码
+ */
+
+/**
+ * 选项表单数据类型定义
+ * @typedef {Object} OptionForm
+ * @property {string|number} [id] - 选项ID(编辑时必填)
+ * @property {string|number} questionId - 题目ID
+ * @property {number} optionNo - 选项序号
+ * @property {string} optionText - 选项文本
+ */
+
+/**
+ * 选项数据项类型定义
+ * @typedef {Object} OptionItem
+ * @property {string} id - 选项ID
+ * @property {string} createUser - 创建用户ID
+ * @property {string} createDept - 创建部门ID
+ * @property {string} createTime - 创建时间
+ * @property {string} updateUser - 更新用户ID
+ * @property {string} updateTime - 更新时间
+ * @property {number} status - 状态
+ * @property {number} isDeleted - 是否删除
+ * @property {string|number} questionId - 题目ID
+ * @property {number} optionNo - 选项序号
+ * @property {string} optionText - 选项文本
+ */
+
+/**
+ * 选项列表响应数据类型定义
+ * @typedef {Object} OptionListResponse
+ * @property {Array<OptionItem>} records - 选项列表
+ * @property {number} total - 总数
+ * @property {number} size - 每页数量
+ * @property {number} current - 当前页码
+ * @property {Array} orders - 排序
+ * @property {boolean} optimizeCountSql - 优化计数SQL
+ * @property {boolean} hitCount - 命中计数
+ * @property {null} countId - 计数ID
+ * @property {null} maxLimit - 最大限制
+ * @property {boolean} searchCount - 搜索计数
+ * @property {number} pages - 总页数
+ */
+
+/**
+ * 获取选项列表
+ * @param {OptionQueryParams} params - 查询参数
+ * @returns {Promise<{code: number, success: boolean, data: OptionListResponse, msg: string}>} 选项列表响应
+ */
+export async function getOptionList(params) {
+  return await request({
+    url: '/api/blade-factory/api/factory/survey-option',
+    method: 'get',
+    params
+  })
+}
+
+/**
+ * 添加选项
+ * @param {OptionForm} data - 选项表单数据
+ * @returns {Promise<{code: number, success: boolean, data: boolean, msg: string}>} 添加结果
+ */
+export async function addOption(data) {
+  return await request({
+    url: '/api/blade-factory/api/factory/survey-option',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 修改选项
+ * @param {OptionForm} data - 选项表单数据
+ * @returns {Promise<{code: number, success: boolean, data: boolean, msg: string}>} 修改结果
+ */
+export async function updateOption(data) {
+  return await request({
+    url: '/api/blade-factory/api/factory/survey-option',
+    method: 'put',
+    data
+  })
+}

+ 98 - 0
src/api/survey/question.js

@@ -0,0 +1,98 @@
+/**
+ * 调查问卷题目管理API接口
+ * @fileoverview 提供题目的增删改查功能
+ */
+
+import request from '@/router/axios'
+
+/**
+ * 题目查询参数类型定义
+ * @typedef {Object} QuestionQueryParams
+ * @property {string|number} surveyId - 调查问卷ID
+ * @property {number} [size=10] - 每页数量
+ * @property {number} [current=1] - 当前页码
+ */
+
+/**
+ * 题目表单数据类型定义
+ * @typedef {Object} QuestionForm
+ * @property {string|number} [id] - 题目ID(编辑时必填)
+ * @property {string|number} surveyId - 调查问卷ID
+ * @property {number} questionNo - 题目序号
+ * @property {string} title - 题目标题
+ * @property {number} questionType - 题目类型 1单选 2多选 3文本
+ * @property {number} isRequired - 是否必填 0否 1是
+ */
+
+/**
+ * 题目数据项类型定义
+ * @typedef {Object} QuestionItem
+ * @property {string} id - 题目ID
+ * @property {string} createUser - 创建用户ID
+ * @property {string} createDept - 创建部门ID
+ * @property {string} createTime - 创建时间
+ * @property {string} updateUser - 更新用户ID
+ * @property {string} updateTime - 更新时间
+ * @property {number} status - 状态
+ * @property {number} isDeleted - 是否删除
+ * @property {string|number} surveyId - 调查问卷ID
+ * @property {number} questionNo - 题目序号
+ * @property {string} title - 题目标题
+ * @property {number} questionType - 题目类型
+ * @property {number} isRequired - 是否必填
+ */
+
+/**
+ * 题目列表响应数据类型定义
+ * @typedef {Object} QuestionListResponse
+ * @property {Array<QuestionItem>} records - 题目列表
+ * @property {number} total - 总数
+ * @property {number} size - 每页数量
+ * @property {number} current - 当前页码
+ * @property {Array} orders - 排序
+ * @property {boolean} optimizeCountSql - 优化计数SQL
+ * @property {boolean} hitCount - 命中计数
+ * @property {null} countId - 计数ID
+ * @property {null} maxLimit - 最大限制
+ * @property {boolean} searchCount - 搜索计数
+ * @property {number} pages - 总页数
+ */
+
+/**
+ * 获取题目列表
+ * @param {QuestionQueryParams} params - 查询参数
+ * @returns {Promise<{code: number, success: boolean, data: QuestionListResponse, msg: string}>} 题目列表响应
+ */
+export async function getQuestionList(params) {
+  return await request({
+    url: '/api/blade-factory/api/factory/survey-question',
+    method: 'get',
+    params
+  })
+}
+
+/**
+ * 添加题目
+ * @param {QuestionForm} data - 题目表单数据
+ * @returns {Promise<{code: number, success: boolean, data: boolean, msg: string}>} 添加结果
+ */
+export async function addQuestion(data) {
+  return await request({
+    url: '/api/blade-factory/api/factory/survey-question',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 修改题目
+ * @param {QuestionForm} data - 题目表单数据
+ * @returns {Promise<{code: number, success: boolean, data: boolean, msg: string}>} 修改结果
+ */
+export async function updateQuestion(data) {
+  return await request({
+    url: '/api/blade-factory/api/factory/survey-question',
+    method: 'put',
+    data
+  })
+}

+ 266 - 0
src/components/survey-question-editor/index.scss

@@ -0,0 +1,266 @@
+.survey-question-editor {
+  .toolbar {
+    margin-bottom: 16px;
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+  }
+  
+  .question-list {
+    .question-items {
+      .question-item {
+        border: 1px solid #e4e7ed;
+        border-radius: 6px;
+        margin-bottom: 16px;
+        padding: 16px;
+        background: #fff;
+        transition: all 0.3s;
+        
+        &:hover {
+          border-color: #409eff;
+          box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
+        }
+        
+        .question-header {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-start;
+          margin-bottom: 12px;
+          
+          .question-info {
+            flex: 1;
+            display: flex;
+            align-items: center;
+            flex-wrap: wrap;
+            gap: 8px;
+            
+            .question-no {
+              font-weight: 600;
+              color: #409eff;
+              font-size: 16px;
+            }
+            
+            .question-title {
+              font-size: 16px;
+              color: #303133;
+              line-height: 1.5;
+              word-break: break-word;
+              flex: 1;
+              min-width: 200px;
+            }
+            
+            .question-type-tag {
+              i {
+                margin-right: 4px;
+              }
+            }
+            
+            .required-tag {
+              font-size: 12px;
+            }
+          }
+          
+          .question-actions {
+            display: flex;
+            gap: 8px;
+            flex-shrink: 0;
+          }
+        }
+        
+        .question-options {
+          padding-left: 24px;
+          
+          .option-item {
+            display: flex;
+            align-items: center;
+            margin-bottom: 8px;
+            padding: 8px 12px;
+            background: #f8f9fa;
+            border-radius: 4px;
+            
+            .option-no {
+              font-weight: 500;
+              color: #606266;
+              margin-right: 8px;
+              min-width: 24px;
+            }
+            
+            .option-text {
+              color: #606266;
+              line-height: 1.4;
+              word-break: break-word;
+            }
+            
+            &:last-child {
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+      
+      .empty-state {
+        text-align: center;
+        padding: 60px 20px;
+        color: #909399;
+        
+        i {
+          font-size: 48px;
+          margin-bottom: 16px;
+          display: block;
+        }
+        
+        p {
+          font-size: 14px;
+          margin: 0;
+        }
+      }
+    }
+  }
+  
+  .question-form {
+    .el-form-item {
+      margin-bottom: 24px;
+    }
+    
+    .el-select {
+      .el-option {
+        i {
+          margin-right: 8px;
+        }
+      }
+    }
+  }
+  
+  .option-management {
+    .option-toolbar {
+      margin-bottom: 16px;
+      display: flex;
+      justify-content: flex-start;
+      align-items: center;
+    }
+    
+    .option-list {
+      .option-item-manage {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 12px 16px;
+        border: 1px solid #e4e7ed;
+        border-radius: 4px;
+        margin-bottom: 8px;
+        background: #fff;
+        transition: all 0.3s;
+        
+        &:hover {
+          border-color: #409eff;
+          background: #f0f9ff;
+        }
+        
+        .option-content {
+          flex: 1;
+          display: flex;
+          align-items: center;
+          
+          .option-no {
+            font-weight: 500;
+            color: #409eff;
+            margin-right: 8px;
+            min-width: 24px;
+          }
+          
+          .option-text {
+            color: #303133;
+            line-height: 1.4;
+            word-break: break-word;
+          }
+        }
+        
+        .option-actions {
+          flex-shrink: 0;
+        }
+        
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+      
+      .empty-options {
+        text-align: center;
+        padding: 40px 20px;
+        color: #909399;
+        
+        i {
+          font-size: 32px;
+          margin-bottom: 12px;
+          display: block;
+        }
+        
+        p {
+          font-size: 14px;
+          margin: 0;
+        }
+      }
+    }
+  }
+  
+  .option-form {
+    .el-form-item {
+      margin-bottom: 24px;
+    }
+  }
+  
+  .dialog-footer {
+    text-align: right;
+    
+    .el-button {
+      margin-left: 12px;
+    }
+  }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .survey-question-editor {
+    .question-list {
+      .question-items {
+        .question-item {
+          .question-header {
+            flex-direction: column;
+            align-items: flex-start;
+            
+            .question-info {
+              margin-bottom: 12px;
+              
+              .question-title {
+                min-width: auto;
+                width: 100%;
+              }
+            }
+            
+            .question-actions {
+              align-self: flex-end;
+            }
+          }
+        }
+      }
+    }
+    
+    .option-management {
+      .option-list {
+        .option-item-manage {
+          flex-direction: column;
+          align-items: flex-start;
+          
+          .option-content {
+            margin-bottom: 8px;
+            width: 100%;
+          }
+          
+          .option-actions {
+            align-self: flex-end;
+          }
+        }
+      }
+    }
+  }
+}

+ 280 - 0
src/components/survey-question-editor/index.vue

@@ -0,0 +1,280 @@
+<template>
+  <div class="survey-question-editor">
+    <!-- 题目列表 -->
+    <div class="question-list">
+      <div class="toolbar">
+        <el-button 
+          type="primary" 
+          icon="el-icon-plus" 
+          @click="handleAddQuestion"
+          :loading="loading"
+        >
+          新增题目
+        </el-button>
+      </div>
+      
+      <div class="question-items" v-loading="loading">
+        <div 
+          v-for="(question) in questionList" 
+          :key="question.id"
+          class="question-item"
+        >
+          <div class="question-header">
+            <div class="question-info">
+              <span class="question-no">{{ question.questionNo }}.</span>
+              <span class="question-title">{{ question.title }}</span>
+              <el-tag 
+                :type="getQuestionTypeType(question.questionType)"
+                size="mini"
+                class="question-type-tag"
+              >
+                <i :class="getQuestionTypeIcon(question.questionType)"></i>
+                {{ getQuestionTypeLabel(question.questionType) }}
+              </el-tag>
+              <el-tag 
+                v-if="question.isRequired"
+                type="danger"
+                size="mini"
+                class="required-tag"
+              >
+                必填
+              </el-tag>
+            </div>
+            <div class="question-actions">
+              <el-button 
+                type="text" 
+                icon="el-icon-edit" 
+                @click="handleEditQuestion(question)"
+                :loading="loading"
+              >
+                编辑
+              </el-button>
+              <el-button 
+                v-if="isQuestionTypeNeedOptions(question.questionType)"
+                type="text" 
+                icon="el-icon-setting" 
+                @click="handleManageOptions(question)"
+                :loading="loading"
+              >
+                管理选项
+              </el-button>
+            </div>
+          </div>
+          
+          <!-- 选项列表 -->
+          <div 
+            v-if="question.options && question.options.length > 0"
+            class="question-options"
+          >
+            <div 
+              v-for="option in question.options" 
+              :key="option.id"
+              class="option-item"
+            >
+              <span class="option-no">{{ option.optionNo }}.</span>
+              <span class="option-text">{{ option.optionText }}</span>
+            </div>
+          </div>
+        </div>
+        
+        <div v-if="questionList.length === 0" class="empty-state">
+          <i class="el-icon-document"></i>
+          <p>暂无题目,点击上方按钮添加题目</p>
+        </div>
+      </div>
+    </div>
+    
+    <!-- 题目编辑对话框 -->
+    <el-dialog
+      :title="questionDialogTitle"
+      :visible.sync="questionDialogVisible"
+      append-to-body
+      width="600px"
+      :close-on-click-modal="false"
+      @close="handleCloseQuestionDialog"
+    >
+      <el-form
+        ref="questionForm"
+        :model="questionForm"
+        :rules="questionFormRules"
+        label-width="100px"
+        class="question-form"
+      >
+        <el-form-item label="题目序号" prop="questionNo">
+          <el-input-number
+            v-model="questionForm.questionNo"
+            :min="1"
+            :max="999"
+            placeholder="请输入题目序号"
+            style="width: 100%"
+          />
+        </el-form-item>
+        
+        <el-form-item label="题目标题" prop="title">
+          <el-input
+            v-model="questionForm.title"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入题目标题"
+            maxlength="200"
+            show-word-limit
+          />
+        </el-form-item>
+        
+        <el-form-item label="题目类型" prop="questionType">
+          <el-select
+            v-model="questionForm.questionType"
+            placeholder="请选择题目类型"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="option in questionTypeOptions"
+              :key="option.value"
+              :label="option.label"
+              :value="option.value"
+            >
+              <i :class="getQuestionTypeIcon(option.value)"></i>
+              {{ option.label }}
+            </el-option>
+          </el-select>
+        </el-form-item>
+        
+        <el-form-item label="是否必填" prop="isRequired">
+          <el-radio-group v-model="questionForm.isRequired">
+            <el-radio
+              v-for="option in questionRequiredOptions"
+              :key="option.value"
+              :label="option.value"
+            >
+              {{ option.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="handleCloseQuestionDialog">取消</el-button>
+        <el-button 
+          type="primary" 
+          @click="handleSubmitQuestion"
+          :loading="loading"
+        >
+          确定
+        </el-button>
+      </div>
+    </el-dialog>
+    
+    <!-- 选项管理对话框 -->
+    <el-dialog
+      title="管理选项"
+      :visible.sync="currentQuestionOptions.length > 0 || optionDialogVisible"
+      width="800px"
+      :close-on-click-modal="false"
+      @close="currentQuestionOptions = []"
+    >
+      <div class="option-management">
+        <div class="option-toolbar">
+          <el-button 
+            type="primary" 
+            icon="el-icon-plus" 
+            @click="handleAddOption"
+            :loading="loading"
+          >
+            新增选项
+          </el-button>
+        </div>
+        
+        <div class="option-list">
+          <div 
+            v-for="option in currentQuestionOptions" 
+            :key="option.id"
+            class="option-item-manage"
+          >
+            <div class="option-content">
+              <span class="option-no">{{ option.optionNo }}.</span>
+              <span class="option-text">{{ option.optionText }}</span>
+            </div>
+            <div class="option-actions">
+              <el-button 
+                type="text" 
+                icon="el-icon-edit" 
+                @click="handleEditOption(option)"
+                :loading="loading"
+              >
+                编辑
+              </el-button>
+            </div>
+          </div>
+          
+          <div v-if="currentQuestionOptions.length === 0" class="empty-options">
+            <i class="el-icon-info"></i>
+            <p>暂无选项,点击上方按钮添加选项</p>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+    
+    <!-- 选项编辑对话框 -->
+    <el-dialog
+      :title="optionDialogTitle"
+      :visible.sync="optionDialogVisible"
+      width="500px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="handleCloseOptionDialog"
+    >
+      <el-form
+        ref="optionForm"
+        :model="optionForm"
+        :rules="optionFormRules"
+        label-width="100px"
+        class="option-form"
+      >
+        <el-form-item label="选项序号" prop="optionNo">
+          <el-input-number
+            v-model="optionForm.optionNo"
+            :min="1"
+            :max="999"
+            placeholder="请输入选项序号"
+            style="width: 100%"
+          />
+        </el-form-item>
+        
+        <el-form-item label="选项内容" prop="optionText">
+          <el-input
+            v-model="optionForm.optionText"
+            type="textarea"
+            :rows="2"
+            placeholder="请输入选项内容"
+            maxlength="100"
+            show-word-limit
+          />
+        </el-form-item>
+      </el-form>
+      
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="handleCloseOptionDialog">取消</el-button>
+        <el-button 
+          type="primary" 
+          @click="handleSubmitOption"
+          :loading="loading"
+        >
+          确定
+        </el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import questionEditorMixin from '@/mixins/survey/questionEditor'
+
+export default {
+  name: 'SurveyQuestionEditor',
+  mixins: [questionEditorMixin]
+}
+</script>
+
+<style lang="scss" scoped>
+@import './index.scss';
+</style>

+ 198 - 1
src/constants/survey.js

@@ -1,6 +1,6 @@
 /**
  * 调查问卷管理相关常量定义
- * @fileoverview 问卷状态、模板类型等枚举值和工具函数
+ * @fileoverview 问卷状态、模板类型、题目类型、必填状态等枚举值和工具函数
  */
 
 /**
@@ -30,6 +30,32 @@ export const SURVEY_TEMPLATE = {
 }
 
 /**
+ * 题目类型枚举
+ * @readonly
+ * @enum {number}
+ */
+export const QUESTION_TYPE = {
+  /** 单选 */
+  SINGLE_CHOICE: 1,
+  /** 多选 */
+  MULTIPLE_CHOICE: 2,
+  /** 文本 */
+  TEXT: 3
+}
+
+/**
+ * 是否必填枚举
+ * @readonly
+ * @enum {number}
+ */
+export const QUESTION_REQUIRED = {
+  /** 否 */
+  NO: 0,
+  /** 是 */
+  YES: 1
+}
+
+/**
  * 问卷状态配置映射
  * @readonly
  * @type {Record<number, {label: string, type: string, color: string}>}
@@ -177,4 +203,175 @@ export function getAllSurveyStatusValues() {
  */
 export function getAllSurveyTemplateValues() {
   return Object.values(SURVEY_TEMPLATE)
+}
+
+/**
+ * 题目类型配置映射
+ * @readonly
+ * @type {Record<number, {label: string, type: string, color: string, icon: string}>}
+ */
+export const QUESTION_TYPE_CONFIG = {
+  [QUESTION_TYPE.SINGLE_CHOICE]: {
+    label: '单选',
+    type: 'primary',
+    color: '#409EFF',
+    icon: 'el-icon-circle-check'
+  },
+  [QUESTION_TYPE.MULTIPLE_CHOICE]: {
+    label: '多选',
+    type: 'success',
+    color: '#67C23A',
+    icon: 'el-icon-check'
+  },
+  [QUESTION_TYPE.TEXT]: {
+    label: '文本',
+    type: 'warning',
+    color: '#E6A23C',
+    icon: 'el-icon-edit'
+  }
+}
+
+/**
+ * 是否必填配置映射
+ * @readonly
+ * @type {Record<number, {label: string, type: string, color: string}>}
+ */
+export const QUESTION_REQUIRED_CONFIG = {
+  [QUESTION_REQUIRED.NO]: {
+    label: '否',
+    type: 'info',
+    color: '#909399'
+  },
+  [QUESTION_REQUIRED.YES]: {
+    label: '是',
+    type: 'danger',
+    color: '#F56C6C'
+  }
+}
+
+/**
+ * 题目类型选项数组
+ * @readonly
+ * @type {Array<{label: string, value: number}>}
+ */
+export const QUESTION_TYPE_OPTIONS = [
+  { label: '单选', value: QUESTION_TYPE.SINGLE_CHOICE },
+  { label: '多选', value: QUESTION_TYPE.MULTIPLE_CHOICE },
+  { label: '文本', value: QUESTION_TYPE.TEXT }
+]
+
+/**
+ * 是否必填选项数组
+ * @readonly
+ * @type {Array<{label: string, value: number}>}
+ */
+export const QUESTION_REQUIRED_OPTIONS = [
+  { label: '否', value: QUESTION_REQUIRED.NO },
+  { label: '是', value: QUESTION_REQUIRED.YES }
+]
+
+/**
+ * 获取题目类型标签
+ * @param {number} questionType - 题目类型值
+ * @returns {string} 题目类型标签
+ */
+export function getQuestionTypeLabel(questionType) {
+  return QUESTION_TYPE_CONFIG[questionType]?.label || '未知类型'
+}
+
+/**
+ * 获取题目类型类型
+ * @param {number} questionType - 题目类型值
+ * @returns {string} 题目类型类型
+ */
+export function getQuestionTypeType(questionType) {
+  return QUESTION_TYPE_CONFIG[questionType]?.type || 'info'
+}
+
+/**
+ * 获取题目类型颜色
+ * @param {number} questionType - 题目类型值
+ * @returns {string} 题目类型颜色
+ */
+export function getQuestionTypeColor(questionType) {
+  return QUESTION_TYPE_CONFIG[questionType]?.color || '#909399'
+}
+
+/**
+ * 获取题目类型图标
+ * @param {number} questionType - 题目类型值
+ * @returns {string} 题目类型图标
+ */
+export function getQuestionTypeIcon(questionType) {
+  return QUESTION_TYPE_CONFIG[questionType]?.icon || 'el-icon-question'
+}
+
+/**
+ * 获取是否必填标签
+ * @param {number} isRequired - 必填值
+ * @returns {string} 必填标签
+ */
+export function getQuestionRequiredLabel(isRequired) {
+  return QUESTION_REQUIRED_CONFIG[isRequired]?.label || '未知'
+}
+
+/**
+ * 获取是否必填类型
+ * @param {number} isRequired - 必填值
+ * @returns {string} 必填类型
+ */
+export function getQuestionRequiredType(isRequired) {
+  return QUESTION_REQUIRED_CONFIG[isRequired]?.type || 'info'
+}
+
+/**
+ * 获取是否必填颜色
+ * @param {number} isRequired - 必填值
+ * @returns {string} 必填颜色
+ */
+export function getQuestionRequiredColor(isRequired) {
+  return QUESTION_REQUIRED_CONFIG[isRequired]?.color || '#909399'
+}
+
+/**
+ * 验证题目类型是否有效
+ * @param {number} questionType - 题目类型值
+ * @returns {boolean} 是否有效
+ */
+export function isValidQuestionType(questionType) {
+  return Object.values(QUESTION_TYPE).includes(questionType)
+}
+
+/**
+ * 验证是否必填值是否有效
+ * @param {number} isRequired - 必填值
+ * @returns {boolean} 是否有效
+ */
+export function isValidQuestionRequired(isRequired) {
+  return Object.values(QUESTION_REQUIRED).includes(isRequired)
+}
+
+/**
+ * 判断题目类型是否需要选项
+ * @param {number} questionType - 题目类型值
+ * @returns {boolean} 是否需要选项
+ */
+export function isQuestionTypeNeedOptions(questionType) {
+  return questionType === QUESTION_TYPE.SINGLE_CHOICE || questionType === QUESTION_TYPE.MULTIPLE_CHOICE
+}
+
+/**
+ * 获取所有题目类型值
+ * @returns {Array<number>} 题目类型值数组
+ */
+export function getAllQuestionTypeValues() {
+  return Object.values(QUESTION_TYPE)
+}
+
+/**
+ * 获取所有是否必填值
+ * @returns {Array<number>} 必填值数组
+ */
+export function getAllQuestionRequiredValues() {
+  return Object.values(QUESTION_REQUIRED)
 }

+ 614 - 0
src/mixins/survey/questionEditor.js

@@ -0,0 +1,614 @@
+/**
+ * 调查问卷题目编辑组件 Mixin
+ * @fileoverview 提供题目编辑、选项管理等功能的混入
+ */
+
+import { 
+  getQuestionList, 
+  addQuestion, 
+  updateQuestion 
+} from '@/api/survey/question'
+import { 
+  getOptionList, 
+  addOption, 
+  updateOption 
+} from '@/api/survey/option'
+import { 
+  QUESTION_TYPE_OPTIONS, 
+  QUESTION_REQUIRED_OPTIONS,
+  QUESTION_TYPE,
+  getQuestionTypeLabel,
+  getQuestionTypeType,
+  getQuestionTypeIcon,
+  getQuestionRequiredLabel,
+  getQuestionRequiredType,
+  isQuestionTypeNeedOptions
+} from '@/constants/survey'
+
+/**
+ * @typedef {Object} QuestionEditorData
+ * @property {string|number} surveyId - 调查问卷ID
+ * @property {Array<QuestionItem>} questionList - 题目列表
+ * @property {boolean} loading - 加载状态
+ * @property {number} total - 总数
+ * @property {number} current - 当前页码
+ * @property {number} size - 每页数量
+ * @property {boolean} questionDialogVisible - 题目对话框显示状态
+ * @property {string} questionDialogMode - 题目对话框模式 add|edit
+ * @property {QuestionForm} questionForm - 题目表单
+ * @property {Object} questionFormRules - 题目表单验证规则
+ * @property {Array<{label: string, value: number}>} questionTypeOptions - 题目类型选项
+ * @property {Array<{label: string, value: number}>} questionRequiredOptions - 是否必填选项
+ * @property {Array<OptionItem>} currentQuestionOptions - 当前题目的选项列表
+ * @property {boolean} optionDialogVisible - 选项对话框显示状态
+ * @property {string} optionDialogMode - 选项对话框模式 add|edit
+ * @property {OptionForm} optionForm - 选项表单
+ * @property {Object} optionFormRules - 选项表单验证规则
+ * @property {string|number} currentQuestionId - 当前编辑的题目ID
+ */
+
+export default {
+  name: 'QuestionEditorMixin',
+  
+  props: {
+    /**
+     * 调查问卷ID
+     * @type {string|number}
+     */
+    surveyId: {
+      type: [String, Number],
+      required: true
+    }
+  },
+  
+  data() {
+    return {
+      /**
+       * 题目列表
+       * @type {Array<QuestionItem>}
+       */
+      questionList: [],
+      
+      /**
+       * 加载状态
+       * @type {boolean}
+       */
+      loading: false,
+      
+      /**
+       * 总数
+       * @type {number}
+       */
+      total: 0,
+      
+      /**
+       * 当前页码
+       * @type {number}
+       */
+      current: 1,
+      
+      /**
+       * 每页数量
+       * @type {number}
+       */
+      size: 100,
+      
+      /**
+       * 题目对话框显示状态
+       * @type {boolean}
+       */
+      questionDialogVisible: false,
+      
+      /**
+       * 题目对话框模式
+       * @type {string}
+       */
+      questionDialogMode: 'add',
+      
+      /**
+       * 题目表单
+       * @type {QuestionForm}
+       */
+      questionForm: {
+        surveyId: '',
+        questionNo: 1,
+        title: '',
+        questionType: QUESTION_TYPE.SINGLE_CHOICE,
+        isRequired: 0
+      },
+      
+      /**
+       * 题目表单验证规则
+       * @type {Object}
+       */
+      questionFormRules: {
+        title: [
+          { required: true, message: '请输入题目标题', trigger: 'blur' },
+          { min: 1, max: 200, message: '题目标题长度在 1 到 200 个字符', trigger: 'blur' }
+        ],
+        questionType: [
+          { required: true, message: '请选择题目类型', trigger: 'change' }
+        ],
+        questionNo: [
+          { required: true, message: '请输入题目序号', trigger: 'blur' },
+          { type: 'number', min: 1, message: '题目序号必须大于0', trigger: 'blur' }
+        ]
+      },
+      
+      /**
+       * 题目类型选项
+       * @type {Array<{label: string, value: number}>}
+       */
+      questionTypeOptions: QUESTION_TYPE_OPTIONS,
+      
+      /**
+       * 是否必填选项
+       * @type {Array<{label: string, value: number}>}
+       */
+      questionRequiredOptions: QUESTION_REQUIRED_OPTIONS,
+      
+      /**
+       * 当前题目的选项列表
+       * @type {Array<OptionItem>}
+       */
+      currentQuestionOptions: [],
+      
+      /**
+       * 选项对话框显示状态
+       * @type {boolean}
+       */
+      optionDialogVisible: false,
+      
+      /**
+       * 选项对话框模式
+       * @type {string}
+       */
+      optionDialogMode: 'add',
+      
+      /**
+       * 选项表单
+       * @type {OptionForm}
+       */
+      optionForm: {
+        questionId: '',
+        optionNo: 1,
+        optionText: ''
+      },
+      
+      /**
+       * 选项表单验证规则
+       * @type {Object}
+       */
+      optionFormRules: {
+        optionText: [
+          { required: true, message: '请输入选项内容', trigger: 'blur' },
+          { min: 1, max: 100, message: '选项内容长度在 1 到 100 个字符', trigger: 'blur' }
+        ],
+        optionNo: [
+          { required: true, message: '请输入选项序号', trigger: 'blur' },
+          { type: 'number', min: 1, message: '选项序号必须大于0', trigger: 'blur' }
+        ]
+      },
+      
+      /**
+       * 当前编辑的题目ID
+       * @type {string|number}
+       */
+      currentQuestionId: ''
+    }
+  },
+  
+  computed: {
+    /**
+     * 题目对话框标题
+     * @returns {string} 对话框标题
+     */
+    questionDialogTitle() {
+      return this.questionDialogMode === 'add' ? '新增题目' : '编辑题目'
+    },
+    
+    /**
+     * 选项对话框标题
+     * @returns {string} 对话框标题
+     */
+    optionDialogTitle() {
+      return this.optionDialogMode === 'add' ? '新增选项' : '编辑选项'
+    },
+    
+    /**
+     * 是否为新增题目模式
+     * @returns {boolean} 是否为新增模式
+     */
+    isAddQuestionMode() {
+      return this.questionDialogMode === 'add'
+    },
+    
+    /**
+     * 是否为编辑题目模式
+     * @returns {boolean} 是否为编辑模式
+     */
+    isEditQuestionMode() {
+      return this.questionDialogMode === 'edit'
+    },
+    
+    /**
+     * 是否为新增选项模式
+     * @returns {boolean} 是否为新增模式
+     */
+    isAddOptionMode() {
+      return this.optionDialogMode === 'add'
+    },
+    
+    /**
+     * 是否为编辑选项模式
+     * @returns {boolean} 是否为编辑模式
+     */
+    isEditOptionMode() {
+      return this.optionDialogMode === 'edit'
+    }
+  },
+  
+  mounted() {
+    this.loadQuestionList()
+  },
+  
+  methods: {
+    /**
+     * 加载题目列表
+     * @returns {Promise<void>}
+     */
+    async loadQuestionList() {
+      try {
+        this.loading = true
+        const params = {
+          surveyId: this.surveyId,
+          size: this.size,
+          current: this.current
+        }
+        
+        const responseOrigin = await getQuestionList(params)
+        const response = responseOrigin.data
+        
+        if (response.code === 200 && response.success) {
+          this.questionList = response.data.records || []
+          this.total = response.data.total || 0
+          
+          // 为每个题目加载选项
+          await this.loadAllQuestionOptions()
+        } else {
+          this.$message.error(response.msg || '获取题目列表失败')
+        }
+      } catch (error) {
+        console.error('加载题目列表失败:', error)
+        this.$message.error('加载题目列表失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    /**
+     * 加载所有题目的选项
+     * @returns {Promise<void>}
+     */
+    async loadAllQuestionOptions() {
+      for (const question of this.questionList) {
+        if (isQuestionTypeNeedOptions(question.questionType)) {
+          question.options = await this.loadQuestionOptions(question.id)
+        } else {
+          question.options = []
+        }
+      }
+    },
+    
+    /**
+     * 加载指定题目的选项
+     * @param {string|number} questionId - 题目ID
+     * @returns {Promise<Array<OptionItem>>} 选项列表
+     */
+    async loadQuestionOptions(questionId) {
+      try {
+        const params = {
+          questionId,
+          size: 100,
+          current: 1
+        }
+        
+        const response = await getOptionList(params)
+        
+        if (response.code === 200 && response.success) {
+          return response.data.records || []
+        } else {
+          console.error('获取选项列表失败:', response.msg)
+          return []
+        }
+      } catch (error) {
+        console.error('加载选项列表失败:', error)
+        return []
+      }
+    },
+    
+    /**
+     * 新增题目
+     * @returns {void}
+     */
+    handleAddQuestion() {
+      this.questionDialogMode = 'add'
+      this.resetQuestionForm()
+      this.questionForm.surveyId = this.surveyId
+      this.questionForm.questionNo = this.getNextQuestionNo()
+      this.questionDialogVisible = true
+    },
+    
+    /**
+     * 编辑题目
+     * @param {QuestionItem} question - 题目数据
+     * @returns {void}
+     */
+    handleEditQuestion(question) {
+      this.questionDialogMode = 'edit'
+      this.setQuestionForm(question)
+      this.questionDialogVisible = true
+    },
+    
+    /**
+     * 管理题目选项
+     * @param {QuestionItem} question - 题目数据
+     * @returns {void}
+     */
+    async handleManageOptions(question) {
+      if (!isQuestionTypeNeedOptions(question.questionType)) {
+        this.$message.warning('文本类型题目不需要设置选项')
+        return
+      }
+      
+      this.currentQuestionId = question.id
+      this.currentQuestionOptions = await this.loadQuestionOptions(question.id)
+    },
+    
+    /**
+     * 新增选项
+     * @returns {void}
+     */
+    handleAddOption() {
+      this.optionDialogMode = 'add'
+      this.resetOptionForm()
+      this.optionForm.questionId = this.currentQuestionId
+      this.optionForm.optionNo = this.getNextOptionNo()
+      this.optionDialogVisible = true
+    },
+    
+    /**
+     * 编辑选项
+     * @param {OptionItem} option - 选项数据
+     * @returns {void}
+     */
+    handleEditOption(option) {
+      this.optionDialogMode = 'edit'
+      this.setOptionForm(option)
+      this.optionDialogVisible = true
+    },
+    
+    /**
+     * 提交题目表单
+     * @returns {Promise<void>}
+     */
+    async handleSubmitQuestion() {
+      try {
+        const valid = await this.$refs.questionForm.validate()
+        if (!valid) return
+        
+        this.loading = true
+        
+        let response
+        if (this.isAddQuestionMode) {
+          response = await addQuestion(this.questionForm)
+        } else {
+          response = await updateQuestion(this.questionForm)
+        }
+        
+        if (response.code === 200 && response.success) {
+          this.$message.success(response.msg || '操作成功')
+          this.questionDialogVisible = false
+          await this.loadQuestionList()
+        } else {
+          this.$message.error(response.msg || '操作失败')
+        }
+      } catch (error) {
+        console.error('提交题目表单失败:', error)
+        this.$message.error('操作失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    /**
+     * 提交选项表单
+     * @returns {Promise<void>}
+     */
+    async handleSubmitOption() {
+      try {
+        const valid = await this.$refs.optionForm.validate()
+        if (!valid) return
+        
+        this.loading = true
+        
+        let response
+        if (this.isAddOptionMode) {
+          response = await addOption(this.optionForm)
+        } else {
+          response = await updateOption(this.optionForm)
+        }
+        
+        if (response.code === 200 && response.success) {
+          this.$message.success(response.msg || '操作成功')
+          this.optionDialogVisible = false
+          this.currentQuestionOptions = await this.loadQuestionOptions(this.currentQuestionId)
+          await this.loadQuestionList()
+        } else {
+          this.$message.error(response.msg || '操作失败')
+        }
+      } catch (error) {
+        console.error('提交选项表单失败:', error)
+        this.$message.error('操作失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    /**
+     * 设置题目表单数据
+     * @param {QuestionItem} question - 题目数据
+     * @returns {void}
+     */
+    setQuestionForm(question) {
+      this.questionForm = {
+        id: question.id,
+        surveyId: question.surveyId,
+        questionNo: question.questionNo,
+        title: question.title,
+        questionType: question.questionType,
+        isRequired: question.isRequired
+      }
+    },
+    
+    /**
+     * 重置题目表单
+     * @returns {void}
+     */
+    resetQuestionForm() {
+      this.questionForm = {
+        surveyId: this.surveyId,
+        questionNo: 1,
+        title: '',
+        questionType: QUESTION_TYPE.SINGLE_CHOICE,
+        isRequired: 0
+      }
+      
+      this.$nextTick(() => {
+        this.$refs.questionForm?.clearValidate()
+      })
+    },
+    
+    /**
+     * 设置选项表单数据
+     * @param {OptionItem} option - 选项数据
+     * @returns {void}
+     */
+    setOptionForm(option) {
+      this.optionForm = {
+        id: option.id,
+        questionId: option.questionId,
+        optionNo: option.optionNo,
+        optionText: option.optionText
+      }
+    },
+    
+    /**
+     * 重置选项表单
+     * @returns {void}
+     */
+    resetOptionForm() {
+      this.optionForm = {
+        questionId: this.currentQuestionId,
+        optionNo: 1,
+        optionText: ''
+      }
+      
+      this.$nextTick(() => {
+        this.$refs.optionForm?.clearValidate()
+      })
+    },
+    
+    /**
+     * 关闭题目对话框
+     * @returns {void}
+     */
+    handleCloseQuestionDialog() {
+      this.questionDialogVisible = false
+      this.resetQuestionForm()
+    },
+    
+    /**
+     * 关闭选项对话框
+     * @returns {void}
+     */
+    handleCloseOptionDialog() {
+      this.optionDialogVisible = false
+      this.resetOptionForm()
+    },
+    
+    /**
+     * 获取下一个题目序号
+     * @returns {number} 下一个序号
+     */
+    getNextQuestionNo() {
+      if (this.questionList.length === 0) return 1
+      const maxNo = Math.max(...this.questionList.map(q => q.questionNo))
+      return maxNo + 1
+    },
+    
+    /**
+     * 获取下一个选项序号
+     * @returns {number} 下一个序号
+     */
+    getNextOptionNo() {
+      if (this.currentQuestionOptions.length === 0) return 1
+      const maxNo = Math.max(...this.currentQuestionOptions.map(o => o.optionNo))
+      return maxNo + 1
+    },
+    
+    /**
+     * 获取题目类型标签
+     * @param {number} questionType - 题目类型
+     * @returns {string} 类型标签
+     */
+    getQuestionTypeLabel(questionType) {
+      return getQuestionTypeLabel(questionType)
+    },
+    
+    /**
+     * 获取题目类型类型
+     * @param {number} questionType - 题目类型
+     * @returns {string} 类型类型
+     */
+    getQuestionTypeType(questionType) {
+      return getQuestionTypeType(questionType)
+    },
+    
+    /**
+     * 获取题目类型图标
+     * @param {number} questionType - 题目类型
+     * @returns {string} 类型图标
+     */
+    getQuestionTypeIcon(questionType) {
+      return getQuestionTypeIcon(questionType)
+    },
+    
+    /**
+     * 获取是否必填标签
+     * @param {number} isRequired - 是否必填
+     * @returns {string} 必填标签
+     */
+    getQuestionRequiredLabel(isRequired) {
+      return getQuestionRequiredLabel(isRequired)
+    },
+    
+    /**
+     * 获取是否必填类型
+     * @param {number} isRequired - 是否必填
+     * @returns {string} 必填类型
+     */
+    getQuestionRequiredType(isRequired) {
+      return getQuestionRequiredType(isRequired)
+    },
+    
+    /**
+     * 判断题目类型是否需要选项
+     * @param {number} questionType - 题目类型
+     * @returns {boolean} 是否需要选项
+     */
+    isQuestionTypeNeedOptions(questionType) {
+      return isQuestionTypeNeedOptions(questionType)
+    }
+  }
+}

+ 43 - 2
src/mixins/survey/surveyIndex.js

@@ -99,7 +99,11 @@ export default {
         status: [
           { required: true, message: '请选择状态', trigger: 'change' }
         ]
-      }
+      },
+      
+      // 题目编辑相关
+      questionEditorVisible: false,
+      currentSurveyId: null
     }
   },
   
@@ -392,6 +396,44 @@ export default {
       return getSurveyTemplateLabel(isTemplate)
     },
     
+    
+    /**
+     * 处理题目编辑
+     * @param {Object} row - 问卷行数据
+     */
+    handleEditQuestions(row) {
+      try {
+        this.currentSurveyId = row.id
+        this.questionEditorVisible = true
+        
+        this.$nextTick(() => {
+          // 确保组件已渲染
+          if (this.$refs.questionEditor) {
+            this.$refs.questionEditor.loadQuestionList()
+          }
+        })
+      } catch (error) {
+        console.error('打开题目编辑器失败:', error)
+        this.$message.error('打开题目编辑器失败')
+      }
+    },
+    
+    /**
+     * 关闭题目编辑器
+     */
+    handleCloseQuestionEditor() {
+      this.questionEditorVisible = false
+      this.currentSurveyId = null
+      
+      // 清理组件引用
+      this.$nextTick(() => {
+        if (this.$refs.questionEditor) {
+          this.$refs.questionEditor = null
+        }
+      })
+    },
+    
+    
     /**
      * 获取模板类型
      * @param {number} isTemplate - 模板值
@@ -400,6 +442,5 @@ export default {
     getTemplateType(isTemplate) {
       return getSurveyTemplateType(isTemplate)
     }
-    
   }
 }

+ 50 - 0
src/views/survey/index.vue

@@ -144,6 +144,14 @@
           >
             编辑
           </el-button>
+          <el-button
+            type="text"
+            size="small"
+            icon="el-icon-setting"
+            @click="handleEditQuestions(row)"
+          >
+            题目编辑
+          </el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -295,19 +303,61 @@
         <el-button @click="dialogVisible = false">关闭</el-button>
       </div>
     </el-dialog>
+   <!-- 题目编辑弹窗 -->
+    <el-dialog
+      title="题目编辑"
+      :visible.sync="questionEditorVisible"
+      width="90%"
+      append-to-body
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      :modal="true"
+      :modal-append-to-body="true"
+      custom-class="question-editor-dialog"
+      @close="handleCloseQuestionEditor"
+    >
+      <survey-question-editor
+        v-if="questionEditorVisible"
+        :survey-id="currentSurveyId"
+        ref="questionEditor"
+      />
+      
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="handleCloseQuestionEditor">关闭</el-button>
+      </div>
+    </el-dialog>
   </basic-container>
 </template>
 
 <script>
 import surveyIndexMixin from '@/mixins/survey/surveyIndex'
+import SurveyQuestionEditor from '@/components/survey-question-editor'
 
 export default {
   name: 'SurveyManagement',
   
+  components: {
+    SurveyQuestionEditor
+  },
+  
   mixins: [surveyIndexMixin]
 }
 </script>
 
 <style lang="scss" scoped>
 @import './index.scss';
+
+// 题目编辑弹窗样式
+::v-deep .question-editor-dialog {
+  .el-dialog__body {
+    padding: 10px 20px;
+    max-height: 70vh;
+    overflow-y: auto;
+  }
+  
+  .el-dialog__footer {
+    padding: 10px 20px 20px;
+    text-align: center;
+  }
+}
 </style>