浏览代码

feat: 选做题设置

zhangjie 3 天之前
父节点
当前提交
20cc3280c6

+ 51 - 0
src/api/subject.ts

@@ -8,6 +8,9 @@ import {
   PaperStructureItem,
   PaperStructureUpdateParams,
   SubjectItem,
+  OptionalQuestionItem,
+  OptionalRuleUpdateParam,
+  OptionalRuleItem,
 } from './types/subject';
 
 // 科目管理
@@ -29,6 +32,21 @@ export function getSubjectDetail(subjectId: number): Promise<SubjectItem> {
   return axios.post('/api/subject/list', { params: { subjectId } });
 }
 
+// 科目分析计算
+export function subjectAnalysis(
+  examId: number,
+  subjectId?: number
+): Promise<boolean> {
+  return axios.post('/api/subject/analysis', { params: { examId, subjectId } });
+}
+
+// 客观题统分
+export function subjectObjectiveMarkScore(examId: number): Promise<boolean> {
+  return axios.post('/api/subject/objective-stat', {
+    params: { examId },
+  });
+}
+
 // 获取科目试卷结构列表
 export function getPaperStructureList(
   subjectId: number
@@ -62,3 +80,36 @@ export function deletePaperStructures(ids: number[]): Promise<boolean> {
     data: { ids },
   });
 }
+
+// 选做题设置
+// 获取选做题试题列表
+export function getOptionalQuestionList(
+  subjectId: number
+): Promise<OptionalQuestionItem[]> {
+  return axios.post('/api/subject/optional-question/list', {
+    params: { subjectId },
+  });
+}
+
+// 获取选做题规则列表
+export function getOptionalQuestionRuleList(
+  subjectId: number
+): Promise<OptionalRuleItem[]> {
+  return axios.post('/api/subject/optional-question/rule/list', {
+    params: { subjectId },
+  });
+}
+
+// 保存选做题设置
+export function saveOptionalQuestionRule(
+  params: OptionalRuleUpdateParam
+): Promise<boolean> {
+  return axios.post('/api/subject/optional-question/rule', params);
+}
+
+// 删除选做题规则
+export function deleteOptionalQuestionRule(id: number): Promise<boolean> {
+  return axios.post('/api/subject/optional-question/rule/delete', {
+    params: { id },
+  });
+}

+ 48 - 0
src/api/types/subject.ts

@@ -1,3 +1,4 @@
+import { OptionalScoreRule } from '@/constants/enumerate';
 import { PageResult, PageParams, CoverArea } from './common';
 
 export interface SubjectTotalScoreStatItem {
@@ -109,3 +110,50 @@ export interface PaperStructureItem {
 export type PaperStructureListPageRes = PaperStructureItem[];
 
 export type PaperStructureUpdateParams = Partial<PaperStructureItem>;
+
+// 选做题试题列表:大题名称	大题号	小题号	满分	间隔分	选做题分组	选做题区
+export interface OptionalQuestionItem {
+  id: number;
+  // 大题名称
+  bigQuestionName: string;
+  // 大题号
+  bigQuestionNo: number;
+  // 小题号
+  smallQuestionNo: number;
+  // 满分
+  fullScore: number;
+  // 间隔分
+  intervalScore: number;
+  // 选做题分组
+  optionalQuestionGroup: string;
+  // 选做题区
+  optionalQuestionArea: string;
+}
+
+// 选做题规则列表:选做题组	规则	大题名称	分值
+export interface OptionalRuleItem {
+  id: number;
+  subjectId: number;
+  // 选做题规则: 几选几
+  ruleSelecCount: number;
+  ruleTotalCount: number;
+  // 取分规则
+  scoreRule: OptionalScoreRule;
+  // 大题名称
+  bigQuestionName: string;
+  // 分值
+  score: number;
+  // 试题结构ids
+  questionStructureIds: number[];
+}
+
+export interface OptionalRuleUpdateParam {
+  id?: number;
+  // 选做题规则: 几选几
+  ruleSelecCount: number;
+  ruleTotalCount: number;
+  // 区分规则
+  scoreRule: OptionalScoreRule;
+  // 试题结构ids
+  questionStructureIds: number[];
+}

+ 8 - 0
src/constants/enumerate.ts

@@ -77,3 +77,11 @@ export const OPTIONAL_EXCEPTION_TYPE = {
   LESS_SELECTED: '少选做',
 };
 export type OptionalExceptionType = keyof typeof OPTIONAL_EXCEPTION_TYPE;
+
+// 选做题取分规则:最高分、最低分、最低分(去掉0分)
+export const OPTIONAL_SCORE_RULE = {
+  MAX_SCORE: '最高分',
+  MIN_SCORE: '最低分',
+  MIN_SCORE_NO_ZERO: '最低分(去掉0分)',
+};
+export type OptionalScoreRule = keyof typeof OPTIONAL_SCORE_RULE;

+ 4 - 0
src/utils/filter.ts

@@ -5,6 +5,7 @@ import {
   EXAM_TYPE,
   MARKING_MODE,
   LOG_TYPE,
+  OPTIONAL_SCORE_RULE,
 } from '@/constants/enumerate';
 import { formatDate } from './utils';
 
@@ -20,6 +21,9 @@ export const dictFilter = {
     MARKING_MODE[val as keyof typeof MARKING_MODE] || DEFAULT_LABEL,
   logType: (val: string) =>
     LOG_TYPE[val as keyof typeof LOG_TYPE] || DEFAULT_LABEL,
+  optionalScoreRule: (val: string) =>
+    OPTIONAL_SCORE_RULE[val as keyof typeof OPTIONAL_SCORE_RULE] ||
+    DEFAULT_LABEL,
 };
 
 // 时间戳过滤器

+ 4 - 0
src/utils/utils.ts

@@ -371,3 +371,7 @@ export function snakeToHump(name: string): string {
 export function deepCopy<T>(data: T): T {
   return JSON.parse(JSON.stringify(data));
 }
+
+export function isEmpty(val: any) {
+  return val === undefined || val === null || val === '';
+}

+ 111 - 0
src/views/subject/OptionalRuleEdit.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="part-box is-filter">
+    <el-space wrap>
+      <span>科目:{{ subjectInfo.code }}-{{ subjectInfo.name }}</span>
+      <span style="margin-left: 20px"
+        >客观总分:{{ subjectInfo.objectiveTotalScore }}</span
+      >
+      <span style="margin-left: 20px"
+        >主观总分:{{ subjectInfo.subjectiveTotalScore }}</span
+      >
+      <span style="margin-left: 20px"
+        >试卷总分:{{ subjectInfo.paperTotalScore }}</span
+      >
+      <el-button type="primary" @click="onAdd">新增</el-button>
+      <el-button type="primary" @click="onEdit"> 编辑 </el-button>
+      <el-button @click="onReturn">返回</el-button>
+    </el-space>
+  </div>
+
+  <div class="part-box">
+    <el-table
+      ref="tableRef"
+      v-loading="loading"
+      :data="dataList"
+      class="page-table"
+    >
+      <el-table-column prop="bigQuestionName" label="大题名称" width="120" />
+      <el-table-column prop="bigQuestionNo" label="大题号" width="80" />
+      <el-table-column prop="smallQuestionNo" label="小题号" width="80" />
+      <el-table-column prop="fullScore" label="满分" width="80" />
+      <el-table-column prop="intervalScore" label="间隔分" width="80" />
+      <el-table-column prop="questionType" label="选做题分组" width="100" />
+      <el-table-column prop="answer" label="选做题区" width="100" />
+    </el-table>
+  </div>
+
+  <!-- 新增/编辑试卷结构弹窗 -->
+  <ModifyPaperStructure
+    ref="modifyPaperStructureRef"
+    :row-data="curRow"
+    :subject-id="subjectId"
+    @modified="getList"
+  />
+</template>
+
+<script setup lang="ts">
+  import { reactive, ref, computed, onMounted } from 'vue';
+  import { useRoute, useRouter } from 'vue-router';
+  import { getSubjectDetail, getOptionalQuestionList } from '@/api/subject';
+  import type { PaperStructureItem } from '@/api/types/subject';
+  import useLoading from '@/hooks/loading';
+
+  import ModifyPaperStructure from './components/ModifyPaperStructure.vue';
+
+  defineOptions({
+    name: 'OptionalRuleEdit',
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+
+  // 从路由参数获取科目ID
+  const subjectId = computed(() => Number(route.params.subjectId) || 0);
+
+  const subjectInfo = reactive({
+    code: '',
+    name: '',
+    objectiveTotalScore: 0,
+    subjectiveTotalScore: 0,
+    paperTotalScore: 0,
+  });
+  // 获取科目详情信息
+  const getSubjectDetailInfo = async () => {
+    const res = await getSubjectDetail(subjectId.value);
+    subjectInfo.code = res.code;
+    subjectInfo.name = res.name;
+    subjectInfo.objectiveTotalScore = res.objectiveTotalScore;
+    subjectInfo.subjectiveTotalScore = res.subjectiveTotalScore;
+    subjectInfo.paperTotalScore = res.paperTotalScore;
+  };
+
+  // 获取试卷结构列表
+  const { loading, setLoading } = useLoading();
+  const dataList = ref<PaperStructureItem[]>([]);
+  // 获取试卷结构列表
+  const getList = async () => {
+    setLoading(true);
+    const res = await getOptionalQuestionList(subjectId.value).catch(() => []);
+    setLoading(false);
+    dataList.value = res || [];
+  };
+
+  const onAdd = () => {
+    // modifyPaperStructureRef.value?.open();
+  };
+
+  const onEdit = () => {
+    // modifyPaperStructureRef.value?.open();
+  };
+
+  const onReturn = () => {
+    router.back();
+  };
+
+  onMounted(() => {
+    if (subjectId.value) {
+      getList();
+      getSubjectDetailInfo();
+    }
+  });
+</script>

+ 45 - 7
src/views/subject/SubjectManage.vue

@@ -103,8 +103,12 @@
             </template>
           </el-dropdown>
 
-          <el-button type="success" @click="onAnalysis">客观题统计</el-button>
-          <el-button type="primary" @click="onAnalysis">分析统计</el-button>
+          <el-button type="success" :loading="loading" @click="onObjectiveMark"
+            >客观题统计</el-button
+          >
+          <el-button type="primary" :loading="loading" @click="onAnalysis"
+            >分析统计</el-button
+          >
           <el-button type="primary" @click="onImportCommand('package')"
             >导入数据包</el-button
           >
@@ -172,7 +176,13 @@
           <el-button size="small" type="primary" link @click="onEdit(row)">
             编辑
           </el-button>
-          <el-button size="small" type="primary" link @click="onAnalysis(row)">
+          <el-button
+            size="small"
+            type="primary"
+            :disabled="loading"
+            link
+            @click="onAnalysis(row)"
+          >
             分析计算
           </el-button>
           <el-button
@@ -217,13 +227,20 @@
 <script setup lang="ts">
   import { reactive, ref, onMounted, computed } from 'vue';
   import { useRouter } from 'vue-router';
-  import { getSubjectList, getSubjectTotalScoreStatList } from '@/api/subject';
+  import {
+    getSubjectList,
+    getSubjectTotalScoreStatList,
+    subjectAnalysis,
+    subjectObjectiveMarkScore,
+  } from '@/api/subject';
   import type {
     SubjectItem,
     SubjectListFilter,
     SubjectTotalScoreStatItem,
   } from '@/api/types/subject';
   import useTable from '@/hooks/table';
+  import useLoading from '@/hooks/loading';
+  import { useAppStore } from '@/store';
 
   import SubjectSettingDialog from './components/SubjectSettingDialog.vue';
 
@@ -232,6 +249,7 @@
   });
 
   const router = useRouter();
+  const appStore = useAppStore();
 
   const searchModel = reactive<SubjectListFilter>({
     subject: null,
@@ -296,7 +314,7 @@
   const getStatData = async () => {
     try {
       // 这里需要传入实际的examId,暂时使用1作为示例
-      const data = await getSubjectTotalScoreStatList(1);
+      const data = await getSubjectTotalScoreStatList(appStore.curExam.id);
       updateChart(data);
     } catch (error) {
       console.error('获取统计数据失败:', error);
@@ -315,8 +333,28 @@
     });
   };
 
-  const onAnalysis = (row: SubjectItem | undefined) => {
-    currentSubject.value = row;
+  const { loading, setLoading } = useLoading();
+  // 分析统计
+  const onAnalysis = async (row: SubjectItem | undefined) => {
+    try {
+      setLoading(true);
+      await subjectAnalysis(appStore.curExam.id, row?.id);
+    } catch (error) {
+      console.error('分析失败:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+  // 客观题统分
+  const onObjectiveMark = async () => {
+    try {
+      setLoading(true);
+      await subjectObjectiveMarkScore(appStore.curExam.id);
+    } catch (error) {
+      console.error('分析失败:', error);
+    } finally {
+      setLoading(false);
+    }
   };
 
   const onSetOptional = (row: SubjectItem) => {

+ 171 - 0
src/views/subject/components/ModifyOptionalQuestionRule.vue

@@ -0,0 +1,171 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="title"
+    width="800px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    top="10vh"
+    append-to-body
+    @close="handleClose"
+    @open="modalBeforeOpen"
+  >
+    <el-steps :active="currentStep" finish-status="success">
+      <el-step title="设置规则" />
+      <el-step title="选择试题" />
+    </el-steps>
+
+    <div style="margin-top: 20px">
+      <!-- 第一步:设置规则 -->
+      <div v-if="currentStep === 0">
+        <OptionalRuleForm ref="ruleFormRef" v-model="ruleFormData" />
+      </div>
+
+      <!-- 第二步:选择试题 -->
+      <div v-if="currentStep === 1">
+        <OptionalQuestionSelect
+          ref="questionSelectRef"
+          v-model="selectedQuestions"
+          :subject-id="props.rowData?.subjectId"
+        />
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="close">取消</el-button>
+        <el-button v-if="currentStep > 0" @click="prevStep">上一步</el-button>
+        <el-button v-if="currentStep < 1" type="primary" @click="nextStep">
+          下一步
+        </el-button>
+        <el-button
+          v-if="currentStep === 1"
+          type="primary"
+          :loading="loading"
+          @click="confirm"
+        >
+          保存
+        </el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, reactive } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import { saveOptionalQuestionRule } from '@/api/subject';
+  import type {
+    OptionalRuleItem,
+    OptionalRuleUpdateParam,
+  } from '@/api/types/subject';
+  import useModal from '@/hooks/modal';
+  import useLoading from '@/hooks/loading';
+  import { objModifyAssign } from '@/utils/utils';
+  import OptionalRuleForm from './OptionalRuleForm.vue';
+  import OptionalQuestionSelect from './OptionalQuestionSelect.vue';
+
+  defineOptions({
+    name: 'ModifyOptionalQuestionRule',
+  });
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  interface Props {
+    rowData: OptionalRuleItem;
+  }
+
+  const props = withDefaults(defineProps<Props>(), {
+    rowData: () => {},
+  });
+
+  const emit = defineEmits(['modified']);
+
+  const isEdit = computed(() => !!props.rowData?.id);
+  const title = computed(() => `${isEdit.value ? '编辑' : '新增'}选做题规则`);
+
+  const currentStep = ref(0);
+  const ruleFormRef = ref();
+  const questionSelectRef = ref();
+
+  // 规则表单数据
+  const initialRuleFormData = {
+    ruleSelecCount: 1,
+    ruleTotalCount: 1,
+    scoreRule: 'MAX_SCORE' as const,
+  };
+  const ruleFormData = reactive({ ...initialRuleFormData });
+
+  // 选中的试题
+  const selectedQuestions = ref<number[]>([]);
+
+  const handleClose = () => {
+    currentStep.value = 0;
+    objModifyAssign(ruleFormData, initialRuleFormData);
+    selectedQuestions.value = [];
+  };
+
+  // 下一步
+  const nextStep = async () => {
+    if (currentStep.value === 0) {
+      // 验证第一步表单
+      const valid = await ruleFormRef.value?.validate().catch(() => false);
+      if (!valid) return;
+    }
+    currentStep.value++;
+  };
+
+  // 上一步
+  const prevStep = () => {
+    currentStep.value--;
+  };
+
+  // 提交
+  const { loading, setLoading } = useLoading();
+  const confirm = async () => {
+    // 验证第二步
+    const valid = await questionSelectRef.value?.validate().catch(() => false);
+    if (!valid) return;
+
+    setLoading(true);
+    const params: OptionalRuleUpdateParam = {
+      ...ruleFormData,
+      questionStructureIds: selectedQuestions.value,
+    };
+
+    if (props.rowData?.id) {
+      params.id = props.rowData.id;
+    }
+
+    let res = true;
+    await saveOptionalQuestionRule(params).catch(() => {
+      res = false;
+    });
+    setLoading(false);
+    if (!res) return;
+
+    ElMessage.success(`${title.value}成功!`);
+    emit('modified');
+    close();
+  };
+
+  /* init modal */
+  function modalBeforeOpen() {
+    if (props.rowData?.id) {
+      // 编辑模式
+      objModifyAssign(ruleFormData, {
+        ruleSelecCount: props.rowData.ruleSelecCount,
+        ruleTotalCount: props.rowData.ruleTotalCount,
+        scoreRule: props.rowData.scoreRule,
+      });
+      selectedQuestions.value = props.rowData.questionStructureIds || [];
+    } else {
+      // 新增模式
+      objModifyAssign(ruleFormData, initialRuleFormData);
+      selectedQuestions.value = [];
+    }
+    currentStep.value = 0;
+  }
+</script>

+ 114 - 0
src/views/subject/components/OptionalQuestionSelect.vue

@@ -0,0 +1,114 @@
+<template>
+  <el-table
+    ref="tableRef"
+    v-loading="loading"
+    :data="dataList"
+    class="question-table"
+    @selection-change="handleSelectionChange"
+  >
+    <el-table-column type="selection" width="55" />
+    <el-table-column prop="bigQuestionName" label="大题名称" width="120" />
+    <el-table-column prop="bigQuestionNo" label="大题号" width="80" />
+    <el-table-column prop="smallQuestionNo" label="小题号" width="80" />
+    <el-table-column prop="fullScore" label="满分" width="80" />
+    <el-table-column prop="intervalScore" label="间隔分" width="80" />
+    <el-table-column
+      prop="optionalQuestionGroup"
+      label="选做题分组"
+      width="100"
+    />
+    <el-table-column prop="optionalQuestionArea" label="选做题区" width="100" />
+  </el-table>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch, onMounted, nextTick } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import { getOptionalQuestionList } from '@/api/subject';
+  import type { OptionalQuestionItem } from '@/api/types/subject';
+  import useLoading from '@/hooks/loading';
+
+  defineOptions({
+    name: 'OptionalQuestionSelect',
+  });
+
+  interface Props {
+    modelValue: number[];
+    subjectId: number;
+  }
+
+  const props = defineProps<Props>();
+  const emit = defineEmits<{
+    'update:modelValue': [value: number[]];
+  }>();
+
+  const tableRef = ref();
+  const dataList = ref<OptionalQuestionItem[]>([]);
+  const { loading, setLoading } = useLoading();
+
+  // 设置已选中的行
+  const setSelectedRows = () => {
+    if (!tableRef.value || !props.modelValue.length) return;
+
+    dataList.value.forEach((row) => {
+      if (props.modelValue.includes(row.id)) {
+        tableRef.value.toggleRowSelection(row, true);
+      }
+    });
+  };
+
+  // 获取试题列表
+  const getList = async () => {
+    if (!props.subjectId) return;
+
+    setLoading(true);
+    try {
+      const res = await getOptionalQuestionList(props.subjectId);
+      dataList.value = res || [];
+
+      // 设置已选中的项
+      await nextTick();
+      setSelectedRows();
+    } catch (error) {
+      dataList.value = [];
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 处理选择变化
+  const handleSelectionChange = (selection: OptionalQuestionItem[]) => {
+    const selectedIds = selection.map((item) => item.id);
+    emit('update:modelValue', selectedIds);
+  };
+
+  // 监听父组件数据变化
+  watch(
+    () => props.modelValue,
+    () => {
+      nextTick(() => {
+        setSelectedRows();
+      });
+    },
+    { deep: true }
+  );
+
+  // 暴露验证方法
+  const validate = () => {
+    if (props.modelValue.length === 0) {
+      ElMessage.warning('请至少选择一个试题');
+      return Promise.reject(new Error('请至少选择一个试题'));
+    }
+    return Promise.resolve(true);
+  };
+
+  defineExpose({
+    validate,
+  });
+
+  onMounted(() => {
+    if (props.subjectId) {
+      getList();
+    }
+  });
+</script>

+ 156 - 0
src/views/subject/components/OptionalRuleDialog.vue

@@ -0,0 +1,156 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="编辑选做题组"
+    width="800px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    top="10vh"
+    append-to-body
+    @close="handleClose"
+    @open="modalBeforeOpen"
+  >
+    <el-button type="primary" @click="addRule">新增</el-button>
+    <el-table v-loading="loading" :data="ruleList">
+      <el-table-column label="选做题组" width="120">
+        <template #default="{ row }">
+          {{ `${row.ruleSelecCount}选${row.ruleTotalCount}` }}
+        </template>
+      </el-table-column>
+
+      <el-table-column label="规则" width="150">
+        <template #default="{ row }">
+          {{ dictFilter.optionalScoreRule(row.scoreRule) }}
+        </template>
+      </el-table-column>
+
+      <el-table-column label="大题名称" prop="bigQuestionName">
+      </el-table-column>
+
+      <el-table-column label="分值" width="100">
+        <template #default="{ row }">
+          {{ row.score }}
+        </template>
+      </el-table-column>
+
+      <el-table-column label="操作" width="120">
+        <template #default="{ row }">
+          <el-button type="primary" size="small" @click="editRule(row)">
+            编辑
+          </el-button>
+          <el-button type="danger" size="small" @click="deleteRule(row)">
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <template #footer> </template>
+  </el-dialog>
+
+  <ModifyOptionalQuestionRule
+    ref="modifyOptionalQuestionRuleRef"
+    :row-data="OptionalRuleItem"
+    @modified="ruleModified"
+  />
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import {
+    getOptionalQuestionRuleList,
+    deleteOptionalQuestionRule,
+  } from '@/api/subject';
+  import type { OptionalRuleItem } from '@/api/types/subject';
+  import useModal from '@/hooks/modal';
+  import useLoading from '@/hooks/loading';
+  import { modalConfirm } from '@/utils/ui';
+  import { dictFilter } from '@/utils/filter';
+
+  import ModifyOptionalQuestionRule from './ModifyOptionalQuestionRule.vue';
+
+  defineOptions({
+    name: 'OptionalRuleDialog',
+  });
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  interface Props {
+    subjectId: number;
+  }
+
+  const props = withDefaults(defineProps<Props>(), {
+    subjectId: 0,
+  });
+
+  const emit = defineEmits(['modified']);
+
+  const modifyOptionalQuestionRuleRef =
+    ref<InstanceType<typeof ModifyOptionalQuestionRule>>();
+  const ruleList = ref<OptionalRuleItem[]>([]);
+  const curRow = ref<OptionalRuleItem>();
+  const { loading, setLoading } = useLoading();
+
+  const handleClose = () => {
+    ruleList.value = [];
+  };
+
+  // 加载选做题规则列表
+  const getList = async () => {
+    if (!props.subjectId) return;
+
+    setLoading(true);
+    try {
+      const data = await getOptionalQuestionRuleList(props.subjectId);
+      ruleList.value = data || [];
+    } catch (error) {
+      ElMessage.error('获取选做题规则列表失败');
+      ruleList.value = [];
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 编辑规则
+  const editRule = (row: OptionalRuleItem) => {
+    curRow.value = row;
+    modifyOptionalQuestionRuleRef.value?.open();
+  };
+
+  // 新增规则
+  const addRule = () => {
+    curRow.value = {};
+    modifyOptionalQuestionRuleRef.value?.open();
+  };
+
+  // 删除规则
+  const deleteRule = async (row: OptionalRuleItem) => {
+    const confirm = await modalConfirm(
+      '确定要删除这条选做题规则吗?',
+      '提示'
+    ).catch(() => false);
+    if (!confirm) return;
+
+    try {
+      await deleteOptionalQuestionRule(row.id);
+      ElMessage.success('删除成功');
+      getList();
+      emit('modified');
+    } catch (error) {
+      console.error('删除失败:', error);
+    }
+  };
+
+  const ruleModified = () => {
+    getList();
+    emit('modified');
+  };
+
+  /* init modal */
+  const modalBeforeOpen = () => {
+    getList();
+  };
+</script>

+ 162 - 0
src/views/subject/components/OptionalRuleForm.vue

@@ -0,0 +1,162 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="formModel"
+    :rules="formRules"
+    label-width="120px"
+  >
+    <el-form-item label="选做题规则" prop="ruleSelecCount">
+      <el-input-number
+        v-model="formModel.ruleSelecCount"
+        :min="1"
+        :max="formModel.ruleTotalCount"
+        :step="1"
+        :precision="0"
+        :controls="false"
+        step-strictly
+        placeholder="请输入"
+        style="width: 80px"
+      />
+      <span>选</span>
+      <el-input-number
+        v-model="formModel.ruleTotalCount"
+        :min="formModel.ruleSelecCount"
+        :max="99"
+        :step="1"
+        :precision="0"
+        :controls="false"
+        step-strictly
+        placeholder="请输入"
+        style="width: 80px"
+      />
+    </el-form-item>
+
+    <el-form-item label="取分规则" prop="scoreRule">
+      <el-radio-group v-model="formModel.scoreRule">
+        <el-radio value="MAX_SCORE">最高分</el-radio>
+        <el-radio value="MIN_SCORE">最低分</el-radio>
+        <el-radio value="MIN_SCORE_NO_ZERO">最低分(去掉0分)</el-radio>
+      </el-radio-group>
+    </el-form-item>
+
+    <div class="rule-description">
+      <p><strong>规则说明:</strong></p>
+      <p>• 选做题数量:学生需要选择作答的题目数量</p>
+      <p>• 总题数:该选做题组包含的题目总数</p>
+      <p>• 取分规则:当学生作答题目数量超过选做题数量时的计分方式</p>
+    </div>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch, nextTick } from 'vue';
+  import type { FormInstance, FormRules } from 'element-plus';
+  import type { OptionalScoreRule } from '@/api/types/subject';
+  import { isEmpty } from '@/utils/utils';
+
+  defineOptions({
+    name: 'OptionalRuleForm',
+  });
+
+  interface FormData {
+    ruleSelecCount: number;
+    ruleTotalCount: number;
+    scoreRule: OptionalScoreRule;
+  }
+
+  interface Props {
+    modelValue: FormData;
+  }
+
+  const props = defineProps<Props>();
+  const emit = defineEmits<{
+    'update:modelValue': [value: FormData];
+  }>();
+
+  const formRef = ref<FormInstance>();
+  const formModel = ref<FormData>({ ...props.modelValue });
+
+  // 监听表单数据变化,同步到父组件
+  const isUpdatingFromParent = ref(false);
+  watch(
+    formModel,
+    (newVal) => {
+      if (!isUpdatingFromParent.value) {
+        emit('update:modelValue', { ...newVal });
+      }
+    },
+    { deep: true }
+  );
+
+  // 监听父组件数据变化
+  watch(
+    () => props.modelValue,
+    (newVal) => {
+      isUpdatingFromParent.value = true;
+      formModel.value = { ...newVal };
+      nextTick(() => {
+        isUpdatingFromParent.value = false;
+      });
+    },
+    { deep: true }
+  );
+
+  const formRules: FormRules<keyof FormData> = {
+    ruleSelecCount: [
+      {
+        validator: (rule, value, callback) => {
+          if (
+            !isEmpty(formModel.value.ruleTotalCount) ||
+            !isEmpty(formModel.value.ruleSelecCount)
+          ) {
+            return callback(new Error('请完成数量输入'));
+          }
+
+          if (
+            formModel.value.ruleSelecCount >= formModel.value.ruleTotalCount
+          ) {
+            return callback(new Error('选做题数量不能大于总题数'));
+          }
+
+          return callback();
+        },
+        trigger: 'change',
+      },
+    ],
+    scoreRule: [
+      { required: true, message: '请选择取分规则', trigger: 'change' },
+    ],
+  };
+
+  // 暴露验证方法
+  const validate = () => {
+    return formRef.value?.validate();
+  };
+
+  defineExpose({
+    validate,
+  });
+</script>
+
+<style scoped>
+  .rule-description {
+    background-color: #f5f7fa;
+    padding: 16px;
+    border-radius: 4px;
+    margin-top: 20px;
+  }
+
+  .rule-description p {
+    margin: 0 0 8px 0;
+    color: #606266;
+    font-size: 14px;
+  }
+
+  .rule-description p:last-child {
+    margin-bottom: 0;
+  }
+
+  .rule-description strong {
+    color: #303133;
+  }
+</style>