Преглед изворни кода

feat: 对接考试管理模块内容

xieyong пре 1 месец
родитељ
комит
f705f31b2d

+ 50 - 0
src/api/examManagement/examManagement.js

@@ -0,0 +1,50 @@
+import request from '@/utils/request';
+
+/**
+ * 分页查询考试管理
+ */
+export async function getPage(params) {
+  const res = await request.get('/ehs/examManagement/page', { params });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 详情
+ */
+export async function getById(id) {
+  const res = await request.get(`/ehs/examManagement/getById/${id}`);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+// 删除
+export async function remove(id) {
+  const res = await request.delete(`/ehs/examManagement/delete?ids=${id}`);
+  if (res.data.code == 0) {
+    return res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+// 发布/保存考试
+export async function save(data) {
+  const res = await request.post('/ehs/examManagement/save', data);
+  if (res.data.code == 0) {
+    return res.data.data || res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+// 批量更新考试人员签到状态
+export async function signIn(data) {
+  const res = await request.put('/ehs/examManagement/signIn', data);
+  if (res.data.code == 0) {
+    return res.data.data || res.data.message;
+  }
+  return Promise.reject(new Error(res.data.message));
+}

+ 99 - 22
src/views/safetyExam/examManagement/index.vue

@@ -41,6 +41,13 @@
                   @click="handleDetail(row)"
                   >查看详情</el-link
                 >
+                <el-link
+                  v-if="isSignInAvailable(row)"
+                  type="primary"
+                  :underline="false"
+                  @click="handleSignIn(row)"
+                  >监考人签到</el-link
+                >
                 <el-link
                   type="danger"
                   :underline="false"
@@ -62,6 +69,21 @@
 <script>
   import publish from './publish.vue';
   import tabMixins from '@/mixins/tableColumnsMixin';
+  import { getPage, getById, remove } from '@/api/examManagement/examManagement.js';
+
+  // 考试状态映射
+  const EXAM_STATUS_MAP = {
+    0: '未开始',
+    1: '已开始',
+    2: '已结束'
+  };
+
+  // 考试类型映射
+  const EXAM_TYPE_MAP = {
+    1: '培训关联考试',
+    2: '专项考试'
+  };
+
   export default {
     components: {
       publish
@@ -79,14 +101,14 @@
             align: 'center'
           },
           {
-            prop: 'name',
+            prop: 'examName',
             label: '考试名称',
             align: 'center',
             minWidth: 180,
             showOverflowTooltip: true
           },
           {
-            prop: 'location',
+            prop: 'examPlace',
             label: '考试地点',
             align: 'center',
             minWidth: 150
@@ -95,28 +117,44 @@
             prop: 'examTime',
             label: '考试时间',
             align: 'center',
-            minWidth: 160
+            minWidth: 180,
+            formatter: (row) => {
+              const start = row.startTime || '';
+              const end = row.endTime || '';
+              if (!start && !end) return '';
+              if (start && end) return `${start} ~ ${end}`;
+              return start || end;
+            }
           },
           {
             prop: 'examType',
             label: '考试类型',
             align: 'center',
-            minWidth: 120
+            minWidth: 120,
+            formatter: (row) => {
+              return EXAM_TYPE_MAP[row.examType] || row.examType || '';
+            }
           },
           {
-            prop: 'examCount',
+            prop: 'examPersonCount',
             label: '考试人数',
             align: 'center',
-            minWidth: 100
+            minWidth: 100,
+            formatter: (row) => {
+              return row.exam_person_count || 0;
+            }
           },
           {
-            prop: 'status',
+            prop: 'examStatus',
             label: '状态',
             align: 'center',
-            minWidth: 100
+            minWidth: 100,
+            formatter: (row) => {
+              return EXAM_STATUS_MAP[row.examStatus] || '';
+            }
           },
           {
-            prop: 'orgUnitName',
+            prop: 'organizationName',
             label: '组织单位',
             align: 'center',
             minWidth: 150
@@ -128,14 +166,16 @@
             minWidth: 150
           },
           {
-            prop: 'publisher',
+            prop: 'createUserName',
             label: '考试计划发布人',
             align: 'center',
             minWidth: 130
           },
           {
             columnKey: 'action',
+            prop: 'action',
             label: '操作',
+            headerSlot: true,
             width: 180,
             align: 'center',
             slot: 'action'
@@ -149,24 +189,37 @@
         return [
           {
             label: '考试名称:',
-            value: 'name',
+            value: 'examName',
             type: 'input',
             width: 220,
             placeholder: '请输入考试名称'
           },
           {
-            label: '考试时间:',
+            label: '发布时间:',
             value: 'examTime',
             type: 'date',
+            width: 280,
+            placeholder: '请选择考试时间范围',
+            valueAr: ['examStartTime', 'examEndTime']
+          },
+          {
+            label: '发布人:',
+            value: 'createUserName',
+            type: 'input',
             width: 220,
-            placeholder: '请选择考试时间'
+            placeholder: '请输入发布人名称'
           },
           {
-            label: '到:',
-            value: 'examTimeEnd',
-            type: 'date',
+            label: '考试状态:',
+            value: 'examStatus',
+            type: 'select',
             width: 220,
-            placeholder: '请选择结束时间'
+            placeholder: '请选择考试状态',
+            planList: [
+              { label: '未开始', value: 0 },
+              { label: '已开始', value: 1 },
+              { label: '已结束', value: 2 }
+            ]
           }
         ];
       }
@@ -177,8 +230,31 @@
         this.$refs.publishRef.open();
       },
       // 查看详情
-      handleDetail(row) {
-        this.$refs.publishRef.open(row, 'view');
+      async handleDetail(row) {
+        try {
+          const detail = await getById(row.id);
+          this.$refs.publishRef.open(detail, 'view');
+        } catch (error) {
+          this.$message.error(error.message || '获取考试详情失败');
+        }
+      },
+      // 监考人签到入口(复用查看详情弹窗,聚焦签到区域)
+      async handleSignIn(row) {
+        try {
+          const detail = await getById(row.id);
+          this.$refs.publishRef.open(detail, 'signIn');
+        } catch (error) {
+          this.$message.error(error.message || '获取考试详情失败');
+        }
+      },
+      // 是否显示监考人签到入口:启用签到 且 当前时间未超过结束时间
+      isSignInAvailable(row) {
+        if (!row || !row.needSignIn) return false;
+        const endTime = row.endTime;
+        if (!endTime) return true;
+        const end = new Date(endTime);
+        if (Number.isNaN(end.getTime())) return true;
+        return Date.now() <= end.getTime();
       },
       // 删除
       handleDelete(row) {
@@ -187,8 +263,9 @@
           cancelButtonText: '取消',
           type: 'warning'
         })
-          .then(() => {
-            this.$message.success('删除成功');
+          .then(() => remove([row.id]))
+          .then((message) => {
+            this.$message.success(message || '删除成功');
             this.$refs.table.reload();
           })
           .catch(() => {});
@@ -205,7 +282,7 @@
           pageNum: page,
           size: limit
         };
-        return [];
+        return getPage(body);
       }
     }
   };

+ 478 - 61
src/views/safetyExam/examManagement/publish.vue

@@ -1,13 +1,14 @@
 <template>
-  <ele-modal
-    :visible.sync="visible"
-    :title="dialogTitle"
-    width="75vw"
-    append-to-body
-    :close-on-click-modal="false"
-    :maxable="true"
-    @close="cancel"
-  >
+  <div>
+    <ele-modal
+      :visible.sync="visible"
+      :title="dialogTitle"
+      width="75vw"
+      append-to-body
+      :close-on-click-modal="false"
+      :maxable="true"
+      @close="cancel"
+    >
     <el-form
       ref="form"
       :model="form"
@@ -19,12 +20,12 @@
       <el-row :gutter="20">
         <el-col :span="8">
           <el-form-item label="考试名称:" prop="name">
-            <el-input v-model="form.name" placeholder="请输入考试名称" />
+            <el-input v-model="form.name" placeholder="请输入考试名称" :disabled="isView" />
           </el-form-item>
         </el-col>
         <el-col :span="8">
           <el-form-item label="考试地点:" prop="location">
-            <el-input v-model="form.location" placeholder="请输入考试地点" />
+            <el-input v-model="form.location" placeholder="请输入考试地点" :disabled="isView" />
           </el-form-item>
         </el-col>
         <el-col :span="8">
@@ -33,6 +34,8 @@
               v-model="form.orgUnit"
               placeholder="请选择组织单位"
               style="width: 100%"
+              @changeGroup="handleOrgUnitChange"
+              :disabled="isView"
             />
           </el-form-item>
         </el-col>
@@ -47,6 +50,7 @@
               placeholder="请选择开始时间"
               style="width: 100%"
               value-format="yyyy-MM-dd HH:mm:ss"
+              :disabled="isView"
             />
           </el-form-item>
         </el-col>
@@ -58,6 +62,7 @@
               placeholder="请选择结束时间"
               style="width: 100%"
               value-format="yyyy-MM-dd HH:mm:ss"
+              :disabled="isView"
             />
           </el-form-item>
         </el-col>
@@ -67,6 +72,7 @@
               v-model="form.examType"
               placeholder="请选择考试类型"
               style="width: 100%"
+              :disabled="isView"
             >
               <el-option
                 v-for="item in examTypeOptions"
@@ -82,17 +88,21 @@
       <el-row :gutter="20">
         <el-col :span="8">
           <el-form-item label="总分数:" prop="totalScore">
-            <el-input v-model="form.totalScore" placeholder="请输入总分数" />
+            <el-input
+              v-model="form.totalScore"
+              placeholder="选择试卷后自动计算"
+              :disabled="true"
+            />
           </el-form-item>
         </el-col>
         <el-col :span="8">
           <el-form-item label="合格分数:" prop="passScore">
-            <el-input v-model="form.passScore" placeholder="请输入合格分数" />
+            <el-input v-model="form.passScore" placeholder="请输入合格分数" :disabled="isView" />
           </el-form-item>
         </el-col>
         <el-col :span="8">
           <el-form-item label="考试时长:" prop="duration">
-            <el-input v-model="form.duration" placeholder="请输入考试时长">
+            <el-input v-model="form.duration" placeholder="请输入考试时长" :disabled="isView">
               <template slot="append">分钟</template>
             </el-input>
           </el-form-item>
@@ -106,17 +116,19 @@
               v-model="form.examUnit"
               placeholder="搜索或选择"
               style="width: 100%"
+              @changeGroup="handleExamUnitChange"
+              :disabled="isView"
             />
           </el-form-item>
         </el-col>
         <el-col :span="8">
           <el-form-item label-width="0">
-            <el-checkbox v-model="form.needSignIn">是否需要签到</el-checkbox>
+            <el-checkbox v-model="form.needSignIn" :disabled="isView">是否需要签到</el-checkbox>
           </el-form-item>
         </el-col>
         <el-col :span="8">
           <el-form-item label-width="0">
-            <el-checkbox v-model="form.randomSort">是否随机排序</el-checkbox>
+            <el-checkbox v-model="form.randomSort" :disabled="isView">是否随机排序</el-checkbox>
           </el-form-item>
         </el-col>
       </el-row>
@@ -125,6 +137,7 @@
       <div class="section-title">试卷详情</div>
       <div class="section-toolbar">
         <el-button
+          v-if="isEdit"
           type="primary"
           size="small"
           @click="handleSelectPaper"
@@ -162,22 +175,25 @@
             <el-input v-model="scope.row.questionCount" placeholder="-" readonly />
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="80" align="center" fixed="right">
-          <template slot-scope="scope">
-            <el-button
-              type="text"
-              class="danger-text"
-              @click="handleDeletePaper(scope.$index)"
-              >删除</el-button
-            >
-          </template>
-        </el-table-column>
+        <template v-if="isEdit">
+          <el-table-column  label="操作" width="80" align="center" fixed="right">
+            <template slot-scope="scope">
+              <el-button
+                type="text"
+                class="danger-text"
+                @click="handleDeletePaper(scope.$index)"
+                >删除</el-button
+              >
+            </template>
+          </el-table-column>
+        </template>
       </el-table>
 
       <!-- 考试人员 -->
       <div class="section-title">考试人员</div>
       <div class="section-toolbar">
         <el-button
+          v-if="isEdit"
           type="primary"
           size="small"
           @click="handleSelectPerson"
@@ -201,33 +217,106 @@
             <el-input v-model="scope.row.deptName" placeholder="-" readonly />
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="80" align="center" fixed="right">
-          <template slot-scope="scope">
-            <el-button
-              type="text"
-              class="danger-text"
-              @click="handleDeletePerson(scope.$index)"
-              >删除</el-button
-            >
-          </template>
-        </el-table-column>
+        <template v-if="dialogType === 'signIn'">
+          <el-table-column align="center" width="120">
+            <template slot="header" slot-scope="scope">
+              <el-checkbox
+                :value="isAllSignedIn"
+                :indeterminate="isIndeterminateSignedIn"
+                @change="handleSignInSelectAll"
+                >签到状态</el-checkbox
+              >
+            </template>
+            <template slot-scope="scope">
+              <!-- 查看:显眼 tag -->
+              <el-tag
+                v-if="dialogType === 'view'"
+                :type="Number(scope.row.signIn) === 1 ? 'success' : 'danger'"
+                effect="dark"
+                >{{ Number(scope.row.signIn) === 1 ? '已签到' : '未签到' }}</el-tag
+              >
+              <!-- 发布 / 编辑:tag -->
+              <el-tag
+                v-else-if="dialogType === 'add' || dialogType === 'edit'"
+                :type="Number(scope.row.signIn) === 1 ? 'success' : 'info'"
+                >{{ Number(scope.row.signIn) === 1 ? '已签到' : '未签到' }}</el-tag
+              >
+              <!-- 监考人签到:可勾选 -->
+              <el-checkbox
+                v-else
+                v-model="scope.row.signIn"
+                :true-label="1"
+                :false-label="0"
+                @change="handlePersonSignInChange(scope.row, scope.$index)"
+                >签到</el-checkbox
+              >
+            </template>
+          </el-table-column>
+        </template>
+        <template v-if="isEdit">
+          <el-table-column label="操作" width="80" align="center" fixed="right">
+            <template slot-scope="scope">
+              <el-button
+                type="text"
+                class="danger-text"
+                @click="handleDeletePerson(scope.$index)"
+                >删除</el-button
+              >
+            </template>
+          </el-table-column>
+        </template>
       </el-table>
     </el-form>
 
     <template v-slot:footer>
-      <el-button v-if="dialogType !== 'view'" type="primary" @click="save()" :loading="loading">保存</el-button>
-      <el-button @click="cancel">{{ dialogType === 'view' ? '关闭' : '取消' }}</el-button>
+      <el-button
+          v-if="form.needSignIn && dialogType === 'signIn'"
+          type="primary"
+          size="small"
+          :loading="signInLoading"
+          @click="handleSaveSignIn"
+          >保存签到</el-button
+        >
+      <el-button v-if="isEdit" type="primary" @click="save()" :loading="loading">保存</el-button>
+      <el-button @click="cancel">{{ isReadOnly ? '关闭' : '取消' }}</el-button>
     </template>
   </ele-modal>
+
+  <!-- 选择试卷 -->
+  <select-paper-dialog
+    ref="selectPaperDialogRef"
+    @confirm="handleSelectPaperConfirm"
+  />
+
+  <!-- 选择考试人员 -->
+  <select-person-dialog
+    ref="selectPersonDialogRef"
+    @confirm="handleSelectPersonConfirm"
+  />
+  </div>
 </template>
 
 <script>
   import DeptSelect from '@/components/CommomSelect/dept-select.vue';
+  import SelectPaperDialog from './selectPaperDialog.vue';
+  import SelectPersonDialog from './selectPersonDialog.vue';
+  import {
+    save as saveExam,
+    signIn as batchSignIn
+  } from '@/api/examManagement/examManagement.js';
+  import { getById as getPaperById } from '@/api/examPaper/examPaper.js';
+
+  // 试卷类型映射(后端数字 → 展示文本)
+  const PAPER_TYPE_MAP = {
+    1: '定向',
+    2: '随机'
+  };
 
   const defForm = {
     name: '',
     location: '',
     orgUnit: '',
+    orgUnitName: '',
     startTime: '',
     endTime: '',
     examType: '',
@@ -235,6 +324,7 @@
     passScore: '',
     duration: '',
     examUnit: '',
+    examUnitName: '',
     needSignIn: false,
     randomSort: false,
     papers: [],
@@ -243,12 +333,15 @@
 
   export default {
     components: {
-      DeptSelect
+      DeptSelect,
+      SelectPaperDialog,
+      SelectPersonDialog
     },
     data() {
       return {
         visible: false,
         loading: false,
+        signInLoading: false,
         dialogType: 'add',
         form: JSON.parse(JSON.stringify(defForm)),
         examTypeOptions: [
@@ -276,7 +369,31 @@
             { required: true, message: '请输入总分数', trigger: 'blur' }
           ],
           passScore: [
-            { required: true, message: '请输入合格分数', trigger: 'blur' }
+            { required: true, message: '请输入合格分数', trigger: 'blur' },
+            {
+              validator: (rule, value, callback) => {
+                if (value === '' || value === null || value === undefined) {
+                  callback();
+                  return;
+                }
+                const pass = Number(value);
+                const total = Number(this.form.totalScore);
+                if (Number.isNaN(pass)) {
+                  callback(new Error('合格分数必须为数字'));
+                  return;
+                }
+                if (pass < 0) {
+                  callback(new Error('合格分数不能小于 0'));
+                  return;
+                }
+                if (total && pass > total) {
+                  callback(new Error('合格分数不能大于总分数'));
+                  return;
+                }
+                callback();
+              },
+              trigger: 'blur'
+            }
           ],
           duration: [
             { required: true, message: '请输入考试时长', trigger: 'blur' }
@@ -287,22 +404,91 @@
         }
       };
     },
+    watch: {
+      // 监听试卷列表:删除试卷时同步清空总分数
+      'form.papers': {
+        handler(newVal) {
+          if (!newVal || newVal.length === 0) {
+            this.form.totalScore = '';
+          }
+        },
+        deep: true
+      }
+    },
     computed: {
       dialogTitle() {
         const titleMap = {
           add: '发布考试',
           edit: '编辑考试',
-          view: '查看考试'
+          view: '查看考试',
+          signIn: '监考人签到'
         };
         return titleMap[this.dialogType] || '发布考试';
+      },
+      // 表单字段是否只读(view/signIn 都视为只读,避免改动基础信息)
+      isView() {
+        return this.dialogType === 'view' || this.dialogType === 'signIn';
+      },
+      // 是否可编辑(add/edit)
+      isEdit() {
+        return this.dialogType === 'add' || this.dialogType === 'edit';
+      },
+      // 是否完全只读(view/signIn)
+      isReadOnly() {
+        return this.dialogType === 'view' || this.dialogType === 'signIn';
+      },
+      // 是否全员已签到
+      isAllSignedIn() {
+        const persons = this.form.persons || [];
+        if (!persons.length) return false;
+        return persons.every((p) => Number(p.signIn) === 1);
+      },
+      // 是否部分签到(用于全选 checkbox 的 indeterminate 态)
+      isIndeterminateSignedIn() {
+        const persons = this.form.persons || [];
+        if (!persons.length) return false;
+        const signed = persons.filter((p) => Number(p.signIn) === 1).length;
+        return signed > 0 && signed < persons.length;
       }
     },
     methods: {
-      open(row, type = 'add') {
+      async open(row, type = 'add') {
         this.visible = true;
         this.dialogType = type;
         if (row && row.id) {
-          this.form = JSON.parse(JSON.stringify({ ...defForm, ...row }));
+          this.form = this.mapDetailToForm(row);
+          // 编辑/查看时,若接口返回了 paperId,异步拉取试卷详情填入 papers
+          if (row.paperId) {
+            try {
+              const paperDetail = await getPaperById(row.paperId);
+              const totalScore = this.calcPaperTotalScore(paperDetail);
+              this.form.papers = [
+                {
+                  id: paperDetail.id,
+                  paperName: paperDetail.paperName,
+                  paperType:
+                    PAPER_TYPE_MAP[paperDetail.paperType] ||
+                    String(paperDetail.paperType || ''),
+                  departmentId: paperDetail.departmentId,
+                  departmentName: paperDetail.departmentName,
+                  deptName: paperDetail.departmentName,
+                  questionCount: paperDetail.totalCount,
+                  totalScore
+                }
+              ];
+              // 详情里带 totalScore 字段时,优先用它回填表单
+              if (
+                totalScore !== undefined &&
+                totalScore !== null &&
+                totalScore !== ''
+              ) {
+                this.form.totalScore = String(totalScore);
+              }
+            } catch (e) {
+              // 拉取失败时,保留接口原始 paperId 占位
+              this.form.papers = [{ id: row.paperId }];
+            }
+          }
         } else {
           this.form = JSON.parse(JSON.stringify(defForm));
         }
@@ -310,6 +496,45 @@
           this.$refs.form && this.$refs.form.clearValidate();
         });
       },
+      // 将后端详情映射为表单字段
+      mapDetailToForm(detail) {
+        const numToStr = (v) =>
+          v === undefined || v === null || v === '' ? '' : String(v);
+        return {
+          ...JSON.parse(JSON.stringify(defForm)),
+          id: detail.id,
+          name: detail.examName || '',
+          location: detail.examPlace || '',
+          orgUnit: numToStr(detail.organizationId),
+          orgUnitName: detail.organizationName || '',
+          startTime: detail.startTime || '',
+          endTime: detail.endTime || '',
+          examType:
+            detail.examType === undefined ||
+            detail.examType === null ||
+            detail.examType === ''
+              ? ''
+              : String(detail.examType),
+          totalScore: numToStr(detail.totalScore),
+          passScore: numToStr(detail.passScore),
+          duration: numToStr(detail.durationMinutes),
+          examUnit: numToStr(detail.examUnitId),
+          examUnitName: detail.examUnitName || '',
+          needSignIn: !!detail.needSignIn,
+          randomSort: !!detail.randomSort,
+          persons: (detail.personList || []).map((p) => ({
+            userId: p.userId,
+            userName: p.userName,
+            departmentId: p.departmentId,
+            departmentName: p.departmentName,
+            signIn: p.signIn === undefined || p.signIn === null ? 0 : Number(p.signIn),
+            // 兼容 publish 表单里的字段命名
+            id: p.userId,
+            deptId: p.departmentId,
+            deptName: p.departmentName
+          }))
+        };
+      },
       cancel() {
         this.form = JSON.parse(JSON.stringify(defForm));
         this.visible = false;
@@ -317,8 +542,78 @@
       },
       // 选择试卷
       handleSelectPaper() {
-        // TODO: 选择试卷弹窗
-        this.$message.info('选择试卷弹窗待接入');
+        if (!this.isEdit) return;
+        this.$refs.selectPaperDialogRef.open();
+      },
+      // 选择试卷确认
+      handleSelectPaperConfirm(paper) {
+        if (!paper) return;
+        // 接口仅支持单一 paperId,直接覆盖现有选择
+        this.form.papers = [paper];
+        // 若选中的 paper 已带 totalScore,直接同步;否则异步拉详情补算
+        if (
+          paper.totalScore !== undefined &&
+          paper.totalScore !== null &&
+          paper.totalScore !== ''
+        ) {
+          this.form.totalScore = String(paper.totalScore);
+        } else {
+          this.fetchPaperTotalScore(paper.id);
+        }
+      },
+      // 异步拉试卷详情并计算总分
+      async fetchPaperTotalScore(paperId) {
+        if (!paperId) return;
+        try {
+          const detail = await getPaperById(paperId);
+          const totalScore = this.calcPaperTotalScore(detail);
+          if (
+            totalScore !== undefined &&
+            totalScore !== null &&
+            totalScore !== ''
+          ) {
+            this.form.totalScore = String(totalScore);
+            // 顺便回写到 papers[0],保证 watcher 与表单同步
+            if (this.form.papers && this.form.papers[0]) {
+              this.$set(this.form.papers[0], 'totalScore', totalScore);
+            }
+          }
+        } catch (e) {
+          // 拉失败不阻断流程
+        }
+      },
+      // 计算试卷总分:优先接口字段,否则按题型 × 每题分数 推算
+      calcPaperTotalScore(detail) {
+        if (!detail) return '';
+        if (
+          detail.totalScore !== undefined &&
+          detail.totalScore !== null &&
+          detail.totalScore !== ''
+        ) {
+          return Number(detail.totalScore);
+        }
+        // 随机卷:typeCount × perScore
+        if (detail.paperType === 2 || detail.paperType === '2') {
+          const tc = detail.typeCount || {};
+          const ss = Number(detail.singleScore || 0);
+          const ms = Number(detail.multipleScore || 0);
+          const js = Number(detail.judgeScore || 0);
+          const qs = Number(detail.qaScore || 0);
+          return (
+            (Number(tc.single) || 0) * ss +
+            (Number(tc.multiple) || 0) * ms +
+            (Number(tc.judge) || 0) * js +
+            (Number(tc.qa) || 0) * qs
+          );
+        }
+        // 定向卷:questions 数组里每题 score 求和
+        if (Array.isArray(detail.questions) && detail.questions.length) {
+          return detail.questions.reduce(
+            (sum, q) => sum + (Number(q.score) || 0),
+            0
+          );
+        }
+        return '';
       },
       // 删除试卷
       handleDeletePaper(index) {
@@ -326,31 +621,153 @@
       },
       // 选择人员
       handleSelectPerson() {
-        // TODO: 选择人员弹窗
-        this.$message.info('选择人员弹窗待接入');
+        if (!this.isEdit) return;
+        const excludeIds = (this.form.persons || []).map(
+          (p) => p.userId || p.id
+        );
+        this.$refs.selectPersonDialogRef.open(excludeIds);
+      },
+      // 选择人员确认
+      handleSelectPersonConfirm(persons) {
+        if (!Array.isArray(persons) || !persons.length) return;
+        // 追加到现有人员列表,并按 userId 去重
+        const existingIds = new Set(
+          (this.form.persons || [])
+            .map((p) => String(p.userId || p.id))
+            .filter((id) => id !== 'undefined' && id !== 'null' && id !== '')
+        );
+        const toAppend = persons
+          .filter((p) => !existingIds.has(String(p.userId || p.id)))
+          .map((p) => ({
+            ...p,
+            // 新选入的人员默认未签到
+            signIn: 0
+          }));
+        this.form.persons = [...(this.form.persons || []), ...toAppend];
       },
       // 删除人员
       handleDeletePerson(index) {
         this.form.persons.splice(index, 1);
       },
+      // 考试人员勾选签到/取消签到
+      handlePersonSignInChange(row) {
+        // checkbox 双向绑定已自动更新 row.signIn,这里做强制规整
+        this.$set(row, 'signIn', Number(row.signIn) === 1 ? 1 : 0);
+      },
+      // 列头全选签到 / 全部取消
+      handleSignInSelectAll(checked) {
+        const value = checked ? 1 : 0;
+        (this.form.persons || []).forEach((p) => {
+          this.$set(p, 'signIn', value);
+        });
+      },
+      // 批量保存签到状态
+      async handleSaveSignIn() {
+        if (!this.form.needSignIn) {
+          this.$message.warning('当前考试未启用签到');
+          return;
+        }
+        if (!this.form.id) {
+          this.$message.warning('考试信息不完整,无法保存签到');
+          return;
+        }
+        const personList = (this.form.persons || [])
+          .filter((p) => p.userId || p.id)
+          .map((p) => ({
+            userId: String(p.userId || p.id),
+            signIn: Number(p.signIn) === 1 ? 1 : 0
+          }));
+        if (!personList.length) {
+          this.$message.warning('暂无考试人员');
+          return;
+        }
+        this.signInLoading = true;
+        try {
+          const message = await batchSignIn({
+            examId: String(this.form.id),
+            personList
+          });
+          this.$message.success('签到状态已保存');
+        } catch (error) {
+          console.error('保存签到状态失败:', error);
+          this.$message.error('保存签到状态失败');
+        } finally {
+          this.signInLoading = false;
+        }
+      },
+      // 组织单位变更,捕获名称
+      handleOrgUnitChange(id, data) {
+        if (id && data) {
+          this.form.orgUnitName = data.name;
+        } else {
+          this.form.orgUnitName = '';
+        }
+      },
+      // 考试单位变更,捕获名称
+      handleExamUnitChange(id, data) {
+        if (id && data) {
+          this.form.examUnitName = data.name;
+        } else {
+          this.form.examUnitName = '';
+        }
+      },
+      // 构造提交参数
+      buildSavePayload() {
+        const f = this.form;
+        // 数值型字段转换
+        const toNumber = (v) => String(v);
+        // 选中的试卷(接口要求单一 paperId)
+        const firstPaper = (f.papers && f.papers[0]) || {};
+        return {
+          examName: f.name,
+          examPlace: f.location,
+          organizationId: toNumber(f.orgUnit),
+          organizationName: f.orgUnitName,
+          startTime: f.startTime,
+          endTime: f.endTime,
+          examType: f.examType,
+          totalScore: toNumber(f.totalScore),
+          passScore: toNumber(f.passScore),
+          durationMinutes: toNumber(f.duration),
+          examUnitId: toNumber(f.examUnit),
+          examUnitName: f.examUnitName,
+          needSignIn: !!f.needSignIn,
+          randomSort: !!f.randomSort,
+          paperId: toNumber(firstPaper.id),
+          personList: (f.persons || []).map((p) => ({
+            userId: toNumber(p.userId || p.id),
+            userName: p.userName,
+            departmentId: toNumber(p.deptId || p.departmentId),
+            departmentName: p.deptName || p.departmentName
+          }))
+        };
+      },
       save() {
-        if (this.dialogType === 'view') {
+        if (this.isReadOnly) {
           this.cancel();
           return;
         }
-        this.$refs.form.validate((valid) => {
-          if (valid) {
-            this.loading = true;
-            setTimeout(() => {
-              this.loading = false;
-              const messageMap = {
-                add: '发布成功',
-                edit: '编辑成功'
-              };
-              this.$message.success(messageMap[this.dialogType] || '保存成功');
-              this.cancel();
-              this.$emit('reload');
-            }, 500);
+        this.$refs.form.validate(async (valid) => {
+          if (!valid) return;
+          const payload = this.buildSavePayload();
+          // 必填校验补充
+          if (!payload.examName || !payload.startTime || !payload.endTime ||
+              payload.totalScore === null || payload.passScore === null ||
+              payload.durationMinutes === null || payload.paperId === null) {
+            this.$message.warning('请完善必填项');
+            return;
+          }
+          this.loading = true;
+          try {
+            const message = await saveExam(payload);
+            this.$message.success('发布成功');
+            this.cancel();
+            this.$emit('reload');
+          } catch (error) {
+            console.error('发布考试:', error);
+            this.$message.error('发布失败');
+          } finally {
+            this.loading = false;
           }
         });
       }

+ 193 - 0
src/views/safetyExam/examManagement/selectPaperDialog.vue

@@ -0,0 +1,193 @@
+<template>
+  <el-dialog
+    title="选择试卷"
+    :visible.sync="visible"
+    width="900px"
+    :close-on-click-modal="false"
+    append-to-body
+    @closed="handleClose"
+  >
+    <!-- 搜索区域 -->
+    <div class="search-area">
+      <el-form :inline="true" size="small" @submit.native.prevent>
+        <el-form-item label="试卷名称:">
+          <el-input
+            v-model="where.paperName"
+            placeholder="支持模糊查询"
+            clearable
+            style="width: 220px"
+            @keyup.enter.native="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="试卷类型:">
+          <el-select
+            v-model="where.paperType"
+            placeholder="请选择试卷类型"
+            clearable
+            style="width: 220px"
+          >
+            <el-option label="定向" value="1" />
+            <el-option label="随机" value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" @click="handleSearch"
+            >查询</el-button
+          >
+          <el-button icon="el-icon-refresh-left" @click="handleReset"
+            >重置</el-button
+          >
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 列表 -->
+    <ele-pro-table
+      ref="table"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      :cache-key="cacheKeyUrl"
+      height="400px"
+      highlight-current-row
+      @current-change="handleCurrentChange"
+    >
+    </ele-pro-table>
+
+    <template slot="footer">
+      <el-button @click="visible = false">取消</el-button>
+      <el-button
+        type="primary"
+        :disabled="!currentRow"
+        @click="handleConfirm"
+        >确定</el-button
+      >
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+  import { getPage } from '@/api/examPaper/examPaper.js';
+
+  const PAPER_TYPE_MAP = {
+    1: '定向',
+    2: '随机'
+  };
+
+  export default {
+    data() {
+      return {
+        visible: false,
+        cacheKeyUrl: 'ehs-examSelectPaper-list',
+        currentRow: null,
+        where: {
+          paperName: '',
+          paperType: ''
+        },
+        columns: [
+          {
+            columnKey: 'index',
+            label: '序号',
+            type: 'index',
+            width: 60,
+            align: 'center'
+          },
+          {
+            prop: 'paperName',
+            label: '试卷名称',
+            align: 'center',
+            minWidth: 200,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'departmentName',
+            label: '所属部门',
+            align: 'center',
+            minWidth: 160,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'paperType',
+            label: '试卷类型',
+            align: 'center',
+            minWidth: 100,
+            formatter: (row) => PAPER_TYPE_MAP[row.paperType] || ''
+          },
+          {
+            prop: 'totalCount',
+            label: '包含题目数量',
+            align: 'center',
+            minWidth: 120
+          }
+        ]
+      };
+    },
+    methods: {
+      open() {
+        this.visible = true;
+        this.currentRow = null;
+        this.$nextTick(() => {
+          this.$refs.table && this.$refs.table.reload({ page: 1 });
+        });
+      },
+      handleCurrentChange(row) {
+        this.currentRow = row;
+      },
+      handleSearch() {
+        const where = { ...this.where };
+        this.$refs.table.reload({ page: 1, where });
+      },
+      handleReset() {
+        this.where = {
+          paperName: '',
+          paperType: ''
+        };
+        this.handleSearch();
+      },
+      handleClose() {
+        this.currentRow = null;
+        this.where = {
+          paperName: '',
+          paperType: ''
+        };
+      },
+      async datasource({ page, limit, where }) {
+        const body = {
+          ...where,
+          pageNum: page,
+          size: limit
+        };
+        return getPage(body);
+      },
+      handleConfirm() {
+        if (!this.currentRow) {
+          this.$message.warning('请选择一份试卷');
+          return;
+        }
+        const row = this.currentRow;
+        const paper = {
+          id: row.id,
+          paperName: row.paperName,
+          paperType: PAPER_TYPE_MAP[row.paperType] || '',
+          paperTypeCode: row.paperType,
+          departmentId: row.departmentId,
+          departmentName: row.departmentName,
+          deptName: row.departmentName,
+          questionCount: row.totalCount,
+          // 试卷总分(列表接口若返回则直接透传,否则 publish.vue 拉详情时再补)
+          totalScore: row.totalScore
+        };
+        this.visible = false;
+        this.$emit('confirm', paper);
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .search-area {
+    margin-bottom: 15px;
+    padding-bottom: 15px;
+    border-bottom: 1px solid #ebeef5;
+  }
+</style>

+ 205 - 0
src/views/safetyExam/examManagement/selectPersonDialog.vue

@@ -0,0 +1,205 @@
+<template>
+  <el-dialog
+    title="选择考试人员"
+    :visible.sync="visible"
+    width="900px"
+    :close-on-click-modal="false"
+    append-to-body
+    @closed="handleClose"
+  >
+    <!-- 搜索区域 -->
+    <div class="search-area">
+      <el-form :inline="true" size="small" @submit.native.prevent>
+        <el-form-item label="姓名:">
+          <el-input
+            v-model="where.name"
+            placeholder="支持模糊查询"
+            clearable
+            style="width: 180px"
+            @keyup.enter.native="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="所属部门:">
+          <dept-select
+            v-model="where.groupId"
+            placeholder="请选择所属部门"
+            style="width: 220px"
+            @changeGroup="handleDeptChange"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" @click="handleSearch"
+            >查询</el-button
+          >
+          <el-button icon="el-icon-refresh-left" @click="handleReset"
+            >重置</el-button
+          >
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 列表 -->
+    <ele-pro-table
+      ref="table"
+      row-key="id"
+      :columns="columns"
+      :datasource="datasource"
+      :cache-key="cacheKeyUrl"
+      height="400px"
+      @selection-change="handleSelectionChange"
+    >
+    </ele-pro-table>
+
+    <template slot="footer">
+      <el-button @click="visible = false">取消</el-button>
+      <el-button
+        type="primary"
+        :disabled="!selectedRows.length"
+        @click="handleConfirm"
+        >确定</el-button
+      >
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+  import DeptSelect from '@/components/CommomSelect/dept-select.vue';
+  import { getUserPage } from '@/api/system/organization';
+
+  export default {
+    components: { DeptSelect },
+    data() {
+      return {
+        visible: false,
+        cacheKeyUrl: 'ehs-examSelectPerson-list',
+        selectedRows: [],
+        excludeIds: [],
+        where: {
+          name: '',
+          groupId: ''
+        },
+        columns: [
+          {
+            type: 'selection',
+            width: 55,
+            align: 'center',
+            selectable: (row) => !this.isExcluded(row.id)
+          },
+          {
+            columnKey: 'index',
+            label: '序号',
+            type: 'index',
+            width: 60,
+            align: 'center'
+          },
+          {
+            prop: 'name',
+            label: '姓名',
+            align: 'center',
+            minWidth: 120
+          },
+          {
+            prop: 'groupName',
+            label: '所属部门',
+            align: 'center',
+            minWidth: 180,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'jobNumber',
+            label: '工号',
+            align: 'center',
+            minWidth: 120
+          },
+          {
+            prop: 'phone',
+            label: '联系方式',
+            align: 'center',
+            minWidth: 130
+          }
+        ]
+      };
+    },
+    methods: {
+      open(excludeIds = []) {
+        this.visible = true;
+        this.selectedRows = [];
+        this.excludeIds = Array.isArray(excludeIds) ? excludeIds : [];
+        this.$nextTick(() => {
+          this.$refs.table && this.$refs.table.reload({ page: 1 });
+        });
+      },
+      handleDeptChange(id, node) {
+        this.where.groupId = node ? id : '';
+      },
+      isExcluded(id) {
+        if (id === undefined || id === null || id === '') return false;
+        const target = String(id);
+        return (this.excludeIds || []).some((item) => String(item) === target);
+      },
+      handleSelectionChange(rows) {
+        this.selectedRows = rows;
+      },
+      handleSearch() {
+        const where = { ...this.where };
+        // 清空选择时,把 groupId 置空防止传 undefined
+        if (!where.groupId) delete where.groupId;
+        this.$refs.table.reload({ page: 1, where });
+      },
+      handleReset() {
+        this.where = {
+          name: '',
+          groupId: ''
+        };
+        this.handleSearch();
+      },
+      handleClose() {
+        this.selectedRows = [];
+        this.where = {
+          name: '',
+          groupId: ''
+        };
+      },
+      async datasource({ page, limit, where }) {
+        const body = {
+          ...where,
+          pageNum: page,
+          size: limit
+        };
+        // 兜底清理空值
+        Object.keys(body).forEach((key) => {
+          if (body[key] === '' || body[key] === null || body[key] === undefined) {
+            delete body[key];
+          }
+        });
+        return getUserPage(body);
+      },
+      handleConfirm() {
+        if (!this.selectedRows.length) {
+          this.$message.warning('请至少选择一名考试人员');
+          return;
+        }
+        const persons = this.selectedRows.map((row) => ({
+          userId: row.id,
+          userName: row.name || '',
+          departmentId: row.groupId || row.deptId || '',
+          departmentName: row.groupName || row.deptName || '',
+          // 兼容 publish 表单里既有字段
+          id: row.id,
+          deptId: row.groupId || row.deptId || '',
+          deptName: row.groupName || row.deptName || ''
+        }));
+        this.visible = false;
+        this.$emit('confirm', persons);
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .search-area {
+    margin-bottom: 15px;
+    padding-bottom: 15px;
+    border-bottom: 1px solid #ebeef5;
+  }
+</style>