Selaa lähdekoodia

基础功能完成

beetle 9 kuukautta sitten
vanhempi
commit
d0555ad5a8
100 muutettua tiedostoa jossa 9672 lisäystä ja 272 poistoa
  1. 3 1
      YBEE.EQM.Admin/config/defaultSettings.ts
  2. 126 5
      YBEE.EQM.Admin/config/routes.ts
  3. 4 4
      YBEE.EQM.Admin/package.json
  4. 12 1
      YBEE.EQM.Admin/scripts/gapi/index.js
  5. 3 4
      YBEE.EQM.Admin/src/app.tsx
  6. 23 0
      YBEE.EQM.Admin/src/common/converter.ts
  7. 2 2
      YBEE.EQM.Admin/src/common/valueEnum.ts
  8. 45 0
      YBEE.EQM.Admin/src/components/AuditTimeline/index.tsx
  9. 12 2
      YBEE.EQM.Admin/src/components/FileLink/index.tsx
  10. 1 1
      YBEE.EQM.Admin/src/components/Footer/index.tsx
  11. 9 4
      YBEE.EQM.Admin/src/components/StatusIcon/index.tsx
  12. 1 0
      YBEE.EQM.Admin/src/components/ThemeSwitch/index.tsx
  13. 1 0
      YBEE.EQM.Admin/src/components/index.ts
  14. 3 4
      YBEE.EQM.Admin/src/global.less
  15. 1 2
      YBEE.EQM.Admin/src/layouts/RootLayout/index.tsx
  16. 4 4
      YBEE.EQM.Admin/src/models/useBaseData.ts
  17. 7 2
      YBEE.EQM.Admin/src/models/useDict.ts
  18. 3 0
      YBEE.EQM.Admin/src/pages/Workbench/index.tsx
  19. 13 9
      YBEE.EQM.Admin/src/pages/auth/Login/index.tsx
  20. 1 1
      YBEE.EQM.Admin/src/pages/bd/Grade/index.tsx
  21. 6 6
      YBEE.EQM.Admin/src/pages/bd/NceeCourseComb/index.tsx
  22. 177 0
      YBEE.EQM.Admin/src/pages/exam-center/ExamCourse/components/ExamCourseEditModal.tsx
  23. 206 0
      YBEE.EQM.Admin/src/pages/exam-center/ExamCourse/index.tsx
  24. 92 10
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamDataReportList.tsx
  25. 1 1
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamGradeAddModal.tsx
  26. 85 16
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamGradeList.tsx
  27. 81 0
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamGradeSettingModal.tsx
  28. 39 1
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamOrgList.tsx
  29. 99 0
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamResultList.tsx
  30. 217 0
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamSampleEditModal.tsx
  31. 372 0
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamSampleList.tsx
  32. 5 1
      YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/index.tsx
  33. 28 7
      YBEE.EQM.Admin/src/pages/exam-center/ExamResultDetail/index.tsx
  34. 128 0
      YBEE.EQM.Admin/src/pages/exam-center/absent-replace/absent-replace-audit/ExamAbsentReplaceAuditList/index.tsx
  35. 487 0
      YBEE.EQM.Admin/src/pages/exam-center/absent-replace/absent-replace-audit/ExamAbsentReplaceAuditOrg/index.tsx
  36. 5 5
      YBEE.EQM.Admin/src/pages/exam-center/absent-replace/absent-replace-audit/index.tsx
  37. 85 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperCourseList/components/AssignWriterModal.tsx
  38. 174 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperCourseList/index.tsx
  39. 139 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/components/ExamPaperQuestionMajorEditModal.tsx
  40. 135 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/components/ExamPaperQuestionMajorModal.tsx
  41. 98 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/components/ExamPaperQuestionMinorSettingModal.tsx
  42. 398 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/index.tsx
  43. 130 0
      YBEE.EQM.Admin/src/pages/exam-center/exam-paper/index.tsx
  44. 308 0
      YBEE.EQM.Admin/src/pages/exam-center/sample/ExamSampleDetail/index.tsx
  45. 22 0
      YBEE.EQM.Admin/src/pages/exam-center/special-student/special-student-audit/ExamSpecialStudentAuditList/index.tsx
  46. 102 53
      YBEE.EQM.Admin/src/pages/exam-center/special-student/special-student-audit/ExamSpecialStudentAuditOrg/index.tsx
  47. 147 0
      YBEE.EQM.Admin/src/pages/exam-center/special-student/special-student-audit/index.tsx
  48. 38 23
      YBEE.EQM.Admin/src/pages/exam-org/OrgExamPlanDetail/components/OrgExamDataPublishList.tsx
  49. 7 1
      YBEE.EQM.Admin/src/pages/exam-org/OrgExamPlanDetail/components/OrgExamDataReportList.tsx
  50. 134 0
      YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceImport/components/ExamAbsentReplaceImportEditModal.tsx
  51. 565 0
      YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceImport/index.tsx
  52. 43 0
      YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/components/ExamAbsentReplaceDetailDrawer.tsx
  53. 353 0
      YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/components/ExamAbsentReplaceEditModal.tsx
  54. 774 0
      YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/index.tsx
  55. 110 0
      YBEE.EQM.Admin/src/pages/exam-org/absent-replace/index.tsx
  56. 165 0
      YBEE.EQM.Admin/src/pages/exam-org/sample/OrgExamSampleCountList/index.tsx
  57. 198 0
      YBEE.EQM.Admin/src/pages/exam-org/sample/OrgExamSampleList/index.tsx
  58. 1 0
      YBEE.EQM.Admin/src/pages/exam-org/sample/index.tsx
  59. 240 0
      YBEE.EQM.Admin/src/pages/exam-org/school-exam-score/OrgSchoolExamScoreReport/components/OrgSchoolExamScoreCourseReport.tsx
  60. 225 0
      YBEE.EQM.Admin/src/pages/exam-org/school-exam-score/OrgSchoolExamScoreReport/index.tsx
  61. 108 0
      YBEE.EQM.Admin/src/pages/exam-org/school-exam-score/index.tsx
  62. 3 31
      YBEE.EQM.Admin/src/pages/exam-org/special-student/OrgExamSpecialStudentReport/components/ExamSpecialStudentDetailDrawer.tsx
  63. 119 31
      YBEE.EQM.Admin/src/pages/exam-org/special-student/OrgExamSpecialStudentReport/index.tsx
  64. 10 10
      YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentImport/components/ExamStudentImportEditModal.tsx
  65. 5 5
      YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentImport/index.tsx
  66. 5 5
      YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentReport/components/ExamStudentEditModal.tsx
  67. 4 4
      YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentReport/index.tsx
  68. 4 2
      YBEE.EQM.Admin/src/pages/system/Menu/components/MenuEditDrawer.tsx
  69. 2 2
      YBEE.EQM.Admin/src/pages/system/Org/index.tsx
  70. 85 0
      YBEE.EQM.Admin/src/pages/teaching-research/suggestion/SuggestionCourseList/components/SuggestionEditDrawer.tsx
  71. 177 0
      YBEE.EQM.Admin/src/pages/teaching-research/suggestion/SuggestionCourseList/index.tsx
  72. 115 0
      YBEE.EQM.Admin/src/pages/teaching-research/suggestion/index.tsx
  73. 109 0
      YBEE.EQM.Admin/src/pages/teaching-research/twcl/TwclCourseList/index.tsx
  74. 381 0
      YBEE.EQM.Admin/src/pages/teaching-research/twcl/TwclDetail/index.tsx
  75. 115 0
      YBEE.EQM.Admin/src/pages/teaching-research/twcl/index.tsx
  76. 3 0
      YBEE.EQM.Admin/src/pages/teaching-research/typing.d.ts
  77. 78 0
      YBEE.EQM.Admin/src/services/apis/ExamAbsentReplaceAuditController.ts
  78. 186 0
      YBEE.EQM.Admin/src/services/apis/ExamAbsentReplaceController.ts
  79. 69 0
      YBEE.EQM.Admin/src/services/apis/ExamCourseController.ts
  80. 23 0
      YBEE.EQM.Admin/src/services/apis/ExamDataReportController.ts
  81. 13 0
      YBEE.EQM.Admin/src/services/apis/ExamGradeController.ts
  82. 13 0
      YBEE.EQM.Admin/src/services/apis/ExamOrgController.ts
  83. 1 1
      YBEE.EQM.Admin/src/services/apis/ExamOrgDataReportController.ts
  84. 3 0
      YBEE.EQM.Admin/src/services/apis/ExamOrgResultController.ts
  85. 68 0
      YBEE.EQM.Admin/src/services/apis/ExamOrgScoreReportController.ts
  86. 168 0
      YBEE.EQM.Admin/src/services/apis/ExamPaperController.ts
  87. 65 0
      YBEE.EQM.Admin/src/services/apis/ExamPaperQuestionMajorController.ts
  88. 78 0
      YBEE.EQM.Admin/src/services/apis/ExamPaperQuestionMinorController.ts
  89. 3 0
      YBEE.EQM.Admin/src/services/apis/ExamPatriarchQuestionnaireProgressController.ts
  90. 45 0
      YBEE.EQM.Admin/src/services/apis/ExamReportingAvgRangeController.ts
  91. 308 0
      YBEE.EQM.Admin/src/services/apis/ExamSampleController.ts
  92. 60 0
      YBEE.EQM.Admin/src/services/apis/ExamSampleStudentController.ts
  93. 39 0
      YBEE.EQM.Admin/src/services/apis/ExamScoreImportController.ts
  94. 4 1
      YBEE.EQM.Admin/src/services/apis/ExamSpecialStudentController.ts
  95. 20 1
      YBEE.EQM.Admin/src/services/apis/ExamStudentController.ts
  96. 4 1
      YBEE.EQM.Admin/src/services/apis/FileController.ts
  97. 8 8
      YBEE.EQM.Admin/src/services/apis/NceeCourseCombController.ts
  98. 107 0
      YBEE.EQM.Admin/src/services/apis/NceeExportController.ts
  99. 112 0
      YBEE.EQM.Admin/src/services/apis/NceePlanController.ts
  100. 59 0
      YBEE.EQM.Admin/src/services/apis/NceeScoreController.ts

+ 3 - 1
YBEE.EQM.Admin/config/defaultSettings.ts

@@ -10,8 +10,10 @@ const Settings: ProLayoutProps & {
     navTheme: 'light',
     // 拂晓蓝
     colorPrimary: '#2f54eb',
+    // colorPrimary: '#722ed1',
     layout: 'mix',
-    contentWidth: 'Fluid',
+    // contentWidth: 'Fluid',
+    contentWidth: 'Fixed',
     fixedHeader: false,
     fixSiderbar: true,
     colorWeak: false,

+ 126 - 5
YBEE.EQM.Admin/config/routes.ts

@@ -75,22 +75,96 @@ export default [
                 path: '/exam-c/plan/result/:examPlanId/:publishId',
                 component: './exam-center/ExamResultDetail',
             },
+            /** 监测抽样详细 */
+            {
+                path: '/exam-c/plan/sample/detail/:id',
+                component: './exam-center/sample/ExamSampleDetail',
+            },
+            /** 监测科目管理*/
+            {
+                path: '/exam-c/plan/course/:examPlanId',
+                component: './exam-center/ExamCourse',
+            },
 
             // ------------------------------------------------
             /** 监测特殊学生审核计划列表 */
             {
                 path: '/exam-c/sp-stu-audit',
-                component: './exam-center/student/special-student-audit',
+                component: './exam-center/special-student/special-student-audit',
             },
             /** 监测特殊学生审核学校列表 */
             {
                 path: '/exam-c/sp-stu-audit/list/:examPlanId',
-                component: './exam-center/student/special-student-audit/ExamSpecialStudentAuditList',
+                component: './exam-center/special-student/special-student-audit/ExamSpecialStudentAuditList',
             },
             /** 监测特殊学生审核学校主页 */
             {
                 path: '/exam-c/sp-stu-audit/org/:sysOrgId/:examPlanId',
-                component: './exam-center/student/special-student-audit/ExamSpecialStudentAuditOrg',
+                component: './exam-center/special-student/special-student-audit/ExamSpecialStudentAuditOrg',
+            },
+
+            // ------------------------------------------------
+            /** 监测缺测替补学生审核计划列表 */
+            {
+                path: '/exam-c/absent-audit',
+                component: './exam-center/absent-replace/absent-replace-audit',
+            },
+            /** 监测缺测替补学生审核学校列表 */
+            {
+                path: '/exam-c/absent-audit/list/:examPlanId',
+                component: './exam-center/absent-replace/absent-replace-audit/ExamAbsentReplaceAuditList',
+            },
+            /** 监测缺测替补学生审核学校主页 */
+            {
+                path: '/exam-c/absent-audit/org/:sysOrgId/:examPlanId',
+                component: './exam-center/absent-replace/absent-replace-audit/ExamAbsentReplaceAuditOrg',
+            },
+
+            // ------------------------------------------------
+            /** 试卷管理 */
+            {
+                path: '/exam-c/ep',
+                component: './exam-center/exam-paper',
+            },
+            {
+                path: '/exam-c/ep/course/:examPlanId',
+                component: './exam-center/exam-paper/ExamPaperCourseList',
+            },
+            {
+                path: '/exam-c/ep/course/detail/:examPaperId',
+                component: './exam-center/exam-paper/ExamPaperDetail',
+            },
+
+
+
+            // ------------------------------------------------
+            // 教研员
+            // ------------------------------------------------
+
+            // ------------------------------------------------
+            /** 双向细目表编制 */
+            {
+                path: '/exam-c/tr-twcl',
+                component: './teaching-research/twcl',
+            },
+            {
+                path: '/exam-c/tr-twcl/course/:examPlanId',
+                component: './teaching-research/twcl/TwclCourseList',
+            },
+            {
+                path: '/exam-c/tr-twcl/course/detail/:examPaperId',
+                component: './teaching-research/twcl/TwclDetail',
+            },
+
+            // ------------------------------------------------
+            /** 问题建议编写 */
+            {
+                path: '/exam-c/tr-qs',
+                component: './teaching-research/suggestion',
+            },
+            {
+                path: '/exam-c/tr-qs/course/:examPlanId',
+                component: './teaching-research/suggestion/SuggestionCourseList',
             },
         ],
     },
@@ -120,6 +194,19 @@ export default [
                 component: './exam-org/OrgExamPlanDetail',
             },
 
+
+            /** 监测抽样统计 */
+            {
+                path: '/exam-s/plan/sample-count/:examDataPublishId',
+                component: './exam-org/sample/OrgExamSampleCountList',
+            },
+            /** 监测抽样名单 */
+            {
+                path: '/exam-s/plan/sample-list/:examDataPublishId',
+                component: './exam-org/sample/OrgExamSampleList',
+            },
+
+
             // ------------------------------------------------
             /** 监测学生信息上报列表 */
             {
@@ -137,6 +224,7 @@ export default [
                 component: './exam-org/student/OrgExamStudentImport',
             },
 
+
             // ------------------------------------------------
             /** 监测特殊学生上报列表 */
             {
@@ -154,6 +242,7 @@ export default [
                 component: './exam-org/special-student/OrgExamSpecialStudentImport',
             },
 
+
             // ------------------------------------------------
             /** 监测教师信息上报列表 */
             {
@@ -171,6 +260,7 @@ export default [
                 component: './exam-org/teacher/OrgExamTeacherImport',
             },
 
+
             // ------------------------------------------------
             /** 监测教师任教科目上报列表 */
             {
@@ -188,6 +278,37 @@ export default [
                 component: './exam-org/teacher-course/OrgExamTeacherCourseImport',
             },
 
+
+            // ------------------------------------------------
+            /** 缺测替补学生上报列表 */
+            {
+                path: '/exam-s/absent',
+                component: './exam-org/absent-replace',
+            },
+            /**缺测替补学生上报处理 */
+            {
+                path: '/exam-s/absent/report/:examPlanId',
+                component: './exam-org/absent-replace/OrgExamAbsentReplaceReport',
+            },
+            /** 缺测替补学生批量导入 */
+            {
+                path: '/exam-s/absent/import/:examPlanId',
+                component: './exam-org/absent-replace/OrgExamAbsentReplaceImport',
+            },
+
+
+            // ------------------------------------------------
+            /** 校考成绩上报列表 */
+            {
+                path: '/exam-s/school-exam-score',
+                component: './exam-org/school-exam-score',
+            },
+            {
+                path: '/exam-s/school-exam-score/report/:examPlanId',
+                component: './exam-org/school-exam-score/OrgSchoolExamScoreReport',
+            },
+
+
             // ------------------------------------------------
             /** 家长问卷进度列表 */
             {
@@ -227,8 +348,8 @@ export default [
             },
             /** 高中选科组合 */
             {
-                path: '/bd/course-comb',
-                component: './bd/CourseComb',
+                path: '/bd/ncee-course-comb',
+                component: './bd/NceeCourseComb',
             },
             /** 学期 */
             {

+ 4 - 4
YBEE.EQM.Admin/package.json

@@ -1,6 +1,6 @@
 {
     "name": "ybee.eqm",
-    "version": "1.0.0",
+    "version": "1.0.1",
     "private": true,
     "description": "",
     "scripts": {
@@ -47,13 +47,13 @@
         "not ie <= 10"
     ],
     "dependencies": {
-        "@ant-design/icons": "^5.1.4",
-        "@ant-design/pro-components": "^2.6.35",
+        "@ant-design/icons": "^5.3.0",
+        "@ant-design/pro-components": "^2.6.47",
         "@ant-design/use-emotion-css": "1.0.4",
         "@reduxjs/toolkit": "^1.9.5",
         "@umijs/route-utils": "^4.0.1",
         "ahooks": "^3.7.8",
-        "antd": "^5.11.2",
+        "antd": "^5.14.0",
         "bignumber.js": "^9.1.1",
         "classnames": "^2.3.2",
         "content-disposition": "^0.5.4",

+ 12 - 1
YBEE.EQM.Admin/scripts/gapi/index.js

@@ -10,6 +10,16 @@ const ENUM_FILE = `${ROOT_PATH}/enums.ts`;
 const TYPING_D = `${ROOT_PATH}/typing.d.ts`;
 const API_PATH = `${ROOT_PATH}/apis`;
 
+// JS关键字
+const JS_KEY_WORDS = [
+    'break', 'else', 'new', 'var', 'case', 'finally', 'return', 'void', 'catch', 'for', 'switch',
+    'while', 'continue', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in',
+    'try', 'do', 'instranceof', 'typeof', 'abstract', 'enum', 'int', 'short', 'boolean', 'export',
+    'interface', 'static', 'byte', 'extends', 'long', 'super', 'char', 'final', 'native', 'synchronized',
+    'class', 'float', 'package', 'throws', 'const', 'goto', 'private ', 'transient', 'debugger',
+    'implements', 'protected ', 'volatile', 'double', 'import', 'public',
+];
+
 /** 数值类型映射 */
 const IntegerMapping = {
     int16: 'number',
@@ -66,7 +76,7 @@ const getActionName = (path) => {
     }
     const n = pp[pp.length - 1];
     let an = kebabToCamelCase(n);
-    if (['import', 'delete'].includes(an.toLocaleLowerCase())) {
+    if (JS_KEY_WORDS.includes(an.toLocaleLowerCase())) {
         an = `${an}Action`;
     }
     return an;
@@ -498,6 +508,7 @@ const generateController = (paths, tagDict, enums) => {
                     actionBody = `${actionBody}...(options||{}), responseType: 'blob', getResponse: true} as RequestOptions;\n`;
                     actionBody = `${actionBody}const res = await request(url, config);\n`;
                     actionBody = `${actionBody}const hcd = res.request.getResponseHeader('Content-Disposition');\n`;
+                    actionBody = `${actionBody}if(!hcd){ return null; }\n`;
                     actionBody = `${actionBody}let fileName = '';\n`;
                     actionBody = `${actionBody}const cd = contentDisposition.parse(hcd)\n`;
                     actionBody = `${actionBody}if (cd?.parameters?.filename) { fileName = cd?.parameters?.filename; }\n`;

+ 3 - 4
YBEE.EQM.Admin/src/app.tsx

@@ -1,5 +1,4 @@
 import SysAuthController from '@/services/apis/SysAuthController';
-import { QuestionCircleOutlined } from '@ant-design/icons';
 import type { Settings as LayoutSettings, MenuDataItem } from '@ant-design/pro-components';
 import type { RunTimeLayoutConfig } from '@umijs/max';
 import { history } from '@umijs/max';
@@ -69,9 +68,9 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
 
     return {
         actionsRender: () => [
-            <QuestionCircleOutlined key="help" onClick={() => {
-                window.open('/handbook/学校操作手册.pdf');
-            }} />,
+            // <QuestionCircleOutlined key="help" onClick={() => {
+            //     window.open('/handbook/学校操作手册.pdf');
+            // }} />,
             <ThemeSwitch key="theme" />,
             <div key="org" style={{ lineHeight: 1, fontSize: token.fontSize }}>
                 {currentUser?.sysOrg?.fullName}

+ 23 - 0
YBEE.EQM.Admin/src/common/converter.ts

@@ -222,3 +222,26 @@ export function toIdKeyDict(list: any[]) {
     }
     return dict;
 }
+
+/**
+ * 数字索引转EXCEL列名
+ * @param index 索引号
+ * @returns 
+ */
+export function toExcelColumnName(index: number) {
+    let num = index;
+
+    const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+    let str = "";
+
+    while (num >= 0) {
+        const n = letters[num % 26];
+        str = `${n}${str}`;
+        if (num === 0) {
+            break;
+        }
+        num = Math.floor(num / 26) - 1;
+    }
+
+    return str;
+}

+ 2 - 2
YBEE.EQM.Admin/src/common/valueEnum.ts

@@ -1,4 +1,4 @@
 export const TrueOrFalseValueEnum = {
-    true: { text: '是' },
-    false: { text: '否' },
+    true: { text: '是', status: 'success' },
+    false: { text: '否', status: 'error' },
 };

+ 45 - 0
YBEE.EQM.Admin/src/components/AuditTimeline/index.tsx

@@ -0,0 +1,45 @@
+import { AuditStatus } from "@/services/enums";
+import { useModel } from "@umijs/max";
+import { Space, Tag, Timeline, TimelineItemProps, Typography, theme } from "antd";
+
+/**
+ * 审核记录时间轴
+ */
+const AuditTimeline: React.FC<{
+    auditList?: API.AuditItem[];
+}> = ({ auditList }) => {
+    const { getKeyDict } = useModel('useDict');
+    const auditStatusDict = getKeyDict('audit_status');
+    const auditActionTypeDict = getKeyDict('audit_action_type');
+    const { token } = theme.useToken();
+
+    return (
+        <Timeline
+            mode="left"
+            items={auditList?.map((t) => {
+                const status = t.status ? auditStatusDict[t.status] : undefined;
+                const type = t.actionType ? auditActionTypeDict[t.actionType] : undefined;
+
+                let item: TimelineItemProps = {
+                    children: (
+                        <Space direction="vertical">
+                            <Space size="large">
+                                <span>{type?.name}</span>
+                                <Typography.Text type="secondary">{t.createTime}</Typography.Text>
+                                <Tag color={status?.antStatus}>{status?.name}</Tag>
+                            </Space>
+                            <span>{t.remark}</span>
+                        </Space>
+                    ),
+                };
+                if (t.status === AuditStatus.REJECTED) {
+                    item.color = token.colorError;
+                }
+
+                return item;
+            }) ?? []}
+        />
+    );
+}
+
+export default AuditTimeline;

+ 12 - 2
YBEE.EQM.Admin/src/components/FileLink/index.tsx

@@ -1,6 +1,6 @@
 import { DeleteOutlined } from "@ant-design/icons";
 import { useEmotionCss } from "@ant-design/use-emotion-css";
-import { Button, Tooltip } from "antd";
+import { Button, Image, Tooltip } from "antd";
 import { useState } from "react";
 import FileIcon from "../FileIcon";
 
@@ -47,6 +47,7 @@ const FileLink: React.FC<{
                 color: token.colorTextSecondary,
                 textAlign: 'right',
                 whiteSpace: 'nowrap',
+                fontSize: token.fontSizeSM,
             },
             '&:hover': {
                 backgroundColor: token.colorBgTextHover,
@@ -90,6 +91,7 @@ const FileLink: React.FC<{
                 setDownloading(false);
             }
         } else if (fileUrl) {
+            console.log(fileUrl)
             window.open(fileUrl);
         }
     }
@@ -98,7 +100,15 @@ const FileLink: React.FC<{
         <div className={`${className} ${card ? 'card' : ''}`}>
             {!fileThumbUrl && <FileIcon type={fileExtName} />}
             {fileThumbUrl &&
-                <img className="thumb" src={fileThumbUrl} onClick={handleClick} />
+                // <img className="thumb" src={fileThumbUrl} onClick={handleClick} />
+                <Image
+                    className="thumb"
+                    src={fileThumbUrl}
+                    preview={{
+                        src: fileUrl,
+                    }}
+                // onClick={handleClick}
+                />
             }
             <Button type="link" size="small" disabled={downloading} onClick={handleClick} style={{ maxWidth: '100%', overflow: 'hidden' }}>
                 <Tooltip title={fileName}>

+ 1 - 1
YBEE.EQM.Admin/src/components/Footer/index.tsx

@@ -30,7 +30,7 @@ const Footer: React.FC = () => {
         <div className={className}>
             {/* <p>{AppConfig.companyFullName}</p> */}
             {/* <p>© 2023-{new Date().getFullYear()} <span>|</span> v{packageInfo?.version ?? ''}</p> */}
-            <p>重庆国家应用数学中心大数据与最优化研究所</p>
+            {/* <p>重庆国家应用数学中心大数据与最优化研究所</p> */}
             <p>© 2023-{new Date().getFullYear()} <span>|</span> v{packageInfo?.version ?? ''}</p>
         </div>
     );

+ 9 - 4
YBEE.EQM.Admin/src/components/StatusIcon/index.tsx

@@ -1,22 +1,24 @@
-import { CheckCircleFilled, CheckCircleOutlined, CloseCircleFilled, CloseCircleOutlined, InfoCircleFilled, InfoCircleOutlined, WarningFilled, WarningOutlined } from "@ant-design/icons";
+import { CheckCircleFilled, CheckCircleOutlined, CloseCircleFilled, CloseCircleOutlined, InfoCircleFilled, InfoCircleOutlined, StopFilled, StopOutlined, WarningFilled, WarningOutlined } from "@ant-design/icons";
 import { useEmotionCss } from "@ant-design/use-emotion-css";
 
 const StatusIconDicts = {
     'success': <CheckCircleOutlined />,
     'warning': <WarningOutlined />,
     'error': <CloseCircleOutlined />,
-    'info': <InfoCircleOutlined />
+    'info': <InfoCircleOutlined />,
+    'forbid': <StopOutlined />,
 };
 
 const StatusIconFilledDicts = {
     'success': <CheckCircleFilled />,
     'warning': <WarningFilled />,
     'error': <CloseCircleFilled />,
-    'info': <InfoCircleFilled />
+    'info': <InfoCircleFilled />,
+    'forbid': <StopFilled />,
 };
 
 const StatusIcon: React.FC<React.HtmlHTMLAttributes<HTMLSpanElement> & {
-    status: 'success' | 'warning' | 'error' | 'info';
+    status: 'success' | 'warning' | 'error' | 'info' | 'forbid';
     filled?: boolean;
 }> = (props) => {
     const { className, status, filled, ...restProps } = props;
@@ -38,6 +40,9 @@ const StatusIcon: React.FC<React.HtmlHTMLAttributes<HTMLSpanElement> & {
             '&.info': {
                 color: token.colorInfo,
             },
+            '&.forbid': {
+                color: token.colorError,
+            },
         };
     });
     return (

+ 1 - 0
YBEE.EQM.Admin/src/components/ThemeSwitch/index.tsx

@@ -62,6 +62,7 @@ const ThemeSwitch = () => {
                         label: '正常模式(间距及文字较大)',
                         icon: <ExpandOutlined />,
                     },
+
                 ],
                 selectedKeys,
                 onClick: (e) => {

+ 1 - 0
YBEE.EQM.Admin/src/components/index.ts

@@ -1,4 +1,5 @@
 export { default as AuditModal } from './AuditModal';
+export { default as AuditTimeline } from './AuditTimeline';
 export { default as CardStepTitle } from './CardStepTitle';
 export { default as FileIcon } from './FileIcon';
 export { default as FileLink } from './FileLink';

+ 3 - 4
YBEE.EQM.Admin/src/global.less

@@ -117,11 +117,10 @@ ol {
 
 .yb-row-error {
   & .ant-table-cell {
-    // background-color: #f5222d22;
     color: #f00;
   }
+}
 
-  // & .ant-table-cell-row-hover {
-  //   background-color: #f5222d22 !important;
-  // }
+.yb-row-editable {
+  background-color: #00000011;
 }

+ 1 - 2
YBEE.EQM.Admin/src/layouts/RootLayout/index.tsx

@@ -1,6 +1,5 @@
 import store, { RootState } from '@/store';
-import { App, ConfigProvider, theme } from 'antd';
-import { MappingAlgorithm } from 'antd/es/config-provider/context';
+import { App, ConfigProvider, MappingAlgorithm, theme } from 'antd';
 import React, { useEffect, useState } from 'react';
 import { Provider, useSelector } from 'react-redux';
 

+ 4 - 4
YBEE.EQM.Admin/src/models/useBaseData.ts

@@ -1,7 +1,7 @@
-import BaseCourseCombController from '@/services/apis/BaseCourseCombController';
 import BaseCourseController from '@/services/apis/BaseCourseController';
 import BaseGradeController from '@/services/apis/BaseGradeController';
 import BaseSemesterController from '@/services/apis/BaseSemesterController';
+import NceeCourseCombController from '@/services/apis/NceeCourseCombController';
 import SysOrgController from '@/services/apis/SysOrgController';
 import { useCallback, useEffect, useState } from 'react';
 
@@ -12,7 +12,7 @@ export type BaseDataModelType = {
     /** 学科 */
     courses?: API.CourseOutput[];
     /** 选科组合 */
-    courseCombs?: API.CourseCombOutput[];
+    nceeCourseCombs?: API.NceeCourseCombOutput[];
     /** 年级 */
     grades?: API.GradeOutput[];
     /** 机构 */
@@ -28,13 +28,13 @@ export default () => {
     const fetchBaseData = useCallback(async () => {
         const semesters = (await BaseSemesterController.getAllList()) ?? [];
         const courses = (await BaseCourseController.getAllList()) ?? [];
-        const courseCombs = (await BaseCourseCombController.getAllList()) ?? [];
+        const nceeCourseCombs = (await NceeCourseCombController.getAllList()) ?? [];
         const grades = (await BaseGradeController.getAllList()) ?? [];
         const sysOrgs = (await SysOrgController.getAllList()) ?? [];
         setBaseData({
             semesters,
             courses,
-            courseCombs,
+            nceeCourseCombs,
             grades,
             sysOrgs,
         });

+ 7 - 2
YBEE.EQM.Admin/src/models/useDict.ts

@@ -26,6 +26,11 @@ type DictTypeCode =
     | 'certificate_type'
     | 'data_import_mode'
     | 'audit_status'
+    | 'audit_action_type'
+    | 'exam_sample_status'
+    | 'exam_score_range_type'
+    | 'question_catalog'
+    | 'cognitive_ability'
     ;
 
 /**
@@ -41,7 +46,7 @@ export default () => {
         }
     });
 
-    const getDict = (code: DictTypeCode) => list?.filter((t) => t.sysDictType?.code === code) ?? [];
+    const getDict = (code: DictTypeCode) => list?.filter((t) => t.sysDictType?.code === code)?.sort((a, b) => a.sort - b.sort) ?? [];
 
     const getDictValueEnum = (code: DictTypeCode, includeStatus: boolean = false, excludeValues?: number[]) => {
         let valueEnum: Record<number, { text: string, status?: string, value?: number }> = {};
@@ -60,7 +65,7 @@ export default () => {
     };
 
     const getDictOptions = (code: DictTypeCode) => {
-        return getDict(code)?.map((t) => ({
+        return getDict(code)?.sort((a, b) => a.sort - b.sort)?.map((t) => ({
             key: t.id,
             value: t.value,
             label: t.name,

+ 3 - 0
YBEE.EQM.Admin/src/pages/Workbench/index.tsx

@@ -1,3 +1,4 @@
+import { toExcelColumnName } from '@/common/converter';
 import { PageContainer, ProCard } from '@ant-design/pro-components';
 import { useEmotionCss } from '@ant-design/use-emotion-css';
 import { useModel } from '@umijs/max';
@@ -40,6 +41,8 @@ const Workbench: React.FC = () => {
         };
     });
 
+    console.log(toExcelColumnName(0));
+
     return (
         <PageContainer>
             <ProCard>

+ 13 - 9
YBEE.EQM.Admin/src/pages/auth/Login/index.tsx

@@ -4,17 +4,14 @@ import { Footer, ThemeSwitch } from '@/components';
 import { isDarkTheme } from '@/layouts/RootLayout';
 import { loginByAccount } from '@/services/account';
 import SysAuthController from '@/services/apis/SysAuthController';
-import {
-    LoadingOutlined,
-    LockOutlined,
-    UserOutlined
-} from '@ant-design/icons';
+import { LoadingOutlined, LockOutlined, UserOutlined } from '@ant-design/icons';
 import type { ProFormInstance } from '@ant-design/pro-components';
 import { ProForm, ProFormCheckbox, ProFormText } from '@ant-design/pro-components';
 import { useEmotionCss } from '@ant-design/use-emotion-css';
 import { useModel } from '@umijs/max';
 import { Alert, App, ConfigProvider, Tooltip, theme } from 'antd';
 import { useCallback, useEffect, useRef, useState } from 'react';
+import defaultSettings from '../../../../config/defaultSettings';
 import packageInfo from '../../../../package.json';
 
 const version = packageInfo.version;
@@ -316,7 +313,12 @@ const Login: React.FC = () => {
     }
 
     return (
-        <ConfigProvider theme={{ algorithm }} >
+        <ConfigProvider theme={{
+            algorithm,
+            token: {
+                colorPrimary: defaultSettings.colorPrimary,
+            }
+        }} >
             <div className={styles.container}>
                 <div className={styles.theme}>
                     <ThemeSwitch />
@@ -354,14 +356,14 @@ const Login: React.FC = () => {
                             message={`当前用户属于【${orgName}】,若有误请勿使用,首登录请输入新密码更换登录密码!`}
                             type="warning"
                             showIcon
-                            style={{ marginBottom: 24, marginTop: -8 }}
+                            style={{ marginBottom: 24 }}
                         />}
                         {status === 'error' && (
                             <Alert
                                 message={errorMessage ?? '错误的用户名和密码!'}
                                 type="error"
                                 showIcon
-                                style={{ marginBottom: 24, marginTop: -8 }}
+                                style={{ marginBottom: 24, marginTop: -24 }}
                             />
                         )}
                         <ProForm<LoginFormValueType>
@@ -372,7 +374,9 @@ const Login: React.FC = () => {
                             labelAlign="left"
                             labelCol={{ span: isActivated ? 6 : 7 }}
                             submitter={{
-                                searchConfig: { submitText: '登录' },
+                                searchConfig: {
+                                    submitText: '登录',
+                                },
                                 render: (_, dom) => dom.pop(),
                                 submitButtonProps: {
                                     loading: submitting,

+ 1 - 1
YBEE.EQM.Admin/src/pages/bd/Grade/index.tsx

@@ -26,7 +26,7 @@ const GradeList: React.FC = () => {
         },
         {
             title: '年级号',
-            dataIndex: 'sequence',
+            dataIndex: 'gradeNumber',
             width: 80,
             align: 'center',
         },

+ 6 - 6
YBEE.EQM.Admin/src/pages/bd/CourseComb/index.tsx → YBEE.EQM.Admin/src/pages/bd/NceeCourseComb/index.tsx

@@ -1,13 +1,13 @@
 import { SuperTable } from '@/components';
-import BaseCourseCombController from '@/services/apis/BaseCourseCombController';
+import NceeCourseCombController from '@/services/apis/NceeCourseCombController';
 import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
 import { useRef } from 'react';
 
 /** 高中选科组合 */
-const CourseCombList: React.FC = () => {
+const NceeCourseCombList: React.FC = () => {
     const actionRef = useRef<ActionType>();
 
-    const columns: ProColumns<API.CourseCombOutput>[] = [
+    const columns: ProColumns<API.NceeCourseCombOutput>[] = [
         {
             title: 'ID',
             dataIndex: 'id',
@@ -49,14 +49,14 @@ const CourseCombList: React.FC = () => {
 
     return (
         <PageContainer title={false} childrenContentStyle={{ paddingTop: 0 }}>
-            <SuperTable<API.CourseCombOutput>
+            <SuperTable<API.NceeCourseCombOutput>
                 headerTitle="高中选科组合列表"
                 actionRef={actionRef}
                 columns={columns}
                 pagination={false}
                 search={false}
                 request={async () => {
-                    const data = await BaseCourseCombController.getAllList();
+                    const data = await NceeCourseCombController.getAllList();
                     return { data, success: true };
                 }}
             />
@@ -64,4 +64,4 @@ const CourseCombList: React.FC = () => {
     );
 };
 
-export default CourseCombList;
+export default NceeCourseCombList;

+ 177 - 0
YBEE.EQM.Admin/src/pages/exam-center/ExamCourse/components/ExamCourseEditModal.tsx

@@ -0,0 +1,177 @@
+import { toSelectOptions } from "@/common/converter";
+import { MovableModalForm } from "@/components";
+import ExamCourseController from "@/services/apis/ExamCourseController";
+import { ExamScoreRangeType } from "@/services/enums";
+import { ProFormDependency, ProFormDigit, ProFormSelect, ProFormTextArea } from "@ant-design/pro-components";
+import { useModel } from "@umijs/max";
+import { App, Col, FormInstance, Row, theme } from "antd";
+import { useRef, useState } from "react";
+
+/** 监测科目编辑对话框 */
+const ExamCourseEditModal: React.FC<{
+    /** 监测年级信息 */
+    data: Partial<API.ExamCourseOutput>;
+    /** 监测年级 */
+    examGrades: API.ExamGradeOutput[];
+    /** 监测科目列表 */
+    examCourses: API.ExamCourseOutput[];
+    /** 保存成功回调 */
+    onOk: () => void;
+    /** 关闭回调 */
+    onClose: () => void;
+}> = ({ examCourses, examGrades, data, onOk, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(onClose, 300); };
+
+    const formRef = useRef<FormInstance>();
+    const { message } = App.useApp();
+    const { baseData } = useModel('useBaseData');
+    const { getDictValueEnum } = useModel('useDict');
+
+    const { token } = theme.useToken();
+
+    return (
+        <MovableModalForm<{
+            examGradeId: number;
+            courseId: number;
+            totalScore: number;
+            examScoreRangeType: ExamScoreRangeType;
+            scoreReportConfig: API.ExamCourseScoreReportConfig;
+        }>
+            title="监测年级科目设置"
+            width={880}
+            open={open}
+            modalProps={{
+                // centered: true,
+                maskClosable: false,
+                onCancel: handleClose,
+            }}
+            initialValues={{
+                examGradeId: data.examGradeId,
+                courseId: data.courseId,
+                totalScore: data.totalScore,
+                examScoreRangeType: data.examScoreRangeType !== undefined ? `${data.examScoreRangeType}` : undefined,
+                scoreReportConfig: {
+                    headerRowIndex: 0,
+                    minorQuestionColumnIndex: 7,
+                    ...(data.scoreReportConfig || {})
+                },
+            }}
+            formRef={formRef}
+            onFinish={async (values) => {
+                const { examGradeId, examScoreRangeType, ...restValues } = values;
+                const grade = examGrades.find(t => t.id === examGradeId);
+                if (!grade) {
+                    return;
+                }
+
+                try {
+                    const p: API.AddExamCourseInput = {
+                        examPlanId: data.examPlanId ?? 0,
+                        examGradeId,
+                        gradeId: grade.gradeId,
+                        examScoreRangeType: JSON.parse(`${examScoreRangeType}`),
+                        ...restValues,
+                    };
+                    if (data.id === 0) {
+                        await ExamCourseController.add(p);
+                    } else {
+                        let updateParams = {
+                            ...p,
+                            id: data.id
+                        } as API.UpdateExamCourseInput;
+                        await ExamCourseController.update(updateParams);
+                    }
+                    message.success('操作成功!');
+                    onOk();
+                    handleClose();
+                }
+                catch { }
+            }}
+        >
+            <Row gutter={[token.paddingLG, 0]}>
+                <Col span={12}>
+                    <ProFormSelect
+                        label="监测年级"
+                        name="examGradeId"
+                        options={examGrades?.sort((a, b) => a.grade.gradeNumber - b.grade.gradeNumber)?.map(t => ({ label: `${t.grade.fullName}(${t.gradeBeginName})`, value: t.id }))}
+                        required
+                        rules={[{ required: true }]}
+                        disabled={data.id !== 0}
+                        fieldProps={{
+                            onChange: () => {
+                                formRef.current?.setFieldsValue({ courseId: undefined });
+                            },
+                        }}
+                    />
+                </Col>
+                <Col span={12}>
+                    <ProFormDependency name={['examGradeId']}>
+                        {({ examGradeId }) => {
+                            const egcs = examCourses?.filter(t => t.examGradeId === examGradeId && t.courseId !== data?.courseId)?.map(t => t.courseId) ?? [];
+                            const bcs = baseData?.courses?.filter(t => !egcs.includes(t.id)) ?? [];
+                            return (
+                                <ProFormSelect
+                                    label="监测科目"
+                                    name="courseId"
+                                    options={toSelectOptions(bcs)}
+                                    disabled={data.id !== 0}
+                                    required
+                                    rules={[{ required: true }]}
+                                />
+                            );
+                        }}
+                    </ProFormDependency>
+                </Col>
+                <Col span={12}>
+                    <ProFormDigit
+                        label="模板标题行号(0表示第1行)"
+                        name={['scoreReportConfig', 'headerRowIndex']}
+                        required
+                        rules={[{ required: true }]}
+                    />
+                </Col>
+                <Col span={12}>
+                    <ProFormDigit
+                        label="小题分开始列(0表示第1列)"
+                        name={['scoreReportConfig', 'minorQuestionColumnIndex']}
+                        required
+                        rules={[{ required: true }]}
+                    />
+                </Col>
+                <Col span={12}>
+                    <ProFormDigit
+                        label="总分"
+                        name="totalScore"
+                        required
+                        rules={[{ required: true }]}
+                    />
+                </Col>
+                <Col span={12}>
+                    <ProFormSelect
+                        label="分数段类型"
+                        name="examScoreRangeType"
+                        required
+                        rules={[{ required: true }]}
+                        valueEnum={getDictValueEnum('exam_score_range_type')}
+                    />
+                </Col>
+            </Row>
+            <ProFormSelect
+                label="模板标题列名(从A列开始,以逗号分隔,显示的顺序即为表格中的顺序,可从EXCEL直接复制粘贴到此,可用逗号连续输入)"
+                name={['scoreReportConfig', 'headerColumnNames']}
+                mode="tags"
+                fieldProps={{
+                    tokenSeparators: [',', ',', '\t'],
+                }}
+                allowClear
+            />
+            <ProFormTextArea
+                label="模板说明"
+                name={['scoreReportConfig', 'remark']}
+            />
+        </MovableModalForm>
+    );
+}
+
+export default ExamCourseEditModal;

+ 206 - 0
YBEE.EQM.Admin/src/pages/exam-center/ExamCourse/index.tsx

@@ -0,0 +1,206 @@
+import { toExcelColumnName, toValueEnum } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamCourseController from '@/services/apis/ExamCourseController';
+import ExamGradeController from '@/services/apis/ExamGradeController';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import ExamPlanController from '@/services/apis/ExamPlanController';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { useParams } from '@umijs/max';
+import { useRequest } from 'ahooks';
+import { App, Button, Space, Typography, theme } from 'antd';
+import { useCallback, useRef, useState } from 'react';
+import ExamCourseEditModal from './components/ExamCourseEditModal';
+
+/** 监测科目列表 */
+const ExamCourseList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number; };
+
+    const { token } = theme.useToken();
+    const actionRef = useRef<ActionType>();
+    const currentRef = useRef<Partial<API.ExamCourseOutput>>();
+
+    const [editShow, setEditShow] = useState(false);
+    const [examCourses, setExamCourses] = useState<API.ExamCourseOutput[]>([]);
+    const { message, modal } = App.useApp();
+
+    const { data: examGrades } = useRequest(async () => {
+        const res = await ExamGradeController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
+        return res ?? [];
+    });
+
+    // 删除
+    const handleDelete = useCallback((id: number) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamPlanController.del({ id });
+                message.success('已删除');
+                actionRef.current?.reload();
+            },
+        });
+    }, []);
+
+    // 初始化双向细目表
+    const handleInitPaper = useCallback(() => {
+        modal.confirm({
+            content: '确定立即初始化双向细目表吗?',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamPaperController.batchInit({ examPlanId: reqParams.examPlanId });
+                message.success('初始完成');
+            },
+        });
+    }, []);
+
+    const columns: ProColumns<API.ExamCourseOutput>[] = [
+        {
+            title: '监测年级',
+            dataIndex: ['examGrade', 'grade', 'name'],
+            valueEnum: toValueEnum(examGrades?.map(t => t.grade) ?? []),
+            width: 88,
+            align: 'center',
+            search: {
+                transform: v => ({ gradeId: v }),
+            },
+        },
+        {
+            title: '科目',
+            dataIndex: ['course', 'name'],
+            width: 64,
+            align: 'center',
+            hideInSearch: true,
+        },
+        {
+            title: '总分',
+            dataIndex: 'totalScore',
+            width: 64,
+            align: 'center',
+            hideInSearch: true,
+        },
+        {
+            title: '分段类型',
+            dataIndex: 'examScoreRangeType',
+            // valueEnum: getDictValueEnum('exam_score_range_type'),
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+        },
+        {
+            title: '成绩模板',
+            hideInSearch: true,
+            children: [
+                {
+                    title: '标题行',
+                    dataIndex: ['scoreReportConfig', 'headerRowIndex'],
+                    width: 64,
+                    align: 'center',
+                },
+                {
+                    title: '小分列',
+                    dataIndex: ['scoreReportConfig', 'minorQuestionColumnIndex'],
+                    width: 64,
+                    align: 'center',
+                },
+                {
+                    title: '标题列名',
+                    dataIndex: ['scoreReportConfig', 'headerColumnNames'],
+                    render: (_, r) => {
+                        return r.scoreReportConfig?.headerColumnNames?.map((t, i) => (
+                            <Typography.Text key={i} style={{ marginRight: token.margin }}>
+                                <Typography.Text type="secondary">[</Typography.Text>
+                                <Typography.Text type="danger">{toExcelColumnName(i)}</Typography.Text>
+                                <Typography.Text type="secondary">]</Typography.Text>
+                                {t}
+                            </Typography.Text>
+                        ));
+                    },
+                },
+                {
+                    title: '备注',
+                    dataIndex: ['scoreReportConfig', 'remark'],
+                    width: 64,
+                },
+            ]
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 88,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <Space>
+                        <a onClick={() => { currentRef.current = r; setEditShow(true); }}>修改</a>
+                        <a onClick={() => handleDelete(r.id)}>删除</a>
+                    </Space>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer
+            title="监测科目管理"
+            onBack={() => history.back()}
+        >
+            <SuperTable<API.ExamCourseOutput>
+                actionRef={actionRef}
+                columns={columns}
+                pagination={false}
+                toolbar={{
+                    title: '监测科目列表',
+                    actions: [
+                        <Button
+                            key="init"
+                            onClick={handleInitPaper}
+                        >初始化双向细目表</Button>,
+                        <Button
+                            key="add"
+                            type="primary"
+                            onClick={() => {
+                                currentRef.current = {
+                                    id: 0,
+                                    examPlanId: reqParams.examPlanId,
+                                };
+                                setEditShow(true);
+                            }}
+                        >添加科目</Button>,
+                    ],
+                }}
+                request={async (params) => {
+                    try {
+                        const res = await ExamCourseController.queryList({
+                            examPlanId: reqParams.examPlanId,
+                            ...params,
+                        });
+                        setExamCourses(res ?? []);
+                        return {
+                            data: res,
+                            success: true,
+                        };
+                    }
+                    catch (ex) { return {}; }
+                }}
+
+            />
+            {editShow && currentRef.current &&
+                <ExamCourseEditModal
+                    examCourses={examCourses}
+                    examGrades={examGrades ?? []}
+                    data={currentRef.current}
+                    onOk={() => actionRef.current?.reload()}
+                    onClose={() => setEditShow(false)}
+                />
+            }
+        </PageContainer>
+    );
+};
+
+export default ExamCourseList;

+ 92 - 10
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamDataReportList.tsx

@@ -1,9 +1,9 @@
-import { CardStepTitle } from "@/components";
+import { CardStepTitle, FileLink, FileUpload } from "@/components";
 import ExamDataReportController from "@/services/apis/ExamDataReportController";
-import { ExamStatus } from "@/services/enums";
+import { ExamStatus, ResourceFileType } from "@/services/enums";
 import { ActionType, ProTable } from "@ant-design/pro-components";
 import { useModel } from "@umijs/max";
-import { App, Button, Typography, theme } from "antd";
+import { App, Button, Space, Typography, theme } from "antd";
 import { useCallback, useRef, useState } from "react";
 import ExamDataReportEditModal from "./ExamDataReportEditModal";
 
@@ -85,6 +85,22 @@ const ExamDataReportList: React.FC<{ examPlanId: number, examPlanStatus?: ExamSt
     //     });
     // }, []);
 
+    // 删除附件
+    const handleDeleteAttachment = useCallback(async (id: number, fileId: string) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamDataReportController.delAttachment({ sourceId: id, fileId })
+                message.success('已删除');
+                actionRef.current?.reload();
+            },
+        });
+    }, []);
+
     return (
         <>
             <ProTable<API.ExamDataReportOutput>
@@ -161,21 +177,81 @@ const ExamDataReportList: React.FC<{ examPlanId: number, examPlanStatus?: ExamSt
                         ellipsis: true,
                         width: 400,
                     },
+                    {
+                        title: '附件',
+                        valueType: 'option',
+                        hideInSearch: true,
+                        width: 280 + token.paddingXS * 2,
+                        // className: 'minw-280',
+                        // fixed: 'right',
+                        render: (_, r) => {
+
+                            const li = r.attachmentList?.map((t, i) => {
+                                return (
+                                    <FileLink
+                                        key={i}
+                                        fileExtName={t.fileExtName}
+                                        fileName={t.fileName}
+                                        url={`${AppConfig.fileViewRoot}?id=${t.fileId}`}
+                                        thumbUrl={t.thumbFileId && t.thumbFileId !== '0' ? `${AppConfig.fileViewRoot}?id=${t.thumbFileId}` : undefined}
+                                        card
+                                        onDelete={async () => handleDeleteAttachment(r.id, t.fileId)}
+                                    />
+                                );
+                            });
+                            return (
+                                <Space direction="vertical" style={{ width: 280 }}>
+                                    {li}
+                                    {(li?.length ?? 0) < 9 &&
+                                        <FileUpload
+                                            addText="添加文件"
+                                            // tipText="仅支持PDF或图片文件"
+                                            accept="*.png,*.jpg,*.jpeg,*.gif,*.pdf,*.doc,*.docx,*.xls,*.xlsx,*.ppt,*.pptx,*.zip"
+                                            limitSize={1024}
+                                            onUpload={async (file, onUploadProgress) => {
+                                                const fsp = file.name.split('.');
+                                                let extName = '';
+                                                if (fsp.length > 1) {
+                                                    extName = fsp[fsp.length - 1].toLowerCase();
+                                                }
+                                                if (extName === '' || !['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip'].includes(extName)) {
+                                                    message.error('文件类型错误,请重新选择!')
+                                                    return { success: false, errorType: 'fileTypeError', errorMessage: '文件类型错误' };
+                                                }
+
+                                                try {
+                                                    const formData = new FormData();
+                                                    formData.append('type', `${ResourceFileType.EXAM_ABSENT_REPLACE}`);
+                                                    formData.append('sourceId', `${r.id}`);
+                                                    formData.append('fileName', `${file.name}`);
+                                                    formData.append('file', file);
+                                                    await ExamDataReportController.uploadAttachment(formData, {
+                                                        onUploadProgress: (p: any) => {
+                                                            const progress = parseFloat((p.loaded / p.total * 100).toFixed(1));
+                                                            onUploadProgress?.(progress);
+                                                        }
+                                                    });
+                                                    actionRef.current?.reload();
+                                                    return { success: true };
+                                                }
+                                                catch {
+                                                    return { success: false };
+                                                }
+                                            }}
+                                        />
+                                    }
+                                </Space>
+                            );
+                        },
+                    },
                     {
                         title: '操作',
                         valueType: 'option',
                         width: 196,
-                        align: 'center',
                         fixed: 'right',
                         render: (_, r) => {
                             return (
                                 <>
-                                    <Button
-                                        type="link"
-                                        size="small"
-                                        disabled={!examPlanStatus || [ExamStatus.STOPPED, ExamStatus.CANCELLED].includes(examPlanStatus) || r.status !== ExamStatus.READY}
-                                        onClick={() => handleRemove(r.id)}
-                                    >移出</Button>
                                     <Button
                                         type="link"
                                         size="small"
@@ -185,6 +261,12 @@ const ExamDataReportList: React.FC<{ examPlanId: number, examPlanStatus?: ExamSt
                                             setEditOpen(true);
                                         }}
                                     >修改</Button>
+                                    <Button
+                                        type="link"
+                                        size="small"
+                                        disabled={!examPlanStatus || [ExamStatus.STOPPED, ExamStatus.CANCELLED].includes(examPlanStatus) || r.status !== ExamStatus.READY}
+                                        onClick={() => handleRemove(r.id)}
+                                    >移出</Button>
                                     <Button
                                         type="link"
                                         size="small"

+ 1 - 1
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamGradeAddModal.tsx

@@ -19,7 +19,7 @@ export type ExamGradeAddModalProps = {
     onClose: () => void;
 };
 
-/** 生产投料单 */
+/** 添加监测年级 */
 const ExamGradeAddModal: React.FC<ExamGradeAddModalProps> = ({ examPlanId, educationStage, semesterId, onClose, onSelect }) => {
     const [open, setOpen] = useState<boolean>(true);
     const handleClose = () => { setOpen(false); setTimeout(onClose, 300); };

+ 85 - 16
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamGradeList.tsx

@@ -1,10 +1,14 @@
-import { CardStepTitle } from "@/components";
+import { TrueOrFalseValueEnum } from "@/common/valueEnum";
+import { CardStepTitle, TagStatus } from "@/components";
 import ExamGradeController from "@/services/apis/ExamGradeController";
 import { EducationStage, ExamStatus } from "@/services/enums";
+import { PlusOutlined, RightCircleOutlined } from "@ant-design/icons";
 import { ActionType, ProTable } from "@ant-design/pro-components";
+import { history } from "@umijs/max";
 import { App, Button, Space, theme } from "antd";
 import { useCallback, useRef, useState } from "react";
 import ExamGradeAddModal from "./ExamGradeAddModal";
+import ExamGradeSettingModal from "./ExamGradeSettingModal";
 
 /** 监测年级管理 */
 const ExamGradeList: React.FC<{
@@ -18,10 +22,13 @@ const ExamGradeList: React.FC<{
     semesterId: number;
 }> = ({ examPlanId, examPlanStatus, educationStage, semesterId }) => {
     const { token } = theme.useToken();
+
     const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
-    const [gradeAddShow, setAddChooseShow] = useState(false);
+    const [gradeAddShow, setGradeAddShow] = useState(false);
+    const [gradeSettingShow, setGradeSettingShow] = useState(false);
 
     const actionRef = useRef<ActionType>();
+    const currentRef = useRef<API.ExamGradeOutput>();
 
     const { message, modal } = App.useApp();
 
@@ -54,6 +61,7 @@ const ExamGradeList: React.FC<{
                 actionRef={actionRef}
                 options={{ density: false, fullScreen: false, setting: false }}
                 pagination={false}
+                scroll={{ x: 'max-content' }}
                 columns={[
                     {
                         title: '序',
@@ -67,8 +75,8 @@ const ExamGradeList: React.FC<{
                     {
                         title: '年级简称',
                         dataIndex: ['grade', 'name'],
-                        width: 112,
-                        align: 'center'
+                        width: 96,
+                        align: 'center',
                     },
                     {
                         title: '年级全称',
@@ -77,38 +85,92 @@ const ExamGradeList: React.FC<{
                     {
                         title: '级',
                         dataIndex: 'gradeBeginName',
-                        width: 112,
-                        align: 'center'
+                        width: 96,
+                        align: 'center',
                     },
                     {
                         title: '届',
                         dataIndex: 'gradeEndName',
+                        width: 96,
+                        align: 'center',
+                    },
+                    {
+                        title: '需要自编监测号',
+                        dataIndex: 'isRequiredSelfExamNumber',
+                        width: 120,
+                        align: 'center',
+                        render: (v, r) => {
+                            const s = TrueOrFalseValueEnum[`${r.isRequiredSelfExamNumber}`];
+                            return (<TagStatus status={s.status}>{s.text}</TagStatus>);
+                        },
+                    },
+                    {
+                        title: '自编监测号长度',
+                        dataIndex: 'selfExamNumberLength',
+                        width: 120,
+                        align: 'center',
+                    },
+                    {
+                        title: '需要抽样监测',
+                        dataIndex: 'isRequiredSample',
                         width: 112,
-                        align: 'center'
+                        align: 'center',
+                        render: (v, r) => {
+                            const s = TrueOrFalseValueEnum[`${r.isRequiredSample}`];
+                            return (<TagStatus status={s.status}>{s.text}</TagStatus>);
+                        },
+                    },
+                    {
+                        title: '监测科目',
+                        dataIndex: 'examCoruses',
+                        className: 'minw-80',
+                        render: (_, r) => {
+                            return r.examCourses?.map(t => t.course?.name).join('、');
+                        },
+                    },
+                    {
+                        title: '备注',
+                        dataIndex: 'remark',
+                        className: 'minw-80',
                     },
                     {
                         title: '操作',
                         valueType: 'option',
-                        width: 88,
+                        width: 120,
                         align: 'center',
+                        fixed: 'right',
                         render: (_, r) => (
-                            <Button
-                                type="link"
-                                size="small"
-                                disabled={examPlanStatus !== ExamStatus.READY}
-                                onClick={() => handleRemove([r.id])}
-                            >移出</Button>
+                            <Space>
+                                <Button
+                                    type="link"
+                                    size="small"
+                                    // disabled={examPlanStatus !== ExamStatus.READY}
+                                    onClick={() => { currentRef.current = r; setGradeSettingShow(true); }}
+                                >配置</Button>
+                                <Button
+                                    type="link"
+                                    size="small"
+                                    disabled={examPlanStatus !== ExamStatus.READY}
+                                    onClick={() => handleRemove([r.id])}
+                                >移出</Button>
+                            </Space>
                         ),
                     }
                 ]}
                 rowKey="id"
                 toolbar={{
                     actions: [
+                        <Button
+                            key="course"
+                            icon={<RightCircleOutlined />}
+                            onClick={() => history.push(`/exam-c/plan/course/${examPlanId}`)}
+                        >监测科目及成绩上报模板管理</Button>,
                         <Button
                             key="add"
                             type="primary"
+                            icon={<PlusOutlined />}
                             disabled={examPlanStatus !== ExamStatus.READY}
-                            onClick={() => setAddChooseShow(true)}
+                            onClick={() => setGradeAddShow(true)}
                         >添加年级</Button>,
                     ],
                 }}
@@ -150,11 +212,18 @@ const ExamGradeList: React.FC<{
                     educationStage={educationStage}
                     onSelect={() => { }}
                     onClose={() => {
-                        setAddChooseShow(false);
+                        setGradeAddShow(false);
                         actionRef.current?.reload();
                     }}
                 />
             }
+            {gradeSettingShow && currentRef.current &&
+                <ExamGradeSettingModal
+                    data={currentRef.current}
+                    onOk={() => { actionRef.current?.reload(); }}
+                    onClose={() => setGradeSettingShow(false)}
+                />
+            }
         </>
     );
 }

+ 81 - 0
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamGradeSettingModal.tsx

@@ -0,0 +1,81 @@
+import { MovableModalForm } from "@/components";
+import ExamGradeController from "@/services/apis/ExamGradeController";
+import { ProFormCheckbox, ProFormText, ProFormTextArea } from "@ant-design/pro-components";
+import { App } from "antd";
+import { useState } from "react";
+
+/** 监测年级设置 */
+const ExamGradeSettingModal: React.FC<{
+    /** 监测年级信息 */
+    data: API.ExamGradeOutput;
+    /** 保存成功回调 */
+    onOk: () => void;
+    /** 关闭回调 */
+    onClose: () => void;
+}> = ({ data, onOk, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(onClose, 300); };
+    const { message } = App.useApp();
+    return (
+        <MovableModalForm<{
+            isRequiredSample: boolean;
+            isRequiredSelfExamNumber: boolean;
+            selfExamNumberLength: number;
+            remark: string;
+        }>
+            title="监测年级参数配置"
+            width={480}
+            open={open}
+            modalProps={{
+                centered: true,
+                maskClosable: false,
+                onCancel: handleClose,
+            }}
+            initialValues={data}
+            onFinish={async (values) => {
+                const p = { id: data.id, ...values };
+                try {
+                    await ExamGradeController.saveSetting(p);
+                    message.success('已保存');
+                    onOk();
+                    handleClose();
+                }
+                catch { }
+            }}
+        >
+            <ProFormCheckbox
+                label="需要抽样监测"
+                name="isRequiredSample"
+            />
+            <ProFormCheckbox
+                label="需要自编监测号"
+                name="isRequiredSelfExamNumber"
+            />
+            <ProFormText
+                label="自编监测号长度"
+                name="selfExamNumberLength"
+                dependencies={['isRequiredSelfExamNumber']}
+                fieldProps={{ defaultValue: 0 }}
+                required
+                rules={[
+                    { required: true },
+                    ({ getFieldValue }) => ({
+                        validator: (_, value) => {
+                            const irsen = getFieldValue("isRequiredSelfExamNumber");
+                            if (irsen && value <= 0) {
+                                return Promise.reject('需要自编监测号时,长度必须大于0');
+                            }
+                            return Promise.resolve();
+                        },
+                    }),
+                ]}
+            />
+            <ProFormTextArea
+                label="备注"
+                name="remark"
+            />
+        </MovableModalForm>
+    );
+}
+
+export default ExamGradeSettingModal;

+ 39 - 1
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamOrgList.tsx

@@ -1,4 +1,5 @@
-import { CardStepTitle, StatusIcon } from "@/components";
+import { TrueOrFalseValueEnum } from "@/common/valueEnum";
+import { CardStepTitle, StatusIcon, TagStatus } from "@/components";
 import ExamOrgController from "@/services/apis/ExamOrgController";
 import ExamOrgDataReportController from "@/services/apis/ExamOrgDataReportController";
 import { DataReportStatus } from "@/services/enums";
@@ -56,6 +57,21 @@ const ExamOrgList: React.FC<{ examPlanId: number, examDataReports: API.ExamDataR
         });
     }, []);
 
+    // 切换是否参与监测
+    const handleSwitch = useCallback(async (id: number, isRequiredExam: boolean) => {
+        modal.confirm({
+            content: `确定立即${isRequiredExam ? '加入' : '取消'}监测吗`,
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamOrgController.switchRequiredSample({ id, isRequiredExam });
+                message.success('操作成功');
+                orgActionRef.current?.reload();
+            },
+        });
+    }, []);
+
     let columns: ProColumns<API.ExamOrgOutput>[] = [
         {
             title: '机构代码',
@@ -73,6 +89,7 @@ const ExamOrgList: React.FC<{ examPlanId: number, examDataReports: API.ExamDataR
         {
             title: '机构全称',
             dataIndex: ['sysOrg', 'fullName'],
+            className: 'minw-160',
         },
         // {
         //     title: '机构名称',
@@ -109,11 +126,30 @@ const ExamOrgList: React.FC<{ examPlanId: number, examDataReports: API.ExamDataR
                 );
             },
         }))),
+        {
+            title: '参与监测',
+            dataIndex: 'isRequiredExam',
+            width: 96,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                // const s = TrueOrFalseValueEnum[`${r.isRequiredExam}`];
+                // return (<TagStatus status={s.status}>{s.text}</TagStatus>);
+                const s = TrueOrFalseValueEnum[`${r.isRequiredExam}`];
+                return (
+                    <Space>
+                        <TagStatus status={s.status}>{s.text}</TagStatus>
+                        <a onClick={() => handleSwitch(r.id, !r.isRequiredExam)}>{r.isRequiredExam ? '取消' : '加入'}</a>
+                    </Space>
+                )
+            },
+        },
         {
             title: '操作',
             valueType: 'option',
             width: 80,
             align: 'center',
+            fixed: 'right',
             render: (_, r) => <a onClick={() => handleRemove([r.id])}>移出</a>,
         }
     ];
@@ -127,10 +163,12 @@ const ExamOrgList: React.FC<{ examPlanId: number, examDataReports: API.ExamDataR
                 size="small"
                 bordered
                 sticky={{ offsetHeader: 56 }}
+                scroll={{ x: 'max-content' }}
                 actionRef={orgActionRef}
                 options={{ density: false, fullScreen: false, setting: false }}
                 columns={columns}
                 rowKey="id"
+                pagination={{ pageSize: 10, showSizeChanger: true }}
                 toolbar={{
                     search: true,
                     actions: [

+ 99 - 0
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamResultList.tsx

@@ -0,0 +1,99 @@
+import { CardStepTitle, FileIcon } from "@/components";
+import ExamScoreImportController from "@/services/apis/ExamScoreImportController";
+import { SendOutlined, UploadOutlined } from "@ant-design/icons";
+import { ProCard } from "@ant-design/pro-components";
+import { App, Button, Card, Space, Typography, Upload, UploadFile, UploadProps, theme } from "antd";
+import { RcFile } from "antd/es/upload";
+import { useCallback, useState } from 'react';
+
+/** 监测结果管理 */
+const ExamResultList: React.FC<{ examPlanId: number, semesterId: number }> = ({ examPlanId, semesterId }) => {
+    const { token } = theme.useToken();
+    const { modal, message } = App.useApp();
+
+    const [fileList, setFileList] = useState<UploadFile[]>([]);
+    const [uploading, setUploading] = useState(false);
+    const [uploadStatus, setUploadStatus] = useState<{ success: boolean; message: string }>();
+
+    const uploadProps: UploadProps = {
+        onRemove: () => {
+            setFileList([]);
+        },
+        beforeUpload: (file) => {
+            setFileList([file]);
+            return false;
+        },
+        listType: 'picture',
+        disabled: uploading,
+        fileList,
+        accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel',
+        iconRender: () => <FileIcon type=".xlsx" />
+    };
+
+    const handleUploadImportWithoutStudentTotalScore = useCallback(async () => {
+        const onConfirm = () => new Promise((resolve) => {
+            modal.confirm({
+                title: '导入提示',
+                content: '初始成绩导入会删除当前计划内所有已存在学生和成绩数据,删除后不能恢复,确定开始导入吗?',
+                okText: '确定',
+                cancelText: '取消',
+                centered: true,
+                onOk: () => resolve(true),
+                onCancel: () => resolve(false),
+            });
+        });
+        const confirm = await onConfirm();
+        if (!confirm) { return; }
+        try {
+            setUploading(true);
+            const formData = new FormData();
+            formData.append('examPlanId', `${examPlanId}`);
+            formData.append('file', fileList[0] as RcFile);
+            await ExamScoreImportController.uploadImportWithoutStudentTotalScore(formData, { timeout: 600000 });
+            message.success('已导入');
+            setFileList([]);
+            setUploadStatus({ success: true, message: '导入成功' });
+        }
+        catch (ex) {
+            setUploadStatus({ success: false, message: `导入失败(${JSON.stringify(ex)})` });
+        }
+        finally {
+            setUploading(false);
+        }
+    }, [fileList]);
+
+    return (
+        <ProCard
+            style={{ marginTop: token.margin }}
+            title={<CardStepTitle>结果管理</CardStepTitle>}
+        >
+            {semesterId < 20232 &&
+                <>
+                    <Typography.Paragraph>
+                        <Typography.Title level={5}>初始成绩导入</Typography.Title>
+                        <Typography.Text>适用于前未上报学生和抽样的批量学生成绩导入。</Typography.Text>
+                    </Typography.Paragraph>
+                    <Card>
+                        <Space direction="vertical" size="large" style={{ width: '100%' }}>
+                            <Upload {...uploadProps}>
+                                <Button icon={<UploadOutlined />} disabled={uploading}>选择文件...</Button>
+                            </Upload>
+                            <Space>
+                                <Button
+                                    type="primary"
+                                    disabled={fileList.length === 0}
+                                    loading={uploading}
+                                    icon={<SendOutlined />}
+                                    onClick={handleUploadImportWithoutStudentTotalScore}
+                                >{uploading ? '正在导入,请稍候' : '开始导入'}</Button>
+                                {uploadStatus && <Typography.Text type={uploadStatus.success ? 'success' : 'danger'}>{uploadStatus?.message}</Typography.Text>}
+                            </Space>
+                        </Space>
+                    </Card>
+                </>
+            }
+        </ProCard>
+    );
+}
+
+export default ExamResultList;

+ 217 - 0
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamSampleEditModal.tsx

@@ -0,0 +1,217 @@
+import { MovableModalForm } from "@/components";
+import ExamSampleController from "@/services/apis/ExamSampleController";
+import { ProFormCheckbox, ProFormDigit, ProFormItem, ProFormText, ProFormTextArea } from "@ant-design/pro-components";
+import { App, Space, Typography } from "antd";
+import { useState } from "react";
+
+/** 添加修改抽样方案 */
+const ExamSampleEditModal: React.FC<{
+    /** 抽样方案 */
+    data: Partial<API.ExamSampleOutput>;
+    /** 保存成功回调 */
+    onFinish: () => void;
+    /** 关闭回调 */
+    onClose: () => void;
+}> = ({ data, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(onClose, 300); };
+
+    const { message } = App.useApp();
+
+    const [isEnabledOnlyOneClassStudentMin, setIsEnabledOnlyOneClassStudentMin] = useState(data.config?.isEnabledOnlyOneClassStudentMin);
+    const [isEnabledGradeNoSampleStudentMin, setIsEnabledGradeNoSampleStudentMin] = useState(data.config?.isEnabledGradeNoSampleStudentMin);
+    const [isEnabledClassStudentMin, setIsEnabledClassStudentMin] = useState(data.config?.isEnabledClassStudentMin);
+
+    const { config, ...restData } = data;
+    return (
+        <MovableModalForm<API.ExamSampleOutput>
+            title="监测抽样方案参数配置"
+            width={720}
+            open={open}
+            modalProps={{
+                centered: true,
+                maskClosable: false,
+                onCancel: handleClose,
+            }}
+            initialValues={{
+                config: {
+                    percent: 40,
+                    startPosition: 1,
+                    interval: 2,
+                    ...config,
+                },
+                ...restData
+            }}
+            onFinish={async (values) => {
+                try {
+                    const { config, ...restValues } = values;
+                    let p: API.AddExamSampleInput = {
+                        ...restValues,
+                        examPlanId: data.examPlanId ?? 0,
+                        config: JSON.stringify({
+                            ...data.config,
+                            ...config,
+                        }),
+                    };
+                    if ((data.id ?? 0) > 0) {
+                        const up = { id: data.id ?? 0, ...p } as API.UpdateExamSampleInput;
+                        await ExamSampleController.update(up);
+                    }
+                    else {
+                        await ExamSampleController.add(p);
+                    }
+                    message.success('操作成功!');
+                    onFinish();
+                    handleClose();
+                    return true;
+                } catch {
+                    message.error('操作失败!');
+                    return false;
+                }
+            }}
+        >
+            {(data.id ?? 0) > 0 &&
+                <>
+                    <ProFormText
+                        label={<strong>方案全称</strong>}
+                        name="fullName"
+                        required
+                        rules={[{ required: true, message: '请输入方案全称' }]}
+                    />
+                    <ProFormText
+                        label={<strong>方案名称</strong>}
+                        name="name"
+                        required
+                        rules={[{ required: true, message: '请输入方案名称' }]}
+                    />
+                    <ProFormText
+                        label={<strong>方案简称</strong>}
+                        name="shortName"
+                        required
+                        rules={[{ required: true, message: '请输入方案简称' }]}
+                    />
+                </>
+            }
+            <ProFormDigit
+                label={<strong>抽样比例</strong>}
+                name={['config', 'percent']}
+                fieldProps={{ addonAfter: '%' }}
+                // width={120}
+                rules={[{ required: true, message: '请输入抽样比例' }]}
+            />
+            <ProFormItem label={<strong>全抽配置</strong>} tooltip="以下全抽规则序号越小优先级越高">
+                <Space direction="vertical">
+                    <div>
+                        <ProFormCheckbox
+                            name={['config', 'isEnabledOnlyOneClassStudentMin']}
+                            noStyle
+                            fieldProps={{
+                                onChange: (e) => setIsEnabledOnlyOneClassStudentMin(e.target.checked),
+                            }}
+                        >1. 年级仅有一个班,且该班学生人数小于等于</ProFormCheckbox>
+                        <Space>
+                            <ProFormDigit
+                                name={['config', 'onlyOneClassStudentMin']}
+                                noStyle
+                                width={96}
+                                fieldProps={{ min: 10, max: 100 }}
+                                disabled={!isEnabledOnlyOneClassStudentMin}
+                                help={false}
+                                rules={[{ required: isEnabledOnlyOneClassStudentMin }]}
+                            />
+                            <label>人,则该班学生全抽</label>
+                        </Space>
+                    </div>
+                    <div>
+                        <ProFormCheckbox
+                            name={['config', 'isEnabledGradeNoSampleStudentMin']}
+                            noStyle
+                            fieldProps={{
+                                onChange: (e) => setIsEnabledGradeNoSampleStudentMin(e.target.checked),
+                            }}
+                        >2. 年级多于一个班,且年级未抽样部分学生人数小于</ProFormCheckbox>
+                        <Space>
+                            <ProFormDigit
+                                name={['config', 'gradeNoSampleStudentMin']}
+                                noStyle
+                                width={96}
+                                fieldProps={{ min: 10, max: 100 }}
+                                disabled={!isEnabledGradeNoSampleStudentMin}
+                                help={false}
+                                rules={[{ required: isEnabledGradeNoSampleStudentMin }]}
+                            />
+                            <label>人,则该年级所有学生全抽</label>
+                        </Space>
+                    </div>
+                    <div>
+                        <ProFormCheckbox
+                            name={['config', 'isEnabledClassStudentMin']}
+                            noStyle
+                            fieldProps={{
+                                onChange: (e) => setIsEnabledClassStudentMin(e.target.checked),
+                            }}
+                        >3. 班级学生人数小于等于</ProFormCheckbox>
+                        <Space>
+                            <ProFormDigit
+                                name={['config', 'classStudentMin']}
+                                noStyle
+                                width={96}
+                                fieldProps={{ min: 10, max: 100 }}
+                                disabled={!isEnabledClassStudentMin}
+                                help={false}
+                                rules={[{ required: isEnabledClassStudentMin }]}
+                            />
+                            <label>人,则该学生全抽</label>
+                        </Space>
+                    </div>
+                </Space>
+            </ProFormItem>
+
+            <ProFormItem label={<strong>位置间距</strong>} required>
+                <Space size="large">
+                    <div>
+                        <label>开始抽样位置:</label>
+                        <ProFormDigit
+                            name={['config', 'startPosition']}
+                            noStyle
+                            width={96}
+                            fieldProps={{ min: 1, max: 50 }}
+                            // help={false}
+                            rules={[{ required: true, message: '请输入开始抽样位置' }]}
+                        />
+                    </div>
+                    <div>
+                        <label>抽样间距:</label>
+                        <ProFormDigit
+                            name={['config', 'interval']}
+                            noStyle
+                            width={96}
+                            fieldProps={{ min: 1, max: 50 }}
+                            // help={false}
+                            rules={[{ required: true, message: '请输入抽样间距' }]}
+                        />
+                    </div>
+                </Space>
+            </ProFormItem>
+
+            <ProFormItem label={<strong>其他设置</strong>}>
+                <Space>
+                    <ProFormCheckbox name={['config', 'isExcludeSpecialStudent']} noStyle>
+                        特殊学生不参与抽样
+                    </ProFormCheckbox>
+                    <ProFormCheckbox name={['config', 'isGradeSeatNumberRandom']} noStyle>
+                        监测顺序号在年级内随机打乱
+                        <Typography.Text type="secondary">(默认在班内按前期成绩排序)</Typography.Text>
+                    </ProFormCheckbox>
+                </Space>
+            </ProFormItem>
+
+            <ProFormTextArea
+                label={<strong>备注说明</strong>}
+                name="remark"
+            />
+        </MovableModalForm>
+    );
+}
+
+export default ExamSampleEditModal;

+ 372 - 0
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/components/ExamSampleList.tsx

@@ -0,0 +1,372 @@
+import { downloadFileByBlob } from "@/common/net/download";
+import { TrueOrFalseValueEnum } from "@/common/valueEnum";
+import { CardStepTitle, StatusIcon, TagStatus } from "@/components";
+import ExamSampleController from "@/services/apis/ExamSampleController";
+import { ExamSampleStatus } from "@/services/enums";
+import { ActionType, ProTable, TableDropdown } from "@ant-design/pro-components";
+import { history, useModel } from "@umijs/max";
+import { App, Button, Space, theme } from "antd";
+import { useCallback, useRef, useState } from "react";
+import ExamSampleEditModal from "./ExamSampleEditModal";
+
+/** 监测抽样管理 */
+const ExamSampleList: React.FC<{
+    /** 监测计划ID */
+    examPlanId: number;
+}> = ({ examPlanId }) => {
+    const { token } = theme.useToken();
+
+    const actionRef = useRef<ActionType>();
+    const [editOpen, setEditOpen] = useState(false);
+
+    const currentRef = useRef<Partial<API.ExamSampleOutput>>();
+    const { getDictValueEnum } = useModel('useDict');
+
+    const { modal, message } = App.useApp();
+
+    // 复制
+    const handleDuplicate = useCallback((id: number) => {
+        modal.confirm({
+            content: '确认复制当前抽样方案吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamSampleController.duplicate({ id });
+                message.success('已复制');
+                actionRef.current?.reload();
+            },
+        });
+    }, [actionRef.current]);
+
+    // 删除
+    const handleDelete = useCallback((id: number) => {
+        modal.confirm({
+            content: '确认删除当前抽样方案吗?',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamSampleController.del({ id });
+                message.success('已删除');
+                actionRef.current?.reload();
+            },
+        });
+    }, [actionRef.current]);
+
+    // 选定
+    const handleSelect = useCallback((id: number) => {
+        modal.confirm({
+            content: '确认选定当前抽样方案作为最终方案吗?',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamSampleController.selectSample({ id });
+                message.success('已选定');
+                actionRef.current?.reload();
+            },
+        });
+    }, [actionRef.current]);
+
+    // 生成
+    const handleGenerate = useCallback((id: number) => {
+        modal.confirm({
+            content: '确定立即生成抽样吗?',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: () => {
+                ExamSampleController.executeSample({ id }, { timeout: 600000 });
+                message.success('已提交');
+                setTimeout(() => actionRef.current?.reload(), 1000);
+            },
+        });
+    }, [actionRef.current]);
+
+    // 下载存档
+    const handleDownloadArchived = useCallback(async (id: number) => {
+        try {
+            message.loading('正在生成文件,请稍侯');
+            const res = await ExamSampleController.exportToArchived({ id }, { timeout: 600000 });
+            if (res) {
+                downloadFileByBlob(res.data, res.fileName);
+            }
+        }
+        catch { }
+        finally {
+            message.destroy();
+        }
+    }, []);
+
+    // 下载发印刷厂
+    const handleDownloadPrintshop = useCallback(async (id: number) => {
+        try {
+            message.loading('正在生成文件,请稍侯');
+            const res = await ExamSampleController.exportToPrintshop({ id }, { timeout: 600000 });
+            if (res) {
+                downloadFileByBlob(res.data, res.fileName);
+            }
+        }
+        catch { }
+        finally {
+            message.destroy();
+        }
+    }, []);
+
+    // 下载统计表
+    const handleDownloadCount = useCallback(async (id: number) => {
+        try {
+            message.loading('正在生成文件,请稍侯');
+            const res = await ExamSampleController.exportSampleCount({ id });
+            if (res) {
+                downloadFileByBlob(res.data, res.fileName);
+            }
+        }
+        catch { }
+        finally {
+            message.destroy();
+        }
+    }, []);
+
+    return (
+        <>
+            <ProTable<API.ExamSampleOutput>
+                headerTitle={<CardStepTitle>监测抽样</CardStepTitle>}
+                style={{ marginTop: token.margin }}
+                search={false}
+                size="small"
+                bordered
+                sticky={{ offsetHeader: 56 }}
+                actionRef={actionRef}
+                options={{ density: false, fullScreen: false, setting: false }}
+                scroll={{ x: 'max-content' }}
+                pagination={false}
+                columns={[
+                    {
+                        title: '序',
+                        dataIndex: 'sequence',
+                        width: 32,
+                        align: 'center',
+                        hideInSearch: true,
+                    },
+                    {
+                        title: '方案简称',
+                        dataIndex: 'name',
+                        render: (v, r) => {
+                            return (<a onClick={() => history.push(`/exam-c/plan/sample/detail/${r.id}`)}>{v}</a>);
+                        },
+                    },
+                    {
+                        title: '抽样比例',
+                        dataIndex: ['config', 'percent'],
+                        valueType: () => ({
+                            type: 'percent',
+                            precision: 1,
+                        }),
+                        width: 80,
+                        align: 'center',
+                    },
+                    {
+                        title: '年级限制',
+                        tooltip: '年级仅有一个班,且该班学生人数小于等于X人,该班全抽',
+                        dataIndex: ['config', 'onlyOneClassStudentMin'],
+                        width: 96,
+                        align: 'center',
+                        render: (v, r) => {
+                            return (
+                                <>
+                                    <StatusIcon status={r.config.isEnabledOnlyOneClassStudentMin ? 'success' : 'forbid'} />
+                                    <span style={{ display: 'inline-block', width: 32, textAlign: 'right' }}>{v}</span>
+                                </>
+                            );
+                        },
+                    },
+                    {
+                        title: '最少未抽',
+                        tooltip: '年级多于一个班,且年级未抽样部分学生人数小于X人,该年级全抽',
+                        dataIndex: ['config', 'gradeNoSampleStudentMin'],
+                        width: 96,
+                        align: 'center',
+                        render: (v, r) => {
+                            return (
+                                <>
+                                    <StatusIcon status={r.config.isEnabledGradeNoSampleStudentMin ? 'success' : 'forbid'} />
+                                    <span style={{ display: 'inline-block', width: 32, textAlign: 'right' }}>{v}</span>
+                                </>
+                            );
+                        },
+                    },
+                    {
+                        title: '班级限制',
+                        tooltip: '班级学生人数小于等于X人,该班全抽',
+                        dataIndex: ['config', 'classStudentMin'],
+                        width: 96,
+                        align: 'center',
+                        render: (v, r) => {
+                            return (
+                                <>
+                                    <StatusIcon status={r.config.isEnabledClassStudentMin ? 'success' : 'forbid'} />
+                                    <span style={{ display: 'inline-block', width: 32, textAlign: 'right' }}>{v}</span>
+                                </>
+                            );
+                        },
+                    },
+                    {
+                        title: '特殊排除',
+                        tooltip: '抽样时排除特殊学生',
+                        dataIndex: ['config', 'isExcludeSpecialStudent'],
+                        width: 96,
+                        align: 'center',
+                        render: (_, r) => {
+                            const s = TrueOrFalseValueEnum[`${r.config.isExcludeSpecialStudent ?? 'false'}`];
+                            return (<TagStatus status={s.status}>{s.text}</TagStatus>);
+                        },
+                    },
+                    {
+                        title: '随机序号',
+                        tooltip: '在年级内随机打乱监测号顺序',
+                        dataIndex: ['config', 'isGradeSeatNumberRandom'],
+                        width: 96,
+                        align: 'center',
+                        render: (_, r) => {
+                            const s = TrueOrFalseValueEnum[`${r.config.isGradeSeatNumberRandom ?? 'false'}`];
+                            return (<TagStatus status={s.status}>{s.text}</TagStatus>);
+                        },
+                    },
+                    {
+                        title: '开始位置',
+                        dataIndex: ['config', 'startPosition'],
+                        width: 80,
+                        align: 'center',
+                    },
+                    {
+                        title: '间距',
+                        dataIndex: ['config', 'interval'],
+                        width: 64,
+                        align: 'center',
+                    },
+                    {
+                        title: '全抽班级',
+                        dataIndex: ['config', 'sampleAllSchoolClassIds'],
+                        width: 96,
+                        align: 'center',
+                        render: (_, r) => `${r.config.sampleAllSchoolClassIds?.length ?? 0}个`,
+                    },
+                    {
+                        title: '方案状态',
+                        dataIndex: 'status',
+                        valueEnum: getDictValueEnum('exam_sample_status', true),
+                        width: 96,
+                        align: 'center',
+                    },
+                    {
+                        title: '已选定',
+                        dataIndex: 'isSelected',
+                        width: 96,
+                        // align: 'center',
+                        render: (_, r) => {
+                            const s = TrueOrFalseValueEnum[`${r.isSelected}`];
+                            return (
+                                <Space>
+                                    <TagStatus status={r.isSelected ? 'success' : 'error'}>{s.text}</TagStatus>
+                                    {!r.isFixedExamSample && r.status === ExamSampleStatus.SUCCESSFUL &&
+                                        <a onClick={() => handleSelect(r.id)}>选定</a>
+                                    }
+                                </Space>
+                            )
+                        },
+                    },
+                    {
+                        title: '操作',
+                        valueType: 'option',
+                        width: 96,
+                        fixed: 'right',
+                        render: (_, r) => {
+                            let menuItems: any[] = [];
+                            if (!r.isFixedExamSample) {
+                                menuItems = [
+                                    { key: 'delete', name: '删除' },
+                                    { key: 'copy', name: '复制' },
+                                    { key: 'generate', name: r.status === ExamSampleStatus.INITIAL ? '生成' : '重新生成' },
+                                ];
+                            }
+                            if (r.status === ExamSampleStatus.SUCCESSFUL) {
+                                menuItems = [
+                                    ...menuItems,
+                                    { key: 'download-archived', name: '下载发归档文件' },
+                                    { key: 'download-print-shop', name: '下载发印刷厂文件' },
+                                    { key: 'download-count', name: '下载统计表' },
+                                ];
+                            }
+                            return (
+                                <Space>
+                                    {!r.isFixedExamSample &&
+                                        <a onClick={() => { currentRef.current = r; setEditOpen(true); }}>修改</a>
+                                    }
+                                    <TableDropdown
+                                        onSelect={(key) => {
+                                            switch (key) {
+                                                case 'delete':
+                                                    handleDelete(r.id);
+                                                    break;
+                                                case 'copy':
+                                                    handleDuplicate(r.id);
+                                                    break;
+                                                case 'generate':
+                                                    handleGenerate(r.id);
+                                                    break;
+                                                case 'download-archived':
+                                                    handleDownloadArchived(r.id);
+                                                    break;
+                                                case 'download-print-shop':
+                                                    handleDownloadPrintshop(r.id);
+                                                    break;
+                                                case 'download-count':
+                                                    handleDownloadCount(r.id);
+                                                    break;
+                                            }
+                                        }}
+                                        menus={menuItems}
+                                    />
+                                </Space>
+                            );
+                        },
+                    }
+                ]}
+                rowKey="id"
+                toolbar={{
+                    actions: [
+                        <Button
+                            key="setting"
+                            type="primary"
+                            onClick={() => {
+                                currentRef.current = {
+                                    id: 0,
+                                    examPlanId,
+                                };
+                                setEditOpen(true);
+                            }}
+                        >添加抽样</Button>,
+                    ],
+                }}
+                request={async () => {
+                    const res = await ExamSampleController.getListByExamPlanId({ examplanid: examPlanId });
+                    return {
+                        data: res ?? [],
+                        success: true,
+                    };
+                }}
+            />
+            {editOpen && currentRef.current &&
+                <ExamSampleEditModal
+                    data={currentRef.current}
+                    onFinish={() => { actionRef.current?.reload(); }}
+                    onClose={() => setEditOpen(false)}
+                />
+            }
+        </>
+    );
+}
+
+export default ExamSampleList;

+ 5 - 1
YBEE.EQM.Admin/src/pages/exam-center/ExamPlanDetail/index.tsx

@@ -10,6 +10,8 @@ import ExamDataPublishList from "./components/ExamDataPublishList";
 import ExamDataReportList from "./components/ExamDataReportList";
 import ExamGradeList from "./components/ExamGradeList";
 import ExamOrgList from "./components/ExamOrgList";
+import ExamResultList from "./components/ExamResultList";
+import ExamSampleList from "./components/ExamSampleList";
 
 const ExamPlanDetail: React.FC = () => {
     const reqParams = useParams() as unknown as { id: number };
@@ -147,8 +149,10 @@ const ExamPlanDetail: React.FC = () => {
             }
 
             <ExamDataReportList examPlanId={reqParams.id} examPlanStatus={data?.status} />
-            <ExamDataPublishList examPlanId={reqParams.id} examPlanStatus={data?.status} />
             <ExamOrgList examPlanId={reqParams.id} examDataReports={data?.examDataReports ?? []} />
+            <ExamSampleList examPlanId={reqParams.id} />
+            <ExamResultList examPlanId={reqParams.id} semesterId={data?.semesterId ?? 99999} />
+            <ExamDataPublishList examPlanId={reqParams.id} examPlanStatus={data?.status} />
 
             <FloatButton.BackTop visibilityHeight={100} />
         </PageContainer>

+ 28 - 7
YBEE.EQM.Admin/src/pages/exam-center/ExamResultDetail/index.tsx

@@ -101,24 +101,45 @@ const ExamResultDetail: React.FC = () => {
         setUploading(true);
         try {
             const uis = uploadItems.filter(t => t.progress === 0 && t.success);
-            const ps = uis.map(row => {
+            let i = 0;
+            let ps: Promise<any>[] = [];
+            for (; i < uis.length; i++) {
+                const row = uis[i];
                 const formData = new FormData();
                 formData.append('examPlanId', `${reqParams.examPlanId}`);
                 formData.append('examDataPublishId', `${reqParams.publishId}`);
                 formData.append('examOrgId', `${row.examOrgId}`);
                 formData.append('sysOrgId', `${row.sysOrgId}`);
                 formData.append('file', row.file as RcFile);
-
-                return ExamOrgResultController.upload(formData, {
+                ps.push(ExamOrgResultController.upload(formData, {
                     onUploadProgress: (p: any) => {
                         let fi = uploadItems.findIndex(t => t.key === row.key);
                         uploadItems[fi].progress = parseFloat((p.loaded / p.total * 100).toFixed(1));
                         setUploadItems([...uploadItems]);
                     }
-                });
-            });
-            await Promise.all(ps);
-            actionRef.current?.reload();
+                }));
+                // const ps = uis.map(row => {
+                //     const formData = new FormData();
+                //     formData.append('examPlanId', `${reqParams.examPlanId}`);
+                //     formData.append('examDataPublishId', `${reqParams.publishId}`);
+                //     formData.append('examOrgId', `${row.examOrgId}`);
+                //     formData.append('sysOrgId', `${row.sysOrgId}`);
+                //     formData.append('file', row.file as RcFile);
+
+                //     return ExamOrgResultController.upload(formData, {
+                //         onUploadProgress: (p: any) => {
+                //             let fi = uploadItems.findIndex(t => t.key === row.key);
+                //             uploadItems[fi].progress = parseFloat((p.loaded / p.total * 100).toFixed(1));
+                //             setUploadItems([...uploadItems]);
+                //         }
+                //     });
+                // });
+                if (i > 0 && (i % 5 === 0 || i === uis.length - 1)) {
+                    await Promise.all(ps);
+                    actionRef.current?.reload();
+                    ps = [];
+                }
+            }
         }
         catch { }
         finally {

+ 128 - 0
YBEE.EQM.Admin/src/pages/exam-center/absent-replace/absent-replace-audit/ExamAbsentReplaceAuditList/index.tsx

@@ -0,0 +1,128 @@
+import { SuperTable } from '@/components';
+import ExamAbsentReplaceAuditController from '@/services/apis/ExamAbsentReplaceAuditController';
+import ExamPlanController from '@/services/apis/ExamPlanController';
+import { DataReportStatus } from '@/services/enums';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel, useParams } from '@umijs/max';
+import { useRequest } from 'ahooks';
+import { Typography, theme } from 'antd';
+import { useRef } from 'react';
+
+/** 缺测替补学生审核监测计划列表 */
+const ExamAbsentReplaceAuditList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const actionRef = useRef<ActionType>();
+
+    const { getDictValueEnum } = useModel('useDict');
+    const { token } = theme.useToken();
+
+    const { data, loading } = useRequest(() => {
+        return ExamPlanController.getById({ id: reqParams.examPlanId });
+    });
+
+    const columns: ProColumns<API.ExamPlanOrgAuditOutput>[] = [
+        {
+            title: '学校名称',
+            dataIndex: 'sysOrgFullName',
+            hideInDescriptions: true,
+            order: 99,
+            search: {
+                transform: (v) => ({ sysOrgName: v }),
+            },
+        },
+        {
+            title: '上报状态',
+            dataIndex: 'dataReportStatus',
+            valueEnum: getDictValueEnum('data_report_status', true),
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+        },
+        {
+            title: '总数量',
+            dataIndex: 'totalCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        {
+            title: '待审核',
+            dataIndex: 'auditCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="warning">{v}</Typography.Text> : null,
+        },
+        {
+            title: '已通过',
+            dataIndex: 'approvedCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="success">{v}</Typography.Text> : null,
+        },
+        {
+            title: '已驳回',
+            dataIndex: 'rejectedCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v, r) => r.rejectedCount > 0 ? <Typography.Text type="danger">{v}</Typography.Text> : null,
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                if (r.dataReportStatus === DataReportStatus.UNREPORT) {
+                    return null;
+                }
+                return (
+                    <a onClick={() => history.push(`/exam-c/absent-audit/org/${r.sysOrgId}/${r.examPlanId}`)}>
+                        {r.auditCount > 0 && r.dataReportStatus === DataReportStatus.REPORTED ? '去审核' : '去查看'}
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer
+            title={`${data?.fullName ?? ''} - 缺测替补学生审核学校名单`}
+            onBack={() => history.back()}
+            loading={loading}
+        >
+            <SuperTable<API.ExamPlanOrgAuditOutput>
+                toolbar={{
+                    title: '学校列表',
+                    // actions: [
+                    //     <Button key="view" type="primary">缺测替补学生汇总名单</Button>,
+                    //     <Button key="confirm">确定审核结果</Button>,
+                    // ],
+                }}
+                actionRef={actionRef}
+                columns={columns}
+                scroll={{ x: 'max-content' }}
+                rowKey="rowNumber"
+                columnEmptyText=""
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamAbsentReplaceAuditController.queryOrgAuditPageList({
+                            ...p,
+                            examPlanId: reqParams.examPlanId,
+                        });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default ExamAbsentReplaceAuditList;

+ 487 - 0
YBEE.EQM.Admin/src/pages/exam-center/absent-replace/absent-replace-audit/ExamAbsentReplaceAuditOrg/index.tsx

@@ -0,0 +1,487 @@
+import { toValueEnum } from "@/common/converter";
+import { AuditModal, CardStepTitle, FileLink, SuperTable, TabBadge } from "@/components";
+import { AuditFormValueType } from "@/components/AuditModal";
+import ExamAbsentReplaceDetailDrawer from "@/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/components/ExamAbsentReplaceDetailDrawer";
+import ExamAbsentReplaceAuditController from "@/services/apis/ExamAbsentReplaceAuditController";
+import ExamAbsentReplaceController from "@/services/apis/ExamAbsentReplaceController";
+import ExamGradeController from "@/services/apis/ExamGradeController";
+import ExamOrgDataReportController from "@/services/apis/ExamOrgDataReportController";
+import SysOrgController from "@/services/apis/SysOrgController";
+import { AuditStatus, DataReportStatus, DataReportType } from "@/services/enums";
+import { AuditOutlined } from "@ant-design/icons";
+import { ActionType, PageContainer, ProCard, ProColumns, ProDescriptions, ProDescriptionsItemProps } from "@ant-design/pro-components";
+import { history, useModel, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { Alert, App, Button, Space, Tag, Typography, theme } from "antd";
+import { useCallback, useRef, useState } from "react";
+
+/**
+ * 缺测替补学生审核学校详细
+ */
+const ExamAbsentReplaceAuditOrg: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number, sysOrgId: number };
+
+    const { token } = theme.useToken();
+    const { getDictValueEnum, getKeyDict, getDict } = useModel('useDict');
+    const auditStatusDict = getKeyDict('audit_status');
+    const dataReportStatusDict = getKeyDict('data_report_status');
+
+    const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
+    const [auditShow, setAuditShow] = useState<{ open: boolean, ids: number[] } | undefined>();
+    const [activeKey, setActiveKey] = useState<React.Key>('2');
+    const [statusCount, seStatusCount] = useState<Record<number, number>>({});
+
+    const [detailOpen, setDetailOpen] = useState(false);
+
+    const actionRef = useRef<ActionType>();
+    const currentRef = useRef<Partial<API.ExamAbsentReplaceOutput>>();
+
+    const { message, modal } = App.useApp();
+
+    // 加载上报数据
+    const { data, loading } = useRequest(async () => {
+        // 上报数据
+        const reportData = await ExamOrgDataReportController.getByTypeExamPlanId({
+            type: DataReportType.ABSENT_REPLACE,
+            examplanid: reqParams.examPlanId,
+            sysorgid: reqParams.sysOrgId,
+        });
+        // 校区
+        const orgBranch = await SysOrgController.getOrgBranchByOrgId({ orgid: reqParams.sysOrgId });
+        // 监测年级
+        const examGrades = await ExamGradeController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
+
+        return {
+            reportData,
+            examGrades,
+            hasOrgBranch: (orgBranch?.length ?? 0) > 0,
+        };
+    });
+
+    const auditable = data?.reportData?.examOrgDataReport?.status === DataReportStatus.REPORTED;
+
+    // 加载数量统计
+    const loadCount = useCallback(async (params: API.ExamAbsentReplacePageInput) => {
+        const m = await ExamAbsentReplaceController.queryStatusCount(params);
+        const tc = m?.reduce((a, b) => a + b.count, 0) ?? 0;
+
+        const d: Record<number, number> = { 0: tc };
+        m?.forEach((t) => { d[t.status] = t.count; });
+
+        seStatusCount(d);
+    }, []);
+
+    // 审核
+    const handleAudit = async (ids: number[], formValues: AuditFormValueType) => {
+        return new Promise<boolean>((resolve, reject) => {
+            modal.confirm({
+                title: '提交确认',
+                content: '确认立即提交吗?',
+                okText: '确定',
+                cancelText: '取消',
+                centered: true,
+                onOk: async () => {
+                    try {
+                        await ExamAbsentReplaceAuditController.audit({ ids, ...formValues });
+                        message.success('已提交');
+                        resolve(true);
+                        setSelectedRowKeys([]);
+                        actionRef.current?.reload();
+                    }
+                    catch {
+                        reject(false);
+                    }
+                },
+                onCancel: () => resolve(false),
+            });
+        });
+    }
+
+    // 反审
+    const handleReaudit = useCallback(async (id: number) => {
+        modal.confirm({
+            title: '提交确认',
+            content: '确认立即反审吗?',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamAbsentReplaceAuditController.reaudit({ id })
+                message.success('已反审');
+                setSelectedRowKeys([]);
+                actionRef.current?.reload();
+            },
+        });
+    }, []);
+
+    // 明细表列定义
+    const detailColumns: ProColumns<API.ExamAbsentReplaceOutput>[] = [
+        {
+            title: '审核状态',
+            dataIndex: 'status',
+            hideInSearch: true,
+            width: 96,
+            align: 'center',
+            valueEnum: getDictValueEnum('audit_status', true),
+            render: (_, r) => {
+                const s = auditStatusDict[r.status];
+                return (
+                    <Space direction="vertical" size="small" style={{ lineHeight: 1 }}>
+                        <Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>
+                        <Typography.Text
+                            type={r.isReplaced ? 'warning' : 'danger'}
+                            style={{ fontSize: token.fontSizeSM }}
+                        >
+                            {r.isReplaced ? '有替补' : '无替补'}
+                        </Typography.Text>
+                    </Space>
+                );
+            },
+        },
+        ...(data?.hasOrgBranch ? [
+            {
+                title: '校区',
+                dataIndex: ['sysOrgBranch', 'name'],
+                width: 88,
+                align: 'center',
+            } as ProColumns,
+        ] : []),
+        {
+            title: '年级',
+            dataIndex: 'gradeId',
+            width: 80,
+            align: 'center',
+            valueEnum: toValueEnum(data?.examGrades?.map(t => ({ id: t.gradeId, name: `${t.grade.name}(${t.gradeBeginName})` })) ?? []),
+            render: (_, r) => {
+                return (
+                    <Space size="small" direction="vertical" style={{ lineHeight: 1 }}>
+                        {r.examGrade?.grade.name}
+                        <Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>({r.examGrade?.gradeBeginName})</Typography.Text>
+                    </Space>
+                );
+            },
+        },
+        {
+            title: '班级',
+            dataIndex: 'classNumber',
+            valueType: 'digit',
+            width: 56,
+            align: 'center',
+            render: (_, r) => `${r.classNumber}班`,
+        },
+        {
+            title: '姓名',
+            dataIndex: 'absentName',
+            width: 112,
+            align: 'center',
+            render: (v, r) => <a onClick={() => { currentRef.current = r; setDetailOpen(true); }}>{v}</a>,
+        },
+        {
+            title: '监测号',
+            dataIndex: 'absentExamNumber',
+            width: 112,
+            align: 'center',
+        },
+        {
+            title: '缺测替补原因',
+            dataIndex: 'absentReason',
+            hideInSearch: true,
+            // width: 240,
+        },
+        {
+            title: '缺测科目',
+            dataIndex: 'absentCourseList',
+            // width: 240,
+            hideInSearch: true,
+            render: (_, r) => {
+                return r.absentCourseList?.map(t => t.name).join('、');
+            },
+        },
+        {
+            title: '家长电话',
+            dataIndex: 'patriarchTel',
+            hideInSearch: true,
+            // width: 128,
+            align: 'center',
+        },
+        {
+            title: '替补学生姓名',
+            dataIndex: 'replaceName',
+            width: 112,
+            align: 'center',
+        },
+        {
+            title: '替补学生监测号',
+            dataIndex: 'replaceExamNumber',
+            width: 120,
+            align: 'center',
+        },
+        {
+            title: '备注',
+            dataIndex: 'remark',
+            hideInSearch: true,
+            className: 'minw-64',
+            // width: 160,
+        },
+        {
+            title: '佐证材料',
+            valueType: 'option',
+            hideInSearch: true,
+            hideInDescriptions: true,
+            width: 280,
+            fixed: 'right',
+            render: (_, r) => {
+                const li = r.attachmentList?.map((t, i) => {
+                    return (
+                        <FileLink
+                            key={i}
+                            fileExtName={t.fileExtName}
+                            fileName={t.fileName}
+                            fileId={t.fileId}
+                            thumbFileId={t.thumbFileId}
+                            card
+                        />
+                    );
+                });
+                return (
+                    <Space direction="vertical" style={{ width: 280 }}>
+                        {li}
+                    </Space>
+                );
+            },
+        },
+        {
+            title: '佐证材料',
+            hideInSearch: true,
+            hideInTable: true,
+            width: 280,
+            fixed: 'right',
+            render: (_, r) => {
+                const li = r.attachmentList?.map((t, i) => {
+                    return (
+                        <FileLink
+                            key={i}
+                            fileExtName={t.fileExtName}
+                            fileName={t.fileName}
+                            fileId={t.fileId}
+                            thumbFileId={t.thumbFileId}
+                            url={`${AppConfig.fileViewRoot}?id=${t.fileId}`}
+                            thumbUrl={t.thumbFileId && t.thumbFileId !== '0' ? `${AppConfig.fileViewRoot}?id=${t.thumbFileId}` : undefined}
+                            card
+                        />
+                    );
+                });
+                return (
+                    <Space direction="vertical" style={{ width: '100%' }}>
+                        {li?.length === 0 ? '未上传' : li}
+                    </Space>
+                );
+            },
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 64,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                if (!auditable) {
+                    return null;
+                }
+
+                if ([AuditStatus.AUDIT, AuditStatus.APPROVE_CANCELED].includes(r.status)) {
+                    return (
+                        <Button
+                            type="link"
+                            size="small"
+                            onClick={() => { setAuditShow({ open: true, ids: [r.id] }); }}
+                        >审核</Button>
+                    );
+                }
+                if (r.status === AuditStatus.APPROVED) {
+                    return (
+                        <Button
+                            type="link"
+                            size="small"
+                            onClick={() => handleReaudit(r.id)}
+                        >反审</Button>
+                    );
+                }
+                return null;
+            },
+        }
+    ];
+
+    // 呈现状态 tab
+    const renderTabItems = useCallback(() => {
+        let items: { key: string; label: React.ReactNode }[] = [];
+        items = items.concat(
+            getDict('audit_status')?.filter(t => t.value > 1)?.sort((a, b) => a.sort - b.sort)?.map((t) => ({
+                key: `${t.value}`,
+                label: (
+                    <span>
+                        {t.name}
+                        <TabBadge color={t.antColor} count={statusCount[t.value] ?? 0} active={activeKey === `${t.value}`} />
+                    </span>
+                ),
+            })),
+        );
+        items.push({
+            key: '0',
+            label: (<span>全部<TabBadge count={statusCount[0]} active={activeKey === 0} /></span>),
+        });
+        return items;
+    }, [activeKey, statusCount]);
+
+    return (
+        <PageContainer
+            title={`${data?.reportData?.examOrgDataReport?.examOrg?.sysOrg?.fullName ?? ''} - 缺测替补学生审核`}
+            onBack={() => history.back()}
+            loading={loading}
+        >
+            <ProCard
+                bodyStyle={{ paddingBottom: 0 }}
+                title={<CardStepTitle>基本情况</CardStepTitle>}
+                extra={data?.reportData?.examOrgDataReport?.status &&
+                    <Space>
+                        <span>学校上报状态</span>
+                        <Tag
+                            style={{ marginRight: 0 }}
+                            color={dataReportStatusDict[data?.reportData.examOrgDataReport.status].antColor}
+                        >
+                            {dataReportStatusDict[data?.reportData.examOrgDataReport.status].name}
+                        </Tag>
+                    </Space>
+                }
+            >
+                <ProDescriptions>
+                    <ProDescriptions.Item label="监测计划">
+                        {data?.reportData?.examPlan?.name}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="学校名称">
+                        {data?.reportData?.examOrgDataReport?.examOrg?.sysOrg?.fullName}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报时间">
+                        {data?.reportData?.examOrgDataReport?.reportTime}
+                    </ProDescriptions.Item>
+                </ProDescriptions>
+            </ProCard>
+
+            <ProCard
+                style={{ marginTop: token.margin }}
+                title={<CardStepTitle>《缺测替补学生明细表》和《会议记录》打印签字盖章电子文件</CardStepTitle>}
+            >
+                <Space direction="vertical" style={{ width: '100%' }}>
+                    {data?.reportData?.examOrgDataReport?.attachmentList?.map((t, i) => <FileLink key={i} {...t} card />) ?? null}
+                </Space>
+            </ProCard>
+
+            <ProCard
+                style={{ marginTop: token.margin }}
+                title={<CardStepTitle>缺测替补学生审核明细列表</CardStepTitle>}
+                subTitle={<Typography.Text type="warning">右侧学校上报状态为 “已上报” 时才能审核</Typography.Text>}
+                extra={data?.reportData?.examOrgDataReport?.status &&
+                    <Space>
+                        <span>学校上报状态</span>
+                        <Tag
+                            style={{ marginRight: 0 }}
+                            color={dataReportStatusDict[data?.reportData.examOrgDataReport.status].antColor}
+                        >
+                            {dataReportStatusDict[data?.reportData.examOrgDataReport.status].name}
+                        </Tag>
+                    </Space>
+                }
+            >
+                <SuperTable<API.ExamAbsentReplaceOutput>
+                    actionRef={actionRef}
+                    scroll={{ x: 'max-content' }}
+                    rowKey="id"
+                    columns={detailColumns}
+                    search={{
+                        filterType: 'light',
+                    }}
+                    cardProps={false}
+                    options={{ setting: false, fullScreen: false }}
+                    toolbar={{
+                        // title: <CardStepTitle>缺测替补学生明细</CardStepTitle>,
+                        // subTitle: <Typography.Text type="danger">右上角上报状态为 “已上报” 时才能审核</Typography.Text>,
+                        menu: {
+                            type: 'tab',
+                            activeKey: activeKey,
+                            items: renderTabItems(),
+                            onChange: (key) => {
+                                setSelectedRowKeys([]);
+                                setActiveKey(key as React.Key);
+                                actionRef.current?.reload();
+                            },
+                        },
+                        actions: [
+                            <Button
+                                key="audit"
+                                type="primary"
+                                icon={<AuditOutlined />}
+                                disabled={(selectedRowKeys.length ?? 0) === 0}
+                                onClick={() => { setAuditShow({ open: true, ids: selectedRowKeys }); }}
+                            >批量审核</Button>
+                        ],
+                    }}
+                    request={async (params, sort) => {
+                        return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                            const qparams = {
+                                ...p,
+                                examPlanId: reqParams.examPlanId,
+                                sysOrgId: reqParams.sysOrgId,
+                            };
+                            await loadCount(qparams);
+                            const res = await ExamAbsentReplaceController.queryPageList({
+                                ...qparams,
+                                status: activeKey !== '0' ? parseInt(activeKey as string) : undefined,
+                            });
+                            return res;
+                        });
+                    }}
+                    rowSelection={{
+                        selectedRowKeys,
+                        getCheckboxProps: (r) => {
+                            return { disabled: !auditable || ![AuditStatus.AUDIT, AuditStatus.APPROVE_CANCELED].includes(r.status) };
+                        },
+                        onSelect: (record, selected, selectedRows) => {
+                            setSelectedRowKeys(selectedRows.map((t) => t.id) || []);
+                        },
+                        onSelectAll: (_, selectedRows) => {
+                            setSelectedRowKeys(selectedRows.map((t) => t.id) || []);
+                        },
+                    }}
+                    tableAlertOptionRender={({ onCleanSelected }) => {
+                        return (<a onClick={() => { setSelectedRowKeys([]); onCleanSelected(); }}>取消选择</a>);
+                    }}
+                />
+            </ProCard>
+
+            {auditShow?.open && auditShow.ids.length > 0 &&
+                <AuditModal
+                    alert={
+                        <Alert
+                            type="info"
+                            showIcon
+                            style={{ marginBottom: token.margin }}
+                            message={`本次审核 ${auditShow.ids.length} 个缺测替补学生`}
+                        />
+                    }
+                    title="缺测替补学生审核"
+                    onAudit={async (res) => await handleAudit(auditShow.ids, res)}
+                    onClose={() => setAuditShow({ open: false, ids: [] })}
+                />
+            }
+            {detailOpen && currentRef.current &&
+                <ExamAbsentReplaceDetailDrawer
+                    data={currentRef.current}
+                    columns={detailColumns as ProDescriptionsItemProps<Partial<API.ExamAbsentReplaceOutput>>[]}
+                    onClose={() => setDetailOpen(false)}
+                />
+            }
+        </PageContainer>
+    );
+}
+
+export default ExamAbsentReplaceAuditOrg;

+ 5 - 5
YBEE.EQM.Admin/src/pages/exam-center/student/special-student-audit/index.tsx → YBEE.EQM.Admin/src/pages/exam-center/absent-replace/absent-replace-audit/index.tsx

@@ -1,6 +1,6 @@
 import { toSelectOptions } from '@/common/converter';
 import { SuperTable } from '@/components';
-import ExamSpecialStudentAuditController from '@/services/apis/ExamSpecialStudentAuditController';
+import ExamAbsentReplaceAuditController from '@/services/apis/ExamAbsentReplaceAuditController';
 import { RightOutlined } from '@ant-design/icons';
 import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
 import { history, useModel } from '@umijs/max';
@@ -8,7 +8,7 @@ import { Typography, theme } from 'antd';
 import { useRef } from 'react';
 
 /** 监测特殊学生审核计划列表 */
-const ExamSpecialStudentAuditPlanList: React.FC = () => {
+const ExamAbsentReplaceAuditPlanList: React.FC = () => {
     const actionRef = useRef<ActionType>();
 
     const { getDictValueEnum } = useModel('useDict');
@@ -92,7 +92,7 @@ const ExamSpecialStudentAuditPlanList: React.FC = () => {
             fixed: 'right',
             render: (_, r) => {
                 return (
-                    <a onClick={() => history.push(`/exam-c/sp-stu-audit/list/${r.id}`)}>
+                    <a onClick={() => history.push(`/exam-c/absent-audit/list/${r.id}`)}>
                         去处理
                         <RightOutlined style={{ marginLeft: token.marginXXS }} />
                     </a>
@@ -113,7 +113,7 @@ const ExamSpecialStudentAuditPlanList: React.FC = () => {
                 columnEmptyText=""
                 request={async (params, sort) => {
                     return SuperTable.requestPageAgent({ params, sort }, async (p) => {
-                        const res = await ExamSpecialStudentAuditController.queryExamPlanPageList({ ...p });
+                        const res = await ExamAbsentReplaceAuditController.queryExamPlanPageList({ ...p });
                         return res;
                     });
                 }}
@@ -122,4 +122,4 @@ const ExamSpecialStudentAuditPlanList: React.FC = () => {
     );
 };
 
-export default ExamSpecialStudentAuditPlanList;
+export default ExamAbsentReplaceAuditPlanList;

+ 85 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperCourseList/components/AssignWriterModal.tsx

@@ -0,0 +1,85 @@
+import { MovableModalForm } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import SysUserController from '@/services/apis/SysUserController';
+import { ExamPaperWriterType } from '@/services/enums';
+import { ProFormSelect } from '@ant-design/pro-components';
+import { App, Typography } from 'antd';
+import { useState } from 'react';
+
+/** 分配编制人 */
+const AssignWriterModal: React.FC<{
+    ids: number[];
+    type: ExamPaperWriterType;
+    onFinish: () => void;
+    onClose?: () => void;
+}> = ({ ids, type, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const { message } = App.useApp();
+
+    return (
+        <MovableModalForm<{
+            examPaperQuestionMajorId?: number;
+            score: number;
+        }>
+            title={type === ExamPaperWriterType.TWCL ? '分配双向细目表编制人' : '分配问题建议撰写人'}
+            width={480}
+            open={open}
+            modalProps={{
+                centered: true,
+                maskClosable: false,
+                onCancel: handleClose,
+            }}
+            onFinish={async (values: any) => {
+                try {
+                    const p: API.AssignExamPaperWriterInput = {
+                        ...values,
+                        ids,
+                    };
+                    if (type === ExamPaperWriterType.TWCL) {
+                        await ExamPaperController.assignTwclWriter(p);
+                    }
+                    else {
+                        await ExamPaperController.assignSuggestionWriter(p);
+                    }
+                    message.success('操作成功!');
+                    onFinish();
+                    handleClose();
+                    return true;
+                } catch {
+                    message.error('操作失败!');
+                    return false;
+                }
+            }}
+        >
+            <ProFormSelect
+                label="用户(输入关键字搜索用户)"
+                name="writerSysUserId"
+                showSearch
+                debounceTime={200}
+                fieldProps={{ filterOption: false }}
+                request={async ({ keyWords }) => {
+                    if (!keyWords) {
+                        return [];
+                    }
+
+                    const list = await SysUserController.queryUserSimplePageList({ searchValue: keyWords, pageIndex: 1, pageSize: 20 });
+                    const v = list?.items?.map(t => ({
+                        value: t.id,
+                        // label: t.name,
+                        label: (
+                            <Typography.Text>
+                                {t.name} [{t.account}] -
+                                <Typography.Text type="secondary">{t.sysOrg.name}</Typography.Text>
+                            </Typography.Text>
+                        ),
+                    })) ?? [];
+                    return v;
+                }}
+            />
+        </MovableModalForm>
+    );
+};
+
+export default AssignWriterModal;

+ 174 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperCourseList/index.tsx

@@ -0,0 +1,174 @@
+import { SuperTable } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import ExamPlanController from '@/services/apis/ExamPlanController';
+import { ExamPaperWriterType } from '@/services/enums';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel, useParams } from '@umijs/max';
+import { useRequest } from 'ahooks';
+import { App, Space, theme } from 'antd';
+import { useRef, useState } from 'react';
+import AssignWriterModal from './components/AssignWriterModal';
+
+/** 试卷列表 */
+const ExamPaperCourseList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const typeRef = useRef<ExamPaperWriterType>();
+    const actionRef = useRef<ActionType>();
+    const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
+
+    const [assignWriterOpen, setAssignWriterOpen] = useState(false);
+
+    const { getDictValueEnum } = useModel('useDict');
+    const { token } = theme.useToken();
+    const { message } = App.useApp();
+
+    const { data, loading } = useRequest(() => {
+        return ExamPlanController.getById({ id: reqParams.examPlanId });
+    });
+
+    const columns: ProColumns<API.ExamPaperLiteOutput>[] = [
+        {
+            title: '年级',
+            dataIndex: ['grade', 'name'],
+            width: 120,
+            align: 'center',
+        },
+        {
+            title: '科目',
+            dataIndex: ['course', 'name'],
+            width: 96,
+            align: 'center',
+        },
+        {
+            title: '总分',
+            dataIndex: 'score',
+            width: 96,
+            align: 'center',
+        },
+        {
+            title: '双向细目表编制人',
+            dataIndex: ['twclSysUser', 'name'],
+            width: 144,
+            align: 'center',
+        },
+        {
+            title: '双向细目表编制状态',
+            dataIndex: 'twclStatus',
+            valueEnum: getDictValueEnum('audit_status', true),
+            width: 160,
+            align: 'center',
+        },
+        {
+            title: '问题建议撰写人',
+            dataIndex: ['suggestionSysUser', 'name'],
+            width: 144,
+            align: 'center',
+        },
+        {
+            title: '问题建议撰写状态',
+            dataIndex: 'suggestionStatus',
+            valueEnum: getDictValueEnum('audit_status', true),
+            width: 160,
+            align: 'center',
+        },
+        {
+            title: '备注',
+            dataIndex: 'remark',
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <a onClick={() => history.push(`/exam-c/ep/course/detail/${r.id}`)}>
+                        去管理
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    const handleAssignWriter = (type: ExamPaperWriterType) => {
+        if (!selectedRowKeys || selectedRowKeys.length === 0) {
+            message.error('至少选择一个科目');
+            return;
+        }
+        typeRef.current = type;
+        setAssignWriterOpen(true);
+    }
+
+    return (
+        <PageContainer
+            title={`${data?.fullName ?? ''} - 双向细目表管理`}
+            onBack={() => history.back()}
+            loading={loading}
+        >
+            <SuperTable<API.ExamPaperLiteOutput>
+                toolbar={{
+                    title: '科目列表',
+                }}
+                actionRef={actionRef}
+                columns={columns}
+                search={false}
+                columnEmptyText=""
+                request={async () => {
+                    const res = await ExamPaperController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
+                    return {
+                        data: res,
+                        success: true,
+                    };
+                }}
+                rowSelection={{
+                    alwaysShowAlert: true,
+                    selectedRowKeys,
+                    onSelect: (record, selected, selectedRows) => {
+                        setSelectedRowKeys(selectedRows.map((t) => t.id) || []);
+                    },
+                    onSelectAll: (_, selectedRows) => {
+                        setSelectedRowKeys(selectedRows.map((t) => t.id) || []);
+                    },
+                }}
+                tableAlertRender={({ selectedRowKeys, onCleanSelected }) => {
+                    return (
+                        <Space size="large">
+                            <span>已选 {selectedRowKeys.length} 项</span>
+                            <a onClick={() => {
+                                setSelectedRowKeys([]);
+                                onCleanSelected();
+                            }}>取消选择</a>
+                        </Space>
+                    );
+                }}
+                tableAlertOptionRender={() => {
+                    return (
+                        <Space size="large">
+                            <a onClick={() => handleAssignWriter(ExamPaperWriterType.TWCL)}>分配双向细目表编制人</a>
+                            <a onClick={() => handleAssignWriter(ExamPaperWriterType.SUGGESTION)}>分配问题建议撰写人</a>
+                        </Space>
+                    );
+                }}
+            />
+
+            {assignWriterOpen && selectedRowKeys && selectedRowKeys.length > 0 && typeRef.current &&
+                <AssignWriterModal
+                    ids={selectedRowKeys}
+                    type={typeRef.current}
+                    onFinish={() => {
+                        setSelectedRowKeys([]);
+                        actionRef.current?.reload();
+                        typeRef.current = undefined;
+                    }}
+                    onClose={() => setAssignWriterOpen(false)}
+                />
+            }
+        </PageContainer>
+    );
+};
+
+export default ExamPaperCourseList;

+ 139 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/components/ExamPaperQuestionMajorEditModal.tsx

@@ -0,0 +1,139 @@
+import { MovableModalForm } from '@/components';
+import ExamPaperQuestionMajorController from '@/services/apis/ExamPaperQuestionMajorController';
+import { FormInstance, ProFormDigit, ProFormText, ProFormTextArea } from '@ant-design/pro-components';
+import { App, Col, Row, theme } from 'antd';
+import { useRef, useState } from 'react';
+
+/** 添加或修改大题 */
+const ExamPaperQuestionMajorEditModal: React.FC<{
+    data: Partial<API.ExamPaperQuestionMajorOutput>;
+    totalScore: number;
+    onFinish: () => void;
+    onClose?: () => void;
+}> = ({ data, totalScore, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const { token } = theme.useToken();
+
+    const formRef = useRef<FormInstance>();
+    const { message } = App.useApp();
+
+    return (
+        <>
+            <MovableModalForm<{
+                sequence: number;
+                number: string;
+                title: string;
+                hint: string;
+                score: number;
+                remark: string;
+            }>
+                title={`${data.id !== 0 ? '修改' : '添加'}大题`}
+                width={720}
+                open={open}
+                formRef={formRef}
+                initialValues={{ ...data }}
+                modalProps={{
+                    centered: true,
+                    maskClosable: false,
+                    onCancel: handleClose,
+                }}
+                onFinish={async (values: any) => {
+                    try {
+                        const p: API.AddExamPaperQuestionMajorInput = {
+                            ...values,
+                            examPaperId: data.examPaperId,
+                        };
+                        if (data.id === 0) {
+                            await ExamPaperQuestionMajorController.add(p);
+                        } else {
+                            let updateParams = { ...p, id: data.id } as API.UpdateExamPaperQuestionMajorInput;
+                            await ExamPaperQuestionMajorController.update(updateParams);
+                        }
+                        message.success('操作成功!');
+                        formRef?.current?.resetFields();
+                        onFinish();
+                        handleClose();
+                        return true;
+                    } catch {
+                        message.error('操作失败!');
+                        return false;
+                    }
+                }}
+            >
+                <Row gutter={[token.marginLG, 0]}>
+                    <Col span={8}>
+                        <ProFormDigit
+                            label="显示顺序号"
+                            name="sequence"
+                            rules={[{ required: true }]}
+                        />
+                    </Col>
+                    <Col span={8}>
+                        <ProFormDigit
+                            label="分值"
+                            name="score"
+                            fieldProps={{
+                                min: 0,
+                                max: totalScore,
+                                precision: 2,
+                                step: 1,
+                            }}
+                            rules={[
+                                { required: true },
+                                () => ({
+                                    validator: (_, value) => {
+                                        if (value === undefined || value === null || value === '' || (value > 0 && value <= totalScore)) {
+                                            return Promise.resolve();
+                                        }
+                                        return Promise.reject(`分值取值范围为:大于0,且小于等于${totalScore}`);
+                                    },
+                                }),
+                            ]}
+                        />
+                    </Col>
+                    <Col span={8}>
+                        <ProFormText
+                            label="题号"
+                            tooltip="一般为中文数字"
+                            name="number"
+                            fieldProps={{
+                                maxLength: 20,
+                                showCount: true,
+                            }}
+                            rules={[{ required: true }]}
+                        />
+                    </Col>
+                </Row>
+                <ProFormText
+                    label="标题"
+                    name="title"
+                    fieldProps={{
+                        maxLength: 100,
+                        showCount: true,
+                    }}
+                />
+                <ProFormTextArea
+                    label="提示语"
+                    name="hint"
+                    fieldProps={{
+                        maxLength: 200,
+                        showCount: true,
+                    }}
+                />
+
+                <ProFormTextArea
+                    label="备注说明"
+                    name="remark"
+                    fieldProps={{
+                        maxLength: 200,
+                        showCount: true,
+                    }}
+                />
+            </MovableModalForm >
+        </>
+    );
+};
+
+export default ExamPaperQuestionMajorEditModal;

+ 135 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/components/ExamPaperQuestionMajorModal.tsx

@@ -0,0 +1,135 @@
+import { MovableModal, SuperTable } from '@/components';
+import ExamPaperQuestionMajorController from '@/services/apis/ExamPaperQuestionMajorController';
+import { ActionType } from '@ant-design/pro-components';
+import { App, Button, Space } from 'antd';
+import { useCallback, useRef, useState } from 'react';
+import ExamPaperQuestionMajorEditModal from './ExamPaperQuestionMajorEditModal';
+
+/** 试卷大题管理 */
+const ExamPaperQuestionMajorModal: React.FC<{
+    examPaperId: number;
+    totalScore: number;
+    onClose?: () => void;
+}> = ({ examPaperId, totalScore, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const actionRef = useRef<ActionType>();
+    const [editOpen, setEditOpen] = useState(false);
+    const currentRef = useRef<Partial<API.ExamPaperQuestionMajorOutput>>();
+
+    const { modal, message } = App.useApp();
+
+    // 删除
+    const handleDelete = useCallback(async (id: number) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamPaperQuestionMajorController.del({ id });
+                message.success('已删除');
+                actionRef.current?.reload();
+            },
+        });
+    }, [actionRef.current]);
+
+    return (
+        <>
+            <MovableModal
+                title="大题管理"
+                width={1080}
+                open={open}
+                onCancel={handleClose}
+                footer={false}
+            >
+                <SuperTable
+                    headerTitle="大题列表"
+                    actionRef={actionRef}
+                    search={false}
+                    cardProps={false}
+                    options={{ setting: false, fullScreen: false }}
+                    toolbar={{
+                        actions: [
+                            <Button key="edit" type="primary" onClick={() => {
+                                currentRef.current = { id: 0, examPaperId };
+                                setEditOpen(true);
+                            }}>添加大题</Button>,
+                        ],
+                    }}
+                    columns={[
+                        {
+                            title: '顺序',
+                            dataIndex: 'sequence',
+                            width: 48,
+                            align: 'center',
+                        },
+                        {
+                            title: '题号',
+                            dataIndex: 'number',
+                            width: 80,
+                            align: 'center',
+                        },
+                        {
+                            title: '标题',
+                            dataIndex: 'title',
+                            width: 160,
+                        },
+                        {
+                            title: '提示语',
+                            dataIndex: 'hint',
+                        },
+                        {
+                            title: '分值',
+                            dataIndex: 'score',
+                            width: 80,
+                            align: 'center',
+                        },
+                        {
+                            title: '备注',
+                            dataIndex: 'remark',
+                            width: 96,
+                        },
+                        {
+                            title: '操作',
+                            valueType: 'option',
+                            width: 96,
+                            align: 'center',
+                            render: (_, r) => {
+                                return (
+                                    <Space>
+                                        <a onClick={() => {
+                                            currentRef.current = r;
+                                            setEditOpen(true);
+                                        }}>修改</a>
+                                        <a onClick={() => handleDelete(r.id)}>删除</a>
+                                    </Space>
+                                );
+                            },
+                        },
+                    ]}
+                    request={async () => {
+                        const res = await ExamPaperQuestionMajorController.getListByExamPaperId({ exampaperid: examPaperId });
+                        return {
+                            data: res,
+                            success: true,
+                        };
+                    }}
+                />
+            </MovableModal>
+
+            {editOpen && currentRef.current &&
+                <ExamPaperQuestionMajorEditModal
+                    data={currentRef.current}
+                    totalScore={totalScore}
+                    onClose={() => setEditOpen(false)}
+                    onFinish={() => actionRef.current?.reload()}
+                />
+            }
+        </>
+    );
+};
+
+export default ExamPaperQuestionMajorModal;

+ 98 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/components/ExamPaperQuestionMinorSettingModal.tsx

@@ -0,0 +1,98 @@
+import { MovableModalForm } from '@/components';
+import ExamPaperQuestionMinorController from '@/services/apis/ExamPaperQuestionMinorController';
+import { ProFormDigit, ProFormItem, ProFormSelect } from '@ant-design/pro-components';
+import { useModel } from '@umijs/max';
+import { App } from 'antd';
+import { useState } from 'react';
+
+/** 批量设置小题 */
+const ExamPaperQuestionMinorSettingModal: React.FC<{
+    ids: number[];
+    examPaperQuestionMajorList: API.ExamPaperQuestionMajorOutput[];
+    onFinish: () => void;
+    onClose?: () => void;
+}> = ({ ids, examPaperQuestionMajorList, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const { message } = App.useApp();
+    const { getDictOptions } = useModel('useDict');
+
+    return (
+        <MovableModalForm<{
+            examPaperQuestionMajorId?: number;
+            score: number;
+        }>
+            title="批量更新小题"
+            width={640}
+            open={open}
+            modalProps={{
+                centered: true,
+                maskClosable: false,
+                onCancel: handleClose,
+            }}
+            onFinish={async (values: any) => {
+                try {
+                    const { questionCatalog, ...restValues } = values;
+                    const p: API.BatchUpdateExamPaperQuestionMinorInput = {
+                        ...restValues,
+                        ids,
+                        questionCatalog: questionCatalog ? JSON.parse(`${questionCatalog}`) : undefined,
+                    };
+                    await ExamPaperQuestionMinorController.batchUpdate(p);
+                    message.success('操作成功!');
+                    onFinish();
+                    handleClose();
+                    return true;
+                } catch {
+                    message.error('操作失败!');
+                    return false;
+                }
+            }}
+        >
+            <ProFormItem label="批量设置小题数量">
+                {ids.length}
+            </ProFormItem>
+            <ProFormSelect
+                label="大题"
+                name="examPaperQuestionMajorId"
+                fieldProps={{
+                    options: examPaperQuestionMajorList?.map(t => ({
+                        label: `${t.number}${t.title !== '' ? '、' : ''}${t.title}`,
+                        value: t.id,
+                    })) ?? []
+                }}
+            />
+            <ProFormSelect
+                label="题型"
+                name="questionCatalog"
+                fieldProps={{
+                    options: getDictOptions('question_catalog').filter(t => t.value > 0),
+                }}
+            />
+            <ProFormDigit
+                label="分值"
+                name="score"
+                fieldProps={{
+                    min: 0,
+                    max: 100,
+                    precision: 2,
+                    step: 1,
+                }}
+                rules={[
+                    { required: true },
+                    () => ({
+                        validator: (_, value) => {
+                            if (value === undefined || value === null || value === '' || (value > 0 && value <= 100)) {
+                                return Promise.resolve();
+                            }
+                            return Promise.reject(`分值取值范围为:大于0,且小于等于${100}`);
+                        },
+                    }),
+                ]}
+            />
+        </MovableModalForm>
+    );
+};
+
+export default ExamPaperQuestionMinorSettingModal;

+ 398 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/ExamPaperDetail/index.tsx

@@ -0,0 +1,398 @@
+import { CardStepTitle } from "@/components";
+import ExamPaperController from "@/services/apis/ExamPaperController";
+import ExamPaperQuestionMajorController from "@/services/apis/ExamPaperQuestionMajorController";
+import ExamPaperQuestionMinorController from "@/services/apis/ExamPaperQuestionMinorController";
+import { ReloadOutlined } from "@ant-design/icons";
+import { ActionType, EditableFormInstance, EditableProTable, PageContainer, ProCard, ProColumns, ProDescriptions } from "@ant-design/pro-components";
+import { history, useModel, useParams } from "@umijs/max";
+import { App, Button, Space, Table, Typography, theme } from "antd";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import ExamPaperQuestionMajorModal from "./components/ExamPaperQuestionMajorModal";
+import ExamPaperQuestionMinorSettingModal from "./components/ExamPaperQuestionMinorSettingModal";
+
+/**
+ * 试卷详情
+ */
+const ExamPaperDetail: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPaperId: number };
+    const { token } = theme.useToken();
+
+    const { getDictOptions } = useModel('useDict');
+
+    const { message } = App.useApp();
+
+    const [majorOpen, setMajorOpen] = useState(false);
+    const [batchSettingOpen, setBatchSettingOpen] = useState(false);
+
+    // const currentEditRef = useRef<React.Key>();
+    const actionRef = useRef<ActionType>();
+    const editorFormRef = useRef<EditableFormInstance<API.ExamPaperQuestionMinorOutput>>();
+    const [editableKeys, setEditableRowKeys] = useState<React.Key[]>();
+
+    const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
+
+    const [data, setData] = useState<API.ExamPaperOutput>();
+    const loadData = useCallback(async () => {
+        const res = await ExamPaperController.getById({ id: reqParams.examPaperId });
+        setData(res);
+    }, [reqParams]);
+
+    const [majorList, setMajorList] = useState<API.ExamPaperQuestionMajorOutput[]>();
+    const loadMajor = useCallback(async () => {
+        const ms = await ExamPaperQuestionMajorController.getListByExamPaperId({ exampaperid: reqParams.examPaperId });
+        setMajorList(ms);
+    }, []);
+
+    useEffect(() => { loadData(); loadMajor(); }, []);
+
+    // 批量设置
+    const handleBatchSetting = () => {
+        if (!selectedRowKeys || selectedRowKeys.length === 0) {
+            message.error('至少选择一个小题');
+            return;
+        }
+        setBatchSettingOpen(true);
+    }
+
+    const columns: ProColumns<API.ExamPaperQuestionMinorOutput>[] = [
+        {
+            title: '序',
+            dataIndex: 'sequence',
+            width: 72,
+            align: 'center',
+            fixed: 'left',
+        },
+        {
+            title: '大题',
+            dataIndex: 'examPaperQuestionMajorId',
+            width: 144,
+            className: 'minw-64',
+            valueType: 'select',
+            fieldProps: {
+                options: majorList?.map(t => ({ label: `${t.number}${t.title !== '' ? '、' : ''}${t.title}`, value: t.id })) ?? []
+            },
+        },
+        {
+            title: '列名',
+            dataIndex: 'columnName',
+            width: 128,
+        },
+        {
+            title: '小题号',
+            dataIndex: 'name',
+            width: 128,
+        },
+        {
+            title: '分值',
+            dataIndex: 'score',
+            // width: 64,
+            // align: 'center',
+            // editable: false,
+            tooltip: '分值取值范围为:大于0,且小于等于100',
+            valueType: 'digit',
+            width: 80 + token.paddingMD,
+            fieldProps: {
+                style: { width: 80 },
+                min: 0,
+                max: 100,
+                precision: 2,
+                step: 0.5,
+            },
+            formItemProps: {
+                required: true,
+                rules: [
+                    { required: true },
+                    () => ({
+                        validator: (_, value) => {
+                            if (value === undefined || value === null || value === '' || (value > 0 && value <= 100)) {
+                                return Promise.resolve();
+                            }
+                            return Promise.reject('分值取值范围为:大于0,且小于等于100');
+                        },
+                    }),
+                ],
+            },
+        },
+        {
+            title: '题型',
+            dataIndex: 'questionCatalog',
+            valueType: 'select',
+            width: 96 + token.paddingMD,
+            fieldProps: {
+                options: getDictOptions('question_catalog').filter(t => t.value > 0),
+                style: { width: 96 },
+            },
+            // formItemProps: {
+            //     required: true,
+            //     rules: [{ required: true }],
+            // },
+        },
+        {
+            title: '认知能力',
+            dataIndex: 'cognitiveAbility',
+            valueType: 'select',
+            width: 96 + token.paddingMD,
+            fieldProps: {
+                options: getDictOptions('cognitive_ability').filter(t => t.value > 0),
+                style: { width: 96 },
+            },
+            // formItemProps: {
+            //     required: true,
+            //     rules: [{ required: true }],
+            // },
+        },
+        {
+            title: '预估难度',
+            dataIndex: 'estimatedDifficulty',
+            tooltip: '预估难度取值范围为:大于等于0,小于等于1',
+            valueType: 'digit',
+            width: 80 + token.paddingMD,
+            fieldProps: {
+                style: { width: 80 },
+                min: 0,
+                max: 1,
+                precision: 2,
+                step: 0.1,
+            },
+            // formItemProps: {
+            //     required: true,
+            //     rules: [
+            //         { required: true },
+            //         () => ({
+            //             validator: (_, value) => {
+            //                 if (value === undefined || value === null || value === '' || (value >= 0 && value <= 1)) {
+            //                     return Promise.resolve();
+            //                 }
+            //                 return Promise.reject('预估难度取值范围为:大于等于0,小于等于1');
+            //             },
+            //         }),
+            //     ],
+            // },
+        },
+        {
+            title: '知识模块',
+            dataIndex: 'knowledgeModule',
+            valueType: 'text',
+            className: 'minw-80',
+            fieldProps: {
+                maxLength: 200,
+                showCount: true,
+            },
+            // formItemProps: {
+            //     required: true,
+            //     rules: [{ required: true }],
+            // },
+        },
+        {
+            title: '知识点',
+            dataIndex: 'knowledgePoint',
+            valueType: 'text',
+            className: 'minw-80',
+            fieldProps: {
+                maxLength: 200,
+                showCount: true,
+            },
+            // formItemProps: {
+            //     required: true,
+            //     rules: [{ required: true }],
+            // },
+        },
+        {
+            title: '选做',
+            dataIndex: 'isChoose',
+            valueType: 'switch',
+            width: 72,
+            align: 'center',
+            fieldProps: {
+                checkedChildren: '是',
+                unCheckedChildren: '否'
+            },
+        },
+        {
+            title: '最小项',
+            dataIndex: 'isLeaf',
+            valueType: 'switch',
+            width: 72,
+            align: 'center',
+            fieldProps: {
+                checkedChildren: '是',
+                unCheckedChildren: '否'
+            },
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 88,
+            fixed: 'right',
+            render: (v, r, _, action) => [
+                <a key="edit" onClick={() => action?.startEditable?.(r.id)}>编辑</a>,
+            ],
+        },
+    ];
+
+    return (
+        <PageContainer
+            title={`${data?.examPlan?.fullName ?? ''} - 双向细目表编制`}
+            onBack={() => history.back()}
+        >
+            <ProCard title={<CardStepTitle>基本信息</CardStepTitle>}>
+                <ProDescriptions size="small">
+                    <ProDescriptions.Item label="年级">
+                        {data?.grade?.name}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="科目">
+                        {data?.course?.name}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="总分">
+                        {data?.score}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="备注">
+                        {data?.remark}
+                    </ProDescriptions.Item>
+                </ProDescriptions>
+            </ProCard>
+
+            <EditableProTable<API.ExamPaperQuestionMinorOutput>
+                toolbar={{
+                    title: <CardStepTitle>双向细目表</CardStepTitle>,
+                    actions: [
+                        <Button key="major" type="primary" onClick={() => setMajorOpen(true)}>大题管理</Button>,
+                        <Button key="reload" icon={<ReloadOutlined />} type="text" onClick={loadData} />,
+                    ],
+                }}
+                sticky={{ offsetHeader: 56 }}
+                style={{ marginTop: token.margin }}
+                scroll={{ x: 'max-content' }}
+                value={[...(data?.examPaperQuestionMinors ?? [])]}
+                columns={columns}
+                bordered
+                actionRef={actionRef}
+                editableFormRef={editorFormRef}
+                rowKey="id"
+                editable={{
+                    editableKeys,
+                    // onValuesChange: (record, recordList) => {
+                    //     if (!data) { return; }
+                    //     const d = { ...data, examPaperQuestionMinors: recordList };
+                    //     setData(d);
+                    // },
+                    onChange: setEditableRowKeys,
+                    actionRender: (r, config, dom) => [dom.save, dom.cancel],
+                    onSave: async (key, record) => {
+                        const p: API.UpdateExamPaperQuestionMinorInput = {
+                            id: record.id,
+                            examPaperId: record.examPaperId,
+                            examPaperQuestionMajorId: record.examPaperQuestionMajorId ?? 0,
+                            questionCatalog: record.questionCatalog ? JSON.parse(`${record.questionCatalog}`) : undefined,
+                            cognitiveAbility: record.cognitiveAbility ? JSON.parse(`${record.cognitiveAbility}`) : undefined,
+                            sequence: record.sequence,
+                            columnName: record.columnName,
+                            name: record.name,
+                            score: record.score,
+                            knowledgeModule: record.knowledgeModule,
+                            knowledgePoint: record.knowledgePoint,
+                            estimatedDifficulty: record.estimatedDifficulty,
+                            isChoose: record.isChoose,
+                            isLeaf: record.isLeaf,
+                        };
+                        await ExamPaperQuestionMinorController.update(p);
+                        const rdata = { ...data }
+                        const ti = rdata?.examPaperQuestionMinors?.findIndex(t => t.id === key) ?? -1;
+                        if (rdata.examPaperQuestionMinors && ti !== -1) {
+                            rdata.examPaperQuestionMinors[ti] = record;
+                            setData(rdata as API.ExamPaperOutput);
+                        }
+                    },
+                    // onCancel: async () => {
+                    //     currentEditRef.current = undefined;
+                    // },
+                }}
+                recordCreatorProps={false}
+                summary={(rows) => {
+                    const tsc = rows.map(t => t.score).reduce((p, c) => p + c, 0);
+                    const type = tsc !== data?.score ? 'danger' : 'success';
+                    return (
+                        <Table.Summary.Row style={{ fontWeight: 'bold' }}>
+                            <Table.Summary.Cell index={0} colSpan={5} align="center">小题分值合计(总分)</Table.Summary.Cell>
+                            <Table.Summary.Cell index={5}>
+                                <Typography.Text type={type}>{tsc}</Typography.Text>
+                            </Table.Summary.Cell>
+                            <Table.Summary.Cell index={6} colSpan={8}>
+                                <Typography.Text type={type}>与科目总分({data?.score}){type === 'danger' ? `不符` : '相符'}</Typography.Text>
+                            </Table.Summary.Cell>
+                        </Table.Summary.Row>
+                    );
+                }}
+                rowSelection={{
+                    selectedRowKeys,
+                    onSelect: (record, selected, selectedRows) => {
+                        setSelectedRowKeys(selectedRows.map((t) => t.id) || []);
+                    },
+                    onSelectAll: (_, selectedRows) => {
+                        setSelectedRowKeys(selectedRows.map((t) => t.id) || []);
+                    },
+                    alwaysShowAlert: true,
+                }}
+                tableAlertRender={({ selectedRowKeys, onCleanSelected }) => {
+                    return (
+                        <Space size="large">
+                            <span>已选 {selectedRowKeys.length} 项</span>
+                            {selectedRowKeys.length > 0 &&
+                                <a onClick={() => {
+                                    setSelectedRowKeys([]);
+                                    onCleanSelected();
+                                }}>取消选择</a>
+                            }
+                        </Space>
+                    );
+                }}
+                tableAlertOptionRender={() => {
+                    return (
+                        <Space size="large">
+                            <a onClick={() => handleBatchSetting()}>批量设置</a>
+                        </Space>
+                    );
+                }}
+            // onRow={(r) => {
+            //     return {
+            //         onClick: () => {
+            //             if (currentEditRef.current === r.id) {
+            //                 return;
+            //             }
+            //             else if (currentEditRef.current) {
+            //                 actionRef.current?.cancelEditable?.(currentEditRef.current);
+            //             }
+            //             currentEditRef.current = r.id;
+            //             actionRef.current?.startEditable?.(r.id);
+            //         },
+            //     };
+            // }}
+            />
+
+            {majorOpen &&
+                <ExamPaperQuestionMajorModal
+                    examPaperId={reqParams.examPaperId}
+                    totalScore={data?.score ?? 0}
+                    onClose={() => {
+                        setMajorOpen(false);
+                        loadMajor();
+                    }}
+                />
+            }
+            {batchSettingOpen &&
+                <ExamPaperQuestionMinorSettingModal
+                    ids={selectedRowKeys}
+                    examPaperQuestionMajorList={majorList ?? []}
+                    onFinish={() => {
+                        loadData();
+                        setSelectedRowKeys([]);
+                    }}
+                    onClose={() => setBatchSettingOpen(false)}
+                />
+            }
+        </PageContainer>
+    );
+}
+
+export default ExamPaperDetail;
+

+ 130 - 0
YBEE.EQM.Admin/src/pages/exam-center/exam-paper/index.tsx

@@ -0,0 +1,130 @@
+import { toSelectOptions } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel } from '@umijs/max';
+import { Typography, theme } from 'antd';
+import { useRef } from 'react';
+
+/** 管理端:双向细目表计划列表 */
+const ExamPaperExamPlanList: React.FC = () => {
+    const actionRef = useRef<ActionType>();
+
+    const { token } = theme.useToken();
+
+    const { getDictValueEnum } = useModel('useDict');
+    const { baseData } = useModel('useBaseData');
+
+    const columns: ProColumns<API.ExamPaperTodoPlanOutput>[] = [
+        {
+            title: '计划名称',
+            dataIndex: 'examPlanFullName',
+            // width: 480,
+            search: {
+                transform: (v) => ({ name: v }),
+            },
+            // render: (v) => {
+            //     return `${v}双向细目表编制计划`;
+            // },
+        },
+        {
+            title: '监测学期',
+            dataIndex: 'semesterId',
+            search: {
+                transform: (v) => v ? ({ semesterId: JSON.parse(v) }) : v,
+            },
+            valueType: 'select',
+            fieldProps: {
+                options: toSelectOptions(baseData?.semesters ?? [], (item) => ({ key: item.id, label: item.nickShortName, value: item.id })),
+            },
+            hideInTable: true,
+            order: 90,
+        },
+        {
+            title: '计划状态',
+            dataIndex: 'examPlanStatus',
+            valueEnum: getDictValueEnum('exam_status', true),
+            width: 80,
+            align: 'center',
+        },
+        {
+            title: '总数量',
+            dataIndex: 'totalCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        {
+            title: '细目表编制未提交',
+            dataIndex: 'twclUnsubmitCount',
+            width: 144,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="warning">{v}</Typography.Text> : null,
+        },
+        {
+            title: '细目表编制已提交',
+            dataIndex: 'twclSubmittedCount',
+            width: 144,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="success">{v}</Typography.Text> : null,
+        },
+        {
+            title: '问题建议未提交',
+            dataIndex: 'suggestionUnsubmitCount',
+            width: 128,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="warning">{v}</Typography.Text> : null,
+        },
+        {
+            title: '问题建议已提交',
+            dataIndex: 'suggestionSubmittedCount',
+            width: 128,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="success">{v}</Typography.Text> : null,
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <a onClick={() => history.push(`/exam-c/ep/course/${r.examPlanId}`)}>
+                        去管理
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer title={false} >
+            <SuperTable<API.ExamPaperTodoPlanOutput>
+                toolbar={{
+                    title: '计划列表',
+                }}
+                rowKey="examPlanId"
+                actionRef={actionRef}
+                columns={columns}
+                // scroll={{ x: 'max-content' }}
+                columnEmptyText=""
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamPaperController.queryExamPlanPageList({ ...p });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default ExamPaperExamPlanList;

+ 308 - 0
YBEE.EQM.Admin/src/pages/exam-center/sample/ExamSampleDetail/index.tsx

@@ -0,0 +1,308 @@
+import { CardStepTitle, SuperTable, TagStatus } from "@/components";
+import ExamSampleController from "@/services/apis/ExamSampleController";
+import ExamStudentController from "@/services/apis/ExamStudentController";
+import { CheckCircleFilled } from "@ant-design/icons";
+import { ActionType, PageContainer, ProCard, ProColumns, ProDescriptions } from "@ant-design/pro-components";
+import { useEmotionCss } from "@ant-design/use-emotion-css";
+import { history, useModel, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { App, FloatButton, Tag, theme } from "antd";
+import lodash from 'lodash';
+import { useCallback, useRef } from "react";
+
+type ClassItem = API.ExamStudentCountItem & {
+    isFirstGrade: boolean;
+
+    classes: {
+        [key: number]: {
+            schoolClassId: string;
+            studentCount: number;
+            isSampleAll: boolean;
+        }
+    }
+};
+
+const EnabledStatus: React.FC<{ enabled: boolean; enabledText?: string; disabledText?: string; }> = ({ enabled, enabledText, disabledText }) => {
+    return (<Tag color={enabled ? 'success' : 'error'}>{enabled ? enabledText ?? '启用' : disabledText ?? '禁用'}</Tag>);
+}
+
+const ExamSampleDetail: React.FC = () => {
+    const reqParams = useParams() as unknown as { id: number };
+
+    const classStuCountClassName = useEmotionCss(({ token }) => {
+        return {
+            position: 'relative',
+            borderRadius: token.borderRadiusXS,
+            '&.selected': {
+                color: token.colorWhite,
+                backgroundColor: token.colorSuccess,
+                paddingRight: token.paddingSM,
+                '.check-icon': {
+                    position: 'absolute',
+                    bottom: 2,
+                    right: 2,
+                    fontSize: token.fontSizeSM,
+                }
+            },
+            ':hover': {
+                cursor: 'pointer',
+                backgroundColor: token.colorLinkHover,
+            }
+        };
+    });
+
+    const { token } = theme.useToken();
+    const { getDictValueEnum, getKeyDict } = useModel('useDict');
+    const examSampleStatusDict = getKeyDict('exam_sample_status');
+
+    const { message, modal } = App.useApp();
+
+    const actionRef = useRef<ActionType>();
+    // const currentRef = useRef<Partial<API.ExamStudentCountItem>>();
+
+    const { data, run, loading } = useRequest(async () => {
+        const examSample = await ExamSampleController.getById({ id: reqParams.id });
+        const res = await ExamStudentController.queryStudentCountPageList({ pageIndex: 1, pageSize: 9999, examPlanId: examSample?.examPlanId ?? 0 });
+
+        const maxClassNumber = (lodash.maxBy(res?.items, 'classNumber') as any)?.classNumber ?? 0;
+
+        const hasInAll = (schoolClassId: string) => examSample?.config.sampleAllSchoolClassIds?.some(t => t === schoolClassId) ?? false;
+
+        let list: ClassItem[] = [];
+        res?.items?.forEach(t => {
+            let item = list.find(o => o.sysOrgId === t.sysOrgId && o.gradeId === t.gradeId) as ClassItem;
+            if (!item) {
+                item = {
+                    ...t,
+                    isFirstGrade: !list.some(o => o.sysOrgId === t.sysOrgId),
+                    classes: {
+                        [t.classNumber]: {
+                            schoolClassId: t.schoolClassId,
+                            studentCount: t.studentCount,
+                            isSampleAll: hasInAll(t.schoolClassId),
+                        },
+                    }
+                } as ClassItem;
+                list.push(item);
+            }
+            else {
+                item.classes[t.classNumber] = {
+                    schoolClassId: t.schoolClassId,
+                    studentCount: t.studentCount,
+                    isSampleAll: hasInAll(t.schoolClassId),
+                };
+            }
+        });
+
+        return {
+            examSample,
+            list,
+            maxClassNumber,
+        };
+    });
+
+    // 切换班级全抽
+    const handleSwitchAll = useCallback(async (schoolClassId: string, isAdd: boolean) => {
+        modal.confirm({
+            // title: `提示`,
+            content: `确认${isAdd ? '加入' : '取消'}全抽吗?`,
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamSampleController.switchExamSampleAllClass({
+                    id: reqParams.id,
+                    schoolClassId,
+                    isAdd,
+                });
+                message.success(`${isAdd ? '加入' : '取消'}`);
+                run();
+                actionRef.current?.reload();
+            },
+        });
+    }, []);
+
+    // 班级列
+    const classColumns = lodash.range(1, (data?.maxClassNumber ?? 0) + 1).map(cn => {
+        return {
+            title: cn,
+            dataIndex: ['classes', `${cn}`, 'studentCount'],
+            width: 56,
+            align: 'center',
+            hideInSearch: true,
+            render: (v, r: ClassItem) => {
+                const c = r.classes[cn];
+                if (!c) {
+                    return null;
+                }
+                if (data?.examSample?.isFixedExamSample) {
+                    return v;
+                }
+                return (
+                    <div
+                        className={`${classStuCountClassName}${c?.isSampleAll ? ' selected' : ''}`}
+                        onClick={() => handleSwitchAll(c.schoolClassId, !c.isSampleAll)}
+                    >
+                        {v}
+                        {c?.isSampleAll && <CheckCircleFilled className="check-icon" />}
+                    </div>
+                );
+            },
+        } as ProColumns;
+    }) ?? [];
+
+    const columns: ProColumns<ClassItem>[] = [
+        {
+            title: '学校代码',
+            dataIndex: 'sysOrgCode',
+            width: 80,
+            align: 'center',
+            onCell: (r) => {
+                if (r.isFirstGrade === true) {
+                    return { rowSpan: r.gradeCount };
+                }
+                return { rowSpan: 0 };
+            },
+        },
+        {
+            title: '学校名称',
+            dataIndex: 'sysOrgName',
+            width: 180,
+            onCell: (r) => {
+                if (r.isFirstGrade === true) {
+                    return { rowSpan: r.gradeCount };
+                }
+                return { rowSpan: 0 };
+            },
+        },
+        {
+            title: '城乡类别',
+            dataIndex: 'urbanRuralType',
+            valueEnum: getDictValueEnum('urban_rural_type'),
+            width: 80,
+            align: 'center',
+            onCell: (r) => {
+                if (r.isFirstGrade === true) {
+                    return { rowSpan: r.gradeCount };
+                }
+                return { rowSpan: 0 };
+            },
+        },
+        {
+            title: '年级',
+            dataIndex: 'gradeName',
+            // render: (v, r) => {
+            //     return `${v}(${r.gradeBeginYear}级)`;
+            // },
+            width: 80,
+            align: 'center',
+        },
+        {
+            title: '学生人数',
+            dataIndex: 'studentCount',
+            valueType: 'digitRange',
+            hideInTable: true,
+            search: {
+                transform: (v) => ({
+                    studentCountMin: v[0],
+                    studentCountMax: v[1],
+                }),
+            },
+            fieldProps: {
+                placeholder: ['最少数量', '最多数量'],
+            },
+        },
+        ...classColumns,
+    ];
+
+    const status = data?.examSample?.status ? examSampleStatusDict[data?.examSample?.status] : undefined;
+
+    return (
+        <PageContainer
+            title={data?.examSample?.fullName}
+            onBack={() => history.back()}
+        >
+            <ProCard
+                title={<CardStepTitle>基本信息</CardStepTitle>}
+            >
+                <ProDescriptions size="small">
+                    <ProDescriptions.Item label="监测名称">
+                        {data?.examSample?.name}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="监测简称">
+                        {data?.examSample?.shortName}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="抽样比例">
+                        {data?.examSample?.config?.percent}%
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item
+                        label="年级限制"
+                        tooltip="年级仅有一个班,且该班学生人数小于等于X人,该班全抽"
+                    >
+                        <EnabledStatus enabled={data?.examSample?.config?.isEnabledOnlyOneClassStudentMin ?? false} />
+                        {data?.examSample?.config?.isEnabledOnlyOneClassStudentMin ? data?.examSample?.config?.onlyOneClassStudentMin : ''}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item
+                        label="最少未抽"
+                        tooltip="年级多于一个班,且年级未抽样部分学生人数小于X人,该年级全抽"
+                    >
+                        <EnabledStatus enabled={data?.examSample?.config?.isEnabledGradeNoSampleStudentMin ?? false} />
+                        {data?.examSample?.config?.isEnabledGradeNoSampleStudentMin ? data?.examSample?.config?.gradeNoSampleStudentMin : ''}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item
+                        label="班级限制"
+                        tooltip="班级学生人数小于等于X人,该班全抽"
+                    >
+                        <EnabledStatus enabled={data?.examSample?.config?.isEnabledClassStudentMin ?? false} />
+                        {data?.examSample?.config?.isEnabledClassStudentMin ? data?.examSample?.config?.classStudentMin : ''}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item
+                        label="特殊排除"
+                        tooltip="抽样时排除特殊学生"
+                    >
+                        <EnabledStatus enabled={data?.examSample?.config?.isExcludeSpecialStudent ?? false} />
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item
+                        label="随机序号"
+                        tooltip="在年级内随机打乱监测号顺序"
+                    >
+                        <EnabledStatus enabled={data?.examSample?.config?.isGradeSeatNumberRandom ?? false} />
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="开始位置">
+                        {data?.examSample?.config.startPosition}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="抽样间距">
+                        {data?.examSample?.config.interval}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="方案状态">
+                        <TagStatus status={status?.antStatus} fill>{status?.name}</TagStatus>
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="方案选定">
+                        <EnabledStatus enabled={data?.examSample?.isSelected ?? false} enabledText="已选定" disabledText="未选定" />
+                    </ProDescriptions.Item>
+                </ProDescriptions>
+            </ProCard>
+            <SuperTable<ClassItem>
+                style={{ marginTop: token.margin }}
+                actionRef={actionRef}
+                loading={loading}
+                search={false}
+                // search={{ filterType: 'light' }}
+                scroll={{ x: 'max-content' }}
+                toolbar={{
+                    title: <CardStepTitle>全抽班级</CardStepTitle>,
+                    subTitle: !data?.examSample?.isFixedExamSample ? '点击下面班级单元格切换全抽班级' : '',
+                }}
+                options={{ setting: false, fullScreen: false }}
+                rowKey="schoolClassId"
+                columns={columns}
+                dataSource={data?.list ?? []}
+                pagination={false}
+            />
+
+            <FloatButton.BackTop visibilityHeight={100} />
+        </PageContainer>
+    );
+}
+
+export default ExamSampleDetail;

+ 22 - 0
YBEE.EQM.Admin/src/pages/exam-center/student/special-student-audit/ExamSpecialStudentAuditList/index.tsx → YBEE.EQM.Admin/src/pages/exam-center/special-student/special-student-audit/ExamSpecialStudentAuditList/index.tsx

@@ -62,6 +62,28 @@ const ExamSpecialStudentAuditList: React.FC = () => {
             hideInSearch: true,
             renderText: (v) => v ? v : null,
         },
+        {
+            title: '前期已认定',
+            dataIndex: 'preIdentifiedCount',
+            width: 88,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        // {
+        //     title: '待审核',
+        //     dataIndex: 'auditCount',
+        //     width: 80,
+        //     align: 'center',
+        //     hideInSearch: true,
+        //     render: (_, r) => {
+        //         const v = r.auditCount - r.preIdentifiedCount;
+        //         if (v <= 0) {
+        //             return null;
+        //         }
+        //         return (<Typography.Text type="warning">{v}</Typography.Text>);
+        //     },
+        // },
         {
             title: '待审核',
             dataIndex: 'auditCount',

+ 102 - 53
YBEE.EQM.Admin/src/pages/exam-center/student/special-student-audit/ExamSpecialStudentAuditOrg/index.tsx → YBEE.EQM.Admin/src/pages/exam-center/special-student/special-student-audit/ExamSpecialStudentAuditOrg/index.tsx

@@ -28,7 +28,7 @@ const ExamSpecialStudentAuditOrg: React.FC = () => {
 
     const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
     const [auditShow, setAuditShow] = useState<{ open: boolean, ids: number[] } | undefined>();
-    const [activeKey, setActiveKey] = useState<React.Key>('0');
+    const [activeKey, setActiveKey] = useState<React.Key>('2');
     const [statusCount, seStatusCount] = useState<Record<number, number>>({});
 
     const [detailOpen, setDetailOpen] = useState(false);
@@ -71,13 +71,56 @@ const ExamSpecialStudentAuditOrg: React.FC = () => {
         seStatusCount(d);
     }, []);
 
+    // 审核
+    const handleAudit = async (ids: number[], formValues: AuditFormValueType) => {
+        return new Promise<boolean>((resolve, reject) => {
+            modal.confirm({
+                title: '提交确认',
+                content: '确认立即提交吗?',
+                okText: '确定',
+                cancelText: '取消',
+                centered: true,
+                onOk: async () => {
+                    try {
+                        await ExamSpecialStudentAuditController.audit({ ids, ...formValues });
+                        message.success('已提交');
+                        resolve(true);
+                        setSelectedRowKeys([]);
+                        actionRef.current?.reload();
+                    }
+                    catch {
+                        reject(false);
+                    }
+                },
+                onCancel: () => resolve(false),
+            });
+        });
+    }
+
+    // 反审
+    const handleReaudit = useCallback(async (id: number) => {
+        modal.confirm({
+            title: '提交确认',
+            content: '确认立即反审吗?',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamSpecialStudentAuditController.reaudit({ id })
+                message.success('已反审');
+                setSelectedRowKeys([]);
+                actionRef.current?.reload();
+            },
+        });
+    }, []);
+
     // 明细表列定义
     const detailColumns: ProColumns<API.ExamSpecialStudentOutput>[] = [
         {
             title: '审核状态',
             dataIndex: 'status',
             hideInSearch: true,
-            width: 80,
+            width: 96,
             align: 'center',
             valueEnum: getDictValueEnum('audit_status', true),
             render: (_, r) => {
@@ -86,15 +129,27 @@ const ExamSpecialStudentAuditOrg: React.FC = () => {
                 // }
                 const s = auditStatusDict[r.status];
                 const ta = <Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>;
-                if (!r.isPreIdentified) {
-                    return ta;
+                if (r.isPreIdentified) {
+                    return (
+                        <Space direction="vertical" size="small" style={{ lineHeight: 1 }}>
+                            {ta}
+                            <Typography.Text type="warning" style={{ fontSize: token.fontSizeSM }}>往期已认定</Typography.Text>
+                        </Space>
+                    );
                 }
-                return (
-                    <Space direction="vertical" size="small">
-                        {ta}
-                        <Typography.Text type="warning" style={{ fontSize: '80%' }}>往期已认定</Typography.Text>
-                    </Space>
-                );
+                if (r.preTotalScore !== null) {
+
+                    return (
+                        <Space direction="vertical" size="small" style={{ lineHeight: 1 }}>
+                            {ta}
+                            <Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
+                                前期{r.preTotalCourse}科得分
+                                <Typography.Text type="warning" strong style={{ fontSize: token.fontSizeSM }}>{r.preTotalScore}</Typography.Text>
+                            </Typography.Text>
+                        </Space>
+                    );
+                }
+                return ta;
             },
         },
         // {
@@ -128,15 +183,23 @@ const ExamSpecialStudentAuditOrg: React.FC = () => {
         {
             title: '年级',
             dataIndex: 'gradeId',
-            width: 144,
+            width: 80,
             align: 'center',
             valueEnum: toValueEnum(data?.examGrades?.map(t => ({ id: t.gradeId, name: `${t.grade.name}(${t.gradeBeginName})` })) ?? []),
+            render: (_, r) => {
+                return (
+                    <Space size="small" direction="vertical" style={{ lineHeight: 1 }}>
+                        {r.examGrade?.grade.name}
+                        <Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>({r.examGrade?.gradeBeginName})</Typography.Text>
+                    </Space>
+                );
+            },
         },
         {
             title: '班级',
             dataIndex: 'classNumber',
             valueType: 'digit',
-            width: 64,
+            width: 56,
             align: 'center',
             render: (_, r) => `${r.classNumber}班`,
         },
@@ -243,56 +306,38 @@ const ExamSpecialStudentAuditOrg: React.FC = () => {
             align: 'center',
             fixed: 'right',
             render: (_, r) => {
-                if (r.isPreIdentified || !auditable || ![AuditStatus.AUDIT, AuditStatus.APPROVE_CANCELED].includes(r.status)) {
+                if (!auditable) {
                     return null;
                 }
-                return (
-                    <Button
-                        type="link"
-                        size="small"
-                        onClick={() => { setAuditShow({ open: true, ids: [r.id] }); }}
-                    >
-                        {r.status === AuditStatus.AUDIT ? '审核' : '反审'}
-                    </Button>
-                );
+
+                if ([AuditStatus.AUDIT, AuditStatus.APPROVE_CANCELED].includes(r.status)) {
+                    return (
+                        <Button
+                            type="link"
+                            size="small"
+                            onClick={() => { setAuditShow({ open: true, ids: [r.id] }); }}
+                        >审核</Button>
+                    );
+                }
+                if (r.status === AuditStatus.APPROVED) {
+                    return (
+                        <Button
+                            type="link"
+                            size="small"
+                            onClick={() => handleReaudit(r.id)}
+                        >反审</Button>
+                    );
+                }
+                return null;
             },
         }
     ];
 
-    // 审核
-    const handleAudit = async (ids: number[], formValues: AuditFormValueType) => {
-        return new Promise<boolean>((resolve, reject) => {
-            modal.confirm({
-                title: '提交确认',
-                content: '确认立即提交吗?',
-                okText: '确定',
-                cancelText: '取消',
-                centered: true,
-                onOk: async () => {
-                    try {
-                        await ExamSpecialStudentAuditController.audit({ ids, ...formValues });
-                        message.success('已提交');
-                        resolve(true);
-                        setSelectedRowKeys([]);
-                        actionRef.current?.reload();
-                    }
-                    catch {
-                        reject(false);
-                    }
-                },
-                onCancel: () => resolve(false),
-            });
-        });
-    }
-
     // 呈现状态 tab
     const renderTabItems = useCallback(() => {
-        let items: { key: string; label: React.ReactNode }[] = [{
-            key: '0',
-            label: (<span>全部<TabBadge count={statusCount[0]} active={activeKey === 0} /></span>),
-        }];
+        let items: { key: string; label: React.ReactNode }[] = [];
         items = items.concat(
-            getDict('audit_status')?.filter(t => t.value > 1)?.map((t) => ({
+            getDict('audit_status')?.filter(t => t.value > 1)?.sort((a, b) => a.sort - b.sort)?.map((t) => ({
                 key: `${t.value}`,
                 label: (
                     <span>
@@ -302,6 +347,10 @@ const ExamSpecialStudentAuditOrg: React.FC = () => {
                 ),
             })),
         );
+        items.push({
+            key: '0',
+            label: (<span>全部<TabBadge count={statusCount[0]} active={activeKey === 0} /></span>),
+        });
         return items;
     }, [activeKey, statusCount]);
 

+ 147 - 0
YBEE.EQM.Admin/src/pages/exam-center/special-student/special-student-audit/index.tsx

@@ -0,0 +1,147 @@
+import { toSelectOptions } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamSpecialStudentAuditController from '@/services/apis/ExamSpecialStudentAuditController';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel } from '@umijs/max';
+import { Typography, theme } from 'antd';
+import { useRef } from 'react';
+
+/** 监测特殊学生审核计划列表 */
+const ExamSpecialStudentAuditPlanList: React.FC = () => {
+    const actionRef = useRef<ActionType>();
+
+    const { getDictValueEnum } = useModel('useDict');
+    const { baseData } = useModel('useBaseData');
+    const { token } = theme.useToken();
+
+    const columns: ProColumns<API.ExamPlanAuditOutput>[] = [
+        {
+            title: '计划名称',
+            dataIndex: 'fullName',
+            // width: 480,
+            search: {
+                transform: (v) => ({ name: v }),
+            },
+        },
+        {
+            title: '监测学期',
+            dataIndex: 'semesterId',
+            search: {
+                transform: (v) => v ? ({ semesterId: JSON.parse(v) }) : v,
+            },
+            valueType: 'select',
+            fieldProps: {
+                options: toSelectOptions(baseData?.semesters ?? [], (item) => ({ key: item.id, label: item.nickShortName, value: item.id })),
+            },
+            hideInTable: true,
+            order: 90,
+        },
+        {
+            title: '计划状态',
+            dataIndex: 'status',
+            valueEnum: getDictValueEnum('exam_status', true),
+            width: 80,
+            align: 'center',
+        },
+        {
+            title: '学校数量',
+            dataIndex: 'orgCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        {
+            title: '总人数',
+            dataIndex: 'totalCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        {
+            title: '前期已认定',
+            dataIndex: 'preIdentifiedCount',
+            width: 88,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        // {
+        //     title: '待审核',
+        //     dataIndex: 'auditCount',
+        //     width: 80,
+        //     align: 'center',
+        //     hideInSearch: true,
+        //     render: (_, r) => {
+        //         const v = r.auditCount - r.preIdentifiedCount;
+        //         if (v <= 0) {
+        //             return null;
+        //         }
+        //         return (<Typography.Text type="warning">{v}</Typography.Text>);
+        //     },
+        // },
+        {
+            title: '待审核',
+            dataIndex: 'auditCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="warning">{v}</Typography.Text> : null,
+        },
+        {
+            title: '已通过',
+            dataIndex: 'approvedCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="success">{v}</Typography.Text> : null,
+        },
+        {
+            title: '已驳回',
+            dataIndex: 'rejectedCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v, r) => r.rejectedCount > 0 ? <Typography.Text type="danger">{v}</Typography.Text> : null,
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <a onClick={() => history.push(`/exam-c/sp-stu-audit/list/${r.id}`)}>
+                        去处理
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer title={false} >
+            <SuperTable<API.ExamPlanAuditOutput>
+                toolbar={{
+                    title: '监测计划',
+                }}
+                actionRef={actionRef}
+                columns={columns}
+                scroll={{ x: 'max-content' }}
+                columnEmptyText=""
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamSpecialStudentAuditController.queryExamPlanPageList({ ...p });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default ExamSpecialStudentAuditPlanList;

+ 38 - 23
YBEE.EQM.Admin/src/pages/exam-org/OrgExamPlanDetail/components/OrgExamDataPublishList.tsx

@@ -1,9 +1,9 @@
 import { downloadFileByBlob } from "@/common/net/download";
 import { CardStepTitle, FileLink } from "@/components";
 import ExamOrgResultController from "@/services/apis/ExamOrgResultController";
-import { DataPublishType, ExamStatus } from "@/services/enums";
+import { DataPublishType, ExamStatus, PublishStatus } from "@/services/enums";
 import { ActionType, ProTable } from "@ant-design/pro-components";
-import { history, useModel } from "@umijs/max";
+import { history } from "@umijs/max";
 import { Space, theme } from "antd";
 import { useRef } from "react";
 
@@ -14,7 +14,7 @@ const OrgExamDataPublishList: React.FC<{ examPlanId: number, examPlanStatus?: Ex
     const actionRef = useRef<ActionType>();
     const dataRef = useRef<API.ExamDataPublishOrgResultOutput[]>();
 
-    const { getDictValueEnum } = useModel('useDict');
+    // const { getDictValueEnum } = useModel('useDict');
 
     return (
         <>
@@ -38,19 +38,22 @@ const OrgExamDataPublishList: React.FC<{ examPlanId: number, examPlanStatus?: Ex
                         align: 'center',
                         render: (_, r, ri) => ri + 1,
                     },
-                    {
-                        title: '类型',
-                        dataIndex: 'type',
-                        valueEnum: getDictValueEnum('data_publish_type'),
-                        width: 80,
-                        align: 'center',
-                        fixed: 'left',
-                    },
+                    // {
+                    //     title: '类型',
+                    //     dataIndex: 'type',
+                    //     valueEnum: getDictValueEnum('data_publish_type'),
+                    //     width: 80,
+                    //     align: 'center',
+                    //     fixed: 'left',
+                    // },
                     {
                         title: '名称',
                         dataIndex: 'name',
-                        width: 240,
+                        width: 320,
                         render: (v, r) => {
+                            if (r.status !== PublishStatus.PUBLISHED) {
+                                return v;
+                            }
                             if (r.type === DataPublishType.QUESTIONNAIRE_PROGRESS) {
                                 return <a onClick={() => history.push(`/exam-s/questionnaire/patriarch/progress/${examPlanId}`)}>{v}</a>
                             }
@@ -60,18 +63,17 @@ const OrgExamDataPublishList: React.FC<{ examPlanId: number, examPlanStatus?: Ex
                     {
                         title: '备注说明',
                         dataIndex: 'remark',
-                        ellipsis: true,
-                        // width: 160,
-                    },
-                    {
-                        title: '发布状态',
-                        dataIndex: 'status',
-                        valueEnum: getDictValueEnum('publish_status', true),
-                        width: 88,
-                        align: 'center',
+                        width: 480,
                     },
+                    // {
+                    //     title: '发布状态',
+                    //     dataIndex: 'status',
+                    //     valueEnum: getDictValueEnum('publish_status', true),
+                    //     width: 88,
+                    //     align: 'center',
+                    // },
                     {
-                        title: '发布时间',
+                        title: '反馈时间',
                         dataIndex: 'publishTime',
                         width: 144,
                         align: 'center',
@@ -79,7 +81,11 @@ const OrgExamDataPublishList: React.FC<{ examPlanId: number, examPlanStatus?: Ex
                     {
                         title: '文件列表',
                         valueType: 'option',
+                        className: 'minw-80',
                         render: (_, r) => {
+                            if (r.status !== PublishStatus.PUBLISHED) {
+                                return null;
+                            }
                             const li = r.examOrgResultList?.map((t, i) => {
                                 return (
                                     <FileLink
@@ -109,8 +115,17 @@ const OrgExamDataPublishList: React.FC<{ examPlanId: number, examPlanStatus?: Ex
                         width: 64,
                         align: 'center',
                         render: (_, r) => {
+                            if (r.status !== PublishStatus.PUBLISHED) {
+                                return null;
+                            }
                             if (r.type === DataPublishType.QUESTIONNAIRE_PROGRESS) {
-                                return <a onClick={() => history.push(`/exam-s/questionnaire/patriarch/progress/${examPlanId}`)}>查看</a>
+                                return (<a onClick={() => history.push(`/exam-s/questionnaire/patriarch/progress/${examPlanId}`)}>查看</a>);
+                            }
+                            if (r.type === DataPublishType.STUDENT_SAMPLE_LIST) {
+                                return (<a onClick={() => history.push(`/exam-s/plan/sample-list/${r.id}`)}>查看</a>);
+                            }
+                            if (r.type === DataPublishType.STUDENT_SAMPLE_COUNT_LIST) {
+                                return (<a onClick={() => history.push(`/exam-s/plan/sample-count/${r.id}`)}>查看</a>);
                             }
                             return null;
                         },

+ 7 - 1
YBEE.EQM.Admin/src/pages/exam-org/OrgExamPlanDetail/components/OrgExamDataReportList.tsx

@@ -91,7 +91,7 @@ const OrgExamDataReportList: React.FC<{
                     title: '上报状态',
                     dataIndex: ['examOrgDataReport', 'status'],
                     valueEnum: getDictValueEnum('data_report_status', true),
-                    width: 88,
+                    width: 80,
                     align: 'center',
                     render: (v, r) => {
                         if (!r.examOrgDataReport?.status) {
@@ -130,6 +130,12 @@ const OrgExamDataReportList: React.FC<{
                                         case DataReportType.TEACHER_COURSE:
                                             history.push(`/exam-s/t-course/report/${examPlanId}`);
                                             break;
+                                        case DataReportType.ABSENT_REPLACE:
+                                            history.push(`/exam-s/absent/report/${examPlanId}`);
+                                            break;
+                                        case DataReportType.SCHOOL_EXAM_SCORE:
+                                            history.push(`/exam-s/school-exam-score/report/${examPlanId}`);
+                                            break;
                                     }
                                 }}
                             >{linkText}</Button>

+ 134 - 0
YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceImport/components/ExamAbsentReplaceImportEditModal.tsx

@@ -0,0 +1,134 @@
+import { MovableModalForm } from '@/components';
+import { FormInstance, ProFormDigit, ProFormSelect, ProFormText, ProFormTextArea } from '@ant-design/pro-components';
+import { Col, Row } from 'antd';
+import { useRef, useState } from 'react';
+
+/** 修改缺测替补导入信息 */
+const ExamAbsentReplaceImportEditModal: React.FC<{
+    data: Partial<API.UploadExamAbsentReplaceOutput>;
+    examGrades: API.ExamGradeOutput[];
+    onFinish: (values: API.UploadExamAbsentReplaceOutput) => void;
+    onClose?: () => void;
+}> = ({ data, examGrades, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const formRef = useRef<FormInstance>();
+
+    return (
+        <>
+            <MovableModalForm<API.UploadExamAbsentReplaceOutput>
+                title="修改导入缺测替补学生信息"
+                width={800}
+                open={open}
+                formRef={formRef}
+                initialValues={{
+                    ...data,
+                }}
+                modalProps={{
+                    centered: true,
+                    maskClosable: false,
+                    onCancel: () => {
+                        formRef?.current?.resetFields();
+                        handleClose();
+                    },
+                }}
+                onFinish={async (values) => {
+                    const { examGradeId, ...restValues } = values;
+                    const eg = examGrades.find(t => t.id === examGradeId);
+                    onFinish({
+                        ...data,
+                        ...restValues,
+                        examGradeId,
+                        gradeId: eg?.gradeId,
+                        isSuccess: true,
+                        errorMessage: [],
+                    });
+                    handleClose();
+                }}
+            >
+                <Row gutter={[24, 0]}>
+                    <Col span={12}>
+                        <ProFormSelect
+                            label="年级"
+                            name="examGradeId"
+                            options={examGrades?.sort((a, b) => a.grade.gradeNumber - b.grade.gradeNumber)?.map(t => ({ label: `${t.grade.fullName}(${t.gradeBeginName})`, value: t.id }))}
+                            required
+                            rules={[{ required: true }]}
+                        />
+                    </Col>
+                    <Col span={12}>
+                        <ProFormDigit
+                            label="班级"
+                            tooltip="班级最小1,最大35"
+                            name="classNumber"
+                            min={1}
+                            max={35}
+                            required
+                            rules={[{ required: true }]}
+                        />
+                    </Col>
+                    <Col span={12}>
+                        <ProFormText
+                            label="姓名"
+                            name="name"
+                            required
+                            rules={[{ required: true, min: 2, max: 100 }]}
+                            fieldProps={{
+                                minLength: 2,
+                                maxLength: 100,
+                                showCount: true,
+                            }}
+                        />
+                    </Col>
+                    <Col span={12}>
+                        <ProFormText
+                            label="监测号"
+                            name="idNumber"
+                            dependencies={['certificateType']}
+                            fieldProps={{
+                                maxLength: 18,
+                                showCount: true,
+                            }}
+                            required
+                        />
+                    </Col>
+                    <Col span={12}>
+                        <ProFormText
+                            label="家长电话"
+                            name="patriarchTel"
+                            fieldProps={{
+                                maxLength: 50,
+                                showCount: true
+                            }}
+                            required
+                            rules={[{ required: true, min: 8 }]}
+                        />
+                    </Col>
+                </Row>
+                <ProFormTextArea
+                    label="特殊原因"
+                    name="applyReason"
+                    fieldProps={{
+                        maxLength: 2000,
+                        rows: 4,
+                        showCount: true,
+                    }}
+                    required
+                    rules={[{ required: true, min: 1 }]}
+                />
+                <ProFormTextArea
+                    label="备注"
+                    name="remark"
+                    fieldProps={{
+                        maxLength: 200,
+                        rows: 4,
+                        showCount: true,
+                    }}
+                />
+            </MovableModalForm >
+        </>
+    );
+};
+
+export default ExamAbsentReplaceImportEditModal;

+ 565 - 0
YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceImport/index.tsx

@@ -0,0 +1,565 @@
+import { toValueEnum } from "@/common/converter";
+import { CardStepTitle, StatusIcon, UploadWrongLocalHeaderError } from "@/components";
+import ExamAbsentReplaceController from "@/services/apis/ExamAbsentReplaceController";
+import ExamGradeController from "@/services/apis/ExamGradeController";
+import ExamPlanController from "@/services/apis/ExamPlanController";
+import SysOrgController from "@/services/apis/SysOrgController";
+import { DataImportMode } from "@/services/enums";
+import { BulbOutlined, InboxOutlined } from "@ant-design/icons";
+import { ActionType, PageContainer, ProCard, ProColumns, ProForm, ProFormInstance, ProFormSelect, ProTable } from "@ant-design/pro-components";
+import { useModel, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { Alert, App, Button, Space, Tooltip, Tour, Typography, Upload, theme } from "antd";
+import type { RcFile, UploadFile, UploadProps } from "antd/lib/upload/interface";
+import { useRef, useState } from "react";
+import ExamAbsentReplaceImportEditModal from "./components/ExamAbsentReplaceImportEditModal";
+
+type ImportFormData = {
+    sysOrgBranchId?: number;
+};
+
+
+/** 缺测替补学生批量导入 */
+const OrgExamAbsentReplaceImport: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const formRef = useRef<ProFormInstance<ImportFormData>>();
+    const actionRef = useRef<ActionType>();
+
+    const [tourOpen, setTourOpen] = useState(false);
+    const currentRef = useRef<API.UploadExamAbsentReplaceOutput>();
+    const [editOpen, setEditOpen] = useState(false);
+
+    const { token } = theme.useToken();
+    const { message, modal, notification } = App.useApp();
+
+    const { getKeyDict } = useModel('useDict');
+    const educationStages = getKeyDict('education_stage');
+    const { initialState } = useModel('@@initialState');
+    const { currentUser } = initialState ?? {};
+
+    const { baseData } = useModel('useBaseData');
+
+    const [fileList, setFileList] = useState<UploadFile[]>([]);
+    const [uploading, setUploading] = useState(false);
+    const [data, setData] = useState<API.UploadExamDataOutput_UploadExamAbsentReplaceOutput>();
+
+    const { data: examBranchPlan, loading } = useRequest(async () => {
+        const res1 = await ExamGradeController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
+        const res2 = await SysOrgController.getOrgBranchByOrgId({ orgid: currentUser?.sysOrgId ?? 0 });
+        const res3 = await ExamPlanController.getById({ id: reqParams.examPlanId });
+        return {
+            examGrades: res1 ?? [],
+            branches: res2 ?? [],
+            plan: res3,
+            hasDistrict: (res2?.length ?? 0) > 0,
+        };
+    });
+
+    const tourDistrictRef = useRef(null);
+    const tourDownloadRef = useRef(null);
+    const tourChooseFileRef = useRef(null);
+    const tourUploadRef = useRef(null);
+    const tourAbsentReplaceCountRef = useRef(null);
+    const tourStuDetailRef = useRef(null);
+    const tourSubmitRef = useRef(null);
+
+
+    const uploadProps: UploadProps = {
+        onRemove: () => {
+            setFileList([]);
+        },
+        beforeUpload: (file) => {
+            setFileList([file]);
+            return false;
+        },
+        fileList,
+        multiple: false,
+        accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel',
+    };
+
+    const handleBack = () => history.back();
+
+    // 计算统计数据
+    const handleClassTotal = (rows: API.UploadExamAbsentReplaceOutput[]) => {
+        const total = rows.length;
+        const s = rows.filter(t => t.isSuccess === true)?.length ?? 0;
+        setData(v => ({ ...v, totalRowCount: total, successRowCount: s, errorRowCount: total - s }));
+    }
+
+    // 上传
+    const handleUpload = async () => {
+        const formData = new FormData();
+        formData.append('examPlanId', `${reqParams.examPlanId}`);
+        fileList.forEach((file) => {
+            formData.append('file', file as RcFile);
+        });
+        setUploading(true);
+        try {
+            const res = await ExamAbsentReplaceController.upload(formData);
+            setData(res);
+        }
+        catch { }
+        finally {
+            setUploading(false);
+        }
+    }
+
+    // 保存编辑
+    const handleEdit = (v: API.UploadExamAbsentReplaceOutput) => {
+        const i = data?.rows?.findIndex(t => t.rowNumber === v.rowNumber) ?? -1;
+        if (i !== -1) {
+            const d = { ...data };
+            if (d.rows) {
+                d.rows[i] = v;
+                setData(d);
+                handleClassTotal(d.rows ?? []);
+            }
+        }
+    }
+
+    // 删除行
+    const handleDelete = (rowNumber: number) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                const i = data?.rows?.findIndex(t => t.rowNumber === rowNumber) ?? -1;
+                if (i !== -1) {
+                    const d = { ...data };
+                    if (d.rows) {
+                        d.rows.splice(i, 1);
+                        setData(d);
+                        handleClassTotal(d.rows ?? []);
+                        message.success('已删除');
+                        return;
+                    }
+                }
+                message.error('删除失败');
+            },
+        });
+    }
+
+    // 确认导入
+    const handleImport = async () => {
+        try {
+            const importFormValues = await formRef.current?.validateFields();
+            if (examBranchPlan?.hasDistrict && !importFormValues) {
+                return;
+            }
+
+            const items = [...(data?.rows ?? [])];
+            if (items.some(t => t.isSuccess === false)) {
+                message.error('还有校验未通过的数据不能导入');
+                return;
+            }
+            const res = await ExamAbsentReplaceController.importAction({
+                examPlanId: reqParams.examPlanId,
+                ...(importFormValues || {}),
+                items: items.map(t => ({
+                    examPlanId: reqParams.examPlanId,
+                    gradeId: t.gradeId ?? 0,
+                    examGradeId: t.examGradeId ?? 0,
+                    classNumber: t.classNumber,
+                    absentName: t.absentName ?? '',
+                    absentExamNumber: t.absentExamNumber ?? '',
+                    absentCourses: JSON.stringify(t.absentCourseList ?? []),
+                    absentReason: t.absentReason ?? '',
+                    isReplaced: t.isReplaced ?? false,
+                    replaceName: t.replaceName,
+                    replaceExamNumber: t.replaceExamNumber,
+                    patriarchTel: t.patriarchTel,
+                    remark: t.remark,
+                })),
+                dataImportMode: DataImportMode.CLEAR_APPEND,
+            });
+            notification.success({
+                message: '导入成功',
+                description: `提交数量:${items.length},成功导入数量:${res ?? 0},相同监测号未导入数量:${items.length - (res ?? 0)}。`,
+                duration: 5
+            })
+            handleBack();
+        }
+        catch {
+            message.error('校区未选择');
+            window.scrollTo({ top: 0, behavior: 'smooth' });
+        }
+    }
+
+    const columns: ProColumns<API.UploadExamAbsentReplaceOutput>[] = [
+        {
+            title: '验证',
+            valueType: 'option',
+            width: 48,
+            align: 'center',
+            render: (_, r) => {
+                if (r.isSuccess) {
+                    return (<StatusIcon status="success" filled />);
+                }
+                return (
+                    <Tooltip title={r.errorMessage}>
+                        <StatusIcon status="error" filled />
+                    </Tooltip>
+                );
+            },
+        },
+        {
+            title: '行号',
+            valueType: 'option',
+            render: (v, _, index) => {
+                return <Typography.Text type="secondary">{index + 1}</Typography.Text>;
+            },
+            width: 48,
+            align: 'center',
+        },
+        {
+            title: '年级',
+            dataIndex: 'gradeId',
+            width: 64,
+            align: 'center',
+            valueEnum: toValueEnum(baseData?.grades ?? [])
+        },
+        {
+            title: '班级',
+            dataIndex: 'classNumber',
+            width: 48,
+            align: 'center',
+        },
+        {
+            title: '缺测学生姓名',
+            dataIndex: 'absentName',
+            align: 'center',
+            width: 120,
+        },
+        {
+            title: '缺测学生监测号',
+            dataIndex: 'absentExamNumber',
+            width: 160,
+            align: 'center',
+        },
+        {
+            title: '缺测原因',
+            dataIndex: 'absentReason',
+            hideInSearch: true,
+            width: 320,
+        },
+        {
+            title: '缺测科目',
+            dataIndex: 'absentCourseText',
+            hideInSearch: true,
+            width: 320,
+        },
+        {
+            title: '家长电话',
+            dataIndex: 'patriarchTel',
+            hideInSearch: true,
+            width: 112,
+            align: 'center',
+        },
+        {
+            title: '替补学生姓名',
+            dataIndex: 'replaceName',
+            align: 'center',
+            width: 120,
+        },
+        {
+            title: '替补学生监测号',
+            dataIndex: 'replaceExamNumber',
+            width: 160,
+            align: 'center',
+        },
+        {
+            title: '备注',
+            dataIndex: 'remark',
+            ellipsis: true,
+            hideInSearch: true,
+            width: 96,
+        },
+        {
+            title: '校验结果',
+            dataIndex: 'errorMessage',
+            hideInSearch: true,
+            width: 280,
+            render: (_, r) => {
+                if (r.isSuccess) {
+                    return '通过';
+                }
+                return `${r.errorMessage?.join('、')}有误`;
+            },
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            fixed: 'right',
+            width: 112,
+            align: 'center',
+            render: (_, r) => {
+                return (
+                    <>
+                        <Button type="link" size="small" onClick={() => {
+                            currentRef.current = { ...r };
+                            setEditOpen(true);
+                        }}>修改</Button>
+                        <Button type="link" size="small" onClick={() => handleDelete(r.rowNumber)}>删除</Button>
+                    </>
+                );
+            },
+        },
+    ];
+
+    let educationStage: API.SysDictDataOutput | undefined;
+    if (examBranchPlan?.plan) {
+        educationStage = educationStages[examBranchPlan.plan.educationStage];
+    }
+
+    return (
+        <PageContainer
+            title="缺测替补学生信息批量导入"
+            onBack={handleBack}
+            loading={loading}
+            extra={
+                <Button
+                    type="link"
+                    icon={<BulbOutlined />}
+                    onClick={() => {
+                        window.scrollTo({ top: 0 });
+                        setTourOpen(true);
+                    }}
+                >查看操作指引</Button>
+            }
+        >
+            {examBranchPlan?.hasDistrict &&
+                <ProCard
+                    title={<CardStepTitle>第一步:选择校区</CardStepTitle>}
+                    ref={tourDistrictRef}
+                >
+                    <Alert type="warning" showIcon message="每个校区缺测替补学生信息填写在一个批量导入文件中,每个校区分开导入,有几个校区导入几次!" closable style={{ marginBottom: token.margin, color: token.colorError }} />
+                    <ProForm<ImportFormData>
+                        layout="inline"
+                        submitter={false}
+                        formRef={formRef}
+                        scrollToFirstError={true}
+                    >
+                        {examBranchPlan?.branches && examBranchPlan.branches.length > 0 &&
+                            <ProFormSelect
+                                label="校区"
+                                name="sysOrgBranchId"
+                                options={examBranchPlan.branches.map(t => ({ label: t.name, value: t.id }))}
+                                style={{ width: 240 }}
+                                required
+                                rules={[{ required: true, message: '校区必须选择' }]}
+                            />
+                        }
+                    </ProForm>
+                </ProCard>
+            }
+
+            <ProCard
+                title={<CardStepTitle>第{examBranchPlan?.hasDistrict ? '二' : '一'}步:下载模板</CardStepTitle>}
+                extra={educationStage &&
+                    <Button
+                        type="primary"
+                        ref={tourDownloadRef}
+                        onClick={() => {
+                            window.open(`/doc-templates/缺测替补学生信息(${educationStage?.name})上报-填报模板.xlsx`);
+                        }}
+                    >下载模板</Button>
+                }
+                style={{ marginTop: token.margin }}
+            >
+                <Typography.Paragraph>
+                    <Typography.Text type="warning" strong>批量上传文件填写说明及注意事项:</Typography.Text>
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>模板格式:</Typography.Text>
+                    模板文件中第一行为填写说明,第二行为标题行(从A至H列分别为年级号、班级号、缺测学生姓名、缺测学生监测号、缺测原因、缺测科目、家长电话、替补学生姓名、替补学生监测号、备注)。
+                    <Typography.Text type="danger">填报数据必须在第一个工作表,并不能删除填写说明和标题行,不能删除和添加表格列!</Typography.Text>
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>A. 年级号</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    小学:1~6,初中:7~9。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>B. 班级号</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    填写班级数字序号。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>C. 缺测学生姓名</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    缺测学生姓名真实姓名。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>D. 缺测学生监测号</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    按实际填写。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>E. 缺测原因</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    根据实际情况填写。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>F. 缺测科目</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    多个学科用“、”或“,”分隔;填写科目全称,如语文、数学、英语,
+                    <Typography.Text type="danger">不能填写简称,如语、数;</Typography.Text>
+                    <Typography.Text strong>道德与法治</Typography.Text>,请填写为
+                    <Typography.Text strong>政治</Typography.Text>
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>G. 家长电话</Typography.Text>
+                    (<Typography.Text type="danger">必填</Typography.Text>):
+                    家长手机号码或座机号码。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>H. 替补学生姓名</Typography.Text>:
+                    有替补学生时必填。
+                </Typography.Paragraph>
+                <Typography.Paragraph>
+                    <Typography.Text strong>I. 替补学生监测号</Typography.Text>:
+                    有替补学生时必填。
+                </Typography.Paragraph>
+
+                <Typography.Text type="danger">特别提醒:在填报电子表格文件时,一定要注意拖动复制单元格时年级号和班级号自动增长的情况。</Typography.Text>
+            </ProCard>
+
+            <ProCard
+                title={<CardStepTitle>第{examBranchPlan?.hasDistrict ? '三' : '二'}步:上传文件</CardStepTitle>}
+                style={{ marginTop: token.margin }}
+                ref={tourChooseFileRef}
+            >
+                <Space direction="vertical" style={{ width: '100%' }}>
+                    <Upload.Dragger {...uploadProps}>
+                        <p className="ant-upload-drag-icon"><InboxOutlined /></p>
+                        <p className="ant-upload-text">点击或拖入文件到此处</p>
+                    </Upload.Dragger>
+
+                    <Space>
+                        <Button
+                            type="primary"
+                            disabled={fileList.length === 0}
+                            loading={uploading}
+                            onClick={handleUpload}
+                            ref={tourUploadRef}
+                        >{uploading ? '上传中' : '立即上传'}</Button>
+                        <UploadWrongLocalHeaderError />
+                    </Space>
+
+                    {data?.structureCorrect === false &&
+                        <Alert
+                            type="error"
+                            showIcon
+                            message={data?.errorMessage?.join('')}
+                        />
+                    }
+                </Space>
+            </ProCard>
+
+            <ProCard
+                style={{ marginTop: token.margin }}
+                title={<CardStepTitle>第{examBranchPlan?.hasDistrict ? '四' : '三'}步:确认数据</CardStepTitle>}
+                subTitle="根据表格验证提示修改数据无误后提交导入"
+            >
+                <div ref={tourAbsentReplaceCountRef}>
+                    <Typography.Title level={5}>1.人数统计</Typography.Title>
+                    <Typography.Paragraph>
+                        总数量:<Typography.Text style={{ marginRight: token.marginLG }}>{data?.totalRowCount ?? 0}</Typography.Text>
+                        验证通过:<Typography.Text type="success" style={{ marginRight: token.marginLG }}>{data?.successRowCount ?? 0}</Typography.Text>
+                        验证失败:<Typography.Text type="danger" style={{ marginRight: token.marginLG }}>{data?.errorRowCount ?? 0}</Typography.Text>
+                    </Typography.Paragraph>
+                </div>
+                <div ref={tourStuDetailRef}>
+                    <ProTable<API.UploadExamAbsentReplaceOutput>
+                        actionRef={actionRef}
+                        cardProps={false}
+                        columns={columns}
+                        size="small"
+                        rowKey="rowNumber"
+                        bordered
+                        options={false}
+                        sticky={{ offsetHeader: 56 }}
+                        dataSource={[...data?.rows ?? []]}
+                        virtual
+                        scroll={{ y: Math.min(document.body.clientHeight - 56 - 24, 800) }}
+                        pagination={false}
+                        rowClassName={(r) => {
+                            if (!r.isSuccess) {
+                                return 'yb-row-error';
+                            }
+                            return '';
+                        }}
+                        toolbar={{
+                            title: <Typography.Title level={5}>2.缺测替补学生明细</Typography.Title>,
+                            actions: [
+                                <Typography.Text type="warning" key="tip">特别说明:上传的文件中与已录入缺测替补学生名单中有相同监测号的学生将不会导入!</Typography.Text>,
+                                <Button
+                                    key="import"
+                                    type="primary"
+                                    disabled={!data?.structureCorrect}
+                                    onClick={handleImport}
+                                    ref={tourSubmitRef}
+                                >确认导入</Button>
+                            ],
+                        }}
+                        search={false}
+                    />
+                </div>
+            </ProCard>
+
+            <Tour
+                steps={[
+                    {
+                        title: '下载模板',
+                        description: '下载批量导入缺测替补学生信息模板文件,请按模板填写说明及注意事项填写文件。',
+                        target: () => tourDownloadRef.current,
+                    },
+                    {
+                        title: '选择文件',
+                        description: '拖动文件至此区域,或点击该区域选择文件上传。',
+                        target: () => tourChooseFileRef.current,
+                    },
+                    {
+                        title: '上传文件',
+                        description: '选择好批量导入文件后,点击此按钮上传文件,后台解析后返回校验结果和数据。上传的文件中与已录入缺测替补学生名单中有相同证件号码的学生将不会导入!',
+                        target: () => tourUploadRef.current,
+                    },
+                    {
+                        title: '人数统计',
+                        description: '上传文件后会显示人数、验证通过和失败的数量。',
+                        target: () => tourAbsentReplaceCountRef.current,
+                    },
+                    {
+                        title: '缺测替补学生明细',
+                        description: '所有上传的缺测替补学生明细,请根据校验结果修改数据。',
+                        target: () => tourStuDetailRef.current,
+                    },
+                    {
+                        title: '确认导入',
+                        description: '检查修改确认批量导入缺测替补学生信息无误后确认导入。',
+                        target: () => tourSubmitRef.current,
+                    },
+                ]}
+                open={tourOpen}
+                onClose={() => setTourOpen(false)}
+            />
+
+            {editOpen && currentRef.current &&
+                <ExamAbsentReplaceImportEditModal
+                    examGrades={examBranchPlan?.examGrades ?? []}
+                    data={currentRef.current}
+                    onFinish={handleEdit}
+                    onClose={() => setEditOpen(false)}
+                />
+            }
+
+            {/* <FloatButton.BackTop visibilityHeight={100} /> */}
+        </PageContainer>
+    );
+}
+
+export default OrgExamAbsentReplaceImport;

+ 43 - 0
YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/components/ExamAbsentReplaceDetailDrawer.tsx

@@ -0,0 +1,43 @@
+import { AuditTimeline } from '@/components';
+import { ProDescriptions, ProDescriptionsItemProps } from '@ant-design/pro-components';
+import { Card, Drawer } from 'antd';
+import { useState } from 'react';
+
+export type ExamAbsentReplaceDetailDrawerProps = {
+    columns: ProDescriptionsItemProps<Partial<API.ExamAbsentReplaceOutput>>[];
+    data: Partial<API.ExamAbsentReplaceOutput>;
+    /** 关闭回调函数 */
+    onClose: () => void;
+};
+
+/** 监测缺测替补学生详情 */
+const ExamAbsentReplaceDetailDrawer: React.FC<ExamAbsentReplaceDetailDrawerProps> = ({ columns, data, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(onClose, 300); };
+    return (
+        <Drawer
+            title="缺测替补学生详情"
+            width={1080}
+            open={open}
+            onClose={handleClose}
+            className="yb-pro-desc hide-header"
+        >
+            <ProDescriptions
+                columns={columns}
+                column={1}
+                dataSource={data}
+                labelStyle={{ width: 96 }}
+            >
+                <ProDescriptions.Item label="审核记录">
+                    {data.auditList && data.auditList.length > 0 ?
+                        <Card style={{ width: '100%' }} bodyStyle={{ paddingBottom: 0 }}>
+                            <AuditTimeline auditList={data.auditList} />
+                        </Card> : '无'
+                    }
+                </ProDescriptions.Item>
+            </ProDescriptions>
+        </Drawer>
+    );
+};
+
+export default ExamAbsentReplaceDetailDrawer;

+ 353 - 0
YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/components/ExamAbsentReplaceEditModal.tsx

@@ -0,0 +1,353 @@
+import { toValueEnum } from '@/common/converter';
+import { MovableModalForm } from '@/components';
+import ExamAbsentReplaceController from '@/services/apis/ExamAbsentReplaceController';
+import ExamSampleStudentController from '@/services/apis/ExamSampleStudentController';
+import { FormInstance, ProFormDependency, ProFormDigit, ProFormRadio, ProFormSelect, ProFormText, ProFormTextArea } from '@ant-design/pro-components';
+import { useModel } from '@umijs/max';
+import { Alert, App, Col, Row, theme } from 'antd';
+import { useRef, useState } from 'react';
+
+export type GradeBranchData = {
+    examGrades: API.ExamGradeOutput[];
+    branches: API.SysOrgLiteOutput[];
+};
+
+/** 修改监测缺测替补学生信息 */
+const ExamAbsentReplaceEditModal: React.FC<{
+    examPlanId: number;
+    data: Partial<API.ExamAbsentReplaceOutput>;
+    gradeBranch: GradeBranchData;
+    onFinish: () => void;
+    onClose?: () => void;
+}> = ({ examPlanId, data, gradeBranch, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const formRef = useRef<FormInstance>();
+    const { message } = App.useApp();
+    const { initialState } = useModel('@@initialState');
+    const { currentUser } = initialState ?? {};
+    const { token } = theme.useToken();
+    const { baseData } = useModel('useBaseData');
+    const hasBranch = gradeBranch.branches && gradeBranch.branches.length > 0;
+
+    const handleReset = () => {
+        let enPrefix = '';
+
+        const gid = formRef.current?.getFieldValue('examGradeId') ?? 0;
+        const grade = gradeBranch.examGrades.find(t => t.id === gid);
+        const classNumber = formRef.current?.getFieldValue('classNumber');
+        if (currentUser?.sysOrg?.code && grade && classNumber) {
+            enPrefix = `${currentUser.sysOrg.code.padStart(3, '0')}${grade.grade.gradeNumber.toString().padStart(2, '0')}${classNumber.toString().padStart(2, '0')}`;
+        }
+
+        formRef.current?.setFieldsValue({
+            'absentExamNumber': enPrefix,
+            'absentName': '',
+            'patriarchTel': '',
+            'replaceExamNumber': enPrefix,
+            'replaceName': '',
+        });
+    }
+
+    return (
+        <MovableModalForm<API.ExamAbsentReplaceOutput & { courseIds: string[] }>
+            title={`${data.id === 0 ? '添加' : '修改'}缺测替补学生信息`}
+            width={800}
+            open={open}
+            formRef={formRef}
+            initialValues={{
+                ...data,
+                examGradeId: data.examGradeId !== undefined ? data.examGradeId : undefined,
+                sysOrgBranchId: data.sysOrgBranchId !== undefined ? data.sysOrgBranchId : undefined,
+                courseIds: (data.absentCourseList?.length ?? 0) > 0 ? data.absentCourseList?.map(t => `${t.id}`) : undefined,
+            }}
+            modalProps={{
+                centered: true,
+                maskClosable: false,
+                onCancel: () => {
+                    formRef?.current?.resetFields();
+                    handleClose();
+                },
+            }}
+            onFinish={async (values) => {
+                const { examGradeId, courseIds, isReplaced, replaceExamNumber, ...restValues } = values;
+                if (values.isReplaced && values.absentExamNumber === values.replaceExamNumber) {
+                    message.error('缺测学生监测号不能与替补学生监测号相同');
+                    return;
+                }
+                const grade = gradeBranch.examGrades.find(t => t.id === examGradeId);
+                if (!grade) {
+                    return;
+                }
+
+                const cs = baseData?.courses?.filter(t => courseIds.includes(`${t.id}`))?.map(t => ({ id: t.id, name: t.name, shortName: t.shortName }));
+
+                let p: API.AddExamAbsentReplaceInput = {
+                    ...restValues,
+                    examPlanId,
+                    examGradeId,
+                    gradeId: grade.gradeId,
+                    absentCourses: JSON.stringify(cs),
+                    isReplaced,
+                    replaceExamNumber: isReplaced ? replaceExamNumber : '',
+                };
+                if (data.id !== 0) {
+                    const up = { id: data.id ?? 0, ...p } as API.UpdateExamAbsentReplaceInput;
+                    await ExamAbsentReplaceController.update(up);
+                }
+                else {
+                    await ExamAbsentReplaceController.add(p);
+                }
+                message.success('已保存');
+
+                onFinish();
+                handleClose();
+            }}
+        >
+            <Alert
+                showIcon
+                closable
+                type="warning"
+                message="温馨提示:请先选择年级、校区(若有)、班级,选择后会自动生成监测号前 7 位,输入正确的 11 位监测号后自动填充姓名。"
+                style={{ marginBottom: token.margin }}
+            />
+            <Row gutter={[24, 0]}>
+                <Col span={8}>
+                    <ProFormSelect
+                        label="年级"
+                        name="examGradeId"
+                        options={gradeBranch?.examGrades?.sort((a, b) => a.grade.gradeNumber - b.grade.gradeNumber)?.map(t => ({ label: `${t.grade.fullName}(${t.gradeBeginName})`, value: t.id }))}
+                        required
+                        rules={[{ required: true }]}
+                        // disabled={data.id !== 0}
+                        fieldProps={{
+                            onChange: handleReset,
+                        }}
+                    />
+                </Col>
+                {hasBranch &&
+                    <Col span={8}>
+                        <ProFormSelect
+                            label="校区"
+                            name="sysOrgBranchId"
+                            options={gradeBranch.branches.map(t => ({ label: t.name, value: t.id }))}
+                            required
+                            rules={[{ required: true }]}
+                            fieldProps={{
+                                onChange: handleReset,
+                            }}
+                        />
+                    </Col>
+
+                }
+                <Col span={8}>
+                    <ProFormDigit
+                        label="班级"
+                        tooltip="班级最小1,最大35"
+                        name="classNumber"
+                        min={1}
+                        max={35}
+                        required
+                        rules={[{ required: true }]}
+                        fieldProps={{
+                            onChange: handleReset,
+                        }}
+                    />
+                </Col>
+            </Row>
+            <Row gutter={[24, 0]}>
+                <Col span={8}>
+                    <ProFormDependency name={['examGradeId', 'sysOrgBranchId', 'classNumber', 'replaceExamNumber']}>
+                        {({ examGradeId, sysOrgBranchId, classNumber, replaceExamNumber }) =>
+                            <ProFormText
+                                label="缺测学生监测号(11位)"
+                                name="absentExamNumber"
+                                required
+                                rules={[
+                                    { required: true, max: 11 },
+                                    ({ setFieldsValue }) => ({
+                                        validator: async (_, value) => {
+                                            const vlen = (value || '').length;
+                                            if (vlen === 0 || vlen === 11) {
+                                                if (vlen === 11) {
+                                                    if (value === replaceExamNumber) {
+                                                        return Promise.reject('不能与替补学生监测号相同');
+                                                    }
+                                                    if (!examGradeId || (hasBranch && !sysOrgBranchId) || !classNumber) {
+                                                        return Promise.reject(`请先选择年级、${hasBranch ? '校区、' : ''}班级`);
+                                                    }
+                                                    const res = await ExamSampleStudentController.queryExamSampleStudent({
+                                                        examPlanId,
+                                                        examNumber: value,
+                                                        examGradeId,
+                                                        sysOrgBranchId,
+                                                        classNumber
+                                                    })
+                                                    if (!res) {
+                                                        return Promise.reject('监测号无效,未找到相应考试生');
+                                                    }
+                                                    else {
+                                                        setFieldsValue({ 'absentName': res.examStudent?.name });
+                                                    }
+                                                }
+                                                return Promise.resolve();
+                                            }
+                                            return Promise.reject('请输入11位监测号!');
+                                        },
+                                    }),
+                                ]}
+                                fieldProps={{
+                                    maxLength: 11,
+                                    showCount: true,
+                                }}
+                            />
+                        }
+                    </ProFormDependency>
+                </Col>
+                <Col span={8}>
+                    <ProFormText
+                        label="缺测学生姓名"
+                        name="absentName"
+                        // readonly
+                        required
+                        rules={[{ required: true, min: 2, max: 100 }]}
+                        fieldProps={{
+                            minLength: 2,
+                            maxLength: 100,
+                            showCount: true,
+                        }}
+                    />
+                </Col>
+                <Col span={8}>
+                    <ProFormText
+                        label="缺测学生家长电话"
+                        name="patriarchTel"
+                        fieldProps={{
+                            maxLength: 50,
+                            showCount: true
+                        }}
+                        required
+                        rules={[{ required: true, min: 8 }]}
+                    />
+                </Col>
+                <Col span={8}>
+                    <ProFormRadio.Group
+                        label="是否有填补学生"
+                        name="isReplaced"
+                        options={[{ label: '有', value: true }, { label: '无', value: false }]}
+                        required
+                        rules={[{ required: true }]}
+                    />
+                </Col>
+                <ProFormDependency name={['isReplaced', 'examGradeId', 'sysOrgBranchId', 'classNumber', 'absentExamNumber']}>
+                    {({ isReplaced, examGradeId, sysOrgBranchId, classNumber, absentExamNumber }) =>
+                        <>
+                            <Col span={8}>
+                                <ProFormText
+                                    label="替补学生监测号(11位)"
+                                    name="replaceExamNumber"
+                                    required={isReplaced}
+                                    disabled={!isReplaced}
+                                    rules={isReplaced ? [
+                                        { required: true, max: 11 },
+                                        ({ setFieldsValue }) => ({
+                                            validator: async (_, value) => {
+                                                const vlen = (value || '').length;
+                                                if (vlen === 0 || vlen === 11) {
+                                                    if (vlen === 11) {
+                                                        if (value === absentExamNumber) {
+                                                            return Promise.reject('不能与缺测学生监测号相同');
+                                                        }
+                                                        if (!examGradeId || (hasBranch && !sysOrgBranchId) || !classNumber) {
+                                                            return Promise.reject(`请先选择年级、${hasBranch ? '校区、' : ''}班级`);
+                                                        }
+                                                        const res = await ExamSampleStudentController.queryExamSampleStudent({
+                                                            examPlanId,
+                                                            examNumber: value,
+                                                            examGradeId,
+                                                            sysOrgBranchId,
+                                                            classNumber,
+                                                        });
+                                                        if (!res) {
+                                                            return Promise.reject('监测号无效,未找到相应考试生');
+                                                        }
+                                                        else {
+                                                            setFieldsValue({ 'replaceName': res.examStudent?.name });
+                                                        }
+                                                    }
+                                                    return Promise.resolve();
+                                                }
+                                                return Promise.reject('请输入11位监测号!');
+                                            },
+                                        }),
+                                    ] : []}
+                                    fieldProps={{
+                                        maxLength: 11,
+                                        showCount: true,
+                                    }}
+                                />
+
+                            </Col>
+                            <Col span={8}>
+                                <ProFormText
+                                    label="替补学生姓名"
+                                    name="replaceName"
+                                    required={isReplaced}
+                                    disabled={!isReplaced}
+                                    rules={isReplaced ? [{ required: true, min: 2, max: 100 }] : []}
+                                    fieldProps={{
+                                        minLength: 2,
+                                        maxLength: 100,
+                                        showCount: true,
+                                    }}
+                                />
+                            </Col>
+                        </>
+                    }
+                </ProFormDependency>
+                <Col span={24}>
+                    <ProFormDependency name={['examGradeId']}>
+                        {({ examGradeId }) =>
+                            <ProFormSelect
+                                label="缺测科目"
+                                name="courseIds"
+                                mode="multiple"
+                                disabled={!examGradeId}
+                                // valueEnum={toValueEnum(baseData?.courses ?? [])}
+                                valueEnum={toValueEnum(gradeBranch?.examGrades?.find(t => t.id === examGradeId)?.examCourses?.map(t => t.course) ?? [])}
+                                required
+                                rules={[{ required: true }]}
+                            />
+                        }
+                    </ProFormDependency>
+                </Col>
+                <Col span={24}>
+                    <ProFormTextArea
+                        label="缺测原因"
+                        name="absentReason"
+                        fieldProps={{
+                            maxLength: 200,
+                            rows: 2,
+                            showCount: true,
+                        }}
+                        required
+                        rules={[{ required: true }]}
+                    />
+                </Col>
+            </Row>
+
+            <ProFormTextArea
+                label="备注"
+                name="remark"
+                fieldProps={{
+                    maxLength: 200,
+                    rows: 2,
+                    showCount: true,
+                }}
+            />
+        </MovableModalForm >
+    );
+};
+
+export default ExamAbsentReplaceEditModal;

+ 774 - 0
YBEE.EQM.Admin/src/pages/exam-org/absent-replace/OrgExamAbsentReplaceReport/index.tsx

@@ -0,0 +1,774 @@
+import { toValueEnum } from "@/common/converter";
+import { downloadFileByBlob } from "@/common/net/download";
+import { CardStepTitle, FileLink, FileUpload, SuperTable, TabBadge } from "@/components";
+import ReportButton from "@/components/ReportButton";
+import ExamAbsentReplaceAuditController from "@/services/apis/ExamAbsentReplaceAuditController";
+import ExamAbsentReplaceController from "@/services/apis/ExamAbsentReplaceController";
+import ExamGradeController from "@/services/apis/ExamGradeController";
+import ExamOrgDataReportController from "@/services/apis/ExamOrgDataReportController";
+import SysOrgController from "@/services/apis/SysOrgController";
+import { AuditStatus, DataReportStatus, DataReportType, ExamStatus, ResourceFileType } from "@/services/enums";
+import { BulbOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons";
+import { ActionType, PageContainer, ProCard, ProColumns, ProDescriptions, ProDescriptionsItemProps, ProTable } from "@ant-design/pro-components";
+import { history, useModel, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { Alert, App, Badge, Button, Card, Drawer, Space, Tag, Tour, Typography, theme } from "antd";
+import { useCallback, useRef, useState } from "react";
+import ExamAbsentReplaceDetailDrawer from "./components/ExamAbsentReplaceDetailDrawer";
+import ExamAbsentReplaceEditModal from "./components/ExamAbsentReplaceEditModal";
+
+const OrgExamAbsentReplaceReport: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const { token } = theme.useToken();
+    const { getDictValueEnum, getKeyDict, getDict } = useModel('useDict');
+    const examStatusDict = getKeyDict('exam_status');
+    const dataReportStatusDict = getKeyDict('data_report_status');
+    const dataReportTypeDict = getKeyDict('data_report_type');
+    const auditStatusDict = getKeyDict('audit_status');
+    // const { baseData } = useModel('useBaseData');
+
+    const { initialState } = useModel('@@initialState');
+    const { currentUser } = initialState ?? {};
+
+    const [detailOpen, setDetailOpen] = useState(false);
+    const [remarkOpen, setRemarkOpen] = useState(false);
+    const [editOpen, setEditOpen] = useState(false);
+    const actionRef = useRef<ActionType>();
+    const currentRef = useRef<Partial<API.ExamAbsentReplaceOutput>>();
+
+    const [activeKey, setActiveKey] = useState<React.Key>('2');
+    const [statusCount, seStatusCount] = useState<Record<number, number>>({});
+
+    const [tourOpen, setTourOpen] = useState(false);
+    const tourAddRef = useRef(null);
+    const tourDownloadRef = useRef(null);
+    const tourUploadRef = useRef(null);
+    const tourReportRef = useRef(null);
+
+    const { message, modal, notification } = App.useApp();
+
+    const { data: gradeBranchData } = useRequest(async () => {
+        const res1 = await ExamGradeController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
+        const res2 = await SysOrgController.getOrgBranchByOrgId({ orgid: currentUser?.sysOrgId ?? 0 });
+        return {
+            examGrades: res1 ?? [],
+            branches: res2 ?? [],
+            hasBranch: (res2?.length ?? 0) > 0,
+        };
+    });
+
+    // 加载上报数据
+    const { data: reportData, run: loadReport } = useRequest(() => {
+        return ExamOrgDataReportController.getByTypeExamPlanId({ type: DataReportType.ABSENT_REPLACE, examplanid: reqParams.examPlanId });
+    });
+
+    // 加载数量统计
+    const loadCount = useCallback(async (params: API.ExamAbsentReplacePageInput) => {
+        const m = await ExamAbsentReplaceController.queryStatusCount(params);
+        const tc = m?.reduce((a, b) => a + b.count, 0) ?? 0;
+
+        const d: Record<number, number> = { 0: tc };
+        m?.forEach((t) => { d[t.status] = t.count; });
+
+        seStatusCount(d);
+    }, []);
+
+    // 加载统计数据
+    const { data: studentCountData, run: loadStudentCount, loading } = useRequest(() => {
+        return ExamAbsentReplaceController.getOrgGradeClassStudentCount({ examplanid: reqParams.examPlanId });
+    });
+
+    // 统计数据列定义
+    let studentCountColumns: ProColumns<any>[] = [
+        {
+            title: '年级',
+            dataIndex: ['Grade', 'name'],
+            width: 64,
+            align: 'center',
+            fixed: 'left',
+        },
+        {
+            title: '合计',
+            dataIndex: 'GradeTotal',
+            width: 64,
+            align: 'center',
+            fixed: 'left',
+        },
+    ];
+    studentCountData?.classNumberList?.forEach(t => {
+        studentCountColumns.push({
+            title: `${t}班`,
+            dataIndex: `${t}`,
+            width: 56,
+            align: 'center',
+            render: (v) => v ? v : null,
+        });
+    });
+    studentCountColumns.push({});
+
+    // 删除
+    const handleDelete = useCallback((id: number) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamAbsentReplaceController.del({ id });
+                message.success('已删除');
+                actionRef.current?.reload();
+                loadStudentCount();
+            },
+        });
+    }, []);
+
+    // 上报
+    const handleSubmit = useCallback(() => {
+        return new Promise<void>((resolve, reject) => {
+            if ((studentCountData?.total ?? 0) > 0 && (reportData?.examOrgDataReport?.attachmentList?.length ?? 0) === 0) {
+                message.error('未上传《缺测替补学生明细表》和《会议记录》打印盖章的扫描电子文件');
+                reject();
+                return;
+            }
+            let content = `共 ${studentCountData?.total ?? 0} 个学生,上报后不能再修改,确定立即上报吗?`;
+            modal.confirm({
+                title: '警告',
+                content,
+                okText: '确定',
+                cancelText: '取消',
+                centered: true,
+                onOk: async () => {
+                    try {
+                        await ExamOrgDataReportController.submit({ examPlanId: reqParams.examPlanId, type: DataReportType.ABSENT_REPLACE })
+                        message.success('已上报');
+                        loadReport();
+                        resolve();
+                    }
+                    catch (ex) {
+                        const exm = ex as any;
+                        notification.error({
+                            message: '上报失败',
+                            description: exm?.info?.errorMessage ? `${exm?.info?.errorMessage}!请仔细检查缺测替补学生明细信息和佐证材料` : JSON.stringify(ex),
+                        })
+                        reject();
+                    }
+                },
+                onCancel: () => reject(),
+            });
+        });
+    }, [studentCountData, reportData]);
+
+    // 删除佐证材料
+    const handleDeleteAttachment = useCallback((id: number, fileId: string) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamAbsentReplaceController.delAttachment({ sourceId: id, fileId });
+                message.success('已删除');
+                actionRef.current?.reload();
+                loadStudentCount();
+            },
+        });
+    }, []);
+
+    // 删除上报佐证材料
+    const handleDeleteReportAttachment = useCallback(async (id: number, fileId: string) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即删除吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamOrgDataReportController.delAttachment({ sourceId: id, fileId });
+                message.success('已删除');
+                loadReport();
+            },
+        });
+    }, []);
+
+    // 下载打印文件
+    const handleDownloadPrintFile = useCallback(async () => {
+        const res = await ExamAbsentReplaceController.exportPrintTable({ examplanid: reqParams.examPlanId });
+        if (res) {
+            downloadFileByBlob(res.data, res.fileName);
+        }
+        else {
+            message.error('下载失败');
+        }
+    }, []);
+
+    // 提交单个学生审核
+    const handleStudentSubmit = useCallback((id: number) => {
+        modal.confirm({
+            title: '警告',
+            content: '确定立即提交吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                await ExamAbsentReplaceAuditController.submit({ id });
+                message.success('已提交');
+                actionRef.current?.reload();
+                loadStudentCount();
+            },
+        });
+    }, [])
+
+    // 是否可操作和上报
+    const reportable = (reportData?.examDataReport?.status === ExamStatus.ACTIVE && reportData?.isExpired === false &&
+        (reportData?.examOrgDataReport?.status === DataReportStatus.UNREPORT ||
+            reportData?.examOrgDataReport?.status === DataReportStatus.REJECTED));
+
+    // 工具栏按定义
+    let detailActions = [];
+    if (reportable) {
+        detailActions.push(
+            <Button
+                key="add"
+                ref={tourAddRef}
+                type="primary"
+                disabled={!reportable}
+                icon={<PlusOutlined />}
+                onClick={() => {
+                    currentRef.current = { id: 0, isReplaced: true };
+                    setEditOpen(true);
+                }}
+            >添加缺测替补学生</Button>
+        );
+        // detailActions.push(
+        //     <Button
+        //         key="import"
+        //         disabled={!reportable}
+        //         icon={<UploadOutlined />}
+        //         onClick={() => history.push(`/exam-s/sp-stu/import/${reqParams.examPlanId}`)}
+        //     >批量导入</Button>
+        // );
+        detailActions.push(
+            <Button
+                key="download"
+                ref={tourDownloadRef}
+                disabled={!reportable}
+                icon={<DownloadOutlined />}
+                onClick={handleDownloadPrintFile}
+            >下载打印表格文件</Button>
+        );
+    }
+
+    // 明细表列定义
+    const detailColumns: ProColumns<API.ExamAbsentReplaceOutput>[] = [
+        {
+            title: '状态',
+            dataIndex: 'status',
+            hideInSearch: true,
+            width: 80,
+            align: 'center',
+            valueEnum: getDictValueEnum('audit_status', true),
+            // render: (_, r) => {
+            //     const s = auditStatusDict[r.status];
+            //     return <Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>;
+            // },
+            render: (_, r) => {
+                const s = auditStatusDict[r.status];
+                return (
+                    <Space direction="vertical" size="small" style={{ lineHeight: 1 }}>
+                        <Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>
+                        <Typography.Text
+                            type={r.isReplaced ? 'warning' : 'danger'}
+                            style={{ fontSize: token.fontSizeSM }}
+                        >
+                            {r.isReplaced ? '有替补' : '无替补'}
+                        </Typography.Text>
+                    </Space>
+                );
+            },
+        },
+        ...(gradeBranchData?.hasBranch ? [{
+            title: '校区',
+            dataIndex: 'sysOrgBranchId',
+            width: 96,
+            align: 'center',
+            hideInSearch: !gradeBranchData?.hasBranch,
+            valueEnum: toValueEnum(gradeBranchData?.branches ?? []),
+        } as ProColumns] : []),
+        {
+            title: '年级',
+            dataIndex: 'gradeId',
+            width: 80,
+            align: 'center',
+            valueEnum: toValueEnum(gradeBranchData?.examGrades?.map(t => ({ id: t.gradeId, name: `${t.grade.name}(${t.gradeBeginName})` })) ?? []),
+            render: (_, r) => {
+                return (
+                    <Space size="small" direction="vertical" style={{ lineHeight: 1 }}>
+                        {r.examGrade?.grade.name}
+                        <Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>({r.examGrade?.gradeBeginName})</Typography.Text>
+                    </Space>
+                );
+            },
+        },
+        {
+            title: '班级',
+            dataIndex: 'classNumber',
+            width: 64,
+            align: 'center',
+            renderText: (v) => `${v}班`,
+        },
+        {
+            title: '姓名',
+            dataIndex: 'absentName',
+            width: 112,
+            align: 'center',
+            render: (v, r) => <a onClick={() => { currentRef.current = r; setDetailOpen(true); }}>{v}</a>,
+        },
+        {
+            title: '监测号',
+            dataIndex: 'absentExamNumber',
+            width: 160,
+            align: 'center',
+        },
+        {
+            title: '缺测替补原因',
+            dataIndex: 'absentReason',
+            hideInSearch: true,
+            width: 240,
+        },
+        {
+            title: '缺测科目',
+            dataIndex: 'absentCourseList',
+            width: 240,
+            render: (_, r) => {
+                return r.absentCourseList?.map(t => t.name).join('、');
+            },
+        },
+        {
+            title: '家长电话',
+            dataIndex: 'patriarchTel',
+            hideInSearch: true,
+            width: 128,
+            align: 'center',
+        },
+        {
+            title: '替补学生姓名',
+            dataIndex: 'replaceName',
+            width: 112,
+            align: 'center',
+        },
+        {
+            title: '替补学生监测号',
+            dataIndex: 'replaceExamNumber',
+            width: 160,
+            align: 'center',
+        },
+        {
+            title: '备注',
+            dataIndex: 'remark',
+            hideInSearch: true,
+            width: 120,
+        },
+
+        {
+            title: '佐证材料(每个学生最多上传3个)',
+            valueType: 'option',
+            hideInSearch: true,
+            width: 280 + token.paddingXS * 2,
+            // className: 'minw-280',
+            fixed: 'right',
+            render: (_, r) => {
+                let editable = reportable && [AuditStatus.UNSUBMIT, AuditStatus.REJECTED, AuditStatus.AUDIT].includes(r.status);
+                if (!editable) {
+                    editable = r.status === AuditStatus.REJECTED && reportData?.examDataReport?.status === ExamStatus.ACTIVE;
+                }
+                const li = r.attachmentList?.map((t, i) => {
+                    return (
+                        <FileLink
+                            key={i}
+                            fileExtName={t.fileExtName}
+                            fileName={t.fileName}
+                            url={`${AppConfig.fileViewRoot}?id=${t.fileId}`}
+                            thumbUrl={t.thumbFileId && t.thumbFileId !== '0' ? `${AppConfig.fileViewRoot}?id=${t.thumbFileId}` : undefined}
+                            card
+                            onDelete={editable ? async () => handleDeleteAttachment(r.id, t.fileId) : undefined}
+                        />
+                    );
+                });
+                return (
+                    <Space direction="vertical" style={{ width: 280 }}>
+                        {li}
+                        {editable && (li?.length ?? 0) < 3 &&
+                            <FileUpload
+                                addText="添加文件"
+                                tipText="仅支持PDF或图片文件"
+                                accept="image/jpeg,image/png,image/gif,application/pdf"
+                                limitSize={20}
+                                onUpload={async (file, onUploadProgress) => {
+                                    const fsp = file.name.split('.');
+                                    let extName = '';
+                                    if (fsp.length > 1) {
+                                        extName = fsp[fsp.length - 1].toLowerCase();
+                                    }
+                                    if (extName === '' || !['jpg', 'jpeg', 'png', 'gif', 'pdf'].includes(extName)) {
+                                        message.error('文件类型错误,请重新选择!')
+                                        return { success: false, errorType: 'fileTypeError', errorMessage: '文件类型错误,请选择扩展名为.jpg、.jpeg、.png或.pdf的文件' };
+                                    }
+
+                                    try {
+                                        const formData = new FormData();
+                                        formData.append('type', `${ResourceFileType.EXAM_ABSENT_REPLACE}`);
+                                        formData.append('sourceId', `${r.id}`);
+                                        formData.append('fileName', `${file.name}`);
+                                        formData.append('file', file);
+                                        await ExamAbsentReplaceController.uploadAttachment(formData, {
+                                            onUploadProgress: (p: any) => {
+                                                const progress = parseFloat((p.loaded / p.total * 100).toFixed(1));
+                                                onUploadProgress?.(progress);
+                                            }
+                                        });
+                                        actionRef.current?.reload();
+                                        return { success: true };
+                                    }
+                                    catch {
+                                        return { success: false };
+                                    }
+                                }}
+                            />
+                        }
+                    </Space>
+                );
+            },
+        },
+        {
+            title: '佐证材料',
+            hideInSearch: true,
+            hideInTable: true,
+            render: (_, r) => {
+                const li = r.attachmentList?.map((t, i) => {
+                    return (
+                        <FileLink
+                            key={i}
+                            fileExtName={t.fileExtName}
+                            fileName={t.fileName}
+                            url={`${AppConfig.fileViewRoot}?id=${t.fileId}`}
+                            thumbUrl={t.thumbFileId && t.thumbFileId !== '0' ? `${AppConfig.fileViewRoot}?id=${t.thumbFileId}` : undefined}
+                            card
+                        />
+                    );
+                });
+                return (
+                    <Space direction="vertical" style={{ width: '100%' }}>
+                        {li?.length === 0 ? '未上传' : li}
+                    </Space>
+                );
+            },
+        },
+
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 96,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                if (reportable && [AuditStatus.UNSUBMIT, AuditStatus.REJECTED, AuditStatus.AUDIT].includes(r.status)) {
+                    return (
+                        <Space>
+                            <a onClick={() => { currentRef.current = r; setEditOpen(true); }}>修改</a>
+                            <a onClick={() => handleDelete(r.id)}>删除</a>
+                        </Space>
+                    );
+                }
+                if (reportData?.examDataReport?.status === ExamStatus.ACTIVE && r.status === AuditStatus.REJECTED) {
+                    return (
+                        <Space>
+                            <a onClick={() => { currentRef.current = r; setEditOpen(true); }}>修改</a>
+                            <a onClick={() => handleDelete(r.id)}>删除</a>
+                            <a onClick={() => handleStudentSubmit(r.id)}>提交</a>
+                        </Space>
+                    );
+                }
+                return null;
+            },
+        }
+    ];
+
+    // 呈现状态 tab
+    const renderTabItems = useCallback(() => {
+        let items: { key: string; label: React.ReactNode }[] = [];
+        items = items.concat(
+            getDict('audit_status')?.filter(t => t.value > 1)?.sort((a, b) => a.sort - b.sort)?.map((t) => ({
+                key: `${t.value}`,
+                label: (
+                    <span>
+                        {t.name}
+                        <TabBadge color={t.antColor} count={statusCount[t.value] ?? 0} active={activeKey === `${t.value}`} />
+                    </span>
+                ),
+            })),
+        );
+        items.push({
+            key: '0',
+            label: (<span>全部<TabBadge count={statusCount[0]} active={activeKey === 0} /></span>),
+        });
+        return items;
+    }, [activeKey, statusCount]);
+
+    return (
+        <PageContainer
+            title={`${reportData?.examPlan?.fullName ?? ''} - 缺测替补学生上报`}
+            onBack={() => history.back()}
+            extra={reportable &&
+                <Button
+                    type="link"
+                    icon={<BulbOutlined />}
+                    onClick={() => {
+                        window.scrollTo({ top: 0 });
+                        setTourOpen(true);
+                    }}
+                >查看操作指引</Button>
+            }
+        >
+            <ProCard
+                title={<CardStepTitle>基本情况</CardStepTitle>}
+                extra={reportData?.examOrgDataReport?.status &&
+                    <Tag
+                        style={{ marginRight: 0 }}
+                        color={dataReportStatusDict[reportData.examOrgDataReport.status].antColor}
+                    >
+                        {dataReportStatusDict[reportData.examOrgDataReport.status].name}
+                    </Tag>
+                }
+            >
+                <ProDescriptions>
+                    <ProDescriptions.Item label="上报类型">
+                        {reportData?.examDataReport?.type ? dataReportTypeDict[reportData?.examDataReport?.type].name : ''}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="监测上报">
+                        {reportData?.examDataReport?.status &&
+                            <Badge
+                                status={examStatusDict[reportData.examDataReport.status].antStatus as any}
+                                text={examStatusDict[reportData.examDataReport.status].name}
+                            />
+                        }
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="截止时间">
+                        <Typography.Text strong>
+                            {reportData?.examDataReport?.endTime}
+                            {reportData?.isExpired && reportData.examDataReport?.status === ExamStatus.ACTIVE &&
+                                <Typography.Text type="danger">(已截止)</Typography.Text>
+                            }
+                        </Typography.Text>
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报人员">{reportData?.examOrgDataReport?.reportSysUser?.name}</ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报时间">{reportData?.examOrgDataReport?.reportTime}</ProDescriptions.Item>
+                    <ProDescriptions.Item label="备注说明">{reportData?.examOrgDataReport?.remark}</ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报说明" span={3}>
+                        <Typography.Text ellipsis>{reportData?.examDataReport?.remark || '无'}</Typography.Text>
+                        {reportData?.examDataReport?.remark && <Button type="link" size="small" onClick={() => setRemarkOpen(true)}>详情</Button>}
+                    </ProDescriptions.Item>
+                </ProDescriptions>
+                <Drawer open={remarkOpen} title="上报说明" width={720} maskClosable onClose={() => setRemarkOpen(false)}>
+                    <pre>{reportData?.examDataReport?.remark || '无'}</pre>
+                </Drawer>
+            </ProCard>
+
+            <ProCard
+                style={{ marginTop: token.margin }}
+                title={<CardStepTitle>缺测替补学生上报</CardStepTitle>}
+                extra={reportable &&
+                    <Space>
+                        <span key="tooltip">离上报结束还有</span>
+                        <ReportButton
+                            key="time"
+                            buttonRef={tourReportRef}
+                            expireTime={reportData?.examDataReport?.endTime}
+                            showIcon
+                            onSubmit={handleSubmit}
+                            onTimerFinish={() => loadReport()}
+                        />
+                    </Space>
+                }
+            >
+                <Alert
+                    type="warning"
+                    message={<Typography.Title level={5}>上报步骤:</Typography.Title>}
+                    description={
+                        <Typography>
+                            <ol>
+                                <li>在下方 <Typography.Text strong>缺测替补学生明细</Typography.Text> 中录入缺测替补学生信息,<Typography.Text type="danger">并上传佐证材料(必传)</Typography.Text>;</li>
+                                <li>缺测替补学生信息录入完成后,点击 <Typography.Text strong>下载打印表格文件</Typography.Text> 下载文件打印签字盖章;</li>
+                                <li>扫描已签字盖章的文件为电子文档(PDF或图片);</li>
+                                <li>在 <Typography.Text strong> 缺测替补学生上报</Typography.Text> 的 <Typography.Text strong>上传《缺测替补学生明细表》打印、签字、盖章的扫描电子文件</Typography.Text> 中上传电子文档<Typography.Text type="danger">(必传)</Typography.Text>;</li>
+                                <li>确认无误后点击 <Typography.Text strong> 缺测替补学生上报</Typography.Text> 右侧的 <Typography.Text strong>立即上报</Typography.Text> 按钮完成上传。</li>
+                            </ol>
+                        </Typography>
+                    }
+                />
+
+                <Typography.Title level={5} style={{ marginTop: token.marginSM }} type="danger">上传《缺测替补学生明细表》打印、签字、盖章的扫描电子文件(最多上传6个文件):</Typography.Title>
+                <Card bordered ref={tourUploadRef}>
+                    <Space direction="vertical" style={{ width: '100%' }}>
+                        {reportData?.examOrgDataReport?.attachmentList?.map((t, i) => {
+                            return (
+                                <FileLink
+                                    key={i}
+                                    fileExtName={t.fileExtName}
+                                    fileName={t.fileName}
+                                    url={`${AppConfig.fileViewRoot}?id=${t.fileId}`}
+                                    thumbUrl={t.thumbFileId && t.thumbFileId !== '0' ? `${AppConfig.fileViewRoot}?id=${t.thumbFileId}` : undefined}
+                                    card
+                                    onDelete={reportable ? async () => handleDeleteReportAttachment(reportData.examOrgDataReportId ?? 0, t.fileId) : undefined}
+                                />
+                            );
+                        }) ?? null}
+                        {reportable && reportData?.examOrgDataReport && (reportData?.examOrgDataReport?.attachmentList?.length ?? 0) < 6 &&
+                            <FileUpload
+                                addText="添加文件"
+                                tipText="仅支持PDF或图片文件"
+                                accept="image/jpeg,image/png,image/gif,application/pdf"
+                                limitSize={20}
+                                onUpload={async (file, onUploadProgress) => {
+                                    const fsp = file.name.split('.');
+                                    let extName = '';
+                                    if (fsp.length > 1) {
+                                        extName = fsp[fsp.length - 1].toLowerCase();
+                                    }
+                                    if (extName === '' || !['jpg', 'jpeg', 'png', 'gif', 'pdf'].includes(extName)) {
+                                        message.error('文件类型错误,请重新选择!')
+                                        return { success: false, errorType: 'fileTypeError', errorMessage: '文件类型错误,请选择扩展名为.jpg、.jpeg、.png或.pdf的文件' };
+                                    }
+
+                                    try {
+                                        const formData = new FormData();
+                                        formData.append('type', `${ResourceFileType.EXAM_ORG_DATA_REPORT}`);
+                                        formData.append('dataReportType', `${DataReportType.ABSENT_REPLACE}`);
+                                        formData.append('examPlanId', `${reportData?.examPlan?.id ?? 0}`);
+                                        formData.append('sourceId', `${reportData?.examOrgDataReportId ?? 0}`);
+                                        formData.append('fileName', `${file.name}`);
+                                        formData.append('file', file);
+                                        await ExamOrgDataReportController.uploadAttachment(formData, {
+                                            onUploadProgress: (p: any) => {
+                                                const progress = parseFloat((p.loaded / p.total * 100).toFixed(1));
+                                                onUploadProgress?.(progress);
+                                            }
+                                        });
+                                        loadReport();
+                                        return { success: true };
+                                    }
+                                    catch {
+                                        return { success: false };
+                                    }
+                                }}
+                            />
+                        }
+                    </Space>
+                </Card>
+
+                <Typography.Title level={5} style={{ marginTop: token.marginSM }}>缺测替补学生人数统计:</Typography.Title>
+                <ProTable<any>
+                    style={{ marginTop: token.margin }}
+                    search={false}
+                    pagination={false}
+                    size="small"
+                    bordered
+                    options={{ setting: false, density: false, reloadIcon: <ReloadOutlined onClick={loadStudentCount} /> }}
+                    rowKey="GradeId"
+                    loading={loading}
+                    columns={studentCountColumns}
+                    columnEmptyText=""
+                    dataSource={studentCountData?.items ?? []}
+                    toolBarRender={false}
+                />
+            </ProCard>
+
+            <SuperTable<API.ExamAbsentReplaceOutput>
+                headerTitle={<CardStepTitle>缺测替补学生明细</CardStepTitle>}
+                style={{ marginTop: token.margin }}
+                actionRef={actionRef}
+                scroll={{ x: '100%' }}
+                rowKey="id"
+                columns={detailColumns}
+                // search={{ showHiddenNum: false }}
+                search={false}
+                options={{ setting: false, fullScreen: false }}
+                toolbar={{
+                    menu: {
+                        type: 'tab',
+                        activeKey: activeKey,
+                        items: renderTabItems(),
+                        onChange: (key) => {
+                            setActiveKey(key as React.Key);
+                            actionRef.current?.reload();
+                        },
+                    },
+                    actions: detailActions,
+                }}
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const qparams = { ...p, examPlanId: reqParams.examPlanId };
+                        await loadCount(qparams);
+                        const res = await ExamAbsentReplaceController.queryPageList({
+                            ...qparams,
+                            status: activeKey !== '0' ? parseInt(activeKey as string) : undefined,
+                        });
+                        return res;
+                    });
+                }}
+            />
+
+            {editOpen && currentRef.current && gradeBranchData &&
+                <ExamAbsentReplaceEditModal
+                    examPlanId={reqParams.examPlanId}
+                    data={currentRef.current}
+                    gradeBranch={gradeBranchData}
+                    onFinish={() => {
+                        actionRef.current?.reload();
+                        loadStudentCount();
+                    }}
+                    onClose={() => setEditOpen(false)}
+                />
+            }
+
+            {detailOpen && currentRef.current &&
+                <ExamAbsentReplaceDetailDrawer
+                    data={currentRef.current}
+                    columns={detailColumns as ProDescriptionsItemProps<Partial<API.ExamAbsentReplaceOutput>>[]}
+                    onClose={() => setDetailOpen(false)}
+                />
+            }
+
+            <Tour
+                steps={[
+                    {
+                        title: '添加缺测替补学生信息',
+                        description: '添加完缺测替补学生信息后,在列表中上传相应的佐证材料(一个学生最多3个图片或PDF)',
+                        target: () => tourAddRef.current,
+                    },
+                    {
+                        title: '下载打印表格文件',
+                        description: '打印表格按年级分页,按文件要求签字和盖章',
+                        target: () => tourDownloadRef.current,
+                    },
+                    {
+                        title: '上传签字盖章文件',
+                        description: '打印文件签字盖章后,扫描成电子文件(PDF或图片)上传,推荐PDF文件,最多上传6个文件',
+                        target: () => tourUploadRef.current,
+                    },
+                    {
+                        title: '上报缺测替补学生信息',
+                        description: '确认无误后立即上报',
+                        target: () => tourReportRef.current,
+                    },
+                ]}
+                open={tourOpen}
+                onClose={() => setTourOpen(false)}
+            />
+        </PageContainer>
+    );
+}
+
+export default OrgExamAbsentReplaceReport;

+ 110 - 0
YBEE.EQM.Admin/src/pages/exam-org/absent-replace/index.tsx

@@ -0,0 +1,110 @@
+import { toSelectOptions } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamOrgDataReportController from '@/services/apis/ExamOrgDataReportController';
+import { DataReportType, ExamStatus } from '@/services/enums';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel } from '@umijs/max';
+import { Tag, Typography } from 'antd';
+import { useRef } from 'react';
+
+/** 缺测替补学生上报列表 */
+const OrgExamSpecialStudentList: React.FC = () => {
+    const actionRef = useRef<ActionType>();
+
+    const { getDictValueEnum, getKeyDict } = useModel('useDict');
+    const { baseData } = useModel('useBaseData');
+    const dataReportStatusDict = getKeyDict('data_report_status');
+
+    const columns: ProColumns<API.ExamPlanOrgDataReportOutput>[] = [
+        {
+            title: '监测名称',
+            dataIndex: 'examPlanFullName',
+            renderText: (v, r) => <a onClick={() => history.push(`/exam-s/absent/report/${r.examPlanId}`)}>{v}</a>,
+            hideInDescriptions: true,
+        },
+        {
+            title: '监测状态',
+            dataIndex: 'examStatus',
+            valueEnum: getDictValueEnum('exam_status', true, [ExamStatus.READY, ExamStatus.CANCELLED]),
+            width: 80,
+            align: 'center',
+            hideInDescriptions: true,
+        },
+        {
+            title: '监测学期',
+            dataIndex: ['semester', 'nickShortName'],
+            search: {
+                transform: (v) => v ? ({ semesterId: JSON.parse(v) }) : v,
+            },
+            valueType: 'select',
+            fieldProps: {
+                options: toSelectOptions(baseData?.semesters ?? [], (item) => ({ key: item.id, label: item.nickShortName, value: item.id })),
+            },
+            hideInTable: true,
+        },
+        {
+            title: '截止时间',
+            dataIndex: 'endTime',
+            hideInSearch: true,
+            width: 144,
+            align: 'center',
+            render: (v, r) => {
+                if (r.examStatus === ExamStatus.READY) {
+                    return null;
+                }
+                return <Typography.Text type={r.examStatus === ExamStatus.ACTIVE ? 'danger' : undefined}>{v}</Typography.Text>;
+            },
+        },
+        {
+            title: '上报人',
+            dataIndex: 'reportSysUserName',
+            width: 88,
+            align: 'center',
+            hideInSearch: true,
+        },
+        {
+            title: '上报时间',
+            dataIndex: 'reportTime',
+            width: 144,
+            align: 'center',
+            hideInSearch: true,
+        },
+        {
+            title: '上报状态',
+            dataIndex: 'status',
+            valueEnum: getDictValueEnum('data_report_status', true),
+            width: 88,
+            align: 'center',
+            render: (v, r) => {
+                if (!r.status) {
+                    return null;
+                }
+                const s = dataReportStatusDict[r.status]
+                return (<Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>);
+            },
+        },
+    ];
+
+    return (
+        <PageContainer title={false}>
+            <SuperTable<API.ExamPlanOrgDataReportOutput>
+                toolbar={{
+                    title: '缺测替补学生上报列表',
+                    subTitle: '点击下方可上报监测名称上报',
+                }}
+                actionRef={actionRef}
+                columns={columns}
+                scroll={{ x: 'max-content' }}
+                rowKey="rowNumber"
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamOrgDataReportController.queryPageList({ ...p, type: DataReportType.ABSENT_REPLACE });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default OrgExamSpecialStudentList;

+ 165 - 0
YBEE.EQM.Admin/src/pages/exam-org/sample/OrgExamSampleCountList/index.tsx

@@ -0,0 +1,165 @@
+import { downloadFileByBlob } from "@/common/net/download";
+import { CardStepTitle, SuperTable } from "@/components";
+import ExamSampleController from "@/services/apis/ExamSampleController";
+import { DataPublishType } from "@/services/enums";
+import { DownloadOutlined } from "@ant-design/icons";
+import { ActionType, PageContainer, ProColumns } from "@ant-design/pro-components";
+import { useEmotionCss } from "@ant-design/use-emotion-css";
+import { history, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { App, Button, Spin, theme } from "antd";
+import { useRef } from "react";
+
+/** 监测抽样数量统计 */
+const OrgExamSampleCountList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examDataPublishId: number };
+
+    const { token } = theme.useToken();
+
+    const actionRef = useRef<ActionType>();
+    const { message } = App.useApp();
+
+    const countRowClassName = useEmotionCss(({ token }) => {
+        return {
+            backgroundColor: token.colorBgLayout,
+            fontWeight: 'bold',
+            fontStyle: 'italic',
+        };
+    });
+
+    const { data, loading } = useRequest(async () => {
+        const sampleRes = await ExamSampleController.getByExamDataPublishId({ examdatapublishid: reqParams.examDataPublishId, type: DataPublishType.STUDENT_SAMPLE_COUNT_LIST });
+        return {
+            examSample: sampleRes,
+        };
+    });
+
+    // 明细表列定义
+    const columns: ProColumns<API.ExamSampleCountOutput>[] = [
+        {
+            title: '学校',
+            dataIndex: 'sysOrgName',
+            width: 280,
+            align: 'center',
+            onCell: (r) => {
+                if (r.gradeId === 9999) {
+                    return { colSpan: 3 };
+                }
+                return { colSpan: 1 };
+            },
+        },
+        {
+            title: '年级',
+            dataIndex: 'gradeName',
+            width: 96,
+            align: 'center',
+            onCell: (r) => {
+                if (r.gradeId === 9999) {
+                    return { colSpan: 0 };
+                }
+                if (r.classNumber === 9999) {
+                    return { colSpan: 2 };
+                }
+                return { colSpan: 1 };
+            },
+        },
+        {
+            title: '班级',
+            dataIndex: 'classNumber',
+            width: 96,
+            align: 'center',
+            renderText: (v) => `${v}班`,
+            onCell: (r) => {
+                if (r.classNumber === 9999 || r.gradeId === 9999) {
+                    return { colSpan: 0 };
+                }
+                return {};
+            },
+        },
+        {
+            title: '区级监测',
+            dataIndex: 'centerStudentCount',
+            width: 96,
+            align: 'center',
+        },
+        {
+            title: '校内考试',
+            dataIndex: 'schoolStudentCount',
+            width: 96,
+            align: 'center',
+        },
+        {
+            title: '合计',
+            dataIndex: 'totalStudentCount',
+            width: 96,
+            align: 'center',
+        },
+        {},
+    ];
+
+    return (
+        <PageContainer
+            title={`${data?.examSample?.examPlan?.fullName ?? '监测计划'} - 监测抽样统计表`}
+            onBack={() => history.back()}
+        >
+            <Spin spinning={loading}>
+                {data?.examSample?.id &&
+                    <>
+                        <SuperTable<API.ExamSampleCountOutput>
+                            headerTitle={<CardStepTitle>监测抽样统计表</CardStepTitle>}
+                            style={{ marginTop: token.margin }}
+                            actionRef={actionRef}
+                            scroll={{ x: 'max-content' }}
+                            search={false}
+                            rowKey="index"
+                            columnEmptyText=""
+                            columns={columns}
+                            pagination={false}
+                            toolbar={{
+                                actions: [
+                                    <Button
+                                        key="export"
+                                        icon={<DownloadOutlined />}
+                                        disabled={!data?.examSample?.id}
+                                        type="primary"
+                                        onClick={async () => {
+                                            try {
+                                                message.loading('正在生成文件,请稍侯');
+                                                if (!data?.examSample?.id) {
+                                                    return;
+                                                }
+                                                const res = await ExamSampleController.exportSampleCountToOrg({ id: data?.examSample?.id });
+                                                if (res) {
+                                                    downloadFileByBlob(res.data, res.fileName);
+                                                }
+                                            }
+                                            catch { }
+                                            finally {
+                                                message.destroy();
+                                            }
+                                        }}
+                                    >下载统计表</Button>,
+                                ],
+                            }}
+                            request={async () => {
+                                const res = await ExamSampleController.getOrgSampleCountListById({ id: data.examSample?.id ?? 0 })
+                                return {
+                                    data: res,
+                                    success: true,
+                                };
+                            }}
+                            rowClassName={(r) => {
+                                if (r.classNumber === 9999 || r.gradeId === 9999) {
+                                    return countRowClassName;
+                                }
+                                return '';
+                            }}
+                        />
+                    </>
+                }
+            </Spin>
+        </PageContainer>
+    );
+}
+
+export default OrgExamSampleCountList;

+ 198 - 0
YBEE.EQM.Admin/src/pages/exam-org/sample/OrgExamSampleList/index.tsx

@@ -0,0 +1,198 @@
+import { toValueEnum } from "@/common/converter";
+import { downloadFileByBlob } from "@/common/net/download";
+import { CardStepTitle, SuperTable } from "@/components";
+import ExamGradeController from "@/services/apis/ExamGradeController";
+import ExamSampleController from "@/services/apis/ExamSampleController";
+import ExamSampleStudentController from "@/services/apis/ExamSampleStudentController";
+import SysOrgController from "@/services/apis/SysOrgController";
+import { DataPublishType, ExamSampleType } from "@/services/enums";
+import { DownloadOutlined } from "@ant-design/icons";
+import { ActionType, PageContainer, ProColumns } from "@ant-design/pro-components";
+import { history, useModel, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { App, Button, Spin, theme } from "antd";
+import { useRef } from "react";
+
+/** 监测抽样学生名单 */
+const OrgExamSampleList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examDataPublishId: number };
+
+    const { token } = theme.useToken();
+    const { getDictValueEnum } = useModel('useDict');
+
+    const actionRef = useRef<ActionType>();
+    const { message } = App.useApp();
+
+    const { initialState } = useModel('@@initialState');
+    const { currentUser } = initialState ?? {};
+
+    const { data, loading } = useRequest(async () => {
+        const orgRes = await SysOrgController.getOrgBranchByOrgId({ orgid: currentUser?.sysOrgId ?? 0 });
+        const sampleRes = await ExamSampleController.getByExamDataPublishId({ examdatapublishid: reqParams.examDataPublishId, type: DataPublishType.STUDENT_SAMPLE_LIST });
+        const egRes = await ExamGradeController.getListByExamPlanId({ examplanid: sampleRes?.examPlanId ?? 0 });
+        return {
+            examGrades: egRes ?? [],
+            branches: orgRes ?? [],
+            hasDistrict: (orgRes?.length ?? 0) > 0,
+            examSample: sampleRes,
+        };
+    });
+
+    // 明细表列定义
+    const columns: ProColumns<API.ExamSampleStudentOutput>[] = [
+        {
+            title: '名单类型',
+            dataIndex: 'examSampleType',
+            valueEnum: getDictValueEnum('exam_sample_type', false, [ExamSampleType.SCHOOL]),
+            width: 72,
+            align: 'center',
+            search: {
+                transform: (v) => ({ examSampleType: JSON.parse(v) }),
+            },
+        },
+        ...(data?.hasDistrict ? [{
+            title: '校区',
+            dataIndex: ['examStudent', 'sysOrgBranchId'],
+            width: 96,
+            align: 'center',
+            hideInSearch: !data?.hasDistrict,
+            valueEnum: toValueEnum(data?.branches ?? []),
+            search: {
+                transform: (v) => ({ sysOrgBranchId: v }),
+            },
+        } as ProColumns] : []),
+        {
+            title: '年级',
+            dataIndex: ['examStudent', 'examGrade', 'gradeId'],
+            width: 144,
+            align: 'center',
+            valueEnum: toValueEnum(data?.examGrades?.map(t => ({ id: t.gradeId, name: `${t.grade.name}(${t.gradeBeginName})` })) ?? []),
+            search: {
+                transform: (v) => ({ gradeId: v }),
+            },
+        },
+        {
+            title: '班级',
+            dataIndex: ['examStudent', 'classNumber'],
+            width: 64,
+            align: 'center',
+            renderText: (v) => `${v}班`,
+            search: {
+                transform: (v) => ({ classNumber: v }),
+            },
+        },
+        {
+            title: '监测号',
+            dataIndex: 'examNumber',
+            width: 128,
+            align: 'center',
+        },
+        {
+            title: '姓名',
+            dataIndex: ['examStudent', 'name'],
+            width: 160,
+            align: 'center',
+            search: {
+                transform: (v) => ({ name: v }),
+            },
+        },
+        {
+            title: '证件类型',
+            dataIndex: ['examStudent', 'certificateType'],
+            valueEnum: getDictValueEnum('certificate_type'),
+            width: 112,
+            align: 'center',
+            search: {
+                transform: (v) => ({ certificateType: JSON.parse(v) }),
+            },
+        },
+        {
+            title: '证件号码',
+            dataIndex: ['examStudent', 'idNumber'],
+            width: 160,
+            align: 'center',
+            search: {
+                transform: (v) => ({ idNumber: v }),
+            },
+        },
+    ];
+
+    return (
+        <PageContainer
+            title={`${data?.examSample?.examPlan?.fullName ?? '监测计划'} - 监测抽样学生名单`}
+            onBack={() => history.back()}
+        >
+            <Spin spinning={loading}>
+                {data?.examSample?.id &&
+                    <>
+                        <SuperTable<API.ExamSampleStudentOutput>
+                            headerTitle={<CardStepTitle>监测抽样学生名单</CardStepTitle>}
+                            style={{ marginTop: token.margin }}
+                            actionRef={actionRef}
+                            scroll={{ x: 'max-content' }}
+                            // loading={loading}
+                            rowKey="id"
+                            columnEmptyText=""
+                            columns={columns}
+                            toolbar={{
+                                actions: [
+                                    <Button
+                                        key="export"
+                                        type="primary"
+                                        icon={<DownloadOutlined />}
+                                        disabled={!data?.examSample?.id}
+                                        onClick={async () => {
+                                            try {
+                                                message.loading('正在生成文件,请稍侯');
+                                                if (!data?.examSample?.id) {
+                                                    return;
+                                                }
+                                                const res = await ExamSampleController.exportToOrg({ id: data?.examSample?.id });
+                                                if (res) {
+                                                    downloadFileByBlob(res.data, res.fileName);
+                                                }
+                                            }
+                                            catch { }
+                                            finally {
+                                                message.destroy();
+                                            }
+                                        }}
+                                    >下载检录表</Button>,
+                                    <Button
+                                        key="export"
+                                        icon={<DownloadOutlined />}
+                                        disabled={!data?.examSample?.id}
+                                        onClick={async () => {
+                                            try {
+                                                message.loading('正在生成文件,请稍侯');
+                                                if (!data?.examSample?.id) {
+                                                    return;
+                                                }
+                                                const res = await ExamSampleController.exportSampleCountToOrg({ id: data?.examSample?.id });
+                                                if (res) {
+                                                    downloadFileByBlob(res.data, res.fileName);
+                                                }
+                                            }
+                                            catch { }
+                                            finally {
+                                                message.destroy();
+                                            }
+                                        }}
+                                    >下载统计表</Button>,
+                                ],
+                            }}
+                            request={async (params, sort) => {
+                                return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                                    const res = await ExamSampleStudentController.queryPageList({ ...p, examSampleId: data?.examSample?.id ?? 0 });
+                                    return res;
+                                });
+                            }}
+                        />
+                    </>
+                }
+            </Spin>
+        </PageContainer>
+    );
+}
+
+export default OrgExamSampleList;

+ 1 - 0
YBEE.EQM.Admin/src/pages/exam-org/sample/index.tsx

@@ -0,0 +1 @@
+export default () => <h1>监测抽样名单</h1>;

+ 240 - 0
YBEE.EQM.Admin/src/pages/exam-org/school-exam-score/OrgSchoolExamScoreReport/components/OrgSchoolExamScoreCourseReport.tsx

@@ -0,0 +1,240 @@
+import { toExcelColumnName } from "@/common/converter";
+import { TagStatus, UploadWrongLocalHeaderError } from "@/components";
+import ExamOrgScoreReportController from "@/services/apis/ExamOrgScoreReportController";
+import { CaretDownOutlined, CaretRightOutlined, InboxOutlined, RightOutlined } from "@ant-design/icons";
+import { ProCard, ProColumns, ProTable } from "@ant-design/pro-components";
+import { Alert, App, Button, Space, Typography, UploadFile, UploadProps, theme } from "antd";
+import Upload, { RcFile } from "antd/es/upload";
+import { useState } from "react";
+
+type TemplateCompareType = {
+    [key: number | string]: {
+        name?: string;
+        isMatched?: boolean;
+    } | string
+};
+
+const OrgSchoolExamScoreCourseReport: React.FC<{
+    data: API.ExamOrgScoreReportItem;
+    sequence: number;
+    reportable: boolean;
+    onUploaded: () => void;
+}> = ({ reportable, data, onUploaded }) => {
+    const { token } = theme.useToken();
+
+    const [fileList, setFileList] = useState<UploadFile[]>([]);
+    const [uploading, setUploading] = useState(false);
+    const [collapsed, setCollapsed] = useState(true);
+    const [uploadData, setUploadData] = useState<API.UploadExamOrgScoreReportOutput>();
+
+    const { message } = App.useApp();
+
+    const uploadProps: UploadProps = {
+        onRemove: () => {
+            setFileList([]);
+        },
+        beforeUpload: (file) => {
+            setFileList([file]);
+            return false;
+        },
+        fileList,
+        multiple: false,
+        accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel',
+    };
+
+    // const { data: courseScore, run: loadCourseScore } = useRequest(() => ExamOrgScoreReportController.queryOne({
+    //     examPlanId: data.examPlanId,
+    //     examGradeId: data.examGradeId,
+    //     courseId: data.courseId,
+    // }), { manual: true });
+
+    // 监测科目模板配置
+    const sconfig = JSON.parse(data.scoreReportConfig as string) as API.ExamCourseScoreReportConfig;
+    // 模板列定义
+    const tplColumns: ProColumns<TemplateCompareType>[] = sconfig?.headerColumnNames?.map((c, ci) => ({
+        title: toExcelColumnName(ci),
+        dataIndex: [ci, 'name'],
+        align: 'center',
+        ellipsis: true,
+        render: (v, r) => {
+            const rv = r[ci] as any;
+            if (r.type === 'data') {
+                return rv;
+            }
+
+            if (r.type === 'template') {
+                return v;
+            }
+            return <span style={{ color: rv.isMatched ? token.colorSuccess : token.colorError }}>{rv.name}</span>
+        },
+    })) ?? [];
+
+    let ds: TemplateCompareType = {};
+    sconfig.headerColumnNames?.forEach((c, ci) => { ds[ci] = { name: c, }; });
+    ds.key = 'rt';
+    ds.type = 'template';
+    ds.typeName = '模板列名';
+    const [list, setList] = useState<TemplateCompareType[]>([ds]);
+
+    // 上传
+    const handleUpload = async () => {
+        const formData = new FormData();
+        formData.append('examPlanId', `${data.examPlanId}`);
+        formData.append('examGradeId', `${data.examGradeId}`);
+        formData.append('gradeId', `${data.gradeId}`);
+        formData.append('courseId', `${data.courseId}`);
+        fileList.forEach((file) => {
+            formData.append('file', file as RcFile);
+        });
+        setUploading(true);
+        try {
+            const res = await ExamOrgScoreReportController.upload(formData);
+            setUploadData(res);
+            setList([
+                ds,
+                {
+                    key: 'ru',
+                    type: 'upload',
+                    typeName: '上传列名',
+                    ...res?.columns
+                },
+                ...res?.rows.filter((t, i) => i < 10).map((t, i) => ({
+                    key: `rd-${i}`,
+                    type: 'data',
+                    typeName: `${i + 1}`,
+                    ...t
+                })) ?? [],
+            ]);
+            onUploaded?.();
+            if (res?.isSuccess) {
+                // loadCourseScore();
+                setCollapsed(true);
+                message.success('已上传');
+            }
+            else {
+                message.error('上传失败');
+            }
+        }
+        catch { }
+        finally {
+            setUploading(false);
+        }
+    }
+
+    const tplColCount = sconfig.headerColumnNames?.length ?? 0;
+    const getCompareDesc = (a: number, b: number) => {
+        if (a === b) {
+            return '与模板要求相符';
+        }
+        if (a > b) {
+            return '少于模板要求';
+        }
+        return '多于模板要求';
+    }
+
+    const isUploaded = data.fileSize !== '0';
+
+    return (
+        <ProCard
+            title={
+                <Space size="large">
+                    {reportable &&
+                        <Button
+                            type="text"
+                            icon={collapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}
+                            onClick={() => { setCollapsed(v => !v); }}
+                            style={{ marginRight: -token.margin }}
+                        />
+                    }
+                    <Typography.Title level={4} style={{ marginBottom: 0 }}>
+                        {data.gradeName}
+                        ({data.gradeBeginYear}级)
+                        <span style={{ marginRight: token.margin }}>-</span>
+                        {data.courseName}
+                    </Typography.Title>
+                    {isUploaded ? <TagStatus status="success">已上传</TagStatus> : <TagStatus status="error">未上传</TagStatus>}
+                </Space>
+            }
+            headerBordered
+            style={{ marginBottom: token.marginLG }}
+            extra={
+                reportable ?
+                    <Button
+                        type={isUploaded ? 'default' : 'primary'}
+                        onClick={() => { setCollapsed(v => !v); }}
+                    >
+                        {isUploaded ? '重新上传' : '上传成绩'}
+                        <RightOutlined />
+                    </Button> :
+                    null
+            }
+            bordered
+            collapsed={collapsed}
+        >
+            <Space direction="vertical" style={{ width: '100%' }}>
+                <Upload.Dragger {...uploadProps}>
+                    <p className="ant-upload-drag-icon"><InboxOutlined /></p>
+                    <p className="ant-upload-text">点击或拖入文件到此处</p>
+                </Upload.Dragger>
+
+                <Space>
+                    <Button
+                        type="primary"
+                        disabled={fileList.length === 0}
+                        loading={uploading}
+                        onClick={handleUpload}
+                    >{uploading ? '上传中' : '立即上传'}</Button>
+                    <UploadWrongLocalHeaderError />
+                </Space>
+
+                {uploadData &&
+                    <Alert
+                        type={uploadData?.isSuccess ? 'success' : 'error'}
+                        showIcon
+                        message={uploadData.isSuccess ? '文件验证通过,上传成功!' : '文件验证未通过,上传失败!'}
+                        description={
+                            <Typography.Paragraph style={{ marginBottom: 0 }}>
+                                模板要求【{tplColCount}】列,实际上传文件为【{uploadData.columnsCount}】列
+                                {getCompareDesc(tplColCount, uploadData.columnsCount)}
+                                {uploadData.errorColumnCount > 0 && `,其中有【${uploadData.errorColumnCount}】列错误(参照下方表格中红色字体标注,请仔细核对)`}。
+                                上传文件中共【{uploadData?.rowCount}】行数据。
+                            </Typography.Paragraph>
+                        }
+                    />
+                }
+
+                <ProTable
+                    rowKey="key"
+                    cardProps={false}
+                    columns={[
+                        {
+                            title: '类型',
+                            dataIndex: 'typeName',
+                            fixed: 'left',
+                            width: 80,
+                            align: 'center',
+                            rowScope: 'row',
+                        },
+                        ...tplColumns,
+                    ]}
+                    size="small"
+                    pagination={false}
+                    search={false}
+                    options={false}
+                    bordered
+                    dataSource={list}
+                    scroll={{ x: 'max-content' }}
+                    toolbar={{
+                        title: <Typography.Title level={5} style={{ marginBottom: 0 }}>模板与上传成绩文件对比情况</Typography.Title>,
+                        subTitle: '下表仅显示上传表格除标题前10行数据',
+                        // actions: [
+                        //     <Button key="detail">查看完整数据</Button>
+                        // ],
+                    }}
+                />
+            </Space>
+        </ProCard>
+    );
+}
+
+export default OrgSchoolExamScoreCourseReport;

+ 225 - 0
YBEE.EQM.Admin/src/pages/exam-org/school-exam-score/OrgSchoolExamScoreReport/index.tsx

@@ -0,0 +1,225 @@
+import { CardStepTitle, FileLink } from "@/components";
+import ReportButton from "@/components/ReportButton";
+import ExamOrgDataReportController from "@/services/apis/ExamOrgDataReportController";
+import ExamOrgScoreReportController from "@/services/apis/ExamOrgScoreReportController";
+import { DataReportStatus, DataReportType, ExamStatus } from "@/services/enums";
+import { BulbOutlined, ReloadOutlined } from "@ant-design/icons";
+import { PageContainer, ProCard, ProDescriptions } from "@ant-design/pro-components";
+import { history, useModel, useParams } from "@umijs/max";
+import { useRequest } from "ahooks";
+import { App, Badge, Button, Drawer, Space, Tag, Tour, Typography, theme } from "antd";
+import { useCallback, useRef, useState } from "react";
+import OrgSchoolExamScoreCourseReport from "./components/OrgSchoolExamScoreCourseReport";
+
+const OrgSchoolExamScoreReport: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const { token } = theme.useToken();
+    const { getKeyDict, } = useModel('useDict');
+    const examStatusDict = getKeyDict('exam_status');
+    const dataReportStatusDict = getKeyDict('data_report_status');
+    const dataReportTypeDict = getKeyDict('data_report_type');
+
+    const [remarkOpen, setRemarkOpen] = useState(false);
+
+    const [tourOpen, setTourOpen] = useState(false);
+    const tourAddRef = useRef(null);
+    const tourDownloadRef = useRef(null);
+    const tourUploadRef = useRef(null);
+    const tourReportRef = useRef(null);
+
+    const { message, modal, notification } = App.useApp();
+
+    // 加载上报数据
+    const { data: reportData, run: loadReport } = useRequest(() => {
+        return ExamOrgDataReportController.getByTypeExamPlanId({ type: DataReportType.SCHOOL_EXAM_SCORE, examplanid: reqParams.examPlanId });
+    });
+
+    const { data: list, run: loadList } = useRequest(() => {
+        return ExamOrgScoreReportController.getListByExamPlanId({ examplanid: reqParams.examPlanId });
+    });
+    const totalCount = list?.length ?? 0;
+    const uploadedCount = list?.filter(t => t.fileSize !== '0')?.length ?? 0;
+
+    // 上报
+    const handleSubmit = useCallback(() => {
+        return new Promise<void>((resolve, reject) => {
+            let content = `共需上传【${totalCount}】个小题成绩,已上传【${uploadedCount}】个,【${totalCount - uploadedCount}】个未上传,上报后不能再修改,确定立即上报吗?`;
+            modal.confirm({
+                title: '上报确认',
+                content,
+                okText: '确定',
+                cancelText: '取消',
+                centered: true,
+                onOk: async () => {
+                    try {
+                        await ExamOrgDataReportController.submit({ examPlanId: reqParams.examPlanId, type: DataReportType.SCHOOL_EXAM_SCORE })
+                        message.success('已上报');
+                        loadReport();
+                        resolve();
+                    }
+                    catch (ex) {
+                        const exm = ex as any;
+                        notification.error({
+                            message: '上报失败',
+                            description: exm?.info?.errorMessage ? `${exm?.info?.errorMessage}!请仔细检查缺测替补学生明细信息和佐证材料` : JSON.stringify(ex),
+                        })
+                        reject();
+                    }
+                },
+                onCancel: () => reject(),
+            });
+        });
+    }, [reportData, totalCount, uploadedCount]);
+
+    // 是否可操作和上报
+    const reportable = (reportData?.examDataReport?.status === ExamStatus.ACTIVE && reportData?.isExpired === false &&
+        (reportData?.examOrgDataReport?.status === DataReportStatus.UNREPORT ||
+            reportData?.examOrgDataReport?.status === DataReportStatus.REJECTED));
+
+
+    return (
+        <PageContainer
+            title={`${reportData?.examPlan?.fullName ?? ''} - 校考成绩上报`}
+            onBack={() => history.back()}
+            extra={reportable &&
+                <Button
+                    type="link"
+                    icon={<BulbOutlined />}
+                    onClick={() => {
+                        window.scrollTo({ top: 0 });
+                        setTourOpen(true);
+                    }}
+                >查看操作指引</Button>
+            }
+        >
+            <ProCard
+                title={<CardStepTitle>基本情况</CardStepTitle>}
+                direction="column"
+                extra={reportData?.examOrgDataReport?.status &&
+                    <Tag
+                        style={{ marginRight: 0 }}
+                        color={dataReportStatusDict[reportData.examOrgDataReport.status].antColor}
+                    >
+                        {dataReportStatusDict[reportData.examOrgDataReport.status].name}
+                    </Tag>
+                }
+            >
+                <ProDescriptions>
+                    <ProDescriptions.Item label="上报类型">
+                        {reportData?.examDataReport?.type ? dataReportTypeDict[reportData?.examDataReport?.type].name : ''}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="监测上报">
+                        {reportData?.examDataReport?.status &&
+                            <Badge
+                                status={examStatusDict[reportData.examDataReport.status].antStatus as any}
+                                text={examStatusDict[reportData.examDataReport.status].name}
+                            />
+                        }
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="截止时间">
+                        <Typography.Text strong>
+                            {reportData?.examDataReport?.endTime}
+                            {reportData?.isExpired && reportData.examDataReport?.status === ExamStatus.ACTIVE &&
+                                <Typography.Text type="danger">(已截止)</Typography.Text>
+                            }
+                        </Typography.Text>
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报人员">{reportData?.examOrgDataReport?.reportSysUser?.name}</ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报时间">{reportData?.examOrgDataReport?.reportTime}</ProDescriptions.Item>
+                    <ProDescriptions.Item label="备注说明">{reportData?.examOrgDataReport?.remark}</ProDescriptions.Item>
+                    <ProDescriptions.Item label="上报说明" span={3}>
+                        <Typography.Text ellipsis>{reportData?.examDataReport?.remark || '无'}</Typography.Text>
+                        {reportData?.examDataReport?.remark && <Button type="link" size="small" onClick={() => setRemarkOpen(true)}>详情</Button>}
+                    </ProDescriptions.Item>
+                </ProDescriptions>
+                <Typography.Title level={5}>小题成绩模板下载</Typography.Title>
+                <Space direction="vertical" style={{ width: '100%' }}>
+                    {reportData?.examDataReport?.attachmentList?.map((t, i) => (
+                        <FileLink key={i} {...t} card />
+                    ))}
+                </Space>
+                <Drawer open={remarkOpen} title="上报说明" width={720} maskClosable onClose={() => setRemarkOpen(false)}>
+                    <pre>{reportData?.examDataReport?.remark || '无'}</pre>
+                </Drawer>
+            </ProCard>
+
+
+            <ProCard
+                title={<CardStepTitle>成绩上报</CardStepTitle>}
+                style={{ marginTop: token.margin }}
+                extra={reportable &&
+                    <Space>
+                        <span key="tooltip">离上报结束还有</span>
+                        <ReportButton
+                            key="time"
+                            buttonRef={tourReportRef}
+                            expireTime={reportData?.examDataReport?.endTime}
+                            showIcon
+                            onSubmit={handleSubmit}
+                            onTimerFinish={() => loadReport()}
+                        />
+                    </Space>
+                }
+            >
+                <Typography.Paragraph>
+                    共需上传【{totalCount}】个小题成绩文件,已上传<Tag color="success" style={{ marginLeft: token.marginXS, fontWeight: 'bold', fontSize: token.fontSizeHeading5 }}>{uploadedCount}</Tag>个,
+                    还有<Tag color="error" style={{ marginLeft: token.marginXS, fontWeight: 'bold', fontSize: token.fontSizeHeading5 }}>{totalCount - uploadedCount}</Tag>个未上传。
+                </Typography.Paragraph>
+            </ProCard>
+
+
+            <ProCard
+                title={<CardStepTitle>各年级各科目小题成绩上传</CardStepTitle>}
+                direction="column"
+                style={{ marginTop: token.margin }}
+                bodyStyle={{ paddingTop: token.paddingLG }}
+                extra={
+                    <Space>
+                        {/* <Button icon={<DownloadOutlined />}>下载小题成绩模板</Button> */}
+                        <Button icon={<ReloadOutlined />} type="text" onClick={loadList} />
+                    </Space>
+                }
+            >
+                {list?.map((t, i) => (
+                    <OrgSchoolExamScoreCourseReport
+                        key={i}
+                        sequence={i + 1}
+                        data={t}
+                        onUploaded={loadList}
+                        reportable={reportable}
+                    />
+                ))}
+            </ProCard>
+
+            <Tour
+                steps={[
+                    {
+                        title: '添加缺测替补学生信息',
+                        description: '添加完缺测替补学生信息后,在列表中上传相应的佐证材料(一个学生最多3个图片或PDF)',
+                        target: () => tourAddRef.current,
+                    },
+                    {
+                        title: '下载打印表格文件',
+                        description: '打印表格按年级分页,按文件要求签字和盖章',
+                        target: () => tourDownloadRef.current,
+                    },
+                    {
+                        title: '上传签字盖章文件',
+                        description: '打印文件签字盖章后,扫描成电子文件(PDF或图片)上传,推荐PDF文件,最多上传6个文件',
+                        target: () => tourUploadRef.current,
+                    },
+                    {
+                        title: '上报缺测替补学生信息',
+                        description: '确认无误后立即上报',
+                        target: () => tourReportRef.current,
+                    },
+                ]}
+                open={tourOpen}
+                onClose={() => setTourOpen(false)}
+            />
+        </PageContainer >
+    );
+}
+
+export default OrgSchoolExamScoreReport;

+ 108 - 0
YBEE.EQM.Admin/src/pages/exam-org/school-exam-score/index.tsx

@@ -0,0 +1,108 @@
+import { toSelectOptions } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamOrgDataReportController from '@/services/apis/ExamOrgDataReportController';
+import { DataReportType, ExamStatus } from '@/services/enums';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel } from '@umijs/max';
+import { Tag, Typography } from 'antd';
+import { useRef } from 'react';
+
+/** 校考成绩上报计划列表 */
+const OrgExamSchoolExamScoreList: React.FC = () => {
+    const actionRef = useRef<ActionType>();
+
+    const { getDictValueEnum, getKeyDict } = useModel('useDict');
+    const { baseData } = useModel('useBaseData');
+    const dataReportStatusDict = getKeyDict('data_report_status');
+
+    const columns: ProColumns<API.ExamPlanOrgDataReportOutput>[] = [
+        {
+            title: '监测名称',
+            dataIndex: 'examPlanFullName',
+            renderText: (v, r) => <a onClick={() => history.push(`/exam-s/school-exam-score/report/${r.examPlanId}`)}>{v}</a>,
+            hideInDescriptions: true,
+        },
+        {
+            title: '监测状态',
+            dataIndex: 'examStatus',
+            valueEnum: getDictValueEnum('exam_status', true, [ExamStatus.READY, ExamStatus.CANCELLED]),
+            width: 80,
+            align: 'center',
+            hideInDescriptions: true,
+        },
+        {
+            title: '监测学期',
+            dataIndex: ['semester', 'nickShortName'],
+            search: {
+                transform: (v) => v ? ({ semesterId: JSON.parse(v) }) : v,
+            },
+            valueType: 'select',
+            fieldProps: {
+                options: toSelectOptions(baseData?.semesters ?? [], (item) => ({ key: item.id, label: item.nickShortName, value: item.id })),
+            },
+            hideInTable: true,
+        },
+        {
+            title: '截止时间',
+            dataIndex: 'endTime',
+            hideInSearch: true,
+            width: 144,
+            align: 'center',
+            render: (v, r) => {
+                if (r.examStatus === ExamStatus.READY) {
+                    return null;
+                }
+                return <Typography.Text type={r.examStatus === ExamStatus.ACTIVE ? 'danger' : undefined}>{v}</Typography.Text>;
+            },
+        },
+        {
+            title: '上报人',
+            dataIndex: 'reportSysUserName',
+            width: 88,
+            align: 'center',
+        },
+        {
+            title: '上报时间',
+            dataIndex: 'reportTime',
+            width: 144,
+            align: 'center',
+        },
+        {
+            title: '上报状态',
+            dataIndex: 'status',
+            valueEnum: getDictValueEnum('data_report_status', true),
+            width: 88,
+            align: 'center',
+            render: (v, r) => {
+                if (!r.status) {
+                    return null;
+                }
+                const s = dataReportStatusDict[r.status]
+                return (<Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>);
+            },
+        },
+    ];
+
+    return (
+        <PageContainer title={false}>
+            <SuperTable<API.ExamPlanOrgDataReportOutput>
+                headerTitle="校考成绩上报列表"
+                actionRef={actionRef}
+                columns={columns}
+                scroll={{ x: 'max-content' }}
+                rowKey="rowNumber"
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamOrgDataReportController.queryPageList({
+                            ...p,
+                            type: DataReportType.SCHOOL_EXAM_SCORE
+                        });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default OrgExamSchoolExamScoreList;

+ 3 - 31
YBEE.EQM.Admin/src/pages/exam-org/special-student/OrgExamSpecialStudentReport/components/ExamSpecialStudentDetailDrawer.tsx

@@ -1,7 +1,6 @@
-import { AuditStatus } from '@/services/enums';
+import { AuditTimeline } from '@/components';
 import { ProDescriptions, ProDescriptionsItemProps } from '@ant-design/pro-components';
-import { useModel } from '@umijs/max';
-import { Card, Drawer, Space, Tag, Timeline, TimelineItemProps, theme } from 'antd';
+import { Card, Drawer } from 'antd';
 import { useState } from 'react';
 
 export type ExamSpecialStudentDetailDrawerProps = {
@@ -15,11 +14,6 @@ export type ExamSpecialStudentDetailDrawerProps = {
 const ExamSpecialStudentDetailDrawer: React.FC<ExamSpecialStudentDetailDrawerProps> = ({ columns, data, onClose }) => {
     const [open, setOpen] = useState<boolean>(true);
     const handleClose = () => { setOpen(false); setTimeout(onClose, 300); };
-
-    const { getKeyDict } = useModel('useDict');
-    const auditStatusDict = getKeyDict('audit_status');
-    const { token } = theme.useToken();
-
     return (
         <Drawer
             title="特殊学生详情"
@@ -37,29 +31,7 @@ const ExamSpecialStudentDetailDrawer: React.FC<ExamSpecialStudentDetailDrawerPro
                 <ProDescriptions.Item label="审核记录">
                     {data.auditList && data.auditList.length > 0 ?
                         <Card style={{ width: '100%' }} bodyStyle={{ paddingBottom: 0 }}>
-                            <Timeline
-                                mode="left"
-                                items={data.auditList?.map((t) => {
-                                    const status = auditStatusDict[t.status ?? 1];
-
-                                    let item: TimelineItemProps = {
-                                        children: (
-                                            <Space direction="vertical">
-                                                <Space size="large">
-                                                    <span>{t.createTime}</span>
-                                                    <Tag color={status.antStatus}>{status?.name}</Tag>
-                                                </Space>
-                                                <span>{t.remark}</span>
-                                            </Space>
-                                        ),
-                                    };
-                                    if (t.status === AuditStatus.REJECTED) {
-                                        item.color = token.colorError;
-                                    }
-
-                                    return item;
-                                }) ?? []}
-                            />
+                            <AuditTimeline auditList={data.auditList} />
                         </Card> : '无'
                     }
                 </ProDescriptions.Item>

+ 119 - 31
YBEE.EQM.Admin/src/pages/exam-org/special-student/OrgExamSpecialStudentReport/index.tsx

@@ -1,5 +1,6 @@
+import { toValueEnum } from "@/common/converter";
 import { downloadFileByBlob } from "@/common/net/download";
-import { CardStepTitle, FileLink, FileUpload, SuperTable } from "@/components";
+import { CardStepTitle, FileLink, FileUpload, SuperTable, TabBadge } from "@/components";
 import ReportButton from "@/components/ReportButton";
 import ExamGradeController from "@/services/apis/ExamGradeController";
 import ExamOrgDataReportController from "@/services/apis/ExamOrgDataReportController";
@@ -19,10 +20,11 @@ const OrgExamSpecialStudentReport: React.FC = () => {
     const reqParams = useParams() as unknown as { examPlanId: number };
 
     const { token } = theme.useToken();
-    const { getDictValueEnum, getKeyDict } = useModel('useDict');
+    const { getDictValueEnum, getKeyDict, getDict } = useModel('useDict');
     const examStatusDict = getKeyDict('exam_status');
     const dataReportStatusDict = getKeyDict('data_report_status');
     const dataReportTypeDict = getKeyDict('data_report_type');
+    const auditStatusDict = getKeyDict('audit_status');
     // const { baseData } = useModel('useBaseData');
 
     const { initialState } = useModel('@@initialState');
@@ -34,6 +36,9 @@ const OrgExamSpecialStudentReport: React.FC = () => {
     const actionRef = useRef<ActionType>();
     const currentRef = useRef<Partial<API.ExamSpecialStudentOutput>>();
 
+    const [activeKey, setActiveKey] = useState<React.Key>('2');
+    const [statusCount, seStatusCount] = useState<Record<number, number>>({});
+
     const [tourOpen, setTourOpen] = useState(false);
     const tourAddRef = useRef(null);
     const tourDownloadRef = useRef(null);
@@ -57,6 +62,17 @@ const OrgExamSpecialStudentReport: React.FC = () => {
         return ExamOrgDataReportController.getByTypeExamPlanId({ type: DataReportType.SP_STUDENT, examplanid: reqParams.examPlanId });
     });
 
+    // 加载数量统计
+    const loadCount = useCallback(async (params: API.ExamSpecialStudentPageInput) => {
+        const m = await ExamSpecialStudentController.queryStatusCount(params);
+        const tc = m?.reduce((a, b) => a + b.count, 0) ?? 0;
+
+        const d: Record<number, number> = { 0: tc };
+        m?.forEach((t) => { d[t.status] = t.count; });
+
+        seStatusCount(d);
+    }, []);
+
     // 加载统计数据
     const { data: studentCountData, run: loadStudentCount, loading } = useRequest(() => {
         return ExamSpecialStudentController.getOrgGradeClassStudentCount({ examplanid: reqParams.examPlanId });
@@ -152,7 +168,7 @@ const OrgExamSpecialStudentReport: React.FC = () => {
             cancelText: '取消',
             centered: true,
             onOk: async () => {
-                await ExamSpecialStudentController.delAttachment({ id, fileId });
+                await ExamSpecialStudentController.delAttachment({ sourceId: id, fileId });
                 message.success('已删除');
                 actionRef.current?.reload();
                 loadStudentCount();
@@ -169,7 +185,7 @@ const OrgExamSpecialStudentReport: React.FC = () => {
             cancelText: '取消',
             centered: true,
             onOk: async () => {
-                await ExamOrgDataReportController.delAttachment({ id, fileId });
+                await ExamOrgDataReportController.delAttachment({ sourceId: id, fileId });
                 message.success('已删除');
                 loadReport();
             },
@@ -184,6 +200,23 @@ const OrgExamSpecialStudentReport: React.FC = () => {
         }
     }, []);
 
+    // // 提交单个学生审核
+    // const handleStudentSubmit = useCallback((id: number) => {
+    //     modal.confirm({
+    //         title: '警告',
+    //         content: '确定立即提交吗',
+    //         okText: '确定',
+    //         cancelText: '取消',
+    //         centered: true,
+    //         onOk: async () => {
+    //             await ExamSpecialStudentAuditController.submit({ id });
+    //             message.success('已提交');
+    //             actionRef.current?.reload();
+    //             loadStudentCount();
+    //         },
+    //     });
+    // }, [])
+
     // 是否可操作和上报
     const reportable = (reportData?.examDataReport?.status === ExamStatus.ACTIVE && reportData?.isExpired === false &&
         (reportData?.examOrgDataReport?.status === DataReportStatus.UNREPORT ||
@@ -233,6 +266,10 @@ const OrgExamSpecialStudentReport: React.FC = () => {
             width: 80,
             align: 'center',
             valueEnum: getDictValueEnum('audit_status', true),
+            render: (_, r) => {
+                const s = auditStatusDict[r.status];
+                return <Tag color={s.antStatus} style={{ marginRight: 0 }}>{s.name}</Tag>;
+            },
         },
         // {
         //     title: '校区',
@@ -245,15 +282,30 @@ const OrgExamSpecialStudentReport: React.FC = () => {
             dataIndex: 'sysOrgBranchId',
             width: 96,
             align: 'center',
-            // hideInSearch: !gradeBranchData?.hasBranch,
-            // valueEnum: toValueEnum(gradeBranchData?.branches ?? []),
+            hideInSearch: !gradeBranchData?.hasBranch,
+            valueEnum: toValueEnum(gradeBranchData?.branches ?? []),
         } as ProColumns] : []),
+        // {
+        //     title: '年级',
+        //     dataIndex: 'gradeId',
+        //     width: 72,
+        //     align: 'center',
+        //     render: (_, r) => `${r.examGrade?.grade.name}`,
+        // },
         {
             title: '年级',
             dataIndex: 'gradeId',
-            width: 72,
+            width: 80,
             align: 'center',
-            render: (_, r) => `${r.examGrade?.grade.name}`,
+            valueEnum: toValueEnum(gradeBranchData?.examGrades?.map(t => ({ id: t.gradeId, name: `${t.grade.name}(${t.gradeBeginName})` })) ?? []),
+            render: (_, r) => {
+                return (
+                    <Space size="small" direction="vertical" style={{ lineHeight: 1 }}>
+                        {r.examGrade?.grade.name}
+                        <Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>({r.examGrade?.gradeBeginName})</Typography.Text>
+                    </Space>
+                );
+            },
         },
         {
             title: '班级',
@@ -315,7 +367,10 @@ const OrgExamSpecialStudentReport: React.FC = () => {
             width: 280,
             fixed: 'right',
             render: (_, r) => {
-                const editable = reportable && [AuditStatus.UNSUBMIT, AuditStatus.REJECTED, AuditStatus.AUDIT].includes(r.status);
+                let editable = reportable && [AuditStatus.UNSUBMIT, AuditStatus.REJECTED, AuditStatus.AUDIT].includes(r.status);
+                if (!editable) {
+                    editable = r.status === AuditStatus.REJECTED && reportData?.examDataReport?.status === ExamStatus.ACTIVE;
+                }
                 const li = r.attachmentList?.map((t, i) => {
                     return (
                         <FileLink
@@ -405,35 +460,54 @@ const OrgExamSpecialStudentReport: React.FC = () => {
         {
             title: '操作',
             valueType: 'option',
-            width: 112,
+            width: 120,
             align: 'center',
             fixed: 'right',
-            hideInTable: !reportable,
+            // hideInTable: !reportable,
             render: (_, r) => {
-                if (![AuditStatus.UNSUBMIT, AuditStatus.REJECTED, AuditStatus.AUDIT].includes(r.status)) {
-                    return null;
+                if (reportable && [AuditStatus.UNSUBMIT, AuditStatus.REJECTED, AuditStatus.AUDIT].includes(r.status)) {
+                    return (
+                        <Space>
+                            <a onClick={() => { currentRef.current = r; setEditOpen(true); }}>修改</a>
+                            <a onClick={() => handleDelete(r.id)}>删除</a>
+                        </Space>
+                    );
                 }
-                return (
-                    <>
-                        <Button
-                            type="link"
-                            size="small"
-                            onClick={() => {
-                                currentRef.current = r;
-                                setEditOpen(true);
-                            }}
-                        >修改</Button>
-                        <Button
-                            type="link"
-                            size="small"
-                            onClick={() => handleDelete(r.id)}
-                        >删除</Button>
-                    </>
-                );
+                // if (reportData?.examDataReport?.status === ExamStatus.ACTIVE && r.status === AuditStatus.REJECTED) {
+                //     return (
+                //         <Space>
+                //             <a onClick={() => { currentRef.current = r; setEditOpen(true); }}>修改</a>
+                //             <a onClick={() => handleDelete(r.id)}>删除</a>
+                //             <a onClick={() => handleStudentSubmit(r.id)}>提交</a>
+                //         </Space>
+                //     );
+                // }
+                return null;
             },
         }
     ];
 
+    // 呈现状态 tab
+    const renderTabItems = useCallback(() => {
+        let items: { key: string; label: React.ReactNode }[] = [];
+        items = items.concat(
+            getDict('audit_status')?.filter(t => t.value > 1)?.sort((a, b) => a.sort - b.sort)?.map((t) => ({
+                key: `${t.value}`,
+                label: (
+                    <span>
+                        {t.name}
+                        <TabBadge color={t.antColor} count={statusCount[t.value] ?? 0} active={activeKey === `${t.value}`} />
+                    </span>
+                ),
+            })),
+        );
+        items.push({
+            key: '0',
+            label: (<span>全部<TabBadge count={statusCount[0]} active={activeKey === 0} /></span>),
+        });
+        return items;
+    }, [activeKey, statusCount]);
+
     return (
         <PageContainer
             title={`${reportData?.examPlan?.fullName ?? ''} - 特殊学生上报`}
@@ -613,11 +687,25 @@ const OrgExamSpecialStudentReport: React.FC = () => {
                 search={false}
                 options={{ setting: false, fullScreen: false }}
                 toolbar={{
+                    menu: {
+                        type: 'tab',
+                        activeKey: activeKey,
+                        items: renderTabItems(),
+                        onChange: (key) => {
+                            setActiveKey(key as React.Key);
+                            actionRef.current?.reload();
+                        },
+                    },
                     actions: detailActions,
                 }}
                 request={async (params, sort) => {
                     return SuperTable.requestPageAgent({ params, sort }, async (p) => {
-                        const res = await ExamSpecialStudentController.queryPageList({ ...p, examPlanId: reqParams.examPlanId });
+                        const qparams = { ...p, examPlanId: reqParams.examPlanId };
+                        await loadCount(qparams);
+                        const res = await ExamSpecialStudentController.queryPageList({
+                            ...qparams,
+                            status: activeKey !== '0' ? parseInt(activeKey as string) : undefined,
+                        });
                         return res;
                     });
                 }}

+ 10 - 10
YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentImport/components/ExamStudentImportEditModal.tsx

@@ -9,10 +9,10 @@ import { useRef, useState } from 'react';
 /** 修改监测学生信息 */
 const ExamStudentImportEditModal: React.FC<{
     data: Partial<API.UploadExamStudentOutput>;
-    hasCourseComb?: boolean;
+    hasNceeCourseComb?: boolean;
     onFinish: (values: API.UploadExamStudentOutput) => void;
     onClose?: () => void;
-}> = ({ data, hasCourseComb, onFinish, onClose }) => {
+}> = ({ data, hasNceeCourseComb, onFinish, onClose }) => {
     const [open, setOpen] = useState<boolean>(true);
     const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
 
@@ -34,7 +34,7 @@ const ExamStudentImportEditModal: React.FC<{
                     ...data,
                     certificateType: data.certificateType !== undefined ? `${data.certificateType}` : undefined,
                     gender: data.gender !== undefined ? `${data.gender}` : undefined,
-                    courseCombId: data.courseCombId !== undefined ? data.courseCombId : undefined,
+                    nceeCourseCombId: data.nceeCourseCombId !== undefined ? data.nceeCourseCombId : undefined,
                 }}
                 modalProps={{
                     centered: true,
@@ -45,8 +45,8 @@ const ExamStudentImportEditModal: React.FC<{
                     },
                 }}
                 onFinish={async (values) => {
-                    const { name, idNumber, examNumber, roomNumber, seatNumber, remark, courseCombId, ...restValues } = values;
-                    const cmb = baseData?.courseCombs?.find(t => t.id === courseCombId);
+                    const { name, idNumber, examNumber, roomNumber, seatNumber, remark, nceeCourseCombId, ...restValues } = values;
+                    const cmb = baseData?.nceeCourseCombs?.find(t => t.id === nceeCourseCombId);
                     onFinish({
                         ...data,
                         ...restValues,
@@ -56,8 +56,8 @@ const ExamStudentImportEditModal: React.FC<{
                         roomNumber: roomNumber?.replaceAll(/\s/g, ''),
                         seatNumber: seatNumber?.replaceAll(/\s/g, ''),
                         remark: remark?.replaceAll(/\s/g, ''),
-                        courseCombId,
-                        courseCombName: cmb?.shortName,
+                        nceeCourseCombId,
+                        nceeCourseCombName: cmb?.shortName,
                         isSuccess: true,
                         errorMessage: [],
                     });
@@ -153,12 +153,12 @@ const ExamStudentImportEditModal: React.FC<{
                             }}
                         />
                     </Col>
-                    {hasCourseComb &&
+                    {hasNceeCourseComb &&
                         <Col span={12}>
                             <ProFormSelect
                                 label="选科组合"
-                                name="courseCombId"
-                                options={baseData?.courseCombs?.map(t => ({ label: t.shortName, value: t.id }))}
+                                name="nceeCourseCombId"
+                                options={baseData?.nceeCourseCombs?.map(t => ({ label: t.shortName, value: t.id }))}
                             />
                         </Col>
                     }

+ 5 - 5
YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentImport/index.tsx

@@ -65,7 +65,7 @@ const OrgExamStudentImport: React.FC = () => {
         return {
             examGrades: res1 ?? [],
             branches: res2 ?? [],
-            hasCourseComb: res3?.educationStage === EducationStage.SENIOR_HIGH_SCHOOL_STAGE,
+            hasNceeCourseComb: res3?.educationStage === EducationStage.SENIOR_HIGH_SCHOOL_STAGE,
         };
     });
 
@@ -198,7 +198,7 @@ const OrgExamStudentImport: React.FC = () => {
                                 examGradeId,
                                 gradeId: grade?.gradeId,
                                 classNumber: t.classNumber,
-                                courseCombId: t.courseCombId,
+                                nceeCourseCombId: t.nceeCourseCombId,
                                 certificateType: t.certificateType ? JSON.parse(`${t.certificateType}`) : CertificateType.NONE,
                                 idNumber: t.idNumber?.toUpperCase(),
                                 name: t.name,
@@ -296,9 +296,9 @@ const OrgExamStudentImport: React.FC = () => {
             width: 112,
             align: 'center',
         },
-        ...(baseData?.hasCourseComb ? [{
+        ...(baseData?.hasNceeCourseComb ? [{
             title: '选科组合',
-            dataIndex: 'courseCombName',
+            dataIndex: 'nceeCourseCombName',
             width: 80,
             align: 'center',
             hideInSearch: true,
@@ -645,7 +645,7 @@ const OrgExamStudentImport: React.FC = () => {
             {editOpen && currentRef.current &&
                 <ExamStudentImportEditModal
                     data={currentRef.current}
-                    hasCourseComb={baseData?.hasCourseComb}
+                    hasNceeCourseComb={baseData?.hasNceeCourseComb}
                     onFinish={handleEdit}
                     onClose={() => setEditOpen(false)}
                 />

+ 5 - 5
YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentReport/components/ExamStudentEditModal.tsx

@@ -11,7 +11,7 @@ import { useRef, useState } from 'react';
 export type GradeBranchData = {
     examGrades: API.ExamGradeOutput[];
     branches: API.SysOrgLiteOutput[];
-    hasCourseComb: boolean;
+    hasNceeCourseComb: boolean;
 };
 
 /** 修改监测学生信息 */
@@ -46,7 +46,7 @@ const ExamStudentEditModal: React.FC<{
                     sysOrgBranchId: data.sysOrgBranchId !== undefined ? data.sysOrgBranchId : undefined,
                     certificateType: data.certificateType !== undefined ? `${data.certificateType}` : undefined,
                     gender: data.gender !== undefined ? `${data.gender}` : undefined,
-                    courseCombId: data.courseCombId !== undefined ? `${data.courseCombId}` : undefined,
+                    nceeCourseCombId: data.nceeCourseCombId !== undefined ? `${data.nceeCourseCombId}` : undefined,
                 }}
                 modalProps={{
                     centered: true,
@@ -196,13 +196,13 @@ const ExamStudentEditModal: React.FC<{
                             }}
                         />
                     </Col>
-                    {gradeBranch.hasCourseComb &&
+                    {gradeBranch.hasNceeCourseComb &&
                         <Col span={12}>
                             <ProFormSelect
                                 label="选科组合"
-                                name="courseCombId"
+                                name="nceeCourseCombId"
                                 tooltip="仅高中学段填写"
-                                valueEnum={toValueEnum(baseData?.courseCombs?.map(t => ({ id: t.id, name: t.shortName })) ?? [])}
+                                valueEnum={toValueEnum(baseData?.nceeCourseCombs?.map(t => ({ id: t.id, name: t.shortName })) ?? [])}
                             />
                         </Col>
                     }

+ 4 - 4
YBEE.EQM.Admin/src/pages/exam-org/student/OrgExamStudentReport/index.tsx

@@ -46,7 +46,7 @@ const OrgExamStudentReport: React.FC = () => {
             examGrades: res1 ?? [],
             branches: res2 ?? [],
             hasDistrict: (res2?.length ?? 0) > 0,
-            hasCourseComb: res3?.educationStage === EducationStage.SENIOR_HIGH_SCHOOL_STAGE,
+            hasNceeCourseComb: res3?.educationStage === EducationStage.SENIOR_HIGH_SCHOOL_STAGE,
         };
     });
 
@@ -242,10 +242,10 @@ const OrgExamStudentReport: React.FC = () => {
             width: 120,
             align: 'center',
         },
-        ...(gradeBranchData?.hasCourseComb ? [{
+        ...(gradeBranchData?.hasNceeCourseComb ? [{
             title: '选科组合',
-            dataIndex: 'courseCombId',
-            valueEnum: toValueEnum((baseData?.courseCombs ?? []).map(t => ({ ...t, name: t.shortName }))),
+            dataIndex: 'nceeCourseCombId',
+            valueEnum: toValueEnum((baseData?.nceeCourseCombs ?? []).map(t => ({ ...t, name: t.shortName }))),
             width: 80,
             align: 'center',
         } as ProColumns] : []),

+ 4 - 2
YBEE.EQM.Admin/src/pages/system/Menu/components/MenuEditDrawer.tsx

@@ -64,12 +64,14 @@ const MenuEditDrawer: React.FC<MenuEditProps> = ({ parentMenus, data, onOk, onCl
         <DrawerForm<FormDataType>
             title={`${data.id === 0 ? '添加' : '修改'}功能菜单`}
             formRef={formRef}
-            visible={visible}
+            open={visible}
             drawerProps={{
                 onClose: handleClose,
                 headerStyle: { paddingLeft: 4 },
                 width: 800,
-                bodyStyle: { backgroundColor: '#f2f2f2' },
+                styles: {
+                    body: { backgroundColor: '#f2f2f2' },
+                },
             }}
             initialValues={data}
             onFinish={handleSubmit}

+ 2 - 2
YBEE.EQM.Admin/src/pages/system/Org/index.tsx

@@ -185,8 +185,8 @@ const OrgList: React.FC = () => {
             fixed: 'right',
             render: (_, r) => {
                 let ops: React.ReactNode[] = [];
-                ops.push(<Button type="link" size="small" onClick={() => { currentRef.current = r; setEditShow(true); }}>修改</Button>);
-                ops.push(<Button type="link" size="small" onClick={() => handleDelete(r.id)}>删除</Button>);
+                ops.push(<Button key="edit" type="link" size="small" onClick={() => { currentRef.current = r; setEditShow(true); }}>修改</Button>);
+                ops.push(<Button key="del" type="link" size="small" onClick={() => handleDelete(r.id)}>删除</Button>);
                 return (<>{ops}</>);
             },
         },

+ 85 - 0
YBEE.EQM.Admin/src/pages/teaching-research/suggestion/SuggestionCourseList/components/SuggestionEditDrawer.tsx

@@ -0,0 +1,85 @@
+import { CardStepTitle } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import { AuditStatus } from '@/services/enums';
+import { DrawerForm, ProFormTextArea } from '@ant-design/pro-components';
+import { App } from 'antd';
+import { useState } from 'react';
+
+/** 问题建议撰写 */
+const SuggestionEditDrawer: React.FC<{
+    data: API.ExamPaperOutput;
+    onFinish: () => void;
+    onClose?: () => void;
+}> = ({ data, onFinish, onClose }) => {
+    const [open, setOpen] = useState<boolean>(true);
+    const handleClose = () => { setOpen(false); setTimeout(() => onClose?.(), 300); };
+
+    const { message } = App.useApp();
+    // 未提交状态
+    const unsubmit = [AuditStatus.UNSUBMIT, AuditStatus.REJECTED].includes(data?.suggestionStatus ?? AuditStatus.UNSUBMIT);
+
+    return (
+        <DrawerForm<{
+            questions: string;
+            suggestions: string;
+        }>
+            title={`撰写【${data.grade?.fullName}${data.course?.name}】问题建议`}
+            width={1080}
+            open={open}
+            drawerProps={{
+                onClose: handleClose,
+                maskClosable: false,
+            }}
+            readonly={!unsubmit}
+            requiredMark={unsubmit}
+            {...(unsubmit ? {} : { submitter: false })}
+            initialValues={{
+                questions: data.questions,
+                suggestions: data.suggestions,
+            }}
+            onFinish={async (values: any) => {
+                try {
+                    const p: API.SaveExamPaperSuggestion = {
+                        ...values,
+                        id: data.id,
+                    };
+                    await ExamPaperController.saveSuggestion(p);
+                    message.success('保存成功!');
+                    onFinish();
+                    handleClose();
+                    return true;
+                } catch {
+                    message.error('保存失败!');
+                    return false;
+                }
+            }}
+        >
+            <ProFormTextArea
+                label={<CardStepTitle>主要问题</CardStepTitle>}
+                name="questions"
+                fieldProps={{
+                    showCount: true,
+                    maxLength: 4000,
+                    rows: 12,
+                }}
+                rules={[
+                    { required: true, message: '请输入主要问题' },
+                ]}
+            />
+            <ProFormTextArea
+                label={<CardStepTitle>改进建议</CardStepTitle>}
+                name="suggestions"
+                fieldProps={{
+                    showCount: true,
+                    maxLength: 4000,
+                    rows: 12,
+                }}
+                rules={[
+                    { required: true, message: '请输入改进建议' },
+                ]}
+            />
+        </DrawerForm>
+    );
+};
+
+export default SuggestionEditDrawer;

+ 177 - 0
YBEE.EQM.Admin/src/pages/teaching-research/suggestion/SuggestionCourseList/index.tsx

@@ -0,0 +1,177 @@
+import { SuperTable, TagStatus } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import ExamPlanController from '@/services/apis/ExamPlanController';
+import { AuditStatus, ExamPaperWriterType } from '@/services/enums';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useParams } from '@umijs/max';
+import { useRequest } from 'ahooks';
+import { App, Button, Typography } from 'antd';
+import { useCallback, useRef, useState } from 'react';
+import { ExamPaperLiteItem } from '../../typing';
+import SuggestionEditDrawer from './components/SuggestionEditDrawer';
+
+/** 问题建议试卷列表 */
+const SuggestionCourseList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const { message, modal } = App.useApp();
+    const currentRef = useRef<ExamPaperLiteItem>();
+    const actionRef = useRef<ActionType>();
+    const [editOpen, setEditOpen] = useState(false);
+    const [submitting, setSubmitting] = useState(false);
+
+    const { data, loading } = useRequest(() => {
+        return ExamPlanController.getById({ id: reqParams.examPlanId });
+    });
+
+    // 提交
+    const handleSubmit = useCallback(async (examPaperId: number) => {
+        modal.confirm({
+            content: '提交后不能再修改,确定立即提交吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                try {
+                    setSubmitting(true);
+                    await ExamPaperController.submitSuggestion({ id: examPaperId });
+                    message.success('已提交');
+                    actionRef.current?.reload();
+                }
+                catch { }
+                finally {
+                    setSubmitting(false);
+                }
+            },
+        });
+    }, []);
+
+    const columns: ProColumns<ExamPaperLiteItem>[] = [
+        {
+            title: '年级',
+            dataIndex: ['grade', 'fullName'],
+            width: 96,
+            align: 'center',
+        },
+        {
+            title: '科目',
+            dataIndex: ['course', 'name'],
+            width: 72,
+            align: 'center',
+        },
+        {
+            title: '总分',
+            dataIndex: 'score',
+            width: 72,
+            align: 'center',
+        },
+        {
+            title: '问题',
+            dataIndex: 'questions',
+            ellipsis: true,
+            render: (v, r) => {
+                if ((r.questions?.length ?? 0) < 200) {
+                    return v;
+                }
+                return `${r.questions?.substring(0, 200)}...`;
+            },
+        },
+        {
+            title: '建议',
+            dataIndex: 'suggestions',
+            ellipsis: true,
+            render: (v, r) => {
+                if ((r.suggestions?.length ?? 0) < 200) {
+                    return v;
+                }
+                return `${r.suggestions?.substring(0, 200)}...`;
+            },
+        },
+        {
+            title: '状态',
+            dataIndex: 'submitted',
+            width: 80,
+            align: 'center',
+            render: (_, r) => {
+                if (r.submitted) {
+                    return <TagStatus status="success">已提交</TagStatus>;
+                }
+                return <TagStatus status="error">未提交</TagStatus>;
+            },
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 112,
+            // align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <>
+                        <Button
+                            type="link"
+                            size="small"
+                            onClick={() => {
+                                currentRef.current = r; setEditOpen(true);
+                            }}
+                        >{r.submitted ? '查看' : '撰写'}</Button>
+                        {!r.submitted &&
+                            <Button
+                                type="link"
+                                size="small"
+                                loading={submitting}
+                                onClick={() => handleSubmit(r.id)}
+                            >提交</Button>
+                        }
+                    </>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer
+            title={`${data?.fullName ?? ''} - 学科问题建议撰写`}
+            onBack={() => history.back()}
+            loading={loading}
+        >
+            <SuperTable<ExamPaperLiteItem>
+                headerTitle="科目列表"
+                actionRef={actionRef}
+                columns={columns}
+                search={false}
+                columnEmptyText=""
+                toolbar={{
+                    actions: [
+                        <Typography.Text key="tip" type="warning">撰写完成后,点击“提交”完成撰写。</Typography.Text>,
+                    ],
+                }}
+                request={async () => {
+                    const res = await ExamPaperController.getWriterListByExamPlanId({
+                        examplanid: reqParams.examPlanId,
+                        writertype: ExamPaperWriterType.SUGGESTION,
+                    });
+                    const data = res?.map(t => {
+                        return {
+                            ...t,
+                            submitted: ![AuditStatus.UNSUBMIT, AuditStatus.REJECTED].includes(t.suggestionStatus),
+                        } as ExamPaperLiteItem;
+                    });
+                    return {
+                        data,
+                        success: true,
+                    };
+                }}
+            />
+            {editOpen && currentRef.current &&
+                <SuggestionEditDrawer
+                    data={currentRef.current}
+                    onClose={() => setEditOpen(false)}
+                    onFinish={() => actionRef.current?.reload()}
+                />
+            }
+        </PageContainer>
+    );
+};
+
+export default SuggestionCourseList;

+ 115 - 0
YBEE.EQM.Admin/src/pages/teaching-research/suggestion/index.tsx

@@ -0,0 +1,115 @@
+import { toSelectOptions } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import { ExamPaperWriterType } from '@/services/enums';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel } from '@umijs/max';
+import { Typography, theme } from 'antd';
+import { useRef } from 'react';
+
+/** 问题建议计划列表 */
+const SuggestionExamPlanList: React.FC = () => {
+    const actionRef = useRef<ActionType>();
+
+    const { token } = theme.useToken();
+
+    const { getDictValueEnum } = useModel('useDict');
+    const { baseData } = useModel('useBaseData');
+
+    const columns: ProColumns<API.ExamPaperTodoPlanOutput>[] = [
+        {
+            title: '计划名称',
+            dataIndex: 'examPlanFullName',
+            // width: 480,
+            search: {
+                transform: (v) => ({ name: v }),
+            },
+            render: (v) => {
+                return `${v}学科问题建议编写计划`;
+            },
+        },
+        {
+            title: '监测学期',
+            dataIndex: 'semesterId',
+            search: {
+                transform: (v) => v ? ({ semesterId: JSON.parse(v) }) : v,
+            },
+            valueType: 'select',
+            fieldProps: {
+                options: toSelectOptions(baseData?.semesters ?? [], (item) => ({ key: item.id, label: item.nickShortName, value: item.id })),
+            },
+            hideInTable: true,
+            order: 90,
+        },
+        {
+            title: '计划状态',
+            dataIndex: 'examPlanStatus',
+            valueEnum: getDictValueEnum('exam_status', true),
+            width: 80,
+            align: 'center',
+        },
+        {
+            title: '总数量',
+            dataIndex: 'totalCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        {
+            title: '未提交',
+            dataIndex: 'suggestionUnsubmitCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="warning">{v}</Typography.Text> : null,
+        },
+        {
+            title: '已提交',
+            dataIndex: 'suggestionSubmittedCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="success">{v}</Typography.Text> : null,
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <a onClick={() => history.push(`/exam-c/tr-qs/course/${r.examPlanId}`)}>
+                        去处理
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer title={false} >
+            <SuperTable<API.ExamPaperTodoPlanOutput>
+                toolbar={{
+                    title: '计划列表',
+                }}
+                rowKey="examPlanId"
+                actionRef={actionRef}
+                columns={columns}
+                // scroll={{ x: 'max-content' }}
+                columnEmptyText=""
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamPaperController.queryWriterExamPlanPageList({ ...p, writerType: ExamPaperWriterType.SUGGESTION });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default SuggestionExamPlanList;

+ 109 - 0
YBEE.EQM.Admin/src/pages/teaching-research/twcl/TwclCourseList/index.tsx

@@ -0,0 +1,109 @@
+import { SuperTable, TagStatus } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import ExamPlanController from '@/services/apis/ExamPlanController';
+import { AuditStatus, ExamPaperWriterType } from '@/services/enums';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useParams } from '@umijs/max';
+import { useRequest } from 'ahooks';
+import { theme } from 'antd';
+import { useRef } from 'react';
+import { ExamPaperLiteItem } from '../../typing';
+
+/** 双向细目表编制试卷列表 */
+const TwclCourseList: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPlanId: number };
+
+    const actionRef = useRef<ActionType>();
+    const { token } = theme.useToken();
+
+    const { data, loading } = useRequest(() => {
+        return ExamPlanController.getById({ id: reqParams.examPlanId });
+    });
+
+    const columns: ProColumns<ExamPaperLiteItem>[] = [
+        {
+            title: '年级',
+            dataIndex: ['grade', 'name'],
+            width: 120,
+            align: 'center',
+        },
+        {
+            title: '科目',
+            dataIndex: ['course', 'name'],
+            width: 120,
+            align: 'center',
+        },
+        {
+            title: '总分',
+            dataIndex: 'score',
+            width: 120,
+            align: 'center',
+        },
+        {
+            title: '备注',
+            dataIndex: 'remark',
+        },
+        {
+            title: '状态',
+            dataIndex: 'submitted',
+            width: 80,
+            align: 'center',
+            render: (_, r) => {
+                if (r.submitted) {
+                    return <TagStatus status="success">已提交</TagStatus>;
+                }
+                return <TagStatus status="error">未提交</TagStatus>;
+            },
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <a onClick={() => history.push(`/exam-c/tr-twcl/course/detail/${r.id}`)}>
+                        去编辑
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer
+            title={`${data?.fullName ?? ''} - 双向细目表编制`}
+            onBack={() => history.back()}
+            loading={loading}
+        >
+            <SuperTable<ExamPaperLiteItem>
+                headerTitle="科目列表"
+                actionRef={actionRef}
+                columns={columns}
+                search={false}
+                columnEmptyText=""
+                request={async () => {
+                    const res = await ExamPaperController.getWriterListByExamPlanId({
+                        examplanid: reqParams.examPlanId,
+                        writertype: ExamPaperWriterType.TWCL,
+                    });
+                    const data = res?.map(t => {
+                        return {
+                            ...t,
+                            submitted: ![AuditStatus.UNSUBMIT, AuditStatus.REJECTED].includes(t.twclStatus),
+                        } as ExamPaperLiteItem;
+                    });
+                    return {
+                        data,
+                        success: true,
+                    };
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default TwclCourseList;

+ 381 - 0
YBEE.EQM.Admin/src/pages/teaching-research/twcl/TwclDetail/index.tsx

@@ -0,0 +1,381 @@
+import { CardStepTitle } from "@/components";
+import ExamPaperController from "@/services/apis/ExamPaperController";
+import ExamPaperQuestionMinorController from "@/services/apis/ExamPaperQuestionMinorController";
+import { AuditStatus } from "@/services/enums";
+import { ReloadOutlined } from "@ant-design/icons";
+import { ActionType, EditableFormInstance, EditableProTable, PageContainer, ProCard, ProColumns, ProDescriptions } from "@ant-design/pro-components";
+import { history, useModel, useParams } from "@umijs/max";
+import { App, Button, Space, Tag, Typography, theme } from "antd";
+import lodash from 'lodash';
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type ExamPaperQuestionMinorItem = API.ExamPaperQuestionMinorOutput & {
+    majorCount: number;
+    isFirstMinor: boolean;
+};
+
+/**
+ * 双向细目表详细
+ */
+const TwclDetail: React.FC = () => {
+    const reqParams = useParams() as unknown as { examPaperId: number };
+    const { token } = theme.useToken();
+
+    const { message, modal } = App.useApp();
+
+    const { getDictOptions } = useModel('useDict');
+
+    // const currentEditRef = useRef<React.Key>();
+    const actionRef = useRef<ActionType>();
+    const editorFormRef = useRef<EditableFormInstance<ExamPaperQuestionMinorItem>>();
+    const [editableKeys, setEditableRowKeys] = useState<React.Key[]>();
+
+    const [dataSource, setDataSource] = useState<ExamPaperQuestionMinorItem[]>();
+    const [data, setData] = useState<API.ExamPaperOutput>();
+    const [loading, setLoading] = useState(false);
+    const [submitting, setSubmitting] = useState(false);
+
+    // 构建表格数据源
+    const buildDataSource = (ms: API.ExamPaperQuestionMinorOutput[]) => {
+        // 各大题小题数量
+        const majors = lodash.countBy(ms, 'examPaperQuestionMajorId');
+        // 各大题下第一个小题
+        const minMinors = lodash.mapValues(lodash.groupBy(ms, 'examPaperQuestionMajorId'), g => lodash.minBy(g, 'sequence')?.id);
+        let ds = [...ms] as ExamPaperQuestionMinorItem[];
+        ds.sort((a, b) => {
+            // 按大题和小题顺序排序
+            return (a.examPaperQuestionMajor?.sequence || 0 + a.sequence) - (b.examPaperQuestionMajor?.sequence || 0 + b.sequence);
+        }).forEach(d => {
+            if (d.examPaperQuestionMajorId) {
+                d.majorCount = majors[d.examPaperQuestionMajorId]
+                if (d.id === minMinors[d.examPaperQuestionMajorId]) {
+                    d.isFirstMinor = true;
+                }
+            }
+        });
+        setDataSource(ds);
+    }
+
+    // 加载数据
+    const loadData = useCallback(async () => {
+        try {
+            setLoading(true);
+            const res = await ExamPaperController.getById({ id: reqParams.examPaperId });
+            setData(res);
+            buildDataSource(res?.examPaperQuestionMinors ?? []);
+        }
+        catch { }
+        finally {
+            setLoading(false);
+        }
+    }, [reqParams]);
+    useEffect(() => { loadData(); }, []);
+
+    // 未提交状态
+    const unsubmit = [AuditStatus.UNSUBMIT, AuditStatus.REJECTED].includes(data?.twclStatus ?? AuditStatus.UNSUBMIT);
+
+    // 列定义
+    const columns: ProColumns<ExamPaperQuestionMinorItem>[] = [
+        {
+            title: '序',
+            width: 40,
+            align: 'center',
+            render: (_, r, ri) => ri + 1,
+            editable: false,
+            rowScope: 'rowgroup',
+            fixed: 'left',
+        },
+        {
+            title: '大题',
+            dataIndex: ['examPaperQuestionMajor', 'title'],
+            editable: false,
+            // width: 160,
+            render: (v, r) => {
+                const m = r.examPaperQuestionMajor;
+                if (!m) {
+                    return '';
+                }
+                return `${m.number}${m.title !== '' ? '、' : ''}${m.title}(${m.score}分)`;
+            },
+            onCell: (r) => {
+                if (r.isFirstMinor) {
+                    if (r.majorCount > 1) {
+                        return { rowSpan: r.majorCount };
+                    }
+                    return {};
+                }
+                return { rowSpan: 0 };
+            },
+        },
+        {
+            title: '小题号',
+            dataIndex: 'name',
+            editable: false,
+            // width: 96,
+        },
+        {
+            title: '分值',
+            dataIndex: 'score',
+            valueType: 'digit',
+            width: 64,
+            align: 'center',
+            editable: false,
+            fieldProps: {
+                precision: 2,
+            },
+        },
+        {
+            title: '题型',
+            dataIndex: 'questionCatalog',
+            valueType: 'select',
+            width: 96 + token.paddingMD,
+            align: 'center',
+            fieldProps: {
+                options: getDictOptions('question_catalog').filter(t => t.value > 0),
+                style: { width: 96 },
+            },
+            formItemProps: {
+                required: true,
+                rules: [{ required: true }],
+            },
+        },
+        {
+            title: '认知能力',
+            dataIndex: 'cognitiveAbility',
+            valueType: 'select',
+            width: 96 + token.paddingMD,
+            align: 'center',
+            fieldProps: {
+                options: getDictOptions('cognitive_ability').filter(t => t.value > 0),
+                style: { width: 96 },
+            },
+            formItemProps: {
+                required: true,
+                rules: [{ required: true }],
+            },
+        },
+        {
+            title: '预估难度',
+            dataIndex: 'estimatedDifficulty',
+            tooltip: '预估难度取值范围为:大于0,小于等于1',
+            valueType: 'digit',
+            width: 80 + token.paddingMD,
+            fieldProps: {
+                style: { width: 80 },
+                min: 0,
+                max: 1,
+                precision: 2,
+                step: 0.1,
+            },
+            formItemProps: {
+                required: true,
+                rules: [
+                    { required: true },
+                    () => ({
+                        validator: (_, value) => {
+                            if (value === undefined || value === null || value === '' || (value > 0 && value <= 1)) {
+                                return Promise.resolve();
+                            }
+                            return Promise.reject('预估难度取值范围为:大于0,小于等于1');
+                        },
+                    }),
+                ],
+            },
+            render: (v, r) => {
+                if ((r.estimatedDifficulty ?? 0) > 0) {
+                    return v;
+                }
+                return <Typography.Text type="warning">{v}</Typography.Text>;
+            },
+        },
+        {
+            title: '知识模块',
+            dataIndex: 'knowledgeModule',
+            valueType: 'text',
+            className: 'minw-160',
+            fieldProps: {
+                maxLength: 200,
+                showCount: true,
+            },
+            formItemProps: {
+                required: true,
+                rules: [{ required: true }],
+            },
+        },
+        {
+            title: '知识点',
+            dataIndex: 'knowledgePoint',
+            valueType: 'text',
+            className: 'minw-160',
+            fieldProps: {
+                maxLength: 200,
+                showCount: true,
+            },
+            formItemProps: {
+                required: true,
+                rules: [{ required: true }],
+            },
+        },
+        {
+            title: '选做',
+            dataIndex: 'isChoose',
+            valueType: 'switch',
+            width: 80,
+            align: 'center',
+            fieldProps: {
+                checkedChildren: '是',
+                unCheckedChildren: '否'
+            },
+        },
+    ];
+    if (unsubmit) {
+        columns.push({
+            title: '操作',
+            valueType: 'option',
+            width: 88,
+            fixed: 'right',
+            render: (v, r, _, action) => [
+                <a key="edit" onClick={() => action?.startEditable?.(r.id)}>编辑</a>,
+            ],
+        });
+    }
+
+    // 提交
+    const handleSubmit = useCallback(async () => {
+        modal.confirm({
+            content: '提交后不能再修改,确定立即提交吗',
+            okText: '确定',
+            cancelText: '取消',
+            centered: true,
+            onOk: async () => {
+                try {
+                    setSubmitting(true);
+                    await ExamPaperController.submitTwcl({ id: reqParams.examPaperId });
+                    message.success('已提交');
+                    loadData();
+                }
+                catch { }
+                finally {
+                    setSubmitting(false);
+                }
+            },
+        });
+    }, []);
+
+    let actions = [<Button key="reload" type="text" icon={<ReloadOutlined />} onClick={loadData} />];
+    if (unsubmit) {
+        actions = [
+            <Typography.Text key="tip" type="warning">所有小题编辑完成后,点击“立即提交”按钮完成该科编制。</Typography.Text>,
+            <Button key="submit" type="primary" loading={submitting} onClick={handleSubmit}>立即提交</Button>,
+            ...actions
+        ];
+    }
+
+    return (
+        <PageContainer
+            title={`${data?.examPlan?.fullName ?? ''} - 双向细目表编制`}
+            onBack={() => history.back()}
+            loading={loading}
+        >
+            <ProCard
+                title={<CardStepTitle>基本信息</CardStepTitle>}
+                extra={
+                    <Space>
+                        <Tag color={unsubmit ? 'error' : 'success'}>{unsubmit ? '未提交' : '已提交'}</Tag>
+                    </Space>
+                }
+            >
+                <ProDescriptions size="small">
+                    <ProDescriptions.Item label="年级">
+                        {data?.grade?.name}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="科目">
+                        {data?.course?.name}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="总分">
+                        {data?.score}
+                    </ProDescriptions.Item>
+                    <ProDescriptions.Item label="备注">
+                        {data?.remark}
+                    </ProDescriptions.Item>
+                </ProDescriptions>
+            </ProCard>
+
+            <EditableProTable<ExamPaperQuestionMinorItem>
+                toolbar={{
+                    title: (<CardStepTitle>双向细目表</CardStepTitle>),
+                    actions,
+                }}
+                style={{ marginTop: token.margin }}
+                scroll={{ x: 'max-content' }}
+                value={dataSource}
+                columns={columns}
+                bordered
+                actionRef={actionRef}
+                editableFormRef={editorFormRef}
+                rowKey="id"
+                // controlled
+                editable={{
+                    editableKeys,
+                    // onValuesChange: (record, recordList) => {
+                    //     if (!data) { return; }
+                    //     const d = { ...data, examPaperQuestionMinors: recordList };
+                    //     setData(d);
+                    //     buildDataSource(recordList);
+                    // },
+                    onChange: setEditableRowKeys,
+                    actionRender: (r, config, dom) => [dom.save, dom.cancel],
+                    onSave: async (key, record) => {
+                        const p: API.UpdateExamPaperQuestionMinorInput = {
+                            id: record.id,
+                            examPaperId: record.examPaperId,
+                            examPaperQuestionMajorId: record.examPaperQuestionMajorId ?? 0,
+                            questionCatalog: record.questionCatalog ? JSON.parse(`${record.questionCatalog}`) : undefined,
+                            cognitiveAbility: record.cognitiveAbility ? JSON.parse(`${record.cognitiveAbility}`) : undefined,
+                            sequence: record.sequence,
+                            columnName: record.columnName,
+                            name: record.name,
+                            score: record.score,
+                            knowledgeModule: record.knowledgeModule,
+                            knowledgePoint: record.knowledgePoint,
+                            estimatedDifficulty: record.estimatedDifficulty,
+                            isChoose: record.isChoose,
+                            isLeaf: record.isLeaf,
+                        };
+                        await ExamPaperQuestionMinorController.update(p);
+                        const rls = data?.examPaperQuestionMinors ?? [];
+                        const ti = rls.findIndex(t => t.id === key);
+                        if (ti !== -1) {
+                            rls[ti] = record;
+                            buildDataSource(rls);
+                        }
+                    },
+                    // onCancel: async (key, record) => {
+                    //     console.log(key);
+                    //     console.log(record);
+                    //     console.log(dataSource);
+                    //     buildDataSource(data?.examPaperQuestionMinors ?? []);
+                    // },
+                }}
+                recordCreatorProps={false}
+            // onRow={(r) => {
+            //     return {
+            //         onClick: () => {
+            //             if (currentEditRef.current === r.id) {
+            //                 return;
+            //             }
+            //             else if (currentEditRef.current) {
+            //                 actionRef.current?.cancelEditable?.(currentEditRef.current);
+            //             }
+            //             currentEditRef.current = r.id;
+            //             actionRef.current?.startEditable?.(r.id);
+            //         },
+            //     };
+            // }}
+            />
+        </PageContainer>
+    );
+}
+
+export default TwclDetail;
+

+ 115 - 0
YBEE.EQM.Admin/src/pages/teaching-research/twcl/index.tsx

@@ -0,0 +1,115 @@
+import { toSelectOptions } from '@/common/converter';
+import { SuperTable } from '@/components';
+import ExamPaperController from '@/services/apis/ExamPaperController';
+import { ExamPaperWriterType } from '@/services/enums';
+import { RightOutlined } from '@ant-design/icons';
+import { ActionType, PageContainer, ProColumns } from '@ant-design/pro-components';
+import { history, useModel } from '@umijs/max';
+import { Typography, theme } from 'antd';
+import { useRef } from 'react';
+
+/** 双向细目表计划列表 */
+const TwclExamPlanList: React.FC = () => {
+    const actionRef = useRef<ActionType>();
+
+    const { token } = theme.useToken();
+
+    const { getDictValueEnum } = useModel('useDict');
+    const { baseData } = useModel('useBaseData');
+
+    const columns: ProColumns<API.ExamPaperTodoPlanOutput>[] = [
+        {
+            title: '计划名称',
+            dataIndex: 'examPlanFullName',
+            // width: 480,
+            search: {
+                transform: (v) => ({ name: v }),
+            },
+            render: (v) => {
+                return `${v}双向细目表编制计划`;
+            },
+        },
+        {
+            title: '监测学期',
+            dataIndex: 'semesterId',
+            search: {
+                transform: (v) => v ? ({ semesterId: JSON.parse(v) }) : v,
+            },
+            valueType: 'select',
+            fieldProps: {
+                options: toSelectOptions(baseData?.semesters ?? [], (item) => ({ key: item.id, label: item.nickShortName, value: item.id })),
+            },
+            hideInTable: true,
+            order: 90,
+        },
+        {
+            title: '计划状态',
+            dataIndex: 'examPlanStatus',
+            valueEnum: getDictValueEnum('exam_status', true),
+            width: 80,
+            align: 'center',
+        },
+        {
+            title: '总数量',
+            dataIndex: 'totalCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? v : null,
+        },
+        {
+            title: '未提交',
+            dataIndex: 'twclUnsubmitCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="warning">{v}</Typography.Text> : null,
+        },
+        {
+            title: '已提交',
+            dataIndex: 'twclSubmittedCount',
+            width: 80,
+            align: 'center',
+            hideInSearch: true,
+            renderText: (v) => v ? <Typography.Text type="success">{v}</Typography.Text> : null,
+        },
+        {
+            title: '操作',
+            valueType: 'option',
+            width: 80,
+            align: 'center',
+            fixed: 'right',
+            render: (_, r) => {
+                return (
+                    <a onClick={() => history.push(`/exam-c/tr-twcl/course/${r.examPlanId}`)}>
+                        去处理
+                        <RightOutlined style={{ marginLeft: token.marginXXS }} />
+                    </a>
+                );
+            },
+        },
+    ];
+
+    return (
+        <PageContainer title={false} >
+            <SuperTable<API.ExamPaperTodoPlanOutput>
+                toolbar={{
+                    title: '计划列表',
+                }}
+                rowKey="examPlanId"
+                actionRef={actionRef}
+                columns={columns}
+                // scroll={{ x: 'max-content' }}
+                columnEmptyText=""
+                request={async (params, sort) => {
+                    return SuperTable.requestPageAgent({ params, sort }, async (p) => {
+                        const res = await ExamPaperController.queryWriterExamPlanPageList({ ...p, writerType: ExamPaperWriterType.TWCL });
+                        return res;
+                    });
+                }}
+            />
+        </PageContainer>
+    );
+};
+
+export default TwclExamPlanList;

+ 3 - 0
YBEE.EQM.Admin/src/pages/teaching-research/typing.d.ts

@@ -0,0 +1,3 @@
+export type ExamPaperLiteItem = API.ExamPaperLiteOutput & {
+    submitted: boolean;
+};

+ 78 - 0
YBEE.EQM.Admin/src/services/apis/ExamAbsentReplaceAuditController.ts

@@ -0,0 +1,78 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 缺测替补审核服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 提交审核 POST /api/exam/absent/replace/audit/submit */
+export async function submit(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/absent/replace/audit/submit';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 审核 POST /api/exam/absent/replace/audit/audit */
+export async function audit(
+    data: API.ExamAbsentReplaceAuditInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/audit/audit';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 反审 POST /api/exam/absent/replace/audit/reaudit */
+export async function reaudit(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/absent/replace/audit/reaudit';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 获取待审核监测计划列表 POST /api/exam/absent/replace/audit/query-exam-plan-page-list */
+export async function queryExamPlanPageList(
+    data: API.ExamPlanPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/audit/query-exam-plan-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamPlanAuditOutput>>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+/** 获取待审核机构列表 POST /api/exam/absent/replace/audit/query-org-audit-page-list */
+export async function queryOrgAuditPageList(
+    data: API.ExamOrgDataReportAuditPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/audit/query-org-audit-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamPlanOrgAuditOutput>>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+export default {
+    /** 提交审核 POST /api/exam/absent/replace/audit/submit */
+    submit,
+    /** 审核 POST /api/exam/absent/replace/audit/audit */
+    audit,
+    /** 反审 POST /api/exam/absent/replace/audit/reaudit */
+    reaudit,
+    /** 获取待审核监测计划列表 POST /api/exam/absent/replace/audit/query-exam-plan-page-list */
+    queryExamPlanPageList,
+    /** 获取待审核机构列表 POST /api/exam/absent/replace/audit/query-org-audit-page-list */
+    queryOrgAuditPageList,
+};

+ 186 - 0
YBEE.EQM.Admin/src/services/apis/ExamAbsentReplaceController.ts

@@ -0,0 +1,186 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 缺测替补管理服务
+// --------------------------------------------------------------------------
+
+import { request, RequestOptions } from '@umijs/max';
+import contentDisposition from 'content-disposition';
+
+/** 上传批量导入文件 POST /api/exam/absent/replace/upload */
+export async function upload(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/exam/absent/replace/upload';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<
+        API.ResponseType<API.UploadExamDataOutput_UploadExamAbsentReplaceOutput>
+    >(url, config);
+    return res?.data;
+}
+
+/** 批量导入监测缺测替补 POST /api/exam/absent/replace/import */
+export async function importAction(
+    data: API.ImportExamAbsentReplaceInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/import';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<number>>(url, config);
+    return res?.data;
+}
+
+/** 添加监测缺测替补 POST /api/exam/absent/replace/add */
+export async function add(data: API.AddExamAbsentReplaceInput, options?: { [key: string]: any }) {
+    const url = '/api/exam/absent/replace/add';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 更新监测缺测替补 POST /api/exam/absent/replace/update */
+export async function update(
+    data: API.UpdateExamAbsentReplaceInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 上传特殊学生佐证材料 POST /api/exam/absent/replace/upload-attachment */
+export async function uploadAttachment(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/exam/absent/replace/upload-attachment';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除特殊学生佐证材料 POST /api/exam/absent/replace/del-attachment */
+export async function delAttachment(
+    data: API.DeleteAttachmentInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/del-attachment';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除监测缺测替补 POST /api/exam/absent/replace/del */
+export async function del(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/absent/replace/del';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 清空监测缺测替补 POST /api/exam/absent/replace/clear */
+export async function clear(
+    data: API.ClearExamAbsentReplaceInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/clear';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 导出监测缺测替补上报打印表格 POST /api/exam/absent/replace/export-print-table */
+export async function exportPrintTable(
+    params: {
+        /**  */
+        examplanid?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/export-print-table';
+    const config = {
+        method: 'POST',
+        params,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 分页查询监测缺测替补列表 POST /api/exam/absent/replace/query-page-list */
+export async function queryPageList(
+    data: API.ExamAbsentReplacePageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/query-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamAbsentReplaceOutput>>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+/** 获取机构班级特殊学生上报人数统计列表 GET /api/exam/absent/replace/get-org-grade-class-student-count */
+export async function getOrgGradeClassStudentCount(
+    params: {
+        /**  */
+        examplanid?: number;
+        /**  */
+        sysorgid?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/get-org-grade-class-student-count';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamAbsentReplaceCountOutput>>(url, config);
+    return res?.data;
+}
+
+/** 获取状态数量 POST /api/exam/absent/replace/query-status-count */
+export async function queryStatusCount(
+    data: API.ExamAbsentReplacePageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/absent/replace/query-status-count';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.StatusCount[]>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 上传批量导入文件 POST /api/exam/absent/replace/upload */
+    upload,
+    /** 批量导入监测缺测替补 POST /api/exam/absent/replace/import */
+    importAction,
+    /** 添加监测缺测替补 POST /api/exam/absent/replace/add */
+    add,
+    /** 更新监测缺测替补 POST /api/exam/absent/replace/update */
+    update,
+    /** 上传特殊学生佐证材料 POST /api/exam/absent/replace/upload-attachment */
+    uploadAttachment,
+    /** 删除特殊学生佐证材料 POST /api/exam/absent/replace/del-attachment */
+    delAttachment,
+    /** 删除监测缺测替补 POST /api/exam/absent/replace/del */
+    del,
+    /** 清空监测缺测替补 POST /api/exam/absent/replace/clear */
+    clear,
+    /** 导出监测缺测替补上报打印表格 POST /api/exam/absent/replace/export-print-table */
+    exportPrintTable,
+    /** 分页查询监测缺测替补列表 POST /api/exam/absent/replace/query-page-list */
+    queryPageList,
+    /** 获取机构班级特殊学生上报人数统计列表 GET /api/exam/absent/replace/get-org-grade-class-student-count */
+    getOrgGradeClassStudentCount,
+    /** 获取状态数量 POST /api/exam/absent/replace/query-status-count */
+    queryStatusCount,
+};

+ 69 - 0
YBEE.EQM.Admin/src/services/apis/ExamCourseController.ts

@@ -0,0 +1,69 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 监测科目管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 添加监测科目 POST /api/exam-course/add */
+export async function add(data: API.AddExamCourseInput, options?: { [key: string]: any }) {
+    const url = '/api/exam-course/add';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 更新监测科目 POST /api/exam-course/update */
+export async function update(data: API.UpdateExamCourseInput, options?: { [key: string]: any }) {
+    const url = '/api/exam-course/update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除监测科目 POST /api/exam-course/del */
+export async function del(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam-course/del';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 根据ID获取监测科目信息 GET /api/exam-course/get-by-id */
+export async function getById(
+    params: {
+        /**  */
+        id?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam-course/get-by-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamCourseOutput>>(url, config);
+    return res?.data;
+}
+
+/** 查询监测年级列表 POST /api/exam-course/query-list */
+export async function queryList(data: API.QueryExamCourseInput, options?: { [key: string]: any }) {
+    const url = '/api/exam-course/query-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamCourseOutput[]>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 添加监测科目 POST /api/exam-course/add */
+    add,
+    /** 更新监测科目 POST /api/exam-course/update */
+    update,
+    /** 删除监测科目 POST /api/exam-course/del */
+    del,
+    /** 根据ID获取监测科目信息 GET /api/exam-course/get-by-id */
+    getById,
+    /** 查询监测年级列表 POST /api/exam-course/query-list */
+    queryList,
+};

+ 23 - 0
YBEE.EQM.Admin/src/services/apis/ExamDataReportController.ts

@@ -36,6 +36,25 @@ export async function del(data: API.BaseId, options?: { [key: string]: any }) {
     return res?.data;
 }
 
+/** 上传附件 POST /api/exam/data/report/upload-attachment */
+export async function uploadAttachment(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/exam/data/report/upload-attachment';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除附件 POST /api/exam/data/report/del-attachment */
+export async function delAttachment(
+    data: API.DeleteAttachmentInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/data/report/del-attachment';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
 /** 开始 POST /api/exam/data/report/start */
 export async function start(data: API.BaseId, options?: { [key: string]: any }) {
     const url = '/api/exam/data/report/start';
@@ -95,6 +114,10 @@ export default {
     update,
     /** 删除上报类型 POST /api/exam/data/report/del */
     del,
+    /** 上传附件 POST /api/exam/data/report/upload-attachment */
+    uploadAttachment,
+    /** 删除附件 POST /api/exam/data/report/del-attachment */
+    delAttachment,
     /** 开始 POST /api/exam/data/report/start */
     start,
     /** 结束 POST /api/exam/data/report/stop */

+ 13 - 0
YBEE.EQM.Admin/src/services/apis/ExamGradeController.ts

@@ -25,6 +25,17 @@ export async function remove(data: API.DelExamGradeInput, options?: { [key: stri
     return res?.data;
 }
 
+/** 保存监测年级设置 POST /api/exam/grade/save-setting */
+export async function saveSetting(
+    data: API.ExamGradeSettingInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/grade/save-setting';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
 /** 根据ID获取监测年级 GET /api/exam/grade/get-by-id */
 export async function getById(
     params: {
@@ -58,6 +69,8 @@ export default {
     addList,
     /** 移出监测年级 POST /api/exam/grade/remove */
     remove,
+    /** 保存监测年级设置 POST /api/exam/grade/save-setting */
+    saveSetting,
     /** 根据ID获取监测年级 GET /api/exam/grade/get-by-id */
     getById,
     /** 根据监测计划ID获取全部监测年级 GET /api/exam/grade/get-list-by-exam-plan-id */

+ 13 - 0
YBEE.EQM.Admin/src/services/apis/ExamOrgController.ts

@@ -25,6 +25,17 @@ export async function remove(data: API.DelExamOrgInput, options?: { [key: string
     return res?.data;
 }
 
+/** 切换机构是否参与区统一监测 POST /api/exam/org/switch-required-sample */
+export async function switchRequiredSample(
+    data: API.SwitchExamOrgRequiredSampleInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/org/switch-required-sample';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
 /** 根据监测计划ID获取监测机构列表 GET /api/exam/org/get-list-by-exam-plan-id */
 export async function getListByExamPlanId(
     params: {
@@ -77,6 +88,8 @@ export default {
     addList,
     /** 移出机构 POST /api/exam/org/remove */
     remove,
+    /** 切换机构是否参与区统一监测 POST /api/exam/org/switch-required-sample */
+    switchRequiredSample,
     /** 根据监测计划ID获取监测机构列表 GET /api/exam/org/get-list-by-exam-plan-id */
     getListByExamPlanId,
     /** 分页查询监测机构列表 POST /api/exam/org/query-page-list */

+ 1 - 1
YBEE.EQM.Admin/src/services/apis/ExamOrgDataReportController.ts

@@ -39,7 +39,7 @@ export async function uploadAttachment(data: FormData, options?: { [key: string]
 
 /** 删除特殊学生佐证材料 POST /api/exam/org/data/report/del-attachment */
 export async function delAttachment(
-    data: API.DeleteExamOrgDataReportAttachmentInput,
+    data: API.DeleteAttachmentInput,
     options?: { [key: string]: any },
 ) {
     const url = '/api/exam/org/data/report/del-attachment';

+ 3 - 0
YBEE.EQM.Admin/src/services/apis/ExamOrgResultController.ts

@@ -36,6 +36,9 @@ export async function download(
     } as RequestOptions;
     const res = await request(url, config);
     const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
     let fileName = '';
     const cd = contentDisposition.parse(hcd);
     if (cd?.parameters?.filename) {

+ 68 - 0
YBEE.EQM.Admin/src/services/apis/ExamOrgScoreReportController.ts

@@ -0,0 +1,68 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 校考成绩上报管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 上传校考成绩 POST /api/exam/org/score/report/upload */
+export async function upload(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/exam/org/score/report/upload';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.UploadExamOrgScoreReportOutput>>(url, config);
+    return res?.data;
+}
+
+/** 查询某科目校考成绩上报数据 POST /api/exam/org/score/report/query-one */
+export async function queryOne(
+    data: API.QueryExamOrgScoreReportInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/org/score/report/query-one';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamOrgScoreReportOutput>>(url, config);
+    return res?.data;
+}
+
+/** 获取机构校考上报明细列表 GET /api/exam/org/score/report/get-list-by-exam-plan-id */
+export async function getListByExamPlanId(
+    params: {
+        /**  */
+        examplanid?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/org/score/report/get-list-by-exam-plan-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamOrgScoreReportItem[]>>(url, config);
+    return res?.data;
+}
+
+/** 合并学校上报小题分 POST /api/exam/org/score/report/merge-minor-score */
+export async function mergeMinorScore(
+    params: {
+        /**  */
+        examplanid?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/org/score/report/merge-minor-score';
+    const config = { method: 'POST', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.IActionResult>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 上传校考成绩 POST /api/exam/org/score/report/upload */
+    upload,
+    /** 查询某科目校考成绩上报数据 POST /api/exam/org/score/report/query-one */
+    queryOne,
+    /** 获取机构校考上报明细列表 GET /api/exam/org/score/report/get-list-by-exam-plan-id */
+    getListByExamPlanId,
+    /** 合并学校上报小题分 POST /api/exam/org/score/report/merge-minor-score */
+    mergeMinorScore,
+};

+ 168 - 0
YBEE.EQM.Admin/src/services/apis/ExamPaperController.ts

@@ -0,0 +1,168 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 试卷管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+import { ExamPaperWriterType } from '../enums';
+
+/** 按监测计划初始化试卷 POST /api/exam/paper/batch-init */
+export async function batchInit(
+    data: API.ExamPaperBatchInitInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/batch-init';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 分配双向细目表编制人 POST /api/exam/paper/assign-twcl-writer */
+export async function assignTwclWriter(
+    data: API.AssignExamPaperWriterInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/assign-twcl-writer';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 分配问题建议撰写人 POST /api/exam/paper/assign-suggestion-writer */
+export async function assignSuggestionWriter(
+    data: API.AssignExamPaperWriterInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/assign-suggestion-writer';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 根据ID获取试卷详情 GET /api/exam/paper/get-by-id */
+export async function getById(
+    params: {
+        /**  */
+        id?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/get-by-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamPaperOutput>>(url, config);
+    return res?.data;
+}
+
+/** 根据监测计划ID获取试卷列表(管理端) GET /api/exam/paper/get-list-by-exam-plan-id */
+export async function getListByExamPlanId(
+    params: {
+        /**  */
+        examplanid?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/get-list-by-exam-plan-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamPaperLiteOutput[]>>(url, config);
+    return res?.data;
+}
+
+/** 获取双向细目表监测计划列表(管理端) POST /api/exam/paper/query-exam-plan-page-list */
+export async function queryExamPlanPageList(
+    data: API.ExamPlanPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/query-exam-plan-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamPaperTodoPlanOutput>>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+/** 分页查询编撰人监测计划列表 POST /api/exam/paper/query-writer-exam-plan-page-list */
+export async function queryWriterExamPlanPageList(
+    data: API.ExamPaperExamPlanPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/query-writer-exam-plan-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamPaperTodoPlanOutput>>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+/** 根据监测计划ID获取待处理试卷列表 GET /api/exam/paper/get-writer-list-by-exam-plan-id */
+export async function getWriterListByExamPlanId(
+    params: {
+        /**  */
+        examplanid: number;
+        /**  */
+        writertype: ExamPaperWriterType;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/get-writer-list-by-exam-plan-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamPaperLiteOutput[]>>(url, config);
+    return res?.data;
+}
+
+/** 保存问题建议 POST /api/exam/paper/save-suggestion */
+export async function saveSuggestion(
+    data: API.SaveExamPaperSuggestion,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/save-suggestion';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 提交双向细目表 POST /api/exam/paper/submit-twcl */
+export async function submitTwcl(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/paper/submit-twcl';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 提交问题建议 POST /api/exam/paper/submit-suggestion */
+export async function submitSuggestion(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/paper/submit-suggestion';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 按监测计划初始化试卷 POST /api/exam/paper/batch-init */
+    batchInit,
+    /** 分配双向细目表编制人 POST /api/exam/paper/assign-twcl-writer */
+    assignTwclWriter,
+    /** 分配问题建议撰写人 POST /api/exam/paper/assign-suggestion-writer */
+    assignSuggestionWriter,
+    /** 根据ID获取试卷详情 GET /api/exam/paper/get-by-id */
+    getById,
+    /** 根据监测计划ID获取试卷列表(管理端) GET /api/exam/paper/get-list-by-exam-plan-id */
+    getListByExamPlanId,
+    /** 获取双向细目表监测计划列表(管理端) POST /api/exam/paper/query-exam-plan-page-list */
+    queryExamPlanPageList,
+    /** 分页查询编撰人监测计划列表 POST /api/exam/paper/query-writer-exam-plan-page-list */
+    queryWriterExamPlanPageList,
+    /** 根据监测计划ID获取待处理试卷列表 GET /api/exam/paper/get-writer-list-by-exam-plan-id */
+    getWriterListByExamPlanId,
+    /** 保存问题建议 POST /api/exam/paper/save-suggestion */
+    saveSuggestion,
+    /** 提交双向细目表 POST /api/exam/paper/submit-twcl */
+    submitTwcl,
+    /** 提交问题建议 POST /api/exam/paper/submit-suggestion */
+    submitSuggestion,
+};

+ 65 - 0
YBEE.EQM.Admin/src/services/apis/ExamPaperQuestionMajorController.ts

@@ -0,0 +1,65 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 试卷大题管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 添加 POST /api/exam/paper/question/major/add */
+export async function add(
+    data: API.AddExamPaperQuestionMajorInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/question/major/add';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 更新 POST /api/exam/paper/question/major/update */
+export async function update(
+    data: API.UpdateExamPaperQuestionMajorInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/question/major/update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除 POST /api/exam/paper/question/major/del */
+export async function del(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/paper/question/major/del';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 根据试卷ID获取大题列表 GET /api/exam/paper/question/major/get-list-by-exam-paper-id */
+export async function getListByExamPaperId(
+    params: {
+        /**  */
+        exampaperid?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/paper/question/major/get-list-by-exam-paper-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamPaperQuestionMajorOutput[]>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 添加 POST /api/exam/paper/question/major/add */
+    add,
+    /** 更新 POST /api/exam/paper/question/major/update */
+    update,
+    /** 删除 POST /api/exam/paper/question/major/del */
+    del,
+    /** 根据试卷ID获取大题列表 GET /api/exam/paper/question/major/get-list-by-exam-paper-id */
+    getListByExamPaperId,
+};

+ 78 - 0
YBEE.EQM.Admin/src/services/apis/ExamPaperQuestionMinorController.ts

@@ -0,0 +1,78 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 试卷小题管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 添加 POST /api/exam-paper-question-minor/add */
+export async function add(
+    data: API.AddExamPaperQuestionMinorInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam-paper-question-minor/add';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 更新 POST /api/exam-paper-question-minor/update */
+export async function update(
+    data: API.UpdateExamPaperQuestionMinorInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam-paper-question-minor/update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 批量更新 POST /api/exam-paper-question-minor/batch-update */
+export async function batchUpdate(
+    data: API.BatchUpdateExamPaperQuestionMinorInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam-paper-question-minor/batch-update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除 POST /api/exam-paper-question-minor/del */
+export async function del(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam-paper-question-minor/del';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 根据试卷ID获取小题列表 GET /api/exam-paper-question-minor/get-list-by-exam-paper-id */
+export async function getListByExamPaperId(
+    params: {
+        /**  */
+        exampaperid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam-paper-question-minor/get-list-by-exam-paper-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamPaperQuestionMinorOutput[]>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 添加 POST /api/exam-paper-question-minor/add */
+    add,
+    /** 更新 POST /api/exam-paper-question-minor/update */
+    update,
+    /** 批量更新 POST /api/exam-paper-question-minor/batch-update */
+    batchUpdate,
+    /** 删除 POST /api/exam-paper-question-minor/del */
+    del,
+    /** 根据试卷ID获取小题列表 GET /api/exam-paper-question-minor/get-list-by-exam-paper-id */
+    getListByExamPaperId,
+};

+ 3 - 0
YBEE.EQM.Admin/src/services/apis/ExamPatriarchQuestionnaireProgressController.ts

@@ -38,6 +38,9 @@ export async function exportUncompletedExcel(
     } as RequestOptions;
     const res = await request(url, config);
     const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
     let fileName = '';
     const cd = contentDisposition.parse(hcd);
     if (cd?.parameters?.filename) {

+ 45 - 0
YBEE.EQM.Admin/src/services/apis/ExamReportingAvgRangeController.ts

@@ -0,0 +1,45 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 统计报表之分数段报表服务
+// --------------------------------------------------------------------------
+
+import { request, RequestOptions } from '@umijs/max';
+import contentDisposition from 'content-disposition';
+
+/** 导出表格 POST /api/exam/reporting/avg/range/export */
+export async function exportAction(
+    params: {
+        /** 监测计划ID */
+        examplanid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/reporting/avg/range/export';
+    const config = {
+        method: 'POST',
+        params,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+export default {
+    /** 导出表格 POST /api/exam/reporting/avg/range/export */
+    exportAction,
+};

+ 308 - 0
YBEE.EQM.Admin/src/services/apis/ExamSampleController.ts

@@ -0,0 +1,308 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 监测抽样方案管理服务
+// --------------------------------------------------------------------------
+
+import { request, RequestOptions } from '@umijs/max';
+import { DataPublishType } from '../enums';
+import contentDisposition from 'content-disposition';
+
+/** 添加监测抽样方案 POST /api/exam/sample/add */
+export async function add(data: API.AddExamSampleInput, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/add';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 更新监测抽样方案 POST /api/exam/sample/update */
+export async function update(data: API.UpdateExamSampleInput, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 复制抽样方案信息 POST /api/exam/sample/duplicate */
+export async function duplicate(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/duplicate';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除监测抽样方案 POST /api/exam/sample/del */
+export async function del(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/del';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 保存全抽班级ID列表 POST /api/exam/sample/save-exam-sample-all-classes */
+export async function saveExamSampleAllClasses(
+    data: API.SaveExamSampleAllClasses,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/save-exam-sample-all-classes';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 切换全抽班级 POST /api/exam/sample/switch-exam-sample-all-class */
+export async function switchExamSampleAllClass(
+    data: API.SwitchExamSampleAllClassInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/switch-exam-sample-all-class';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 选定方案 POST /api/exam/sample/select-sample */
+export async function selectSample(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/select-sample';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 执行抽样 POST /api/exam/sample/execute-sample */
+export async function executeSample(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/execute-sample';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 导出抽样方案存档文件 POST /api/exam/sample/export-to-archived */
+export async function exportToArchived(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/export-to-archived';
+    const config = {
+        method: 'POST',
+        data,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 导出给印刷厂和网阅机构文件 POST /api/exam/sample/export-to-printshop */
+export async function exportToPrintshop(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/export-to-printshop';
+    const config = {
+        method: 'POST',
+        data,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 导出给学校 POST /api/exam/sample/export-to-org */
+export async function exportToOrg(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/export-to-org';
+    const config = {
+        method: 'POST',
+        data,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 导出抽样统计表 POST /api/exam/sample/export-sample-count */
+export async function exportSampleCount(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/export-sample-count';
+    const config = {
+        method: 'POST',
+        data,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 导出学校抽样统计表 POST /api/exam/sample/export-sample-count-to-org */
+export async function exportSampleCountToOrg(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/exam/sample/export-sample-count-to-org';
+    const config = {
+        method: 'POST',
+        data,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 根据ID获取抽样方案 GET /api/exam/sample/get-by-id */
+export async function getById(
+    params: {
+        /**  */
+        id: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/get-by-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSampleOutput>>(url, config);
+    return res?.data;
+}
+
+/** 根据监测计划ID获取全部抽样方案 GET /api/exam/sample/get-list-by-exam-plan-id */
+export async function getListByExamPlanId(
+    params: {
+        /**  */
+        examplanid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/get-list-by-exam-plan-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSampleOutput[]>>(url, config);
+    return res?.data;
+}
+
+/** 查询已发布抽样 GET /api/exam/sample/get-by-exam-data-publish-id */
+export async function getByExamDataPublishId(
+    params: {
+        /** 监测发布内容ID */
+        examdatapublishid: number;
+        /** 抽样数据发布类型 */
+        type: DataPublishType;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/get-by-exam-data-publish-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSamplePlanOutput>>(url, config);
+    return res?.data;
+}
+
+/** 获取抽样统计表 GET /api/exam/sample/get-sample-count-list-by-id */
+export async function getSampleCountListById(
+    params: {
+        /**  */
+        id: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/get-sample-count-list-by-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSampleCountOutput[]>>(url, config);
+    return res?.data;
+}
+
+/** 获取学校抽样统计表 GET /api/exam/sample/get-org-sample-count-list-by-id */
+export async function getOrgSampleCountListById(
+    params: {
+        /**  */
+        id: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/get-org-sample-count-list-by-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSampleCountOutput[]>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 添加监测抽样方案 POST /api/exam/sample/add */
+    add,
+    /** 更新监测抽样方案 POST /api/exam/sample/update */
+    update,
+    /** 复制抽样方案信息 POST /api/exam/sample/duplicate */
+    duplicate,
+    /** 删除监测抽样方案 POST /api/exam/sample/del */
+    del,
+    /** 保存全抽班级ID列表 POST /api/exam/sample/save-exam-sample-all-classes */
+    saveExamSampleAllClasses,
+    /** 切换全抽班级 POST /api/exam/sample/switch-exam-sample-all-class */
+    switchExamSampleAllClass,
+    /** 选定方案 POST /api/exam/sample/select-sample */
+    selectSample,
+    /** 执行抽样 POST /api/exam/sample/execute-sample */
+    executeSample,
+    /** 导出抽样方案存档文件 POST /api/exam/sample/export-to-archived */
+    exportToArchived,
+    /** 导出给印刷厂和网阅机构文件 POST /api/exam/sample/export-to-printshop */
+    exportToPrintshop,
+    /** 导出给学校 POST /api/exam/sample/export-to-org */
+    exportToOrg,
+    /** 导出抽样统计表 POST /api/exam/sample/export-sample-count */
+    exportSampleCount,
+    /** 导出学校抽样统计表 POST /api/exam/sample/export-sample-count-to-org */
+    exportSampleCountToOrg,
+    /** 根据ID获取抽样方案 GET /api/exam/sample/get-by-id */
+    getById,
+    /** 根据监测计划ID获取全部抽样方案 GET /api/exam/sample/get-list-by-exam-plan-id */
+    getListByExamPlanId,
+    /** 查询已发布抽样 GET /api/exam/sample/get-by-exam-data-publish-id */
+    getByExamDataPublishId,
+    /** 获取抽样统计表 GET /api/exam/sample/get-sample-count-list-by-id */
+    getSampleCountListById,
+    /** 获取学校抽样统计表 GET /api/exam/sample/get-org-sample-count-list-by-id */
+    getOrgSampleCountListById,
+};

+ 60 - 0
YBEE.EQM.Admin/src/services/apis/ExamSampleStudentController.ts

@@ -0,0 +1,60 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 监测抽样学生管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 分页查询抽样学生信息 POST /api/exam/sample/student/query-page-list */
+export async function queryPageList(
+    data: API.ExamSampleStudentPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/student/query-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamSampleStudentOutput>>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+/** 根据监测号查询已发布监测方案中当前机构下学生信息 GET /api/exam/sample/student/get-by-exam-number */
+export async function getByExamNumber(
+    params: {
+        /** 监测计划ID */
+        examplanid: number;
+        /** 监测号 */
+        examnumber: string;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/student/get-by-exam-number';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSampleStudentOutput>>(url, config);
+    return res?.data;
+}
+
+/** 查询监测学生信息 POST /api/exam/sample/student/query-exam-sample-student */
+export async function queryExamSampleStudent(
+    data: API.QueryExamSampleStudentInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/sample/student/query-exam-sample-student';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.ExamSampleStudentOutput>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 分页查询抽样学生信息 POST /api/exam/sample/student/query-page-list */
+    queryPageList,
+    /** 根据监测号查询已发布监测方案中当前机构下学生信息 GET /api/exam/sample/student/get-by-exam-number */
+    getByExamNumber,
+    /** 查询监测学生信息 POST /api/exam/sample/student/query-exam-sample-student */
+    queryExamSampleStudent,
+};

+ 39 - 0
YBEE.EQM.Admin/src/services/apis/ExamScoreImportController.ts

@@ -0,0 +1,39 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 学生成绩导入服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 上传文件并完成批量导入前期未上报学生名单的各科成绩 POST /api/exam/score/import/upload-import-without-student-total-score */
+export async function uploadImportWithoutStudentTotalScore(
+    data: FormData,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/score/import/upload-import-without-student-total-score';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 批量导入学生总成绩 POST /api/exam/score/import/upload-import-student-total-score */
+export async function uploadImportStudentTotalScore(
+    data: FormData,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/score/import/upload-import-student-total-score';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 上传文件并完成批量导入前期未上报学生名单的各科成绩 POST /api/exam/score/import/upload-import-without-student-total-score */
+    uploadImportWithoutStudentTotalScore,
+    /** 批量导入学生总成绩 POST /api/exam/score/import/upload-import-student-total-score */
+    uploadImportStudentTotalScore,
+};

+ 4 - 1
YBEE.EQM.Admin/src/services/apis/ExamSpecialStudentController.ts

@@ -60,7 +60,7 @@ export async function uploadAttachment(data: FormData, options?: { [key: string]
 
 /** 删除特殊学生佐证材料 POST /api/exam/special/student/del-attachment */
 export async function delAttachment(
-    data: API.DeleteExamSpecialStudentAttachmentInput,
+    data: API.DeleteAttachmentInput,
     options?: { [key: string]: any },
 ) {
     const url = '/api/exam/special/student/del-attachment';
@@ -106,6 +106,9 @@ export async function exportPrintTable(
     } as RequestOptions;
     const res = await request(url, config);
     const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
     let fileName = '';
     const cd = contentDisposition.parse(hcd);
     if (cd?.parameters?.filename) {

+ 20 - 1
YBEE.EQM.Admin/src/services/apis/ExamStudentController.ts

@@ -87,7 +87,24 @@ export async function getOrgGradeClassStudentCount(
 ) {
     const url = '/api/exam/student/get-org-grade-class-student-count';
     const config = { method: 'GET', params, ...(options || {}) };
-    const res = await request<API.ResponseType<API.ExamStudentCountOutput>>(url, config);
+    const res = await request<API.ResponseType<API.ExamStudentGradeClassStudentCountOutput>>(
+        url,
+        config,
+    );
+    return res?.data;
+}
+
+/** 分页查询班级学生人数 POST /api/exam/student/query-student-count-page-list */
+export async function queryStudentCountPageList(
+    data: API.ExamStudentCountPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/exam/student/query-student-count-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.ExamStudentCountItem>>>(
+        url,
+        config,
+    );
     return res?.data;
 }
 
@@ -108,4 +125,6 @@ export default {
     queryPageList,
     /** 获取机构班级上报人数统计列表 GET /api/exam/student/get-org-grade-class-student-count */
     getOrgGradeClassStudentCount,
+    /** 分页查询班级学生人数 POST /api/exam/student/query-student-count-page-list */
+    queryStudentCountPageList,
 };

+ 4 - 1
YBEE.EQM.Admin/src/services/apis/FileController.ts

@@ -36,6 +36,9 @@ export async function download(
     } as RequestOptions;
     const res = await request(url, config);
     const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
     let fileName = '';
     const cd = contentDisposition.parse(hcd);
     if (cd?.parameters?.filename) {
@@ -48,7 +51,7 @@ export async function download(
 export async function view(
     params: {
         /**  */
-        id?: string;
+        id: string;
     },
     options?: { [key: string]: any },
 ) {

+ 8 - 8
YBEE.EQM.Admin/src/services/apis/BaseCourseCombController.ts → YBEE.EQM.Admin/src/services/apis/NceeCourseCombController.ts

@@ -9,7 +9,7 @@
 
 import { request } from '@umijs/max';
 
-/** 根据ID获取高中选科组合 GET /api/base/course/comb/get-by-id */
+/** 根据ID获取高中选科组合 GET /api/ncee/course/comb/get-by-id */
 export async function getById(
     params: {
         /**  */
@@ -17,23 +17,23 @@ export async function getById(
     },
     options?: { [key: string]: any },
 ) {
-    const url = '/api/base/course/comb/get-by-id';
+    const url = '/api/ncee/course/comb/get-by-id';
     const config = { method: 'GET', params, ...(options || {}) };
-    const res = await request<API.ResponseType<API.CourseCombOutput>>(url, config);
+    const res = await request<API.ResponseType<API.NceeCourseCombOutput>>(url, config);
     return res?.data;
 }
 
-/** 获取所有高中选科组合 GET /api/base/course/comb/get-all-list */
+/** 获取所有高中选科组合 GET /api/ncee/course/comb/get-all-list */
 export async function getAllList(options?: { [key: string]: any }) {
-    const url = '/api/base/course/comb/get-all-list';
+    const url = '/api/ncee/course/comb/get-all-list';
     const config = { method: 'GET', ...(options || {}) };
-    const res = await request<API.ResponseType<API.CourseCombOutput[]>>(url, config);
+    const res = await request<API.ResponseType<API.NceeCourseCombOutput[]>>(url, config);
     return res?.data;
 }
 
 export default {
-    /** 根据ID获取高中选科组合 GET /api/base/course/comb/get-by-id */
+    /** 根据ID获取高中选科组合 GET /api/ncee/course/comb/get-by-id */
     getById,
-    /** 获取所有高中选科组合 GET /api/base/course/comb/get-all-list */
+    /** 获取所有高中选科组合 GET /api/ncee/course/comb/get-all-list */
     getAllList,
 };

+ 107 - 0
YBEE.EQM.Admin/src/services/apis/NceeExportController.ts

@@ -0,0 +1,107 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 高中模拟分析导出服务
+// --------------------------------------------------------------------------
+
+import { request, RequestOptions } from '@umijs/max';
+import contentDisposition from 'content-disposition';
+
+/** 导出联盟区县模拟划线报表 POST /api/ncee/export/export-alliance-district */
+export async function exportAllianceDistrict(
+    params: {
+        /**  */
+        nceeplanid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/ncee/export/export-alliance-district';
+    const config = {
+        method: 'POST',
+        params,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 导出已选科的模拟划线报表 POST /api/ncee/export/export-direction-seleted */
+export async function exportDirectionSeleted(
+    params: {
+        /**  */
+        nceeplanid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/ncee/export/export-direction-seleted';
+    const config = {
+        method: 'POST',
+        params,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+/** 导出未选科的模拟划线报表 POST /api/ncee/export/export-direction-unseleted */
+export async function exportDirectionUnseleted(
+    params: {
+        /**  */
+        nceeplanid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/ncee/export/export-direction-unseleted';
+    const config = {
+        method: 'POST',
+        params,
+        ...(options || {}),
+        responseType: 'blob',
+        getResponse: true,
+    } as RequestOptions;
+    const res = await request(url, config);
+    const hcd = res.request.getResponseHeader('Content-Disposition');
+    if (!hcd) {
+        return null;
+    }
+    let fileName = '';
+    const cd = contentDisposition.parse(hcd);
+    if (cd?.parameters?.filename) {
+        fileName = cd?.parameters?.filename;
+    }
+    return { fileName, data: res.data };
+}
+
+export default {
+    /** 导出联盟区县模拟划线报表 POST /api/ncee/export/export-alliance-district */
+    exportAllianceDistrict,
+    /** 导出已选科的模拟划线报表 POST /api/ncee/export/export-direction-seleted */
+    exportDirectionSeleted,
+    /** 导出未选科的模拟划线报表 POST /api/ncee/export/export-direction-unseleted */
+    exportDirectionUnseleted,
+};

+ 112 - 0
YBEE.EQM.Admin/src/services/apis/NceePlanController.ts

@@ -0,0 +1,112 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 高中模拟划线计划管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 添加计划 POST /api/ncee/plan/add */
+export async function add(data: API.AddNceePlanInput, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/add';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 更新计划 POST /api/ncee/plan/update */
+export async function update(data: API.UpdateNceePlanInput, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/update';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 删除计划 POST /api/ncee/plan/del */
+export async function del(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/del';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 开始监测 POST /api/ncee/plan/start */
+export async function start(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/start';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 结束监测 POST /api/ncee/plan/stop */
+export async function stop(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/stop';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 取消监测 POST /api/ncee/plan/cancel */
+export async function cancel(data: API.BaseId, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/cancel';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 根据ID获取计划 GET /api/ncee/plan/get-by-id */
+export async function getById(
+    params: {
+        /**  */
+        id?: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/ncee/plan/get-by-id';
+    const config = { method: 'GET', params, ...(options || {}) };
+    const res = await request<API.ResponseType<API.NceePlanOutput>>(url, config);
+    return res?.data;
+}
+
+/** 分页查询计划列表 POST /api/ncee/plan/query-page-list */
+export async function queryPageList(data: API.NceePlanPageInput, options?: { [key: string]: any }) {
+    const url = '/api/ncee/plan/query-page-list';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.PageResponse<API.NceePlanOutput>>>(url, config);
+    return res?.data;
+}
+
+/** 获取我的单据状态数量 POST /api/ncee/plan/query-status-count */
+export async function queryStatusCount(
+    data: API.NceePlanPageInput,
+    options?: { [key: string]: any },
+) {
+    const url = '/api/ncee/plan/query-status-count';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<API.StatusCount[]>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 添加计划 POST /api/ncee/plan/add */
+    add,
+    /** 更新计划 POST /api/ncee/plan/update */
+    update,
+    /** 删除计划 POST /api/ncee/plan/del */
+    del,
+    /** 开始监测 POST /api/ncee/plan/start */
+    start,
+    /** 结束监测 POST /api/ncee/plan/stop */
+    stop,
+    /** 取消监测 POST /api/ncee/plan/cancel */
+    cancel,
+    /** 根据ID获取计划 GET /api/ncee/plan/get-by-id */
+    getById,
+    /** 分页查询计划列表 POST /api/ncee/plan/query-page-list */
+    queryPageList,
+    /** 获取我的单据状态数量 POST /api/ncee/plan/query-status-count */
+    queryStatusCount,
+};

+ 59 - 0
YBEE.EQM.Admin/src/services/apis/NceeScoreController.ts

@@ -0,0 +1,59 @@
+// @ts-ignore
+/* eslint-disable */
+
+// 该文件自动生成,请勿手动修改!
+
+// --------------------------------------------------------------------------
+// 高中模拟划线成绩管理服务
+// --------------------------------------------------------------------------
+
+import { request } from '@umijs/max';
+
+/** 上传成绩(仅原始分,适用于五区联考) POST /api/ncee/score/upload-only-raw-score */
+export async function uploadOnlyRawScore(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/ncee/score/upload-only-raw-score';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 上传成绩(带转换分和等级,适用于六校联考) POST /api/ncee/score/upload-with-convert-score */
+export async function uploadWithConvertScore(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/ncee/score/upload-with-convert-score';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 上传未选科原始成绩(适用于高一未选科) POST /api/ncee/score/upload-no-direction-course */
+export async function uploadNoDirectionCourse(data: FormData, options?: { [key: string]: any }) {
+    const url = '/api/ncee/score/upload-no-direction-course';
+    const config = { method: 'POST', data, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+/** 执行模拟划线 POST /api/ncee/score/execute */
+export async function execute(
+    params: {
+        /**  */
+        nceeplanid: number;
+    },
+    options?: { [key: string]: any },
+) {
+    const url = '/api/ncee/score/execute';
+    const config = { method: 'POST', params, ...(options || {}) };
+    const res = await request<API.ResponseType<any>>(url, config);
+    return res?.data;
+}
+
+export default {
+    /** 上传成绩(仅原始分,适用于五区联考) POST /api/ncee/score/upload-only-raw-score */
+    uploadOnlyRawScore,
+    /** 上传成绩(带转换分和等级,适用于六校联考) POST /api/ncee/score/upload-with-convert-score */
+    uploadWithConvertScore,
+    /** 上传未选科原始成绩(适用于高一未选科) POST /api/ncee/score/upload-no-direction-course */
+    uploadNoDirectionCourse,
+    /** 执行模拟划线 POST /api/ncee/score/execute */
+    execute,
+};

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä