소스 검색

Merge branch 'master' of http://121.4.203.192:9000/mingxue/front

唐宏才 3 년 전
부모
커밋
7a53456eb5
69개의 변경된 파일6774개의 추가작업 그리고 1180개의 파일을 삭제
  1. 4 1
      .gitignore
  2. 13 13
      babel.config.js
  3. 229 0
      doc/Mind/ElectiveEngine.cs
  4. 517 0
      doc/Mind/ElectiveGeneration.cs
  5. 102 0
      doc/Mind/FacultyEstimate.cs
  6. 136 0
      doc/Mind/PrimaryElective.cs
  7. 140 0
      doc/Mind/StudentClassDispatch.cs
  8. 52 0
      mock/index.js
  9. 81 0
      mock/mock-server.js
  10. 28 0
      mock/modules/elective-dispatch.js
  11. 534 0
      mock/modules/elective-generation.js
  12. 25 0
      mock/utils.js
  13. 4 0
      package.json
  14. 31 0
      public/ar/hd.html
  15. 45 0
      public/ar/index.html
  16. 31 0
      public/ar/qh.html
  17. 51 0
      public/usign/css/index.css
  18. 64 0
      public/usign/index.html
  19. 307 0
      public/usign/js/usign.js
  20. 81 0
      src/api/webApi/elective/dispatch.js
  21. 33 0
      src/api/webApi/elective/generation.js
  22. 0 0
      src/api/webApi/elective/index.js
  23. 0 1
      src/assets/styles/common.scss
  24. 98 1
      src/common/mx-config.js
  25. 19 4
      src/common/mx-extension.js
  26. 10 0
      src/components/Cache/modules/mx-classTree-translate-mixin.js
  27. 77 77
      src/components/EvaluationTitle/index.vue
  28. 1 1
      src/components/MxChart/index.vue
  29. 28 0
      src/components/MxCondition/condition-object/condition-dispatch-class.js
  30. 15 0
      src/components/MxCondition/condition-object/condition-dispatch-gender.js
  31. 14 0
      src/components/MxCondition/condition-object/condition-generation-category.js
  32. 17 0
      src/components/MxCondition/condition-object/condition-generation-group.js
  33. 4 2
      src/components/MxCondition/condition-object/condition-local-group.js
  34. 60 4
      src/components/MxTable/index.vue
  35. 48 0
      src/components/MxTable/mx-table-column.vue
  36. 86 0
      src/components/mx-select/mx-select.vue
  37. 1 2
      src/filters/index.js
  38. 13 0
      src/main.js
  39. 19 5
      src/router/index.js
  40. 307 0
      src/utils/usign.js
  41. 163 0
      src/views/elective/dispatch/components/choose-class.vue
  42. 242 0
      src/views/elective/dispatch/components/class-adjust.vue
  43. 82 0
      src/views/elective/dispatch/components/class-table.vue
  44. 201 0
      src/views/elective/dispatch/components/dispatch-table.vue
  45. 90 0
      src/views/elective/dispatch/components/edit-group.vue
  46. 199 0
      src/views/elective/dispatch/components/set-classcount.vue
  47. 78 0
      src/views/elective/dispatch/detail.vue
  48. 73 0
      src/views/elective/dispatch/index.vue
  49. 23 0
      src/views/elective/generation/components/elective-flow-major.vue
  50. 21 0
      src/views/elective/generation/components/elective-flow-rank-descriptor.vue
  51. 30 0
      src/views/elective/generation/components/elective-flow-table-style.css
  52. 244 0
      src/views/elective/generation/components/elective-generation-charts.vue
  53. 65 0
      src/views/elective/generation/components/elective-generation-commands.vue
  54. 72 0
      src/views/elective/generation/components/elective-generation-flow-log.vue
  55. 96 0
      src/views/elective/generation/components/elective-generation-master.vue
  56. 156 0
      src/views/elective/generation/components/elective-generation-steps.vue
  57. 168 0
      src/views/elective/generation/components/elective-generation-table.vue
  58. 218 0
      src/views/elective/generation/detail.vue
  59. 119 0
      src/views/elective/generation/index.vue
  60. 1 0
      src/views/permission/components/round-score-query.vue
  61. 6 6
      src/views/permission/components/steps/fauclty/faculty-forms.vue
  62. 6 6
      src/views/permission/components/steps/fauclty/faculty-result.vue
  63. 2 2
      src/views/permission/components/steps/round-setting-group.vue
  64. 3 3
      src/views/questioncenter/LearnHelper.vue
  65. 1 1
      src/views/questioncenter/bestpaper.vue
  66. 1 1
      src/views/questioncenter/generating.vue
  67. 684 646
      src/views/questioncenter/smart.vue
  68. 376 376
      src/views/videocourse/video_course.vue
  69. 29 28
      vue.config.js

+ 4 - 1
.gitignore

@@ -1 +1,4 @@
-/yarn.lock
+yarn.lock
+node_modules/
+package-lock.json
+.idea/

+ 13 - 13
babel.config.js

@@ -1,13 +1,13 @@
-module.exports = {
-  presets: [
-    // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
-    '@vue/cli-plugin-babel/preset'
-  ],
-  'env': {
-    'development': {
-      // babel-plugin-dynamic-import-node plugin only does one thing by converting all import() to require().
-      // This plugin can significantly increase the speed of hot updates, when you have a large number of pages.
-      'plugins': ['dynamic-import-node']
-    }
-  }
-}
+module.exports = {
+  presets: [
+    // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
+    '@vue/cli-plugin-babel/preset'
+  ],
+  'env': {
+    'development': {
+      // babel-plugin-dynamic-import-node plugin only does one thing by converting all import() to require().
+      // This plugin can significantly increase the speed of hot updates, when you have a large number of pages.
+      'plugins': ['dynamic-import-node', 'macros']
+    }
+  }
+}

+ 229 - 0
doc/Mind/ElectiveEngine.cs

@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace mxdemo.Mind
+{
+    #region models
+
+    /// <summary>
+    /// 选科组合
+    /// </summary>
+    public class SubjectGroup
+    {
+        public int Limitation { get; set; } // 人数限制
+        public string Name { get; set; }
+        public string Code { get; set; }
+        public long Id { get; set; }
+    }
+
+    /// <summary>
+    /// 志愿
+    /// </summary>
+    public class Preference
+    {
+        public int Rank { get; set; } // 第1、2、3志原 越小优先级越高
+        public long GroupId {get;set;}
+        public long StudentId { get; set; }
+        public long Id { get; set; }
+    }
+
+    /// <summary>
+    /// 得分情况
+    /// </summary>
+    public class GroupScore
+    {
+        public decimal Score { get; set; }
+        public long StudentId { get; set; }
+        public long GroupId { get; set; }
+    }
+
+    /// <summary>
+    /// 学生信息
+    /// </summary>
+    public class Student
+    {
+        public long Id { get; set; }
+        public string No { get; set; }
+        public string Name { get; set; }
+    }
+
+    public class ElectiveElement
+    {
+        public Student Student { get; set; }
+        public Preference Preference { get; set; }
+        public SubjectGroup Group { get; set; }
+
+        // 计算属性
+        public int PreferenceRank { get; set; } // 志愿排名
+        public int GroupRank { get; set; }  // 组合排名,即成绩排名(组合全校排名),同样越小优先级越高
+    }
+
+    #endregion
+
+    public class ElectiveResult
+    {
+        public ElectiveResult(List<Student> students)
+        {
+            this.UnmatchedStudents = students;
+        }
+
+        public List<Student> MatchedStudents { get; set; }
+            = new List<Student>();
+        public Dictionary<SubjectGroup, List<ElectiveElement>> MatchedDetails { get; set; }
+            = new Dictionary<SubjectGroup, List<ElectiveElement>>();
+        public List<Student> UnmatchedStudents { get; set; }
+
+        public void AppendMatched(ElectiveElement element)
+        {
+            // 应该还有更多细节需要考虑
+            var appendStudent = element.Student;
+            if (MatchedStudents.Contains(appendStudent)) return;
+
+            if (UnmatchedStudents.Contains(appendStudent))
+                RemoveUnmatched(appendStudent);
+
+            var appendGroup = MatchedDetails.ContainsKey(element.Group)
+                ? MatchedDetails[element.Group]
+                : new List<ElectiveElement>();
+            if (appendGroup.Count < element.Group.Limitation)
+            {
+                MatchedDetails[element.Group] = appendGroup;
+                AddMatchedAndDetails(element);
+            }
+            else
+            {
+                AddUnmatched(appendStudent);
+            }
+        }
+
+        private void AddMatchedAndDetails(ElectiveElement element)
+        {
+            // 应该还有更多细节需要考虑
+            MatchedDetails[element.Group].Add(element);
+            MatchedStudents.Add(element.Student);
+        }
+
+        private void AddUnmatched(Student student)
+        {
+            // 应该还有更多细节需要考虑
+            UnmatchedStudents.Add(student);
+        }
+
+        private void RemoveUnmatched(Student student)
+        {
+            // 应该还有更多细节需要考虑
+            UnmatchedStudents.Remove(student);
+        }
+    }
+
+    public interface IElectiveStrategy
+    {
+        /// <summary>
+        /// 从开放选科中取出按优先级分组的选科
+        /// </summary>
+        /// <returns></returns>
+        List<List<SubjectGroup>> GetAllPrioritizedGroups();
+
+        /// <summary>
+        /// 参与这次运算的志愿优先级集合
+        /// </summary>
+        /// <returns></returns>
+        List<List<int>> GetAllPrioritizedPreferenceRanks();
+
+        /// <summary>
+        /// 参与这次运算的所有学生
+        /// </summary>
+        /// <returns></returns>
+        List<Student> GetAllStudents();
+    }
+
+    public abstract class ElectiveEngine: IComparer<ElectiveElement>
+    { 
+        protected IElectiveStrategy ElectiveStrategy { get; set; }
+
+
+        protected ElectiveEngine(IElectiveStrategy electiveStrategy)
+        {
+            ElectiveStrategy = electiveStrategy;
+        }
+
+        public ElectiveResult Execute()
+        {
+            var prioritizedGroups = ElectiveStrategy.GetAllPrioritizedGroups();
+            var prioritizedPreferenceRanks = ElectiveStrategy.GetAllPrioritizedPreferenceRanks();
+            var allStudents = ElectiveStrategy.GetAllStudents();
+            var electiveResult = new ElectiveResult(allStudents);
+
+            foreach(var groups in prioritizedGroups)
+            {
+                foreach(var ranks in prioritizedPreferenceRanks)
+                {
+                    var students = electiveResult.UnmatchedStudents;
+                    var elements = BuildElectiveElements(students, groups, ranks);
+                    ExecuteCore(elements, electiveResult);
+                }
+            }
+            return electiveResult;
+        }
+
+        private void ExecuteCore(List<ElectiveElement> elements, ElectiveResult context)
+        {
+            // 核心方法优先级排序, 已经按期望排好了录取顺序
+            elements.Sort(this);  // this.IComparer<ElectiveElement>.Compare
+            // 接下来考虑每个组合容量的问题
+            foreach(var element in elements)
+            {
+                context.AppendMatched(element);
+            }
+        }
+
+        protected virtual List<ElectiveElement> BuildElectiveElements(List<Student> students, List<SubjectGroup> groups, List<int> ranks)
+        {
+            // 根据组合与志愿 生成不同的选科元素
+            // 可以按计算需要,相互挂一些引用,方便计算过程中的删除操作
+            // TODO: 如果引入意向专业,也可以改写这个生成的逻辑
+            throw new NotImplementedException();
+        }
+
+        /// <summary>
+        /// 优先级比较
+        /// </summary>
+        /// <param name="x"></param>
+        /// <param name="y"></param>
+        /// <returns></returns>
+        public abstract int Compare(ElectiveElement x, ElectiveElement y);
+    }
+
+    public class PreferenceAheadElectiveEngine : ElectiveEngine
+    {
+        public PreferenceAheadElectiveEngine(IElectiveStrategy electiveStrategy)
+            :base(electiveStrategy)
+        {
+
+        }
+
+        public override int Compare(ElectiveElement x, ElectiveElement y)
+        {
+            // TODO: 容错处理
+            if (x.PreferenceRank == y.PreferenceRank) return y.GroupRank - x.GroupRank;
+            return y.PreferenceRank - x.PreferenceRank;
+        }
+    }
+
+    public class ScoreAheadElectiveEngine : ElectiveEngine
+    {
+        public ScoreAheadElectiveEngine(IElectiveStrategy electiveStrategy)
+            : base(electiveStrategy)
+        {
+
+        }
+
+        public override int Compare(ElectiveElement x, ElectiveElement y)
+        {
+            // TODO: 容错处理
+            if (x.GroupRank == y.GroupRank) return y.PreferenceRank - x.PreferenceRank;
+            return y.GroupRank - x.GroupRank;
+        }
+    }
+}

+ 517 - 0
doc/Mind/ElectiveGeneration.cs

@@ -0,0 +1,517 @@
+using System;
+using System.Collections.Generic;
+using static mxdemo.Mind.IStudentClassDispatchService;
+
+namespace mxdemo.Mind
+{
+    // 选科数据与决策会有很多重合的地方
+    public enum EnumElectiveGeneration
+    {
+        Init = 0, // 初始化态,选科正式开启前的状态,开启之后往下一代推进
+        Primary = 1, // 初选(正常报名)阶段,初选时间结束时往下一代推进 (先暂定进入PrimaryDM之前选科设置还可以修改,之后禁止,防止扰乱数据)
+        PrimaryDM, // (PrimaryDecisionMaking)初选录取阶段,应用决策之后,开启补录报名(时间段)并往下一代推进
+        BackTracking, // 补录报名时间结束后往下一代推进
+        BackTrackingDM, // 调剂OR提醒,如果为直接调剂,则跳过FinalAdjust,如果为提醒(设时间段)并往下一代推进
+        FinalAdjust, // 调剂报名期间
+        FinalAdjustDM, // 调剂决策,这个阶段会最终定版
+        RankBalance, // 可选步骤,行为类似强制调剂
+        // Dispatch, // 分班?分班是否要进入此结构?貌似结构不太统一!应该还是要分出去做
+        Terminate // 最终代,选科流程数据全部封存
+    }
+
+    public enum EnumElectiveOperation
+    {
+        None = 0,
+        Canceled,
+        Disabled,
+        Optional,
+        Rejected,
+        Approved,
+        Forced
+    }
+
+    // 待思考问题:
+    /*
+    1 哪个结点开始不允许修改选科设置
+    2 未操作志愿、未选完志愿、未响应决策的学生是否需要区分显示
+    3 补录决策时,是否存在部分调剂、部分提醒的情况?
+    4 调剂行为定义:直接修改学生最终录取组合,触发发签名
+     */
+
+    #region models
+
+    /// <summary>
+    /// Generation init时生成的缓存数据,方便后续计算
+    /// 生成开放组合的数据即可
+    /// 每次修改选科设置(在允许范围内修改)需要刷新
+    /// </summary>
+    public class ElectiveStudentScore
+    {
+        long id;
+        long roundId;
+
+        long studentId;
+        long groupId;
+
+        decimal scoreTotal; // 9科成绩,看是否有必要存
+        decimal scoreGroup; // 6科成绩
+        int rankTotal; // 9科全校排名,看是否有必要存
+        int rankGroup; // 6科全校排名
+    }
+
+    /// <summary>
+    /// 每一代都为体现学生的选科数据变化,任何操作都可以被记录下来,只是最终哪条生效的问题
+    /// 当然也可以根据实际情况减少部分记录,类下面的注解将讨论这些问题
+    /// </summary>
+    public class ElectiveGenerationFlowData
+    {
+        long id;
+        long roundId;
+
+        long studentId;
+        long groupId;
+
+        EnumElectiveGeneration generation;
+        EnumElectiveOperation operation;
+        long operateBy;
+        DateTime operateTime;
+        int rank; // Primary中可能存在多志愿,用来控制此阶段的志愿优先级,如第1志愿=0,第2志愿=1,第三志愿=2
+
+        string remark;
+        // 以下2个字段用来挂载生命周期关键引用对象,具体每个环节挂什么,需要再斟酌。
+        string refType;
+        long refId;
+    }
+    public class ElectiveGenerationFlowLog : ElectiveGenerationFlowData
+    {
+        // + 区分ElectiveGenerationFlowData的字段
+    }
+
+    public class RoundForFlow : mxdemo.Mind.IStudentClassDispatchService.Round // Round到时候合并在一起
+    {
+        bool allMatched; // 所有学生录取完毕,可以开始推进排名均衡或者分班
+        EnumElectiveGeneration currentGeneration; // 内部字段
+
+        // + new fields
+        int disenrollCount; // 未录取学生数量 - 用于图表展示,或者内部判定allMatched
+        // bool enablePushNextDMGeneration; // 是否可以强制推进下一代决策进程 = 当前为报名进程,且学生均已报名
+        bool allowDMAlgorithm; // 当前为决策进程,支持运行匹配算法(BackTrackingDM,FinalAdjustDM)
+        bool doneDMAlgorithm; // 当前为决策进程,且已经运行了匹配算法
+        bool allowForce; // 当前进程代是否允许强制调剂录取(原来BackTrackingDM,FinalAdjustDM,RankBalance),现在调整为(FinalAdjustDM,RankBalance)
+    }
+
+    // 1 初选决策时,需要进行设置
+    // 2 补录决策时,需要进行设置,此时要根据具体数据来定
+    //   如果所有学生都已经匹配完毕(Approved,Forced都属性已匹配),则可以推进排名均衡或者分班
+    //   如果还有未匹配的,则推进到FinalAdjust
+    public class FlowPushSetting
+    {
+        long id;
+        long roundId;
+        DateTime begin;
+        DateTime end;
+        bool onlyRecommand; // 仅允许学生选择推荐组合, 否则允许学生填报有空缺的组合
+        bool onlyAgree; // 调剂阶段,只允许学生选择同意, 否则允许学生拒绝
+    }
+
+    public class ElectiveEsign
+    {
+        long id;
+        long roundId;
+        long flowDataId; // 对应哪条匹配数据(录取或者调剂)【匹配算法结果表也可如此挂载flowDataId,这样可以追溯匹配原因】
+
+        string esignPath; // 签字保存图片
+        bool actived; // 有效标记,可以考虑:如果学生需要签名且未签名时,如果再遇到新的调剂时,旧的签名还要继续么?我觉得应该是不需要的
+        // 可能还有其它字段
+    }
+
+    /*
+     总则:ElectiveGenerationFlowData记录的是每一代数据的最终结果,如果想要更精细的记录,可以结合ElectiveGenerationFlowLog
+    1 Generation.Init 可有可无,不需要生成初始数据?
+
+    2 Generation.Primary
+        报名时:
+        ADD operation=Approved
+
+        取消时:
+        UPDATE operation=Cancel
+
+        排名挤出时-被挤出人:
+        UPDATE operation=Disabled, remark = '排名挤出'
+        refType = 'ElectiveGenerationFlowData'
+        refId = ElectiveGenerationFlowData.id // 报名解发挤出那条记录的ElectiveGenerationFlowData.id
+
+        取消时恢复补挤出的人(只有当被影响的人还有剩余报名次数的时候才自动恢复,因为期间他可能自己又补填了一个志愿):
+        QueryBy refId
+        UPDATE operation=Approved WHEN COUNT(operation=Approved)<setting.PreferenceCount
+        remark = ''
+        refType = ''
+        refId = 0 // 取消对应记录的ID
+
+        Primary中学生的有效报名数据:operation=Approved
+
+    3 Generation.PrimaryDM
+        目标:启用算法,给每个学生生成一条决策数据,表示初选录取情况(算法见ElectiveEngine草稿)
+
+        录取:
+        ADD operation=Approved
+        remark = 录取说明?
+        refType = 'ElectiveEsign'
+        refId = ElectiveEsign.id // 生成一条待签记录
+        // 这样的话签名与选科的代数据就可分割开了,具体说是否一定要学生签名才能推进下一代?
+        // 我看未必,因为学校不可能因为个别钉子户就暂停教务
+
+        未录取,但自选专业满足
+        ADD operation=Optional, groupId=推荐组合
+        remark = 未录取原因
+        未录取,也无自选专业满足
+        ADD operation=Rejected, groupId=推荐组合
+        remark = 未录取原因
+
+        PrimaryDM中 operation=[Optional,Rejected]的学生,即是一代补录流程的目标学生
+
+    4 Generation.BackTracking
+        操作逻辑重用Generation.Primary
+
+    5 Generation.BackTrackingDM
+        操作逻辑重用Generation.PrimaryDM,
+        计算差别:仅1个志愿,但还是可以引用自选专业,因为学生在此期间可能更改了自选专业
+
+        操作差别:校长在推动下一代流程时,有可能会开始手动调剂
+        UPDATE operation=Forced  // Forced操作等同于Approved录取状态,即告诉学生被调剂了,签字同意即可(即也挂ref钩子)
+
+    6 Generation.FinalAdjust
+        可选步骤,有可能跳过,如未跳过,则是调剂报名
+        同意:前端会强制签名,所以这条在保存时可以直接挂钩ElectiveEsign
+        ADD operation=Approved, refType='ElectiveEsign', refId=ElectiveEsign.id
+        拒绝:
+        ADD operation=Rejected, remark=不同意原因
+        附:没有记录则是未参与的
+
+    7 Generation.FinalAdjustDM
+        针对未参与的或operation=Rejected, 校长会在此环节强制调剂,以达到最终定版的目的
+        ADD operation=Forced, refType='ElectiveEsign', refId=0 // 待签名
+
+    8 Generation.RankBalance
+        可选步骤,也是产生类似Generation.FinalAdjustDM数据逻辑,只是学校出发的角度不一样
+        ADD operation=Forced, refType='ElectiveEsign', refId=0 // 待签名
+
+    9 Generation.Dispatch 待定应该要分出去单独做
+
+    10 Generation.Terminate
+        不产生数据,但需要记录此状态,表示选科数据封存(记录在round实例中)
+     */
+
+    #endregion
+
+    public interface IElectiveService
+    {
+        #region getElectiveSummary
+        /// <summary>
+        /// 选科过程中的统计数据值,除了要展示之外还需要做查询支持
+        /// </summary>
+        public class ElectiveSummaryValue
+        {
+            // 展示字段
+            int groupId; // 组合ID,没有组合为0比如未参与报名人数(实际只有两种统计数据,针对某个组合的统计和不针对组合的统计)
+            int value; // 数值
+            string color; // 支持'R','G','B','',或者'#FA1234'等hexString
+            bool bold; // 加粗,重点展示
+            bool star; // 带*,重点展示
+
+            // 反向查询代码 将统计逻辑的条件关键字放入此代码,回传后台能反向解析并恢复明细
+            // 想法:让统计全部基于ElectiveGenerationFlowData,形成一种或多种QueryDto{roundId:'',generation:'',groupId:''...}等, 序列化实际查询条件
+            // string queryCode; // 4.18 此字段往上提升了1级放在了ElectiveSummaryCategory上,详情请看注释
+            bool disabled; // 不支持查询
+        }
+
+        /// <summary>
+        /// 查询的某一列
+        /// </summary>
+        public class ElectiveSummaryCategory
+        {
+            string category; // 身份标识,类似字段名/列名,可能被前端用来定制渲染模板
+            string displayName; // 显示名称
+            string detailName; // 4.19 详情页对应的groupId的业务意义:如报名组合、录取组合、推荐组合、初录报名组合、...
+            string queryCode; // 4.18 如果直接查询,就定义此queryCode,前端会收拢此代所有的queryCode,在2级页面形成查询条件选项卡;
+                              // 4.18 后台需要一个配置,比如PrimaryDM代之后,整列没有数据直接隐藏,
+                              // 4.18 不向前端输出,以减少2级页面的条件项,因为随着选科进行,残留的学生越来越少,减少条件项可以帮助校长更快定位学生
+                              // 4.18 从原来ElectiveSummaryValue内部的queryCode升级而来,反查明细时由方法其它参数提供groupId, roundId, generation
+
+            ElectiveSummaryValue[] values;
+        }
+
+        public interface IElectiveGenerationSummary
+        {
+            int roundId { get; set; } // 选科轮次
+            EnumElectiveGeneration generation { get; set; } // 进程代
+        }
+
+        /// <summary>
+        /// 进程代的统计结果从primary开始出数据
+        /// </summary>
+        public class ElectivePrimarySummary : IElectiveGenerationSummary
+        {
+            ElectiveSummaryCategory[][] categories; // 是多维数组,以应对多志愿的情况
+
+            public int roundId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public EnumElectiveGeneration generation { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+        }
+
+        /// <summary>
+        /// 非Primary非DM的某一代的统计结果
+        /// </summary>
+        public class ElectiveGenerationSummary: IElectiveGenerationSummary
+        {
+            /// <summary>
+            /// 对某一代的统计项目,比如在初选决策、补录报名的统计等,下面接口会细述
+            /// </summary>
+            ElectiveSummaryCategory[] categories;
+
+            int IElectiveGenerationSummary.roundId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            EnumElectiveGeneration IElectiveGenerationSummary.generation { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+        }
+
+        public class ElectiveGenerationDMSummary: ElectiveGenerationSummary
+        {
+            /// <summary>
+            /// 迭加到某决策代的统计项目,比如当前选科的总录取人数、待签人数、已签人数等,下面接口会细述
+            /// </summary>
+            ElectiveSummaryCategory[] accumulates;
+        }
+
+        /// <summary>
+        /// 获取选科进程的统计数据,一直汇总到当前进程代
+        /// </summary>
+        /// <returns></returns>
+        IElectiveGenerationSummary[] getElectiveSummary();
+        // 下面开始按代来描述ElectiveGenerationSummary需要返回的具体内容
+        /*  一、ElectivePrimarySummary,所需ElectiveSummaryCategory->{category:'',displayName:''}定义如下:
+         *  // hht.22/4/10 这个属性不要了 {'expectedCount','设置人数'},  -- 选科设置的人数, 为方便输出与图表展示,可能后续会有重复输出
+         *  {'actualCount','实际人数'},   -- 填报第X志愿的人数
+         *  {'unfinishedCount','未完成人数'},  -- 未填报第X志愿的人数(根据ElectiveGenerationFlowData实际Approved&Rank情况统计即可,因为如果报名还在进行中,本身就是动态变化的东西)
+         *  按第一志愿、第二志愿、...的顺序输出数据
+         *
+         *  二、ElectiveGenerationDMSummary.accumulates
+         *  仅决策代需要统计此数据,决策代[PrimaryDM BackTrackingDM FinalAdjustDM RankBalance]
+         *  // hht.22/4/12 同上,这个属性也不要了 {'expectedCount', '设置人数'} -- 选科设置人数
+         *  {'approvedCount', '录取人数'} -- 到此阶段为止,正常录取人数
+         *  {'forcedCount', '调剂录取人数'} -- 到此阶段为止,调剂录取人数
+         *  {'esignedCount', '已签人数'} -- 到此阶段为止,触发的有效签名统计 这个先
+         *  {'esigningCount', '待签人数'} -- 到此阶段为止,阶段触发有效的应签未签统计 [4.18 因为签字提前和强制调剂更改,所以只有FinalAdjustDM RankBalance这两代会产生待签数据了]
+         *
+         *  三、ElectiveGenerationSummary.categories 只统计某代的数据
+         *  A PrimaryDM
+         *  {'indicateCount','指标'} -- 正负表示超过设置的人数,负数表示低于设置的人数
+         *  {'approvedCount, '录取人数'} -- 正常录取人数
+         *  {'adjustCount','调剂人数'} -- 未录取人数,理论上应该<=指标的绝对值
+         *  {'matchedCount','专业符合'} -- 未录取可被调剂的人(志愿不满足但自选专业满足) // 4.19 为保存自选专业与报告一致,每次进入初选决策之后关闭自选专业功能,校长开启选科(或者选科结束???)时打开
+         *  {'nonmatchedCount','专业不符'} -- 未录取不可被调剂的人(志愿与自选专业均不满足)
+         *
+         *  B BackTracking FinalAdjust
+         *  {'matchedApproved','专业符合同意'} -- 统计有效补录报名,且报名与推荐相同
+         *  {'matchedNotOptional','专业符合改填'} -- 统计有效补录报名,且报名与推荐不同
+         *  {'matchedRejected','专业符合拒绝'} -- 统计拒绝报名的人数
+         *  {'matchedNonaction','专业符合未填'} -- 统计未参的人数(因为有可能被排名挤出)
+         *  {'nonmatchedApproved','专业不符同意'}
+         *  {'nonmatchedNotOptional','专业不符改填'}
+         *  {'nonmatchedRejected','专业不符拒绝'}
+         *  {'nonmatchedNonaction','专业不符未填'}
+         *
+         *  C BackTrackingDM FinalAdjustDM RankBalance
+         *  {'matchedApproved','专业符合填报录取'} -- 正常录取 [BackTrackingDM FinalAdjustDM]
+         *  {'matchedApprovedByConfig','专业符合拒填录取'} -- 正常录取 [BackTrackingDM FinalAdjustDM]
+         *  {'matchedApprovedByNoaction','专业符合未填录取'} -- 正常录取 [BackTrackingDM FinalAdjustDM]
+         *  {'matchedRejectByRankout','专业符合填报未录'} -- 正常录取 [BackTrackingDM FinalAdjustDM] // 4.19需要区分填报后被匹配算法踢出的人
+         *  「专业不符」* 4
+         *  {'forcedCount','调剂录取'} -- 强制调剂录取 [FinalAdjustDM RankBalance]
+         *  {'indicateCount','指标'} -- 正负表示超过设置的人数,负数表示低于设置的人数
+         *  {'matchedCount','专业符合'} -- 正常录取 [BackTrackingDM]
+         *  {'nonmatchedCount','专业不符'} -- 正常录取 [BackTrackingDM]
+         *
+        */
+        #endregion
+
+        #region getElectiveSummaryDetail 汇总明细清单
+
+        /// <summary>
+        /// 明细肯定是通过 某个组合某个学生 来呈现的
+        /// </summary>
+        public interface IElectiveGenerationDetail
+        {
+            long id { get; set; }
+            EnumElectiveGeneration generation { get; set; }
+            string category { get; set; } // ElectiveSummaryCategory.category
+            int roundId { get; set; }
+            int studentId { get; set; }
+            string studentName { get; set; }
+            int classId { get; set; }
+            string className { get; set; }
+            int groupId { get; set; }
+            string groupName { get; set; }
+            string datetime { get; set; }
+        }
+
+        public class ElectiveGenerationRankDescriptor
+        {
+            string key; // 类似字段名,相同的业务定义相同的名称,在代数据迭代到一个格子里显示时,同字段将被覆盖掉
+            int value; // 实际值
+            string description;  // 描述文字
+        }
+
+        public class ElectiveGenerationFlowHistory // : IElectiveGenerationDetail 可以考虑接收IElectiveGenerationDetail接口约束,也可以不,因为history只会展示关键信息
+        {
+            EnumElectiveGeneration generation; // <=ElectiveGenerationDetail.generation
+            int groupId; // ElectiveGenerationFlowData.groupId
+            string description; // 一志愿 二志愿 三志愿 初选报名 初选录取 未录取(能显示匹配算法具体原因更好) 推荐 补录报名 ...
+            ElectiveGenerationRankDescriptor[] rankDescriptors; // 与匹配算法相关的数据提供,之所以定义成这样,是这些项极有可能还会进一步调整
+            /* rankDescriptors 估计需要边做边整理了:
+             * Primary [{ 'expected', 250, '设置人数'},{'inTimeRankInGrade', 267, '实时排名'}] 所有报名的人都会产生
+             * PrimaryDM [{'indicate', 25, '补录指标'},{'bestGroupSum', '最优分组人数'}, {'rankInIndicate', 19, '成绩最优排名'}] 未录取需要参与补录的人会产生新的统计维度
+             * 注:最优分组人数与成绩最优排名是指:有补录指标的组合,在剩余待补录的人员中的最好成绩所在组合的总人数与自己的排名情况
+             * ... 后面的generation以此类推
+             * */
+
+        }
+
+        /// <summary>
+        /// 此类相当于ElectiveGenerationFlowData的DTO,将字段转义为UI需要的内容
+        /// 明细的学生组合信息, 主要包含报名信息;
+        /// 还有一部分自选专业的信息,需要借助其它接口
+        /// </summary>
+        public class ElectiveGenerationDetail : IElectiveGenerationDetail
+        {
+            ElectiveGenerationFlowHistory[] histories; // 4.19 当前student截止到当前代的全部generation flow data数据,但需要转换为业务格式
+
+            public long id { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public string category { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public EnumElectiveGeneration generation { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public int roundId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public int studentId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public string studentName { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public int classId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public string className { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public int groupId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public string groupName { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+            public string datetime { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+        }
+
+        public class ElectiveGroupGenerationStatistic
+        {
+            public int groupId;
+            ElectiveGenerationRankDescriptor[] descriptors;
+        }
+
+        public class ElectiveGenerationDetailWrapper
+        {
+            ElectiveGroupGenerationStatistic[] groupDescriptors; // 迭代至当前代的组合统计信息,主要统计指标(缺少人数)与实报(实际报名人数)
+            ElectiveGenerationDetail[] details; // 学生明细
+        }
+
+        /// <summary>
+        /// 初选报名搭配使用,展示其它可报组合情况
+        /// </summary>
+        public class ElectiveGenerationOptionalMajor
+        {
+            ElectiveOptionalMajor[] majors; // 自选专业列表
+
+            int bestMatchedGroupId; // 最佳匹配
+        }
+
+        /// <summary>
+        /// Map: {key: studentid, value: ElectiveGenerationOptionalMajor}
+        /// </summary>
+        /// <param name="studentIds"></param>
+        /// <returns>批量取学生自选专业匹配情况</returns>
+        Dictionary<int, ElectiveGenerationOptionalMajor> getGenerationOptionalMajorsBatch(int[] studentIds);
+
+        /// <summary>
+        /// 通过summary中输出的queryCode反查明细
+        /// </summary>
+        /// <param name="roundId"></param>
+        /// <param name="generation">当前进程代</param>
+        /// <param name="groupId">针对组合的查询会传,否则传0或者''(具体看返回的时候给的什么值,比如未报名的)</param>
+        /// <param name="queryCode"></param>
+        /// <returns></returns>
+        ElectiveGenerationDetailWrapper getElectiveGenerationDetails(int pageNo, int pageSize, int roundId, int generation, int groupId, string queryCode);
+
+        #endregion
+
+        #region decision-making
+
+        /// <summary>
+        /// 匹配算法,算法名称代表的具体逻辑请看注解(不要顾名思义)
+        /// </summary>
+        public enum EnumElectiveDMAlgorithm
+        {
+            /// <summary>
+            /// 按成绩:
+            /// ElectiveEngine中,相当于[未录学生]/[组合*ALL]/[志愿*ALL]一并运行,排序时排名优先;
+            /// 以上逻辑循环迭代,每次只补空缺组合,直至未录学生=0。
+            /// 第1次迭代之后运算的学生均属于可调剂名单,不会有不可调剂名单
+            /// </summary>
+            RankFirst,
+
+            /// <summary>
+            /// 按专业:
+            /// ElectiveEngine中,相当于[未录学生]/[组合*ALL]/[志愿1][志愿2,志愿3][自选专业伪志愿]/三波运行,排序时排名优先;
+            /// 以上逻辑循环迭代,每次只补空缺组合,直至伪志愿运算结束。
+            /// [志愿1][志愿2,志愿3]迭代的录取学生为正常录取;自选专业伪志愿为可调剂名单;运行结束后的未录学生为不可调剂名单。
+            /// </summary>
+            MajorFirst, //
+        }
+
+        /// <summary>
+        /// 执行选科匹配算法。为减少错误,对DM代数据全删全加可能好点。
+        /// </summary>
+        /// <param name="algorithm"></param>
+        void applyElectiveDMAlgorithm(EnumElectiveDMAlgorithm algorithm);
+
+        #endregion
+
+        #region forced
+
+        /// <summary>
+        /// 决策阶段,强制调剂录取
+        /// </summary>
+        /// <param name="studentId"></param>
+        /// <param name="groupId"></param>
+        void enrollByForce(long studentId, int groupId);
+
+        /// <summary>
+        /// 取消强制调剂操作
+        /// </summary>
+        /// <param name="id">ElectiveGenerationFlowData.id</param>
+        void cancelEnrollByForce(long id);
+
+        #endregion
+
+        #region generation推进
+
+        /// <summary>
+        /// 决策完毕时,推进下一代进行
+        /// </summary>
+        /// <param name="setting"></param>
+        void pushGenerationSetting(FlowPushSetting setting);
+
+        /// <summary>
+        /// 如果在所有学生全部录取的情况,可以在任意决策结点跳转至排名均衡
+        /// </summary>
+        void jumpGenerationRankBalance();
+
+        /// <summary>
+        /// 如果在所有学生全部录取的情况,可以在任意决策结点跳转至终止态,封存数据
+        /// </summary>
+        void terminateGeneration();
+
+        /// <summary>
+        /// 在任意报名阶段,如果校长发现数据已经完全OK,则可以强制推进进程,提前进入决策
+        /// </summary>
+        void flushIntoGenerationDM();
+
+        #endregion
+
+        #region 排名均衡(略)先完成前面的
+        #endregion
+    }
+}

+ 102 - 0
doc/Mind/FacultyEstimate.cs

@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+
+namespace mxdemo.Mind
+{
+    #region model
+
+    public class FacultyForm
+    {
+        public int subjectId;   // 这个想想「班会、体育、自习」怎么处理?
+        public string subjectName; // 名称
+        public bool isFixed; // 固定师资,用以区分6门合格考与等级考学科,非固定科目需要填写合格考与等级考
+        public bool isOffical; // 正式科目,9科为正式科目,非正式科目提供的校验不一样
+        public int classesCountPrev; // 周课时数-分班前-仅参考用
+        public int teacherCount; // 单科老师数量
+        public int teacherClassesCount; // 单科老师周课时数
+        public int classesCount; // 单科总课时数,前端会提供辅助计算
+        public int levelClassesCount; // 等级考课时数-分班后
+        public int qualifiedClassesCount; // 合格考课时数-分班后
+    }
+
+    public class FacultyResult : FacultyForm
+    {
+        public int levelClassCount; // 等级考班级数
+        public int qualifiedClassCount; // 合格考班级数
+        public int requiredClassesCount; // 所需课时
+        public int actualClassesCount; // 实际课时
+        public int missingClassesCount; // 缺少课时,正数表示缺的,负数表示超的
+        public int missingTeacherCount; // 缺少老师,正负同上
+
+        public bool completed; // 是否计算结束
+    }
+
+    public class GroupSetting
+    {
+        // 略,延用之前的结构内容
+    }
+
+    public class GroupModel
+    {
+        // 后台自己设置标识,前端会原样返回,前端展示与操作只会用到索引
+        public string groupIds;
+        public List<GroupSetting> roundGroups;
+        public List<FacultyResult> faculties;
+    }
+
+    public class ScoreImportConfig // 需要调整结构
+    {
+        #region 不变部分
+        //      {
+        //"params": {},
+        //"roundId": 14,
+        //"schoolId": 31,
+        //"year": 2021,
+        //"name": "第1次选科",
+        //"beginTime": "2022-03-05",
+        //"endTime": "2022-03-20",
+        //"state": "1",
+        //"importWeight":[]
+        #endregion
+
+        #region 变更部分
+        // 新增模型列表
+        public List<GroupModel> groupModels;
+        // 选择其中1个模型
+        public int activeGroupModelIndex; // 构想的是传索引,因为前端会做删除等调整,而且新建时也可能同时创建多个引用,此时无ID能够做出区分
+        // 选志愿的数量,默认为1
+        public int preferenceCount = 1;
+        #endregion
+
+        #region 新增控制配置
+        public bool rankInvisible; // 排名不可见,默认false: 排名可见
+        public bool scoreInvisible; // 分数不可见,默认false: 分数可见
+        public bool enableEsign;  // 开启电子签名,默认false: 不需要
+        public bool enablePaperSign; // 开启电子签名的情况下,是否需要纸质签名(促进学生使用班牌),默认false: 不需要
+        #endregion
+    }
+
+    #endregion
+
+    public interface ISelectedScoreImport
+    {
+        // ScoreImportConfig的结构有变化见上面region
+        ScoreImportConfig getScoreImportConfig(int roundId);
+
+        /// <summary>
+        /// 生成空的师资表格,在校长全部重新填写的时候调用。如果是从已完成的模型复制,则不会调用,前端会自行处理
+        /// </summary>
+        /// <returns></returns>
+        List<FacultyResult> generateFaculties();
+
+        // 计算师资匹配结果
+        // 构想:全量传数据,但后台只挑需要的groupModels[targetIndex]进行计算,计算完毕标记为completed=true
+        // 计算结束,需要标记出groupModels中,completed=true的模型,最优解为isBest=true
+        // 最优解:faculties.sum(f => Math.Abs(f.missingTeacherCount)) 总和最小的GroupModel为最优
+        ScoreImportConfig calculateFaculty(ScoreImportConfig round, int groupModelIndex);
+
+        void saveScoreImportConfig({ round: ScoreImportConfig }); // config结构变了,保存也要注意
+
+        void queryScoreImportDetails({ round: ScoreImportConfig, groupModelIndex: int }); // config结构变了,查询时要指定哪个模型
+    }
+}

+ 136 - 0
doc/Mind/PrimaryElective.cs

@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+
+namespace mxdemo.Mind
+{
+    #region model
+
+    public class ElectiveSetting
+    {
+
+        //"selectResult": {
+        //  "params": {},
+        //  "roundId": 8,
+        //  "schoolId": 1,
+        //  "year": 2021,
+        //  "name": "第三次",
+        //  "beginTime": "2022-02-16",
+        //  "endTime": "2022-02-17",
+        //  "groupIds": "1,2,3,10,11,12",
+
+        public int preferenceCount; // 选填志愿数量
+
+  //  "groupList": [
+  //    {
+  //      "params": {},
+  //      "groupId": 1,
+  //      "mask": 448,
+  //      "name": "物化生",
+  //      "rank": 1
+  //    },
+  //    {
+  //  "params": { },
+  //      "groupId": 2,
+  //      "mask": 416,
+  //      "name": "物化政",
+  //      "rank": 2
+  //    },
+  //    {
+  //  "params": { },
+  //      "groupId": 3,
+  //      "mask": 400,
+  //      "name": "物化地",
+  //      "rank": 3
+  //    },
+  //    {
+  //  "params": { },
+  //      "groupId": 10,
+  //      "mask": 56,
+  //      "name": "历政地",
+  //      "rank": 10
+  //    },
+  //    {
+  //  "params": { },
+  //      "groupId": 11,
+  //      "mask": 104,
+  //      "name": "历政生",
+  //      "rank": 11
+  //    },
+  //    {
+  //  "params": { },
+  //      "groupId": 12,
+  //      "mask": 88,
+  //      "name": "历生地",
+  //      "rank": 12
+  //    }
+  //  ],
+  //  "state": "1"
+  //},
+  //"allowSelect": false
+
+    }
+
+    // 学生这里无论什么时间得到的都是最终结果,不需要中间快照,以此区分校长的选科数据。
+    // 校长的选科数据,初选时与学生相同;补录时也需要这份数据、但也需要初选与补录的差异部分;排名均衡同理
+    public class ElectiveSelectModel
+    {
+        public int groupId; // 组合
+        public string groupName; // 名称
+        public int classCount; // 班级数
+        public int personCount; // 限制人数
+
+        public int personInTime; // 实时人数
+        public int rankInGroup; // 选科实时排名
+        public int rankInGrade; // 选科全校排名
+
+        public bool allowSelect; // 是否可以报名
+        public string disabledReason; // 不可报名时的原因
+
+        public bool selected;  // 已报名
+        public int selectedRank; // 多志愿时的排序
+    }
+
+    // 自选专业
+    public class ElectiveOptionalMajor
+    {
+        public int collegeId;
+        public string collegeName;
+        // 可能还包含院校的一些其它属性
+
+        public string majorCategoryCode;
+        public string majorCategoryName;
+
+        public Dictionary<string, string> majors;
+
+        public string limitationA; // 选科限制1
+        public string limitationB; // 选科限制2
+
+        public List<int> matchedGroupIds; // 匹配的组合
+    }
+
+    #endregion
+
+    // 本期会涉及很多隐藏需求,前端会去back-ui按页面+按功能设置很多按钮功能,后续配置为权限组来操作
+    // 本期后台输出数据的时候不需要按权限屏蔽数据,全量输出即可,以减少后端开发的工作量
+    // 因为本期要控制隐藏的内容比功能入口、API入口要更精细的粒度,可能是列表的某几列,可能是某些按钮,如果严格API数据规范可能会增加海量的工作。
+    public interface IPrimaryElectiveService
+    {
+        // 旧接口getStudentSelected,返回内容+preferenceCount,志愿数
+        ElectiveSetting getStudentSelected();
+
+        // 学生获取选科状态数据,得到是每个组合最终状态和结果
+        List<ElectiveSelectModel> getPrimaryElectives();
+
+        // 学生获取自选专业及匹配情况
+        List<ElectiveOptionalMajor> getOptionalMajors();
+
+        // 初选报名。这里简化原型里的操作,直接提交,如需排序,调sortElectiveSelected接口,并刷新getPrimaryElectives
+        void submitPrimaryElective(ElectiveSelectModel model);
+
+        // 调整排序。这里简化原型里的操作,并刷新getPrimaryElectives
+        void sortPrimaryElective(ElectiveSelectModel model);
+
+        // 取消报名
+        void cancelPrimaryElective(ElectiveSelectModel model);
+    }
+}

+ 140 - 0
doc/Mind/StudentClassDispatch.cs

@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Generic;
+
+namespace mxdemo.Mind
+{
+    public interface IStudentClassDispatchService
+    {
+        // 1 班级设置 参考校长的class-manage页面
+        //   a 从年级树缓存中查找当前选科学年对应的班级进行选择
+        //   b 允许用户输入不存在的班级,输入完毕后确认是否创建新班级
+        //       附:(创建后务必刷新缓存)(暂不考虑批量创建班级)(创建接口参考class-manage页面)
+
+        // 2 应用班级设置 设置人数及分配模式,得到初步分派的结果
+        // 3 手工微调 将查询组件化,允许加入多个对比查询,作选中操作,执行转移/或者交换操作
+
+        #region model
+
+        /// <summary>
+        /// 选科轮次,仅显示分班关键字段,后续可能还会加选科流程的其它字段
+        /// </summary>
+        public class Round
+        {
+            // 本身的字段
+            int roundId; // 选科轮次ID
+            string groupIds; // 开放组合
+            object[] roundGroups; // 这里包含各组合以及班级数设置
+            // ...
+
+            // 流程控制字段
+            bool allowDispatch; // 标记当前是否可以进行分班逻辑
+            Dictionary<int, int> enrollGroupCount; // 各组合录取人数,按组合汇总
+            bool dispatchCompleted; // 所有学生已经分配完毕
+        }
+
+        /// <summary>
+        /// 班级,仅显示分班关键字段
+        /// </summary>
+        public class Clazz
+        {
+            int classId;
+            string name;
+        }
+
+        public class Student
+        {
+            int studentId;
+            int sex;
+            int rankInGroup; // 组合内排名
+        }
+
+        public class DispatchSetting
+        {
+            int roundId; // 轮次ID
+            int groupId; // 组合ID
+
+            int classId; // 班级
+            int expectedCount; // 期望人数
+            int? actualCount; // 实际人数,应用设置才产生数量
+            int? actualCountInMale; // 实际男生,应用设置才产生数量
+            int? actualCountInFemale; // 实际女生,应用设置才产生数量
+        }
+
+        public class DispatchResult
+        {
+            int roundId; // 轮次ID
+            int groupId; // 组合ID
+
+            int classId; // 班级
+            Student[] students; // 分配的学生
+        }
+
+        public enum EnumDispatchMode
+        {
+            RankBalance, // 按成绩平均分配,默认按这种分配方式,可保证各个班成绩都差不多
+            RankPriority, // 按成绩优先,会导致成绩好的集中到一起
+            Random // 随机
+        }
+
+        /// <summary>
+        /// 查询参数
+        /// </summary>
+        public class DispatchQuery
+        {
+            int roundId;
+            int groupId;
+            int classId;
+            int? sex;
+        }
+        #endregion
+        /// <summary>
+        /// 按学年,次数查询
+        /// ROUND: 如果allowDispatch=false,分班页仅只读不能操作
+        /// </summary>
+        /// <returns></returns>
+        Round GetRoundStatus(int year, int round); // 类似/front/report/getStudentSelected
+
+        /// <summary>
+        /// 获取分班配置
+        /// </summary>
+        /// <param name="roundId"></param>
+        /// <returns></returns>
+        DispatchSetting[] GetDispatchSettings(int roundId);
+
+        /// <summary>
+        /// 保存配置
+        /// </summary>
+        /// <param name="settings"></param>
+        void SaveDispatchSettings(DispatchSetting[] settings);
+
+        /// <summary>
+        /// 应用配置,分派学生,按组合执行
+        /// </summary>
+        /// <param name="roundId"></param>
+        /// <param name="mode"></param>
+        /// <returns></returns>
+        DispatchResult ApplyDispatchSettings(int roundId, int groupId, EnumDispatchMode mode = EnumDispatchMode.RankBalance);
+
+        /// <summary>
+        /// 分派名单查询,不需要分页,按班展示,启动table排序功能
+        /// </summary>
+        /// <param name="roundId"></param>
+        /// <param name="groupIds"></param>
+        /// <returns></returns>
+        DispatchResult GetDispatchResult(DispatchQuery query);
+
+        /// <summary>
+        /// 分派转移,用这个接口实现转移学生或者交换学生(手工操作),后台在操作成功后记得刷新setting中的3个人数汇总字段
+        /// </summary>
+        /// <param name="roundId"></param>
+        /// <param name="students"></param>
+        /// <param name="toClassId"></param>
+        void ExchangeDispatch(int roundId, Student[] students, int toClassId);
+
+        /// <summary>
+        /// 分配完毕后发布,锁定分班流程
+        /// </summary>
+        /// <param name="roundId"></param>
+        void PublishDispatch(int roundId);
+    }
+}

+ 52 - 0
mock/index.js

@@ -0,0 +1,52 @@
+const Mock = require('mockjs')
+const { param2Obj } = require('./utils')
+
+let mocks = []
+
+// for front mock
+// please use it cautiously, it will redefine XMLHttpRequest,
+// which will cause many of your third-party libraries to be invalidated(like progress event).
+function mockXHR(inputMocks) {
+  mocks = inputMocks
+  // mock patch
+  // https://github.com/nuysoft/Mock/issues/300
+  Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
+  Mock.XHR.prototype.send = function() {
+    if (this.custom.xhr) {
+      this.custom.xhr.withCredentials = this.withCredentials || false
+
+      if (this.responseType) {
+        this.custom.xhr.responseType = this.responseType
+      }
+    }
+    this.proxy_send(...arguments)
+  }
+
+  function XHR2ExpressReqWrap(respond) {
+    return function(options) {
+      let result = null
+      if (respond instanceof Function) {
+        const { body, type, url } = options
+        // https://expressjs.com/en/4x/api.html#req
+        result = respond({
+          method: type,
+          body: JSON.parse(body),
+          query: param2Obj(url)
+        })
+      } else {
+        result = respond
+      }
+      return Mock.mock(result)
+    }
+  }
+
+  for (const i of mocks) {
+    Mock.mock(new RegExp(i.url), i.type || 'get', XHR2ExpressReqWrap(i.response))
+  }
+}
+
+module.exports = {
+  mockXHR,
+  mocks,
+}
+

+ 81 - 0
mock/mock-server.js

@@ -0,0 +1,81 @@
+const chokidar = require('chokidar')
+const bodyParser = require('body-parser')
+const chalk = require('chalk')
+const path = require('path')
+const Mock = require('mockjs')
+
+const mockDir = path.join(process.cwd(), 'mock')
+
+function registerRoutes(app) {
+  let mockLastIndex
+  const { mocks } = require('./index.js')
+  const mocksForServer = mocks.map(route => {
+    return responseFake(route.url, route.type, route.response)
+  })
+  for (const mock of mocksForServer) {
+    app[mock.type](mock.url, mock.response)
+    mockLastIndex = app._router.stack.length
+  }
+  const mockRoutesLength = Object.keys(mocksForServer).length
+  return {
+    mockRoutesLength: mockRoutesLength,
+    mockStartIndex: mockLastIndex - mockRoutesLength
+  }
+}
+
+function unregisterRoutes() {
+  Object.keys(require.cache).forEach(i => {
+    if (i.includes(mockDir)) {
+      delete require.cache[require.resolve(i)]
+    }
+  })
+}
+
+// for mock server
+const responseFake = (url, type, respond) => {
+  return {
+    url: new RegExp(`${process.env.VUE_APP_BASE_API}${url}`),
+    type: type || 'get',
+    response(req, res) {
+      console.log('request invoke:' + req.path)
+      res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond))
+    }
+  }
+}
+
+module.exports = app => {
+  // parse app.body
+  // https://expressjs.com/en/4x/api.html#req.body
+  app.use(bodyParser.json())
+  app.use(bodyParser.urlencoded({
+    extended: true
+  }))
+
+  const mockRoutes = registerRoutes(app)
+  var mockRoutesLength = mockRoutes.mockRoutesLength
+  var mockStartIndex = mockRoutes.mockStartIndex
+
+  // watch files, hot reload mock server
+  chokidar.watch(mockDir, {
+    ignored: /mock-server/,
+    ignoreInitial: true
+  }).on('all', (event, path) => {
+    if (event === 'change' || event === 'add') {
+      try {
+        // remove mock routes stack
+        app._router.stack.splice(mockStartIndex, mockRoutesLength)
+
+        // clear routes cache
+        unregisterRoutes()
+
+        const mockRoutes = registerRoutes(app)
+        mockRoutesLength = mockRoutes.mockRoutesLength
+        mockStartIndex = mockRoutes.mockStartIndex
+
+        console.log(chalk.magentaBright(`\n > Mock Server hot reload success! changed  ${path}`))
+      } catch (error) {
+        console.log(chalk.redBright(error))
+      }
+    }
+  })
+}

+ 28 - 0
mock/modules/elective-dispatch.js

@@ -0,0 +1,28 @@
+const Mock = require('mockjs')
+
+module.exports = [
+  {
+    url: '/mock/front/dispatch/getClass',
+    type:'get',
+    response: config => {
+      return {
+        code: 200,
+        msg: 'success',
+        data:
+          [{
+            classId: 1,
+            className:202,
+          },{
+            classId: 2,
+            className:203,
+          },
+            {
+              classId: 3,
+              className:204,
+            }
+          ]
+
+      }
+    }
+  },
+]

+ 534 - 0
mock/modules/elective-generation.js

@@ -0,0 +1,534 @@
+const Mock = require('mockjs')
+const Random = Mock['Random']
+
+const mockGeneration = 8 // primary
+const mockGroups = [1, 2, 3, 4, 5, 6]
+const mockPreferenceCount = 3 // 1 or 3 // 1志愿/3志愿
+
+module.exports = [
+  {
+    url: '/mock/front/report/getElectiveStatus',
+    type: 'get',
+    response: config => {
+      return {
+        code: 200,
+        msg: 'success',
+        data: {
+          year: 2021,
+          roundId: 1,
+          roundName: '第X次选科',
+          groupIds: mockGroups.toString(),
+          roundGroups: mockGroups.map(groupId => ({
+            groupId: groupId,
+            classCount: Random.integer(2, 6),
+            personCount: function() {
+              return this.classCount * 50
+            },
+            limitPerson: false,
+            rankOut: false
+          })),
+          allMatched: Random.boolean(1, 10, 8),
+          currentGeneration: mockGeneration,
+
+          // +
+          disenrollCount: Random.integer(20, 100), // 未录人数
+          enablePushNextDMGeneration: Random.boolean(1, 10, 8),
+          allowDMAlgorithm: true,
+          doneDMAlgorithm: Random.boolean(1, 10, 8),
+          allowForce: Random.boolean(1, 10, 8)
+        }
+      }
+    }
+  },
+  {
+    url: '/mock/front/report/getElectiveSummary',
+    type: 'get',
+    response: config => {
+      const results = []
+      for (let generation = 1; generation <= mockGeneration; generation++) {
+        const copyGeneration = generation
+        switch (generation) {
+          case 1:
+            // primary
+            const allPreferences = []
+            for (let pref = 0; pref < mockPreferenceCount; pref++) {
+              const preferences = []
+              const groupDefines = [
+                {
+                  category: 'actualCount',
+                  queryCode: Random.cname(),
+                  displayName: '已报名',
+                  detailName: '报名组合',
+                  values: mockGroups.map(groupId => ({
+                    groupId: groupId,
+                    value: Random.integer(120, 400),
+                    color: 'B',
+                    bold: false,
+                    star: false,
+                    disabled: false
+                  }))
+                }
+              ]
+              const nonGroupDefines = [
+                {
+                  category: 'unfinishedCount',
+                  queryCode: Random.cname(),
+                  displayName: '未报名',
+                  detailName: '',
+                  values: [{
+                    groupId: 0,
+                    value: Random.integer(0, 10),
+                    color: '',
+                    bold: false,
+                    star: false,
+                    disabled: false
+                  }]
+                }
+              ]
+              preferences.push(...groupDefines)
+              preferences.push(...nonGroupDefines)
+              allPreferences.push(preferences)
+            }
+            const primary = {
+              roundId: 1,
+              generation: copyGeneration,
+              categories: allPreferences
+            }
+            results.push(primary)
+            break
+          default:
+            const accumulateDefine = {
+              generations: [2, 4, 6, 7],
+              factory: () => [
+                {
+                  category: 'approvedCount',
+                  queryCode: Random.cname(),
+                  displayName: '正常录取',
+                  detailName: '录取组合',
+                  values: mockGroups.map(groupId => ({
+                    groupId: groupId,
+                    value: Random.integer(120, 300),
+                    color: '',
+                    bold: false,
+                    star: false,
+                    disabled: false
+                  }))
+                },
+                {
+                  category: 'forcedCount',
+                  queryCode: Random.cname(),
+                  displayName: '调剂录取',
+                  detailName: '调剂组合',
+                  values: mockGroups.map(groupId => ({
+                    groupId: groupId,
+                    value: Random.integer(0, 10),
+                    color: '',
+                    bold: false,
+                    star: false,
+                    disabled: false
+                  }))
+                }
+              ]
+            }
+            const commonDefines = [
+              {
+                generations: [2],
+                factory: () => [
+                  {
+                    category: 'indicateCount',
+                    displayName: '指标',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: true,
+                      queryCode: 'abc',
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'approvedCount',
+                    queryCode: Random.cname(),
+                    displayName: '录取人数',
+                    detailName: '录取组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'adjustCount',
+                    queryCode: Random.cname(),
+                    displayName: '调剂人数',
+                    detailName: '调剂组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'matchedCount',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合',
+                    detailName: '推荐组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'nonmatchedCount',
+                    queryCode: Random.cname(),
+                    displayName: '专业不符',
+                    detailName: '推荐组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  }
+                ]
+              },
+              {
+                generations: [3, 5],
+                factory: () => [
+                  {
+                    category: 'matchedApproved',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合同意',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'matchedNotOptional',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合改选',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'matchedRejected',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合拒绝',
+                    detailName: '拒绝组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'matchedNonaction',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合未填',
+                    detailName: '推荐组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'matchedRankout',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合被挤出',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'nonmatchedApproved',
+                    queryCode: Random.cname(),
+                    displayName: '专业符合已填',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'nonmatchedRejected',
+                    queryCode: Random.cname(),
+                    displayName: '不可调剂拒绝',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'nonmatchedNonaction',
+                    queryCode: Random.cname(),
+                    displayName: '不可调剂未填',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  }
+                ]
+              },
+              {
+                generations: [4],
+                factory: () => [
+                  {
+                    category: 'approvedCount',
+                    queryCode: Random.cname(),
+                    displayName: '补录录取',
+                    detailName: '录取组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'forcedCount',
+                    queryCode: Random.cname(),
+                    displayName: '调剂录取',
+                    detailName: '录取组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'matchedCount',
+                    queryCode: Random.cname(),
+                    displayName: '可调剂人数',
+                    detailName: '调剂组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'nonmatchedCount',
+                    queryCode: Random.cname(),
+                    displayName: '不可调剂人数',
+                    detailName: '报名组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  }
+                ]
+              },
+              {
+                generations: [6],
+                factory: () => [
+                  {
+                    category: 'approvedCount',
+                    queryCode: Random.cname(),
+                    displayName: '补录录取',
+                    detailName: '录取组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  },
+                  {
+                    category: 'forcedCount',
+                    queryCode: Random.cname(),
+                    displayName: '调剂录取',
+                    detailName: '录取组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  }
+                ]
+              },
+              {
+                generations: [7],
+                factory: () => [
+                  {
+                    category: 'forcedCount',
+                    queryCode: Random.cname(),
+                    displayName: '调剂录取',
+                    detailName: '录取组合',
+                    values: mockGroups.map(groupId => ({
+                      groupId: groupId,
+                      value: Random.integer(120, 400),
+                      color: '',
+                      bold: false,
+                      star: false,
+                      disabled: false
+                    }))
+                  }
+                ]
+              }
+            ]
+            const defines = commonDefines.find(d => d.generations.includes(generation))
+            const inAccDef = accumulateDefine.generations.includes(generation)
+            if (defines) {
+              results.push({
+                roundId: 1,
+                generation: copyGeneration,
+                categories: defines.factory(),
+                accumulates: inAccDef ? accumulateDefine.factory() : undefined
+              })
+            }
+            break
+        }
+      }
+
+      return {
+        code: 200,
+        msg: 'success',
+        data: results
+      }
+    }
+  },
+  {
+    url: '/mock/front/report/getElectiveGenerationDetails',
+    type: 'get',
+    response: config => {
+      return {
+        code: 200,
+        msg: 'success',
+        total: Random.integer(5, 200),
+        data: {
+          groupDescriptors: mockGroups.map(groupId => {
+            return {
+              groupId,
+              descriptors: [
+                { key: 'setting', value: Random.integer(100, 9999), description: '设置人数' },
+                { key: 'actual', value: Random.integer(0, 9999), description: '实报人数' }
+              ]
+            }
+          }),
+          'details|5-20': [
+            {
+              'id|+1': 1000,
+              'roundId': 1,
+              'studentId|+1': 100,
+              'studentName': Random.cname(),
+              'groupId': config.query.groupId,
+              'groupName': Random.cname(),
+              'classId|+1': 20,
+              'className': Random.cname(),
+              'datetime': Random.date(),
+              'histories|4-15': [
+                {
+                  'generation|1': [1, 2, 3, 4, 5, 6, 7],
+                  'groupId|1': mockGroups,
+                  'description': Random.cname(),
+                  'rankDescriptors|1-3': [
+                    { key: Random.cname(), value: Random.integer(1, 300), description: Random.cname() }
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      }
+    }
+  },
+  {
+    url: '/mock/front/report/getGenerationOptionalMajorsBatch',
+    type: 'get',
+    response: config => {
+      let studentIds = config.query.studentIds
+      if (typeof studentIds === 'string') studentIds = studentIds.split(',')
+      const majorsMap = {}
+      studentIds.forEach(id => {
+        majorsMap[id] = {
+          'bestMatchedGroupId|1': mockGroups,
+          'majors|0-6': [{
+            'collegeId|+1': 5000,
+            'collegeName': Random.cname(),
+            'majorCategoryCode': Random.cname(),
+            'majorCategoryName': Random.cname(),
+            'majors': {},
+            'limitationA': '', // 选科限制1
+            'limitationB': '', // 选科限制2
+            'matchedGroupIds|0-3': mockGroups // 匹配的组合
+          }]
+        }
+      })
+      return {
+        code: 200,
+        msg: 'success',
+        data: majorsMap
+      }
+    }
+  }
+]

+ 25 - 0
mock/utils.js

@@ -0,0 +1,25 @@
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+function param2Obj(url) {
+  const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ')
+  if (!search) {
+    return {}
+  }
+  const obj = {}
+  const searchArr = search.split('&')
+  searchArr.forEach(v => {
+    const index = v.indexOf('=')
+    if (index !== -1) {
+      const name = v.substring(0, index)
+      const val = v.substring(index + 1, v.length)
+      obj[name] = val
+    }
+  })
+  return obj
+}
+
+module.exports = {
+  param2Obj
+}

+ 4 - 0
package.json

@@ -53,6 +53,8 @@
     "jsencrypt": "3.0.0-rc.1",
     "jszip": "^3.7.1",
     "jszip-utils": "^0.1.0",
+    "mockjs": "^1.1.0",
+    "module": "^1.2.5",
     "nprogress": "0.2.0",
     "pizzip": "^3.1.1",
     "quill": "1.3.7",
@@ -73,11 +75,13 @@
     "@vue/cli-plugin-eslint": "4.4.6",
     "@vue/cli-service": "4.4.6",
     "babel-eslint": "10.1.0",
+    "babel-plugin-macros": "^3.1.0",
     "chalk": "4.1.0",
     "connect": "3.6.6",
     "eslint": "7.15.0",
     "eslint-plugin-vue": "7.2.0",
     "lint-staged": "10.5.3",
+    "require-context.macro": "^1.2.2",
     "runjs": "4.4.2",
     "sass": "1.32.0",
     "sass-loader": "10.1.0",

+ 31 - 0
public/ar/hd.html

@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+
+<head>
+  <meta charset="UTF-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>湖南大学</title>
+</head>
+<style>
+  * {
+    margin: 0;
+    padding: 0;
+  }
+  #app {
+    width: 100vw;
+    height: 100vh;
+  }
+
+  iframe {
+    border: none;
+  }
+</style>
+
+<body>
+  <div id="app">
+    <iframe allowfullscreen="true" width="100%" height="100%" src="http://720yun.com/pano/256zccfvk0s"></iframe>
+  </div>
+</body>
+
+</html>

+ 45 - 0
public/ar/index.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+
+<head>
+  <meta charset="utf-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+  <meta name="renderer" content="webkit">
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+  <title>vr</title>
+
+  <script>
+
+  </script>
+  <style>
+    * {
+      margin: 0;
+      padding: 0;
+    }
+
+    #app {
+      padding: 30px;
+    }
+
+    ul {
+      list-style-type: disclosure-closed;
+    }
+
+    ul li+li {
+      margin-top: 20px;
+    }
+  </style>
+</head>
+
+<body>
+  <div id="app">
+    <ul>
+      <li>
+        <a href="./qh.html">清华大学</a>
+      </li>
+      <li><a href="./hd.html">湖南大学</a></li>
+    </ul>
+  </div>
+</body>
+
+</html>

+ 31 - 0
public/ar/qh.html

@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+
+<head>
+  <meta charset="UTF-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>清华大学</title>
+</head>
+<style>
+  * {
+    margin: 0;
+    padding: 0;
+  }
+  #app {
+    width: 100vw;
+    height: 100vh;
+  }
+
+  iframe {
+    border: none;
+  }
+</style>
+
+<body>
+  <div id="app">
+    <iframe allowfullscreen="true" width="100%" height="100%" src="http://720yun.com/t/a7727upvq4i"></iframe>
+  </div>
+</body>
+
+</html>

+ 51 - 0
public/usign/css/index.css

@@ -0,0 +1,51 @@
+* {
+  margin: 0;
+  padding: 0;
+}
+
+#app {
+  padding: 20px;
+  box-sizing: border-box;
+  display: flex;
+}
+
+form {
+  flex: 1;
+}
+
+.form-item {
+  display: flex;
+}
+
+textarea[type=text] {
+  width: 300px;
+  height: 80px;
+  outline: none;
+  padding: 3px;
+}
+
+#encrypt {
+  margin-top: 20px;
+  width: 60px;
+  height: 28px;
+  background-color: #1ecdcd;
+  border: none;
+  border-radius: 4px;
+  color: #ffffff;
+  cursor: pointer;
+}
+
+#copy {
+  margin-top: 20px;
+  width: 60px;
+  height: 28px;
+  background-color: #ae1ecd;
+  border: none;
+  border-radius: 4px;
+  color: #ffffff;
+  cursor: pointer;
+}
+
+#payload {
+  margin-left: 10px;
+}

+ 64 - 0
public/usign/index.html

@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+
+<head>
+  <meta charset="UTF-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Document</title>
+  <script src="./js/usign.js"></script>
+  <script type="module">
+    window.onload = () => {
+      const resultDom = document.querySelector('#result');
+      const btn = document.querySelector('#encrypt');
+      btn.addEventListener('click', () => {
+        const inputDom = document.querySelector('#params');
+        const payloadDom = document.querySelector('#payload');
+        const value = inputDom.value;
+        let payload = undefined;
+        try {
+          if (payloadDom.value.trim()) {
+            payload = JSON.parse(payloadDom.value);
+          }
+          resultDom.value = u_sign(value, payload);
+        } catch (error) {
+          alert('参数错误');
+        }
+      });
+      const copy = document.querySelector('#copy');
+      copy.addEventListener('click', () => {
+        const value = resultDom.value;
+        if (value) {
+          resultDom.select();
+          document.execCommand("Copy");
+          resultDom.blur();
+        }
+      });
+    }
+  </script>
+  <link rel="stylesheet" href="./css/index.css" />
+</head>
+
+<body>
+  <div id="app">
+    <form>
+      <div class="form-item">
+        <label>请求地址及参数:</label>
+        <textarea id="params" type="text"
+          placeholder="示例: /youzy.dms.basiclib.api.career.job.hits.inc?jobCode=02030502"></textarea>
+        <textarea id="payload" type="text"
+          placeholder='示例(json字符串): {"provinceCode":43,"score":0,"year":2021,"course":"物理","bzType":"本专"}'></textarea>
+      </div>
+      <input id="encrypt" type="button" value="加密">
+    </form>
+    <form>
+      <div class="form-item">
+        <label>结果:</label>
+        <textarea id="result" type="text"></textarea>
+      </div>
+      <input id="copy" type="button" value="复制">
+    </form>
+  </div>
+</body>
+
+</html>

+ 307 - 0
public/usign/js/usign.js

@@ -0,0 +1,307 @@
+function Rc(n) {
+  return null == n ? "" : pu(n)
+}
+function pu(n) {
+  if ("string" == typeof n)
+    return n;
+  if (yh(n))
+    return c(n, pu) + "";
+  if (yc(n))
+    return ps ? ps.call(n) : "";
+  var t = n + "";
+  return "0" == t && 1 / n == -Rn ? "-0" : t
+}
+function V(n) {
+  return B(n) ? H(n) : p(n)
+}
+function B(n) {
+  return undefined;
+}
+function p(n) {
+  return n.split("")
+}
+function W(n, t) {
+  for (var r = n.length; r-- && y(t, n[r], 0) > -1;)
+    ;
+  return r
+}
+function y(n, t, r) {
+  return t === t ? q(n, t, r) : g(n, b, r)
+}
+function q(n, t, r) {
+  for (var e = r - 1, u = n.length; ++e < u;)
+    if (n[e] === t)
+      return e;
+  return -1
+}
+function Au(n, t, r) {
+  var e = n.length;
+  return r = r === undefined ? e : r,
+    !t && r >= e ? n : fu(n, t, r)
+}
+function fu(n, t, r) {
+  var e = -1
+    , u = n.length;
+  t < 0 && (t = -t > u ? 0 : u + t),
+    r = r > u ? u : r,
+    r < 0 && (r += u),
+    u = t > r ? 0 : r - t >>> 0,
+    t >>>= 0;
+  for (var i = Array(u); ++e < u;)
+    i[e] = n[e + t];
+  return i
+}
+function xa(n, t, r) {
+  if (n = Rc(n),
+    n && (r || t === undefined))
+    return n.replace(Wt, "");
+  if (!n || !(t = pu(t)))
+    return n;
+  var e = V(n);
+  return Au(e, 0, W(e, V(t)) + 1).join("")
+}
+
+
+
+
+
+
+const rr = {
+  '00d8': function (e, t) {
+    var t = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+      , r = {
+        rotl: function (e, t) {
+          return e << t | e >>> 32 - t
+        },
+        rotr: function (e, t) {
+          return e << 32 - t | e >>> t
+        },
+        endian: function (e) {
+          if (e.constructor == Number)
+            return 16711935 & r.rotl(e, 8) | 4278255360 & r.rotl(e, 24);
+          for (var t = 0; t < e.length; t++)
+            e[t] = r.endian(e[t]);
+          return e
+        },
+        randomBytes: function (e) {
+          for (var t = []; e > 0; e--)
+            t.push(Math.floor(256 * Math.random()));
+          return t
+        },
+        bytesToWords: function (e) {
+          for (var t = [], r = 0, n = 0; r < e.length; r++,
+            n += 8)
+            t[n >>> 5] |= e[r] << 24 - n % 32;
+          return t
+        },
+        wordsToBytes: function (e) {
+          for (var t = [], r = 0; r < 32 * e.length; r += 8)
+            t.push(e[r >>> 5] >>> 24 - r % 32 & 255);
+          return t
+        },
+        bytesToHex: function (e) {
+          for (var t = [], r = 0; r < e.length; r++)
+            t.push((e[r] >>> 4).toString(16)),
+              t.push((15 & e[r]).toString(16));
+          return t.join("")
+        },
+        hexToBytes: function (e) {
+          for (var t = [], r = 0; r < e.length; r += 2)
+            t.push(parseInt(e.substr(r, 2), 16));
+          return t
+        },
+        bytesToBase64: function (e) {
+          for (var r = [], n = 0; n < e.length; n += 3)
+            for (var i = e[n] << 16 | e[n + 1] << 8 | e[n + 2], o = 0; o < 4; o++)
+              8 * n + 6 * o <= 8 * e.length ? r.push(t.charAt(i >>> 6 * (3 - o) & 63)) : r.push("=");
+          return r.join("")
+        },
+        base64ToBytes: function (e) {
+          e = e.replace(/[^A-Z0-9+\/]/gi, "");
+          for (var r = [], n = 0, i = 0; n < e.length; i = ++n % 4)
+            0 != i && r.push((t.indexOf(e.charAt(n - 1)) & Math.pow(2, -2 * i + 8) - 1) << 2 * i | t.indexOf(e.charAt(n)) >>> 6 - 2 * i);
+          return r
+        }
+      };
+    return r;
+  },
+  "9a63": function (e, t) {
+    var r = {
+      utf8: {
+        stringToBytes: function (e) {
+          return r.bin.stringToBytes(unescape(encodeURIComponent(e)))
+        },
+        bytesToString: function (e) {
+          return decodeURIComponent(escape(r.bin.bytesToString(e)))
+        }
+      },
+      bin: {
+        stringToBytes: function (e) {
+          for (var t = [], r = 0; r < e.length; r++)
+            t.push(255 & e.charCodeAt(r));
+          return t
+        },
+        bytesToString: function (e) {
+          for (var t = [], r = 0; r < e.length; r++)
+            t.push(String.fromCharCode(e[r]));
+          return t.join("")
+        }
+      }
+    };
+    return r
+  },
+  "8349": function (e, t) {
+    function r(e) {
+      return !!e.constructor && "function" == typeof e.constructor.isBuffer && e.constructor.isBuffer(e)
+    }
+    return function (e) {
+      return null != e && (r(e) || function (e) {
+        return "function" == typeof e.readFloatLE && "function" == typeof e.slice && r(e.slice(0, 0))
+      }(e) || !!e._isBuffer)
+    }
+  },
+}
+
+
+function a(e, r) {
+  var t = rr["00d8"]()
+    , n = rr["9a63"]().utf8
+    , i = rr["8349"]
+    , o = rr["9a63"]().bin
+  e.constructor == String ? e = r && "binary" === r.encoding ? o.stringToBytes(e) : n.stringToBytes(e) : i(e) ? e = Array.prototype.slice.call(e, 0) : Array.isArray(e) || e.constructor === Uint8Array || (e = e.toString());
+  for (var s = t.bytesToWords(e), u = 8 * e.length, c = 1732584193, f = -271733879, d = -1732584194, l = 271733878, h = 0; h < s.length; h++)
+    s[h] = 16711935 & (s[h] << 8 | s[h] >>> 24) | 4278255360 & (s[h] << 24 | s[h] >>> 8);
+  s[u >>> 5] |= 128 << u % 32,
+    s[14 + (u + 64 >>> 9 << 4)] = u;
+  var p = a._ff
+    , b = a._gg
+    , y = a._hh
+    , m = a._ii;
+  for (h = 0; h < s.length; h += 16) {
+    var g = c
+      , v = f
+      , _ = d
+      , w = l;
+    c = p(c, f, d, l, s[h + 0], 7, -680876936),
+      l = p(l, c, f, d, s[h + 1], 12, -389564586),
+      d = p(d, l, c, f, s[h + 2], 17, 606105819),
+      f = p(f, d, l, c, s[h + 3], 22, -1044525330),
+      c = p(c, f, d, l, s[h + 4], 7, -176418897),
+      l = p(l, c, f, d, s[h + 5], 12, 1200080426),
+      d = p(d, l, c, f, s[h + 6], 17, -1473231341),
+      f = p(f, d, l, c, s[h + 7], 22, -45705983),
+      c = p(c, f, d, l, s[h + 8], 7, 1770035416),
+      l = p(l, c, f, d, s[h + 9], 12, -1958414417),
+      d = p(d, l, c, f, s[h + 10], 17, -42063),
+      f = p(f, d, l, c, s[h + 11], 22, -1990404162),
+      c = p(c, f, d, l, s[h + 12], 7, 1804603682),
+      l = p(l, c, f, d, s[h + 13], 12, -40341101),
+      d = p(d, l, c, f, s[h + 14], 17, -1502002290),
+      c = b(c, f = p(f, d, l, c, s[h + 15], 22, 1236535329), d, l, s[h + 1], 5, -165796510),
+      l = b(l, c, f, d, s[h + 6], 9, -1069501632),
+      d = b(d, l, c, f, s[h + 11], 14, 643717713),
+      f = b(f, d, l, c, s[h + 0], 20, -373897302),
+      c = b(c, f, d, l, s[h + 5], 5, -701558691),
+      l = b(l, c, f, d, s[h + 10], 9, 38016083),
+      d = b(d, l, c, f, s[h + 15], 14, -660478335),
+      f = b(f, d, l, c, s[h + 4], 20, -405537848),
+      c = b(c, f, d, l, s[h + 9], 5, 568446438),
+      l = b(l, c, f, d, s[h + 14], 9, -1019803690),
+      d = b(d, l, c, f, s[h + 3], 14, -187363961),
+      f = b(f, d, l, c, s[h + 8], 20, 1163531501),
+      c = b(c, f, d, l, s[h + 13], 5, -1444681467),
+      l = b(l, c, f, d, s[h + 2], 9, -51403784),
+      d = b(d, l, c, f, s[h + 7], 14, 1735328473),
+      c = y(c, f = b(f, d, l, c, s[h + 12], 20, -1926607734), d, l, s[h + 5], 4, -378558),
+      l = y(l, c, f, d, s[h + 8], 11, -2022574463),
+      d = y(d, l, c, f, s[h + 11], 16, 1839030562),
+      f = y(f, d, l, c, s[h + 14], 23, -35309556),
+      c = y(c, f, d, l, s[h + 1], 4, -1530992060),
+      l = y(l, c, f, d, s[h + 4], 11, 1272893353),
+      d = y(d, l, c, f, s[h + 7], 16, -155497632),
+      f = y(f, d, l, c, s[h + 10], 23, -1094730640),
+      c = y(c, f, d, l, s[h + 13], 4, 681279174),
+      l = y(l, c, f, d, s[h + 0], 11, -358537222),
+      d = y(d, l, c, f, s[h + 3], 16, -722521979),
+      f = y(f, d, l, c, s[h + 6], 23, 76029189),
+      c = y(c, f, d, l, s[h + 9], 4, -640364487),
+      l = y(l, c, f, d, s[h + 12], 11, -421815835),
+      d = y(d, l, c, f, s[h + 15], 16, 530742520),
+      c = m(c, f = y(f, d, l, c, s[h + 2], 23, -995338651), d, l, s[h + 0], 6, -198630844),
+      l = m(l, c, f, d, s[h + 7], 10, 1126891415),
+      d = m(d, l, c, f, s[h + 14], 15, -1416354905),
+      f = m(f, d, l, c, s[h + 5], 21, -57434055),
+      c = m(c, f, d, l, s[h + 12], 6, 1700485571),
+      l = m(l, c, f, d, s[h + 3], 10, -1894986606),
+      d = m(d, l, c, f, s[h + 10], 15, -1051523),
+      f = m(f, d, l, c, s[h + 1], 21, -2054922799),
+      c = m(c, f, d, l, s[h + 8], 6, 1873313359),
+      l = m(l, c, f, d, s[h + 15], 10, -30611744),
+      d = m(d, l, c, f, s[h + 6], 15, -1560198380),
+      f = m(f, d, l, c, s[h + 13], 21, 1309151649),
+      c = m(c, f, d, l, s[h + 4], 6, -145523070),
+      l = m(l, c, f, d, s[h + 11], 10, -1120210379),
+      d = m(d, l, c, f, s[h + 2], 15, 718787259),
+      f = m(f, d, l, c, s[h + 9], 21, -343485551),
+      c = c + g >>> 0,
+      f = f + v >>> 0,
+      d = d + _ >>> 0,
+      l = l + w >>> 0
+  }
+  return t.endian([c, f, d, l])
+};
+
+
+a._ff = function (e, t, r, n, i, o, a) {
+  var s = e + (t & r | ~t & n) + (i >>> 0) + a;
+  return (s << o | s >>> 32 - o) + t
+}
+  ,
+  a._gg = function (e, t, r, n, i, o, a) {
+    var s = e + (t & n | r & ~n) + (i >>> 0) + a;
+    return (s << o | s >>> 32 - o) + t
+  }
+  ,
+  a._hh = function (e, t, r, n, i, o, a) {
+    var s = e + (t ^ r ^ n) + (i >>> 0) + a;
+    return (s << o | s >>> 32 - o) + t
+  }
+  ,
+  a._ii = function (e, t, r, n, i, o, a) {
+    var s = e + (r ^ (t | ~n)) + (i >>> 0) + a;
+    return (s << o | s >>> 32 - o) + t
+  }
+
+function n(e) {
+  var t = rr["00d8"]()
+    , n = rr["9a63"]().utf8
+    , i = rr["8349"]()
+    , o = rr["9a63"]().bin
+  if (null == e)
+    throw new Error("Illegal argument " + e);
+  var n = t.wordsToBytes(a(e));
+  return t.bytesToHex(n)
+}
+
+
+// 第一个参数是请求地址及path参数,第二个参数是payload参数
+function u_sign(e, t = undefined) {
+  var r, o = "9SASji5OWnG41iRKiSvTJHlXHmRySRp1", a = "", s = t || {}, u = (e = e || "").split("?");
+  if (u.length > 0 && (r = u[1]),
+    r) {
+    var c = r.split("&")
+      , f = "";
+    c.forEach((function (e) {
+      var t = e.split("=");
+      f += "".concat(t[0], "=").concat(encodeURI(t[1]), "&")
+    }
+    )),
+      a = "".concat(xa(f, "&"), "&").concat(o)
+  } else
+    a = Object.keys(s).length > 0 ? "".concat(JSON.stringify(s), "&").concat(o) : "&".concat(o);
+  return a = a.toLowerCase(),
+    n(a)
+}
+
+// 示例:
+// u_sign('/youzy.dms.basiclib.api.career.job.hits.inc?jobCode=02030502')

+ 81 - 0
src/api/webApi/elective/dispatch.js

@@ -0,0 +1,81 @@
+import request from '@/utils/request'
+
+// 查询 ROUND
+export function getRound(params) {
+  return request({
+    url: '/front/elective/classes/round',
+    method: 'get',
+    params
+  })
+}
+
+// 查询分班配置
+export function getSettings(params) {
+  return request({
+    url: '/front/elective/classes/settings',
+    method: 'get',
+    params
+  })
+}
+
+// 保存分班配置
+export function saveSettings(query,data) {
+  return request({
+    url: '/front/elective/classes/settings',
+    method: 'post',
+    params: query,
+    data: data
+  })
+}
+
+// 应用分班配置
+export function applySettings(params) {
+  return request({
+    url: '/front/elective/classes/settings/apply',
+    method: 'post',
+    params
+  })
+}
+
+// 分派名单查询
+export function classesResult(params) {
+  return request({
+    url: '/front/elective/classes/result',
+    method: 'get',
+    params
+  })
+}
+
+// 分派转移
+export function resultDispatch(data) {
+  return request({
+    url: '/front/elective/classes/result/dispatch',
+    method: 'post',
+    data
+  })
+}
+
+// 锁定分班
+export function lockDispatch(params) {
+  return request({
+    url: '/front/elective/classes',
+    method: 'post',
+    params
+  })
+}
+
+export function getClass(params) {
+  return request({
+    url: '/front/elective/classes/list',
+    method: 'get',
+    params
+  })
+}
+
+export function getDispatchResult(params) {
+  return request({
+    url: '/mock/front/report/getDispatchResult',
+    method: 'get',
+    params
+  })
+}

+ 33 - 0
src/api/webApi/elective/generation.js

@@ -0,0 +1,33 @@
+import request from '@/utils/request'
+
+export function getElectiveStatus(params) {
+  return request({
+    url: '/mock/front/report/getElectiveStatus',
+    method: 'get',
+    params
+  })
+}
+
+export function getElectiveSummary(params) {
+  return request({
+    url: '/mock/front/report/getElectiveSummary',
+    method: 'get',
+    params
+  })
+}
+
+export function getElectiveGenerationDetails(params) {
+  return request({
+    url: '/mock/front/report/getElectiveGenerationDetails',
+    method: 'get',
+    params
+  })
+}
+
+export function getGenerationOptionalMajorsBatch(params) {
+  return request({
+    url: '/mock/front/report/getGenerationOptionalMajorsBatch',
+    method: 'get',
+    params
+  })
+}

+ 0 - 0
src/api/webApi/elective/index.js


+ 0 - 1
src/assets/styles/common.scss

@@ -618,7 +618,6 @@
   width: 100%;
 }
 
-
 .f12 {
   font-size: 12px;
 }

+ 98 - 1
src/common/mx-config.js

@@ -4,8 +4,10 @@ export default {
     primary: '#47C6A2',
     primary_up: '#51C9A7',
     primary_down: '#D2F1E8',
+    primary_report: '#2EC7C9',
     yellow_up: '#F5AB86',
     yellow_down: '#FDEAE1',
+    yellow_report: '#FFA400',
     blue_up: '#608EDF',
     blue_down: '#DBE8FF',
     audit_bg: {
@@ -64,7 +66,7 @@ export default {
       title: '人生价值观探索',
       description: '价值观是指一个人对各类事物的意义和重要性的评价与看法,是一套判断事物是否有价值的观念体系,是指导我们选择与行动的内在指南。它指引着我们的生活。不同价值观的人会在不同的职业和专业领域实现自己的价值。下面的探索活动将帮助你了解自己在生命中所看的东西,指引你去探索属于自己专业的未来。'
     },
-    occupation:{
+    occupation: {
       title: '职业兴趣探索',
       description: '与学业和职业相关的兴趣,隐藏在生活的方方面面。探索兴趣的方法非常多样,通常人们通过思考自己在学习、生活中的表现或者对某些职业、活动的感受来确定自己的兴趣。职业研究者根据兴趣与职业世界、学业世界的关联把人的兴趣分为六种类型。下面让我们一起来看看你的兴趣代码及与之相关的专业和学科。'
     },
@@ -72,5 +74,100 @@ export default {
       title: '知识兴趣探索',
       description: '知识兴趣是我们对某类知识的喜欢程度,通过知识兴趣的探索,你将会对自己喜欢在哪个领域进行学习有所洞察,它们很可能就是你未来渴望学习的专业。'
     }
+  },
+  electiveGenerationOptions: {
+    init: {
+      key: 'init',
+      value: 0,
+      decisionMaking: false,
+      stepsVisible: false,
+      title: '未开启',
+      description: '',
+      icon: ''
+    },
+    primary: {
+      key: 'primary',
+      value: 1,
+      decisionMaking: false,
+      stepsVisible: true,
+      title: '初录数据',
+      description: '',
+      icon: ''
+    },
+    primaryDM: {
+      key: 'primaryDM',
+      value: 2,
+      decisionMaking: true,
+      stepsVisible: true,
+      title: '通知补录',
+      description: '',
+      icon: ''
+    },
+    backTracking: {
+      key: 'backTracking',
+      value: 3,
+      decisionMaking: false,
+      stepsVisible: true,
+      title: '补录数据',
+      description: '',
+      icon: ''
+    },
+    backTrackingDM: {
+      key: 'backTrackingDM',
+      value: 4,
+      decisionMaking: true,
+      stepsVisible: true,
+      title: '通知二次补录',
+      description: '',
+      icon: ''
+    },
+    finalAdjust: {
+      key: 'finalAdjust',
+      value: 5,
+      decisionMaking: false,
+      stepsVisible: true,
+      title: '二次补录数据',
+      description: '',
+      icon: ''
+    },
+    finalAdjustDM: {
+      key: 'finalAdjustDM',
+      value: 6,
+      decisionMaking: true,
+      stepsVisible: true,
+      title: '调剂决策',
+      description: '',
+      icon: ''
+    },
+    rankBalance: {
+      key: 'rankBalance',
+      value: 7,
+      decisionMaking: true,
+      stepsVisible: true,
+      title: '排名均衡',
+      description: '',
+      icon: ''
+    },
+    terminate: {
+      key: 'terminate',
+      value: 8,
+      decisionMaking: false,
+      stepsVisible: false,
+      title: '选科结束',
+      description: '',
+      icon: ''
+    }
+  },
+  electiveDMAlgorithm: {
+    rankFirst: {
+      key: 'rankFirst',
+      value: 0,
+      title: '按成绩'
+    },
+    majorFirst: {
+      key: 'majorFirst',
+      value: 1,
+      title: '按专业'
+    }
   }
 }

+ 19 - 4
src/common/mx-extension.js

@@ -1,11 +1,23 @@
 export default {
   install(Vue) {
     // Array ext.
-    Array.prototype.first = function() {
-      return this.length ? this[0] : null
+    Array.prototype.first = function(predicate) {
+      if (predicate == null || typeof predicate !== 'function') {
+        return this.length ? this[0] : null
+      }
+      for (let i = 0; i < this.length; i++) {
+        const item = this[i]
+        if (predicate(item)) return item
+      }
     }
-    Array.prototype.last = function() {
-      return this.length ? this[this.length - 1] : null
+    Array.prototype.last = function(predicate) {
+      if (predicate == null || typeof predicate !== 'function') {
+        return this.length ? this[this.length - 1] : null
+      }
+      for (let i = this.length - 1; i >= 0; i--) {
+        const item = this[i]
+        if (predicate(item)) return item
+      }
     }
     Array.prototype.groupBy = function(propGetter, groupName = 'label', listName = 'options') {
       const results = []
@@ -51,6 +63,9 @@ export default {
         return this
       }
     }
+    String.prototype.tailingFix = function(fix) {
+      return this.endsWith(fix) ? this : this + fix
+    }
 
     // Global func.
     Vue.prototype.deepClone = function(obj) {

+ 10 - 0
src/components/Cache/modules/mx-classTree-translate-mixin.js

@@ -1,4 +1,5 @@
 import cacheMixin from '@/components/Cache/mx-cache-mixin.js'
+
 export default {
   mixins: [cacheMixin],
   data() {
@@ -43,5 +44,14 @@ export default {
       })
       return nodes.join('/')
     },
+    getClassName(classId) {
+      if (!classId) return ''
+      const nodes = []
+      this.classTree.forEach(grade => {
+        const match = grade.classList.find(c => c.classId == classId)
+        if (match) nodes.push(match.className)
+      })
+      return nodes.join('/')
+    }
   }
 }

+ 77 - 77
src/components/EvaluationTitle/index.vue

@@ -1,77 +1,77 @@
-<template>
-  <el-card :class="{ 'box-card': cardStyle, 'evaluation-margin': withMarginBottom }">
-    <el-row :gutter="24">
-      <el-col :span="navBackButton ? 22 - slotSpan : 24 - slotSpan">
-        <div class="evaluation-title-row">
-          {{ title }}
-          <span class="evaulation-title-subItem">{{ subTitle }}</span>
-        </div>
-      </el-col>
-      <el-col v-if="slotSpan" :span="slotSpan">
-        <solt></solt>
-      </el-col>
-      <el-col v-if="navBackButton" :span="2" style="text-align: right">
-        <el-button @click="navAction ? navAction() : $router.go(-1)" size="small" round>返回</el-button>
-      </el-col>
-    </el-row>
-  </el-card>
-</template>
-<script>
-export default {
-  props: {
-    title: {
-      type: String,
-      default: "",
-    },
-    subTitle: {
-      type: String,
-      default: "",
-    },
-    navBackButton: {
-      type: Boolean,
-      default: false,
-    },
-    slotSpan: {
-      type: Number,
-      default: 0,
-    },
-    cardStyle: {
-      type: Boolean,
-      default: true,
-    },
-    withMarginBottom: {
-      type: Boolean,
-      default: true,
-    },
-    navAction: {
-      type: Function,
-      default: null,
-    },
-  },
-};
-</script>
-<style scoped>
-.evaluation-margin {
-  margin-bottom: 15px;
-}
-
-.evaluation-title-row {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-
-  display: flex;
-  justify-content: flex-start;
-  align-items: baseline;
-}
-
-.evaulation-title-subItem {
-  color: #999;
-  font-size: 12px;
-  margin-left: 10px;
-}
-
-/deep/ .el-card__body {
-  padding: 15px;
-}
-</style>
+<template>
+  <el-card :class="{ 'box-card': cardStyle, 'evaluation-margin': withMarginBottom }">
+    <el-row :gutter="24">
+      <el-col :span="navBackButton ? 22 - slotSpan : 24 - slotSpan">
+        <div class="evaluation-title-row">
+          {{ title }}
+          <span class="evaulation-title-subItem">{{ subTitle }}</span>
+        </div>
+      </el-col>
+      <el-col v-if="slotSpan" :span="slotSpan">
+        <solt></solt>
+      </el-col>
+      <el-col v-if="navBackButton" :span="2" style="text-align: right">
+        <el-button @click="navAction ? navAction() : $router.go(-1)" size="small" round>返回</el-button>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+<script>
+export default {
+  props: {
+    title: {
+      type: String,
+      default: "",
+    },
+    subTitle: {
+      type: String,
+      default: "",
+    },
+    navBackButton: {
+      type: Boolean,
+      default: false,
+    },
+    slotSpan: {
+      type: Number,
+      default: 0,
+    },
+    cardStyle: {
+      type: Boolean,
+      default: true,
+    },
+    withMarginBottom: {
+      type: Boolean,
+      default: true,
+    },
+    navAction: {
+      type: Function,
+      default: null,
+    },
+  },
+};
+</script>
+<style scoped>
+.evaluation-margin {
+  margin-bottom: 15px;
+}
+
+.evaluation-title-row {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  display: flex;
+  justify-content: flex-start;
+  align-items: baseline;
+}
+
+.evaulation-title-subItem {
+  color: #999;
+  font-size: 12px;
+  margin-left: 10px;
+}
+
+/deep/ .el-card__body {
+  padding: 15px;
+}
+</style>

+ 1 - 1
src/components/MxChart/index.vue

@@ -39,7 +39,7 @@ export default {
     options: function(opt) {
       if (!this.chart || !opt) return
       console.log('mx-chart set option', this.options)
-      this.chart.setOption(opt)
+      this.chart.setOption(opt, true) // true: notMerge
     }
   },
   mounted() {

+ 28 - 0
src/components/MxCondition/condition-object/condition-dispatch-class.js

@@ -0,0 +1,28 @@
+import conditionObjectBase from '../condition-object-base.js'
+import { getClass } from '@/api/webApi/elective/dispatch.js'
+export default {
+  ...conditionObjectBase,
+  dependentKeys: ['localGroupId'],
+  key: 'dispatchClassId',
+  title: '班级',
+  isDependencyReady(params) {
+    return params.localGroupId
+  },
+  getList: function(param, $vue) {
+    return new Promise((resolve, reject) => {
+      getClass({
+        groupId: param.localGroupId,
+        roundId: $vue.model.dispatchRoundId
+      }).then(res => {
+        resolve(res.data)
+      })
+      .catch(e => reject(e))
+    })
+  },
+  getCode: function(item) {
+    return item.id
+  },
+  getLabel: function(item) {
+    return item.name
+  }
+}

+ 15 - 0
src/components/MxCondition/condition-object/condition-dispatch-gender.js

@@ -0,0 +1,15 @@
+import conditionObjectBase from '../condition-object-base.js'
+import MxConfig from '@/common/mx-config'
+export default {
+  ...conditionObjectBase,
+  key: 'dispatchGender',
+  title: '性别',
+  getList: function(param, $vue) {
+    return Promise.resolve(MxConfig.form.sexOptions.map(item => {
+      return {
+        id: item.value,
+        name: item.text
+      }
+    }))
+  },
+}

+ 14 - 0
src/components/MxCondition/condition-object/condition-generation-category.js

@@ -0,0 +1,14 @@
+import conditionBase from '../condition-object-base'
+
+export default {
+  ...conditionBase,
+  key: 'generationQueryCode',
+  title: '类别',
+  getList: function(param, $vue) {
+    if ($vue.localData.categories?.length) {
+      return Promise.resolve($vue.localData.categories)
+    } else {
+      return Promise.resolve([])
+    }
+  }
+}

+ 17 - 0
src/components/MxCondition/condition-object/condition-generation-group.js

@@ -0,0 +1,17 @@
+import localGroup from './condition-local-group'
+
+export default {
+  ...localGroup,
+  key: 'generationGroupId',
+  dependentKeys: ['generationQueryCode'],
+  disableAllByForce: true,
+  isDependencyReady(params) {
+    return true
+  },
+  getList: function(params, $vue) {
+    if ($vue.localData.ignoreGroupCategories.includes(params.generationQueryCode)) {
+      return Promise.resolve([])
+    }
+    return this._getList(params, $vue)
+  }
+}

+ 4 - 2
src/components/MxCondition/condition-object/condition-local-group.js

@@ -1,11 +1,13 @@
 import conditionBase from '../condition-object-base'
-import * as back from '@/api/webApi/back'
 
 export default {
   ...conditionBase,
   key: 'localGroupId',
   title: '组合',
   getList: function(param, $vue) {
+    return this._getList(param, $vue)
+  },
+  _getList: function(param, $vue) {
     if ($vue.localData.groups?.length) {
       return Promise.resolve($vue.localData.groups)
     } else {
@@ -17,5 +19,5 @@ export default {
   },
   getLabel(item) {
     return item.groupName
-  },
+  }
 }

+ 60 - 4
src/components/MxTable/index.vue

@@ -1,9 +1,10 @@
 <template>
-  <el-table ref="table" :data="rows" @selection-change="$emit('selection-change', $event)">
+  <el-table ref="table" :border="border" :data="rows" :span-method="mergeRowsColumns"
+            @selection-change="$emit('selection-change', $event)" tooltip-effect="dark">
     <template v-for="(prop, key) in propDefines">
       <template v-if="prop.hidden"></template>
-      <el-table-column v-else-if="!prop.type" :key="key" :label="prop.label" :width="prop.width"
-                       :align="prop.align || 'center'">
+      <el-table-column v-else-if="!prop.type" :key="key" :label="prop.label" :prop="key" :width="prop.width"
+                       :min-width="prop.minWidth" :align="prop.align || 'center'" :fixed="prop.fixed">
         <template v-if="prop.slotHeader" #header="scopeHeader">
           <slot :name="prop.slotHeader" v-bind="{
             ...scopeHeader,
@@ -25,6 +26,35 @@
           </slot>
           <span v-else>{{ scope.row[key] }}</span>
         </template>
+        <!--    TODO: hht 22.4.11 未实现跨组件传递slot, 先支持固定级别    -->
+        <template v-if="prop.children">
+          <el-table-column v-for="(childProp, childKey) in prop.children" :key="childKey" :label="childProp.label"
+                           :prop="childKey"
+                           :width="childProp.width" :fixed="childProp.fixed"
+                           :min-width="childProp.minWidth" :align="childProp.align || 'center'">
+            <template v-if="childProp.slotHeader" #header="scopeHeader">
+              <slot :name="childProp.slotHeader" v-bind="{
+                  ...scopeHeader,
+                  key: childKey,
+                  label: childProp.label,
+                  prop: childProp
+                }">{{ childProp.label }}
+              </slot>
+            </template>
+            <template slot-scope="scope">
+              <slot v-if="childProp.slot" :name="childProp.slot" v-bind="{
+                  ...scope,
+                  key: childKey,
+                  label: childProp.label,
+                  value: scope.row[childKey],
+                  prop: childProp
+                }">
+                <span>{{ scope.row[childKey] }}</span>
+              </slot>
+              <span v-else>{{ scope.row[childKey] }}</span>
+            </template>
+          </el-table-column>
+        </template>
       </el-table-column>
       <el-table-column :key="key" v-else :type="prop.type" :label="prop.label" :width="prop.width"
                        :align="prop.align || 'center'"></el-table-column>
@@ -32,8 +62,22 @@
   </el-table>
 </template>
 <script>
+import MxTableColumn from '@/components/MxTable/mx-table-column'
+
 export default {
+  components: { MxTableColumn },
+  inject: {
+    mergeTable: {
+      default: () => {
+        // do nothing.
+      }
+    }
+  },
   props: {
+    border: {
+      type: Boolean,
+      default: false
+    },
     rows: {
       type: Array,
       default: () => []
@@ -42,7 +86,7 @@ export default {
       type: Object,
       default: () => ({
         id: {
-          label: 'id', // label
+          label: '', // label
           slot: '', // define slot if need custom body
           slotHeader: '', // define slot if need custom header
           slotFooter: '' // define slot if need custm footer
@@ -59,6 +103,18 @@ export default {
   methods: {
     getRuntimeTable() {
       return this.$refs.table
+    },
+    mergeRowsColumns(scope) {
+      if (typeof this['mergeTable'] === 'function') {
+        return this['mergeTable'](scope)
+      }
+    },
+    scrollToRight() {
+      // use `setTimeout` instead of `$nextTick`
+      // - because css style not ready in next-tick, flow maybe work well in `nextTick.nextTick`
+      setTimeout(() => {
+        this.$refs.table.bodyWrapper.scrollLeft = Number(this.$refs.table.bodyWidth.replace('px', ''))
+      }, 500)
     }
   }
 }

+ 48 - 0
src/components/MxTable/mx-table-column.vue

@@ -0,0 +1,48 @@
+<template>
+  <el-table-column v-bind="propAttrs">
+    <template v-if="propAttrs.slotHeader" #header="scopeHeader">
+      <slot :name="propAttrs.slotHeader" v-bind="{
+            ...scopeHeader,
+            key: propKey,
+            label: propAttrs.label,
+            prop: propAttrs
+          }">{{ propAttrs.label }}
+      </slot>
+    </template>
+    <template slot-scope="scope">
+      <slot v-if="propAttrs.slot" :name="propAttrs.slot" v-bind="{
+            ...scope,
+            key: propKey,
+            label: propAttrs.label,
+            value: scope.row[propKey],
+            prop: propAttrs
+          }">
+        <span>{{ scope.row[propKey] }}</span>
+      </slot>
+      <span v-else>{{ scope.row[propKey] }}</span>
+    </template>
+    <template v-if="propAttrs.children">
+      <mx-table-column v-for="(childProp, childKey) in propAttrs.children" :key="childKey" :prop="childProp">
+      </mx-table-column>
+    </template>
+  </el-table-column>
+</template>
+
+<script>
+export default {
+  // TODO: hht, 22.4.11 需要实现跨组件传递slot, 才能使用该组件
+  name: 'mx-table-column',
+  props: {
+    propKey: {
+      type: String
+    },
+    propAttrs: {
+      type: Object
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 86 - 0
src/components/mx-select/mx-select.vue

@@ -0,0 +1,86 @@
+<template>
+  <el-col :span="span">
+    <el-select
+      class="search-select"
+      :clearable="clearable"
+      :placeholder="placeholder"
+      :multiple="multiple"
+      v-model="selectedValue"
+      @change="onSelectChange"
+    >
+      <el-option
+        :disabled="item[itemDisabled]"
+        v-for="item in list"
+        :key="item[itemValue]"
+        :label="item[itemLabel]"
+        :value="item[itemValue]"
+      ></el-option>
+    </el-select>
+  </el-col>
+</template>
+<script>
+export default {
+  props: {
+    list: {
+      type: Array,
+      required: true,
+      default: () => []
+    },
+    itemDisabled: {
+      type: String,
+      default: 'disabled'
+    },
+    itemLabel: {
+      type: String,
+      default: 'name'
+    },
+    itemValue: {
+      type: String,
+      default: 'id'
+    },
+    clearable: {
+      type: Boolean,
+      default: false
+    },
+    placeholder: {
+      type: String,
+      default: '请选择'
+    },
+    multiple: {
+      type: Boolean,
+      default: false
+    },
+    span: {
+      type: Number,
+      default: 24
+    },
+    offset: {
+      type: Number,
+      default: 0
+    },
+    value: [String, Number, Array]
+  },
+  methods: {
+    onSelectChange(val) {
+      this.$emit('input', val)
+      this.$emit('change', val)
+    }
+  },
+  computed: {
+    selectedValue: {
+      get: function() {
+        return this.value
+      },
+      set: function(newValue) {
+        this.$emit('input', newValue)
+      }
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.search-select {
+  width: 100%;
+  margin-bottom: 10px;
+}
+</style>

+ 1 - 2
src/filters/index.js

@@ -4,7 +4,6 @@
 export default {
   classTailing: function(name) {
     if (!name) return name
-    if (name.endsWith('班')) return name
-    return name + '班'
+    return  name.tailingFix('班')
   }
 }

+ 13 - 0
src/main.js

@@ -114,6 +114,19 @@ Vue.use(Element, {
   size: Cookies.get('size') || 'medium' // set element-ui default size
 })
 
+/* use mockjs in dev-environment */
+import requireContext from 'require-context.macro';
+if (process.env.NODE_ENV === 'development') {
+  const mockModules = requireContext('../mock/modules', false, /\.js$/)
+  const mocks = mockModules.keys()
+    .map(key => mockModules(key))
+    .reduce(function(prev, current) {
+      return prev.concat(current)
+    }, [])
+  const { mockXHR } = require('../mock')
+  mockXHR(mocks)
+}
+
 Vue.config.productionTip = false
 
 window.myVue = new Vue({

+ 19 - 5
src/router/index.js

@@ -1,5 +1,7 @@
 import Vue from 'vue'
 import Router from 'vue-router'
+/* Layout */
+import Layout from '@/layout'
 
 // 解决冗余导航报错
 const originalPush = Router.prototype.push
@@ -10,10 +12,6 @@ Router.prototype.push = function push(location) {
 
 Vue.use(Router)
 
-/* Layout */
-import Layout from '@/layout'
-import ParentView from '@/components/ParentView'
-
 /**
  * Note: 路由配置项
  *
@@ -763,8 +761,24 @@ export const constantRoutes = [{
         meta: {
           title: '选科测评报告'
         }
+      },
+      {
+        path: '/elective/dispatch/detail',
+        component: (resolve) => require(['@/views/elective/dispatch/detail'], resolve),
+        name: 'DispatchDetail',
+        meta: {
+          title: '分班详情'
+        }
+      },
+      {
+        path: '/elective/generation/detail',
+        component: (resolve) => require(['@/views/elective/generation/detail'], resolve),
+        name: 'ElectiveGenerationDetail',
+        meta: {
+          title: '选科名单详情'
+        }
       }
-      ]
+    ]
   }
 ]
 

+ 307 - 0
src/utils/usign.js

@@ -0,0 +1,307 @@
+function Rc(n) {
+  return null == n ? "" : pu(n)
+}
+function pu(n) {
+  if ("string" == typeof n)
+    return n;
+  if (yh(n))
+    return c(n, pu) + "";
+  if (yc(n))
+    return ps ? ps.call(n) : "";
+  var t = n + "";
+  return "0" == t && 1 / n == -Rn ? "-0" : t
+}
+function V(n) {
+  return B(n) ? H(n) : p(n)
+}
+function B(n) {
+  return undefined;
+}
+function p(n) {
+  return n.split("")
+}
+function W(n, t) {
+  for (var r = n.length; r-- && y(t, n[r], 0) > -1;)
+    ;
+  return r
+}
+function y(n, t, r) {
+  return t === t ? q(n, t, r) : g(n, b, r)
+}
+function q(n, t, r) {
+  for (var e = r - 1, u = n.length; ++e < u;)
+    if (n[e] === t)
+      return e;
+  return -1
+}
+function Au(n, t, r) {
+  var e = n.length;
+  return r = r === undefined ? e : r,
+    !t && r >= e ? n : fu(n, t, r)
+}
+function fu(n, t, r) {
+  var e = -1
+    , u = n.length;
+  t < 0 && (t = -t > u ? 0 : u + t),
+    r = r > u ? u : r,
+    r < 0 && (r += u),
+    u = t > r ? 0 : r - t >>> 0,
+    t >>>= 0;
+  for (var i = Array(u); ++e < u;)
+    i[e] = n[e + t];
+  return i
+}
+function xa(n, t, r) {
+  if (n = Rc(n),
+    n && (r || t === undefined))
+    return n.replace(Wt, "");
+  if (!n || !(t = pu(t)))
+    return n;
+  var e = V(n);
+  return Au(e, 0, W(e, V(t)) + 1).join("")
+}
+
+
+
+
+
+
+const rr = {
+  '00d8': function (e, t) {
+    var t = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+      , r = {
+        rotl: function (e, t) {
+          return e << t | e >>> 32 - t
+        },
+        rotr: function (e, t) {
+          return e << 32 - t | e >>> t
+        },
+        endian: function (e) {
+          if (e.constructor == Number)
+            return 16711935 & r.rotl(e, 8) | 4278255360 & r.rotl(e, 24);
+          for (var t = 0; t < e.length; t++)
+            e[t] = r.endian(e[t]);
+          return e
+        },
+        randomBytes: function (e) {
+          for (var t = []; e > 0; e--)
+            t.push(Math.floor(256 * Math.random()));
+          return t
+        },
+        bytesToWords: function (e) {
+          for (var t = [], r = 0, n = 0; r < e.length; r++,
+            n += 8)
+            t[n >>> 5] |= e[r] << 24 - n % 32;
+          return t
+        },
+        wordsToBytes: function (e) {
+          for (var t = [], r = 0; r < 32 * e.length; r += 8)
+            t.push(e[r >>> 5] >>> 24 - r % 32 & 255);
+          return t
+        },
+        bytesToHex: function (e) {
+          for (var t = [], r = 0; r < e.length; r++)
+            t.push((e[r] >>> 4).toString(16)),
+              t.push((15 & e[r]).toString(16));
+          return t.join("")
+        },
+        hexToBytes: function (e) {
+          for (var t = [], r = 0; r < e.length; r += 2)
+            t.push(parseInt(e.substr(r, 2), 16));
+          return t
+        },
+        bytesToBase64: function (e) {
+          for (var r = [], n = 0; n < e.length; n += 3)
+            for (var i = e[n] << 16 | e[n + 1] << 8 | e[n + 2], o = 0; o < 4; o++)
+              8 * n + 6 * o <= 8 * e.length ? r.push(t.charAt(i >>> 6 * (3 - o) & 63)) : r.push("=");
+          return r.join("")
+        },
+        base64ToBytes: function (e) {
+          e = e.replace(/[^A-Z0-9+\/]/gi, "");
+          for (var r = [], n = 0, i = 0; n < e.length; i = ++n % 4)
+            0 != i && r.push((t.indexOf(e.charAt(n - 1)) & Math.pow(2, -2 * i + 8) - 1) << 2 * i | t.indexOf(e.charAt(n)) >>> 6 - 2 * i);
+          return r
+        }
+      };
+    return r;
+  },
+  "9a63": function (e, t) {
+    var r = {
+      utf8: {
+        stringToBytes: function (e) {
+          return r.bin.stringToBytes(unescape(encodeURIComponent(e)))
+        },
+        bytesToString: function (e) {
+          return decodeURIComponent(escape(r.bin.bytesToString(e)))
+        }
+      },
+      bin: {
+        stringToBytes: function (e) {
+          for (var t = [], r = 0; r < e.length; r++)
+            t.push(255 & e.charCodeAt(r));
+          return t
+        },
+        bytesToString: function (e) {
+          for (var t = [], r = 0; r < e.length; r++)
+            t.push(String.fromCharCode(e[r]));
+          return t.join("")
+        }
+      }
+    };
+    return r
+  },
+  "8349": function (e, t) {
+    function r(e) {
+      return !!e.constructor && "function" == typeof e.constructor.isBuffer && e.constructor.isBuffer(e)
+    }
+    return function (e) {
+      return null != e && (r(e) || function (e) {
+        return "function" == typeof e.readFloatLE && "function" == typeof e.slice && r(e.slice(0, 0))
+      }(e) || !!e._isBuffer)
+    }
+  },
+}
+
+
+function a(e, r) {
+  var t = rr["00d8"]()
+    , n = rr["9a63"]().utf8
+    , i = rr["8349"]
+    , o = rr["9a63"]().bin
+  e.constructor == String ? e = r && "binary" === r.encoding ? o.stringToBytes(e) : n.stringToBytes(e) : i(e) ? e = Array.prototype.slice.call(e, 0) : Array.isArray(e) || e.constructor === Uint8Array || (e = e.toString());
+  for (var s = t.bytesToWords(e), u = 8 * e.length, c = 1732584193, f = -271733879, d = -1732584194, l = 271733878, h = 0; h < s.length; h++)
+    s[h] = 16711935 & (s[h] << 8 | s[h] >>> 24) | 4278255360 & (s[h] << 24 | s[h] >>> 8);
+  s[u >>> 5] |= 128 << u % 32,
+    s[14 + (u + 64 >>> 9 << 4)] = u;
+  var p = a._ff
+    , b = a._gg
+    , y = a._hh
+    , m = a._ii;
+  for (h = 0; h < s.length; h += 16) {
+    var g = c
+      , v = f
+      , _ = d
+      , w = l;
+    c = p(c, f, d, l, s[h + 0], 7, -680876936),
+      l = p(l, c, f, d, s[h + 1], 12, -389564586),
+      d = p(d, l, c, f, s[h + 2], 17, 606105819),
+      f = p(f, d, l, c, s[h + 3], 22, -1044525330),
+      c = p(c, f, d, l, s[h + 4], 7, -176418897),
+      l = p(l, c, f, d, s[h + 5], 12, 1200080426),
+      d = p(d, l, c, f, s[h + 6], 17, -1473231341),
+      f = p(f, d, l, c, s[h + 7], 22, -45705983),
+      c = p(c, f, d, l, s[h + 8], 7, 1770035416),
+      l = p(l, c, f, d, s[h + 9], 12, -1958414417),
+      d = p(d, l, c, f, s[h + 10], 17, -42063),
+      f = p(f, d, l, c, s[h + 11], 22, -1990404162),
+      c = p(c, f, d, l, s[h + 12], 7, 1804603682),
+      l = p(l, c, f, d, s[h + 13], 12, -40341101),
+      d = p(d, l, c, f, s[h + 14], 17, -1502002290),
+      c = b(c, f = p(f, d, l, c, s[h + 15], 22, 1236535329), d, l, s[h + 1], 5, -165796510),
+      l = b(l, c, f, d, s[h + 6], 9, -1069501632),
+      d = b(d, l, c, f, s[h + 11], 14, 643717713),
+      f = b(f, d, l, c, s[h + 0], 20, -373897302),
+      c = b(c, f, d, l, s[h + 5], 5, -701558691),
+      l = b(l, c, f, d, s[h + 10], 9, 38016083),
+      d = b(d, l, c, f, s[h + 15], 14, -660478335),
+      f = b(f, d, l, c, s[h + 4], 20, -405537848),
+      c = b(c, f, d, l, s[h + 9], 5, 568446438),
+      l = b(l, c, f, d, s[h + 14], 9, -1019803690),
+      d = b(d, l, c, f, s[h + 3], 14, -187363961),
+      f = b(f, d, l, c, s[h + 8], 20, 1163531501),
+      c = b(c, f, d, l, s[h + 13], 5, -1444681467),
+      l = b(l, c, f, d, s[h + 2], 9, -51403784),
+      d = b(d, l, c, f, s[h + 7], 14, 1735328473),
+      c = y(c, f = b(f, d, l, c, s[h + 12], 20, -1926607734), d, l, s[h + 5], 4, -378558),
+      l = y(l, c, f, d, s[h + 8], 11, -2022574463),
+      d = y(d, l, c, f, s[h + 11], 16, 1839030562),
+      f = y(f, d, l, c, s[h + 14], 23, -35309556),
+      c = y(c, f, d, l, s[h + 1], 4, -1530992060),
+      l = y(l, c, f, d, s[h + 4], 11, 1272893353),
+      d = y(d, l, c, f, s[h + 7], 16, -155497632),
+      f = y(f, d, l, c, s[h + 10], 23, -1094730640),
+      c = y(c, f, d, l, s[h + 13], 4, 681279174),
+      l = y(l, c, f, d, s[h + 0], 11, -358537222),
+      d = y(d, l, c, f, s[h + 3], 16, -722521979),
+      f = y(f, d, l, c, s[h + 6], 23, 76029189),
+      c = y(c, f, d, l, s[h + 9], 4, -640364487),
+      l = y(l, c, f, d, s[h + 12], 11, -421815835),
+      d = y(d, l, c, f, s[h + 15], 16, 530742520),
+      c = m(c, f = y(f, d, l, c, s[h + 2], 23, -995338651), d, l, s[h + 0], 6, -198630844),
+      l = m(l, c, f, d, s[h + 7], 10, 1126891415),
+      d = m(d, l, c, f, s[h + 14], 15, -1416354905),
+      f = m(f, d, l, c, s[h + 5], 21, -57434055),
+      c = m(c, f, d, l, s[h + 12], 6, 1700485571),
+      l = m(l, c, f, d, s[h + 3], 10, -1894986606),
+      d = m(d, l, c, f, s[h + 10], 15, -1051523),
+      f = m(f, d, l, c, s[h + 1], 21, -2054922799),
+      c = m(c, f, d, l, s[h + 8], 6, 1873313359),
+      l = m(l, c, f, d, s[h + 15], 10, -30611744),
+      d = m(d, l, c, f, s[h + 6], 15, -1560198380),
+      f = m(f, d, l, c, s[h + 13], 21, 1309151649),
+      c = m(c, f, d, l, s[h + 4], 6, -145523070),
+      l = m(l, c, f, d, s[h + 11], 10, -1120210379),
+      d = m(d, l, c, f, s[h + 2], 15, 718787259),
+      f = m(f, d, l, c, s[h + 9], 21, -343485551),
+      c = c + g >>> 0,
+      f = f + v >>> 0,
+      d = d + _ >>> 0,
+      l = l + w >>> 0
+  }
+  return t.endian([c, f, d, l])
+};
+
+
+a._ff = function (e, t, r, n, i, o, a) {
+  var s = e + (t & r | ~t & n) + (i >>> 0) + a;
+  return (s << o | s >>> 32 - o) + t
+}
+  ,
+  a._gg = function (e, t, r, n, i, o, a) {
+    var s = e + (t & n | r & ~n) + (i >>> 0) + a;
+    return (s << o | s >>> 32 - o) + t
+  }
+  ,
+  a._hh = function (e, t, r, n, i, o, a) {
+    var s = e + (t ^ r ^ n) + (i >>> 0) + a;
+    return (s << o | s >>> 32 - o) + t
+  }
+  ,
+  a._ii = function (e, t, r, n, i, o, a) {
+    var s = e + (r ^ (t | ~n)) + (i >>> 0) + a;
+    return (s << o | s >>> 32 - o) + t
+  }
+
+function n(e) {
+  var t = rr["00d8"]()
+    , n = rr["9a63"]().utf8
+    , i = rr["8349"]()
+    , o = rr["9a63"]().bin
+  if (null == e)
+    throw new Error("Illegal argument " + e);
+  var n = t.wordsToBytes(a(e));
+  return t.bytesToHex(n)
+}
+
+
+// 第一个参数是请求地址及path参数,第二个参数是payload参数
+export function u_sign(e, t = undefined) {
+  var r, o = "9SASji5OWnG41iRKiSvTJHlXHmRySRp1", a = "", s = t || {}, u = (e = e || "").split("?");
+  if (u.length > 0 && (r = u[1]),
+    r) {
+    var c = r.split("&")
+      , f = "";
+    c.forEach((function (e) {
+      var t = e.split("=");
+      f += "".concat(t[0], "=").concat(encodeURI(t[1]), "&")
+    }
+    )),
+      a = "".concat(xa(f, "&"), "&").concat(o)
+  } else
+    a = Object.keys(s).length > 0 ? "".concat(JSON.stringify(s), "&").concat(o) : "&".concat(o);
+  return a = a.toLowerCase(),
+    n(a)
+}
+
+// 示例:
+// u_sign('/youzy.dms.basiclib.api.career.job.hits.inc?jobCode=02030502')

+ 163 - 0
src/views/elective/dispatch/components/choose-class.vue

@@ -0,0 +1,163 @@
+<template>
+  <div>
+    <el-form  :rules="rules" ref="form" :model="form" label-width="80px" @submit.native.prevent>
+      <el-form-item label-width="0">
+        <el-checkbox-group
+          v-model="tmpClassIds"
+          :max="roundGroup.classCount"
+        >
+          <el-checkbox style="margin-bottom: 20px" v-for="item in classList"
+                       :disabled="item.disabled" :label="item.classId" :key="item.classId"
+          >{{ item.className }}
+          </el-checkbox>
+        </el-checkbox-group>
+      </el-form-item>
+      <el-form-item label="输入班级" prop="name" v-if="inputVisible" >
+        <el-tooltip class="item" effect="dark" content="输入班级名称后回车创建班级" :hide-after="1500" placement="bottom-start">
+          <el-input
+            style="width:60%"
+            class="input-new-tag"
+            v-model="form.name"
+            ref="saveTagInput"
+            size="small"
+            @keyup.enter.native="handleInputEnter"
+          >
+          </el-input>
+        </el-tooltip>
+        <div class="size-icon" @click="handleInputConfirm">
+          <i class="icon el-icon-circle-close"></i>
+        </div>
+      </el-form-item>
+    <el-button v-else type="primary" size="small" @click="showInput">新增班级</el-button>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import MxClassTreeTranslateMixin from '@/components/Cache/modules/mx-classTree-translate-mixin.js'
+import * as busiClass from '@/api/webApi/busi-classes.js'
+
+export default {
+  name: 'choose-class',
+  props: {
+    year: {
+      type: Number
+    },
+  },
+  mixins: [MxClassTreeTranslateMixin],
+  data() {
+    return {
+      tmpClasses: [],
+      roundGroup: {},
+      settingContainer: [],
+      inputVisible: false,
+      form: {
+        name: '',
+        type: 1, // 默认为行政班
+        year: '', // 学年
+        gradeId: '', // 年级
+        subjectId: '1,2,3,4,5,6,7,8,9' // 默认为全科目
+      },
+      rules: {
+        name: [
+          { required: true, message: '班级名称不能为空', trigger: 'blur' }
+        ]
+      },
+      tmpClassIds: []
+    }
+  },
+  computed: {
+    classList() {
+      if (!this.classTree?.length) return []
+      if (!this.year) return []
+      // 获取该年份下的年级
+      const classTree = this.classTree[this.classTree.findIndex(i => i.year = this.year)]
+      this.form.gradeId = classTree.gradeId
+      this.form.year = classTree.year
+      return this.classTree[this.classTree.findIndex(i => i.year = this.year)].classList.map(item => {
+        item['disabled'] = this.settingContainer.some(c => c.classId == item.classId && c.groupId !== this.roundGroup.groupId)
+        return item
+      })
+    }
+  },
+  methods: {
+    close() {
+      this.handleInputConfirm()
+    },
+    validate() {
+      //
+      if (this.tmpClassIds.length !== this.roundGroup.classCount) return false
+      return true
+    },
+    open(roundGroup, settingContainer) {
+      this.roundGroup = roundGroup
+      this.settingContainer = settingContainer
+      this.tmpClasses = settingContainer.filter(setting => setting.groupId == roundGroup.groupId)
+      this.tmpClassIds = this.tmpClasses.map(c => c.classId)
+    },
+    confirm() {
+      const mergeClasses = this.tmpClassIds.map(id => {
+        return this.tmpClasses.find(c => c.classId == id) || {
+          classId: id, // 班级Id
+          roundId: this.roundGroup.roundId, // 轮次Id
+          groupId: this.roundGroup.groupId, // 组合Id
+          actualCount: 0, // 应用才产生 实际人数
+          expectedCount: 0, // 期望人数
+          actualCountInMale: 0, // 应用才产生 实际男生
+          actualCountInFemale: 0 // 应用才产生 实际女生
+        }
+      })
+      console.log(mergeClasses)
+      this.tmpClasses.forEach(c => this.settingContainer.remove(c))
+      mergeClasses.forEach(c => this.settingContainer.push(c))
+      this.handleInputConfirm()
+      return this.settingContainer
+    },
+    handleInputConfirm() {
+      this.inputVisible = false
+    },
+    handleInputEnter() {
+      console.log('回车')
+      this.$refs.form.validate(valid => {
+        if (valid) {
+          console.log('提交')
+          this.addClass()
+        }
+      })
+    },
+    showInput() {
+      this.inputVisible = true
+      this.$nextTick(_ => {
+        this.$refs.saveTagInput.$refs.input.focus()
+      })
+    },
+    addClass() {
+      busiClass.add(this.form).then(res => {
+        this.form.name = ''
+        this.msgSuccess('保存成功')
+        // this.getClassTreeByCache(false)
+        this.refreshClassTree()
+      }).finally()
+    },
+    refreshClassTree() {
+      this.$store.dispatch('GetInfo') // 借机清除了用户缓存 // clear cache
+      this.loadTranslateClassTree()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.size-icon {
+  font-size: 25px;
+  cursor: pointer;
+  display: inline-block;
+  height: 100%;
+  position: absolute;
+}
+
+.icon {
+  margin-left: 20px;
+  color: red;
+}
+</style>

+ 242 - 0
src/views/elective/dispatch/components/class-adjust.vue

@@ -0,0 +1,242 @@
+<template>
+  <el-dialog
+    title="班级调整"
+    :visible.sync="show"
+    :close-on-click-modal="false"
+    width="70%"
+  >
+    <el-form ref="form" :model="form" label-width="50px" label-position="right">
+      <el-row>
+        <el-row>
+
+          <el-col class="flex-center-column" :span="12">
+            <el-form-item>
+              <mx-select @change="getStudents('from',$event)" :span="12" :list="display" v-model="fromClassId"
+                         placeholder="请选择" item-label="className" item-value="classId"
+              ></mx-select>
+            </el-form-item>
+            <el-form-item>
+              <class-table :list="studentsTableLeft" @confirm="studentSelected($event,'from')"></class-table>
+            </el-form-item>
+          </el-col>
+          <el-col class="flex-center-column" :span="12">
+            <el-form-item>
+              <mx-select @change="getStudents('to',$event)" :span="12" :list="display" v-model="toClassId"
+                         placeholder="请选择" item-label="className" item-value="classId"
+              ></mx-select>
+            </el-form-item>
+            <el-form-item>
+              <class-table :list="studentsTableRight" @confirm="studentSelected($event,'to')"></class-table>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-col :span="24" class="flex-center" v-if="fromClassId && toClassId" style="margin-top: 20px">
+          <el-transfer
+            v-model="tranRight"
+            :titles="[fromClassId ? `${getClassName(fromClassId)}(添加)` : '未选择', toClassId ? `${getClassName(toClassId)}(添加)`: '未选择']"
+            :data="parentFrame"
+            @change="transferChange"
+          >
+          </el-transfer>
+        </el-col>
+      </el-row>
+    </el-form>
+    <span slot="footer" class="dialog-footer">
+    <el-button @click="close">取 消</el-button>
+    <el-button type="primary" @click="confirm">确 定</el-button>
+  </span>
+  </el-dialog>
+</template>
+
+<script>
+import { classesResult, resultDispatch } from '@/api/webApi/elective/dispatch'
+import MxClassTreeTranslateMixin from '@/components/Cache/modules/mx-classTree-translate-mixin.js'
+import MxSelect from '@/components/mx-select/mx-select'
+import ClassTable from './class-table'
+
+export default {
+  name: 'class-adjust',
+  components: {
+    MxSelect,
+    ClassTable
+  },
+  inject: ['refreshData'],
+  mixins: [MxClassTreeTranslateMixin],
+  data() {
+    return {
+      form: {},
+      studentsTableLeft: [],
+      studentsTableRight: [],
+      fromClassId: '',
+      toClassId: '',
+      group: '',
+      settings: [],
+      selectedLeft: [],
+      selectedRight: [],
+      show: false,
+    }
+  },
+  created() {
+  },
+  computed: {
+    tranRight() {
+      if (!this.selectedLeft.length) return []
+      return this.selectedLeft.map(s => { return s.studentId })
+    },
+    parentFrame() {
+      const selected = this.selectedRight.concat(this.selectedLeft)
+      console.log(selected)
+      return selected.map(item => {
+        return {
+          key: item.studentId,
+          label: item.name,
+          disabled: true
+        }
+      })
+    },
+    display() {
+      return this.settings.map(item => {
+        return {
+          classId: item.classId,
+          className: this.getClassName(item.classId),
+          disabled: this.getDisable(item.classId)
+        }
+      })
+    }
+  },
+  methods: {
+    close() {
+      this.show =false,
+        this.format()
+    },
+    format(){
+      this.form = {}
+      this.studentsTableLeft =[]
+      this.studentsTableRight =[]
+      this.fromClassId = ''
+      this.toClassId = ''
+      this.group = ''
+      this.settings = []
+      this.selectedLeft = []
+      this.selectedRight = []
+      this.show =false
+    },
+    getTranLeft() {
+      // 获取左tran实际的数据
+      // 母框去重右框
+      return this.parentFrame.filter(item => {
+        if (this.tranRight.findIndex(r => r == item.key) == -1) {
+          return item
+        }
+      })
+    },
+    getTranRight() {
+      // 获取左tran实际的数据
+      // 母框去重右框
+      return this.parentFrame.filter(item => {
+        if (this.tranRight.findIndex(r => r == item.key) != -1) return item
+      })
+    },
+    saveResultDispatch(clazzId,students) {
+      // 分派转移
+      resultDispatch({
+        roundId: this.group.roundId,
+        students: students,
+        toClazzId: clazzId
+      }).then(res => {
+        if(res.code == 200) this.$message.success(res.msg)
+        console.log(res)
+      })
+    },
+    confirm() {
+      if( !this.fromClassId || !this.toClassId) {
+        this.$message.warning('请先选择需要调整的两个班级')
+        return
+      }
+      // 左tran 是包含 右边的  (只是不显示右tran的数据)要先去重
+      // 左tran 去掉跟左table重复的数据就是 右tran 需要调换的
+      // 同理右tran 去掉跟右table重复的数据就是 左tran 需要调换的
+      // 1.去重
+      // 左tran实际勾选去除左边table
+      const left = this.getTranLeft().filter(item => {
+        return this.studentsTableLeft.findIndex(s => s.studentId == item.key) == -1
+      }).map(item => item.key)
+      //  右tran实际勾选去除右边table
+      const right = this.getTranRight().filter(item => {
+        return this.studentsTableRight.findIndex(s => s.studentId == item.key) == -1
+      }).map(item => item.key)
+      console.log(left) // 左边的学生放入左边表格
+      if( !left.length && !right.length) {
+        this.$message.warning('调整人数不可为0')
+        return
+      }
+      // 2.分别调用调换接口
+      if (left.length) this.saveResultDispatch(this.fromClassId,left)
+      console.log(left)// 左边的学生放入左边表格
+      console.log(right)// 右边的学生放入右边表格
+      if (right.length) this.saveResultDispatch(this.toClassId,right)
+      this.$nextTick(()=> {
+        this.show = false
+        // 注销数据
+        this.format()
+        this.refreshData()
+      })
+
+      // 2.分别调用调换接口
+
+    },
+    studentSelected(list, type) {
+      // 1 添加至母框
+      if (type == 'from') this.selectedLeft = list
+      if (type == 'to') this.selectedRight = list
+
+    },
+    getStudents(type, classId) {
+      console.log(type)
+      console.log(classId)
+      classesResult({
+        groupId: this.group.groupId,
+        roundId: this.group.roundId,
+        classId: classId
+      }).then(res => {
+        if( type == 'from') {
+          this.studentsTableLeft = res.rows
+        }else if( type == 'to') {
+          this.studentsTableRight = res.rows
+        }
+      })
+    },
+    // 判断是否有这个元素
+    getDisable(itemId) {
+      return this.fromClassId == itemId || this.toClassId == itemId
+    },
+    transferChange(currentVal, direction, array) {
+      if (!this.fromClassId || !this.toClassId) {
+        this.$message.warning('需要选择两个班级')
+        return
+      }
+    },
+    open(row, settings) {
+      console.log(row)
+      console.log(settings)
+      this.group = row
+      this.settings = settings
+      this.show = true
+    }
+  }
+}
+</script>
+
+<style scoped>
+.flex-center-column {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.flex-center {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+}
+</style>

+ 82 - 0
src/views/elective/dispatch/components/class-table.vue

@@ -0,0 +1,82 @@
+<template>
+  <div>
+    <mx-table :propDefines="propDefines" :rows="list" @selection-change="handleSelectionChange">
+    </mx-table>
+    <!-- 操作 确认和分页-->
+    <div class="tmp">
+
+    <!-- <el-button v-if="list.length > 0 && selected.length > 0" type="primary" @click="confirm">确认选择</el-button>-->
+      <el-pagination
+        layout="prev, pager, next"
+        :total="10">
+      </el-pagination>
+    </div>
+  </div>
+
+</template>
+<script>
+export default {
+  props: {
+    type: {
+      String: '',  //1 用于class-adjust 2 用于完成分班展示
+      default: '1'
+    },
+    list: {
+      required: true,
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      propDefines: {
+        selection: {
+          label: '选择',
+          type:'selection',
+          hidden: this.type == '2'
+        },
+        groupName: {
+          label: '组合',
+          hidden: this.type == '1'
+        },
+        className: {
+          label: '班级'
+        },
+        name: {
+          label: '姓名',
+        },
+        sex: {
+          label: '性别',
+        },
+        rankInGroup: {
+          label: '班级排名'
+        },
+        rankInGrade: {
+          label: '年级排名',
+          hidden: this.type == '1'
+        }
+      },
+      selected: [],
+    }
+  },
+  computed: {
+  },
+  methods: {
+    handleSelectionChange(row) {
+      this.selected = row
+      this.$emit('confirm',this.selected)
+    },
+    confirm() {
+    }
+  }
+}
+
+</script>
+<style scoped>
+.tmp{
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  margin: 10px 0;
+}
+</style>

+ 201 - 0
src/views/elective/dispatch/components/dispatch-table.vue

@@ -0,0 +1,201 @@
+<template>
+  <el-row>
+    <mx-table  :propDefines="propDefines" :rows="displayRows">
+      <template #classCount="{row,$index}">
+        <el-input-number size="small" v-model="row.classCount" @change="classCountChange(row,$index)" :min="0"
+                         :disabled="row.classCount != 0" label="label"
+        ></el-input-number>
+      </template>
+      <!-- 分班编辑-->
+      <template #edit="{row}">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          @click="openEditDialog(row)"
+        >编辑
+        </el-button>
+      </template>
+      <template #className="{row}">
+        <div v-if="row.groupClass.length">
+          <span  class="btn-class" @click="toDetail(row,item.classId)" v-for="item in row.groupClass">{{item.className}}</span>
+        </div>
+        <div v-else>
+          <span ></span>
+        </div>
+      </template>
+      <!-- 班级调整 -->
+      <template #adjust="{row}">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          @click="adjust(row)"
+        >调整
+        </el-button>
+      </template>
+      <template #detail="{row}">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          @click="toDetail(row)"
+        >详情
+        </el-button>
+      </template>
+    </mx-table>
+    <edit-group :year="round.year"  ref="editDialog"></edit-group>
+    <class-adjust ref="adjustDialog"></class-adjust>
+  </el-row>
+</template>
+<script>
+import MxClassTreeTranslateMixin from '@/components/Cache/modules/mx-classTree-translate-mixin.js'
+import MxSelectTranslateMixin from '@/components/Cache/modules/mx-select-translate-mixin.js'
+import ClassAdjust from './class-adjust'
+import SetClasscount from './set-classcount'
+import EditGroup from './edit-group'
+import MxTransferMixin from '@/components/mx-transfer-mixin.js'
+
+export default {
+  components: {
+    EditGroup,
+    SetClasscount,
+    ClassAdjust,
+  },
+  mixins: [MxClassTreeTranslateMixin, MxSelectTranslateMixin,MxTransferMixin],
+  props: {
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+    round: {
+      type: Object,
+      default: {},
+    },
+    settings: {
+      type: Array,
+      default: [],
+    }
+  },
+  data() {
+    return {
+      modifyGroupSettings: [],
+      dataList: [],
+      propDefines: {
+        groupName: {
+          label: '组合'
+        },
+        number: {
+          label: '录取人数'
+        },
+        classCount: {
+          label: '班级数',
+          slot: 'classCount'
+        },
+        edit: {
+          label: '分班编辑',
+          slot: 'edit'
+        },
+        groupClass: {
+          label: '班级名称',
+          slot: 'className'
+        },
+        expectedCount: {
+          label: '人数'
+        },
+        adjust: {
+          label: '操作',
+          slot: 'adjust'
+        },
+        proportion: {
+          label: '男女比例',
+        },
+        detail: {
+          label: '详情',
+          slot: 'detail'
+        }
+      }
+    }
+  },
+  computed: {
+    displayRows() {
+      if (!this.classTree.length) return []
+      if (!this.listGroupsOptions.length) return []
+      // if (!this.settings.length) return []
+      if (!this.round.groupList) return []
+      const rows = this.round.roundGroups.map(rg => ({
+          groupId: rg.groupId,
+          roundId: this.round.roundId,
+          groupName: this.translateGroup(rg.groupId),
+          number: this.round.enrollGroupCount[rg.groupId] || 0, // 录取人数
+          classCount: rg.classCount, // 班级数
+          expectedCount: this.settings.filter(item => item.groupId == rg.groupId).map(item => item.expectedCount).toString(),
+          groupClass: this.settings
+            .filter(item => item.groupId == rg.groupId)
+            .map(item => {
+              return {
+                classId: item.classId,
+                className: this.getClassName(item.classId)
+              }
+            })
+        }))
+      console.log('displayRows computed:', rows)
+      return rows
+    }
+  },
+  created() {
+
+  },
+  methods: {
+    openEditDialog(row) {
+      if (!row.classCount) {
+        this.$message.warning('班级数为0时不可分班')
+        return
+      }
+      this.modifyGroupSettings = this.deepClone(this.settings)
+      this.$refs.editDialog.open(row, this.modifyGroupSettings)
+    },
+    adjust(row) {
+      // 调整 未分班不可调整
+      const filterSettings = this.settings.filter(item => item.groupId == row.groupId)
+      if(filterSettings.length == 0){
+        this.$message.warning('未分班不可调整')
+        return
+      }
+      this.$refs.adjustDialog.open(row,this.settings.filter(item => item.groupId == row.groupId))
+    },
+    toDetail(row,classId ='') {
+      console.log(classId)
+      const params = {group: row,classId:classId,groupIds: (this.round.groupIds &&this.round.groupIds.split(','))||[]}
+      const path = '/elective/dispatch/detail'
+      console.log('prev transfer', row, this.round)
+      this.transferTo(path, params)
+    },
+    editCount(row) {
+      // 设定分配人数
+      const filter = this.settings.filter(item => item.groupId == row.groupId)
+      if (filter.length == 0) {
+        this.$message.warning('班级未编辑')
+        return
+      }
+      // if(){
+      //   this.$message.warning('需要选择')
+      // }
+      this.$refs.setClassDialog.open(row, this.settings)
+    },
+    classCountChange(newVal, index) {
+      this.round.groupList[index] = newVal
+    }
+  }
+}
+</script>
+<style scoped>
+.btn-class{
+  margin-right: 10px;
+  cursor: pointer;
+  border-bottom:1px solid #42b983;
+}
+</style>

+ 90 - 0
src/views/elective/dispatch/components/edit-group.vue

@@ -0,0 +1,90 @@
+<template>
+  <el-dialog
+    :title="`分班编辑(${roundGroup.groupName})`"
+    :visible.sync="show"
+    :close-on-click-modal="false"
+    width="70%"
+  >
+    <div>
+        <el-steps :active="active" finish-status="success">
+          <el-step title="设定班级"></el-step>
+          <el-step title="设定班级人数"></el-step>
+          <el-step title="完成"></el-step>
+        </el-steps>
+      <div style="padding: 20px 10px">
+        <choose-class v-if="active == 0" :year="year" ref="editClassDialog"></choose-class>
+        <set-classcount  v-if="active == 1" ref="setClassDialog"></set-classcount>
+        <p>
+          <el-button v-if="active == 0" @click="confirm" type="primary" style="float: right">下一步</el-button>
+        </p>
+      </div>
+    </div>
+    <span slot="footer" class="dialog-footer">
+      <el-button @click="show = false">取 消</el-button>
+      <el-button type="primary" @click="save()" :disabled="active != 1">确 定</el-button>
+    </span>
+  </el-dialog>
+</template>
+<script>
+
+import ChooseClass from '@/views/elective/dispatch/components/choose-class'
+import SetClasscount from '@/views/elective/dispatch/components/set-classcount'
+
+export default {
+  props: {
+    year: {
+      type: Number
+    }
+  },
+  components: {
+    ChooseClass,
+    SetClasscount
+  },
+  data() {
+    return {
+      active: 0,
+      show: false,
+      roundGroup: {},
+      settingContainer: [],
+
+    }
+  },
+  methods: {
+    open(roundGroup, settingContainer) {
+      this.active = 0
+      this.show = true
+      this.roundGroup = roundGroup
+      this.settingContainer = settingContainer
+      this.$nextTick(() => {
+        this.$refs.editClassDialog.open(roundGroup, settingContainer)
+      })
+    },
+    confirm() {
+      // 验证
+      const flag =this.$refs.editClassDialog.validate()
+      if (flag) {
+        // 下一步 保存班级
+        this.$refs.editClassDialog.confirm()
+        this.active = 1
+        // this.modifyGroupSettings = this.deepClone(this.settingContainer.filter(item => item.groupId == row.groupId))
+        this.$nextTick(() => {
+          this.$refs.setClassDialog.init(this.roundGroup,this.settingContainer)
+        })
+        return
+      }
+      this.$message.error(`${this.roundGroup.groupName}设定了${this.roundGroup.classCount}个班级`)
+      console.log(flag)
+    },
+    save() {
+      // 验证
+      const flag =this.$refs.setClassDialog.valid()
+      if(flag) this.show = false
+
+
+    }
+  }
+}
+</script>
+<style>
+
+</style>

+ 199 - 0
src/views/elective/dispatch/components/set-classcount.vue

@@ -0,0 +1,199 @@
+<template>
+    <div>
+      <p style="margin-bottom: 10px;">该组合录取人数为:{{roundGroup.number}}</p>
+      <el-table
+        :data="formatSetting"
+        style="width: 100%"
+      >
+        <el-table-column
+          label="序号"
+          prop="sortIndex"
+          width="100"
+        >
+        </el-table-column>
+        <el-table-column
+          label="组合"
+          width="180"
+        >
+          <template>
+            {{roundGroup.groupName}}
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="className"
+          label="班级名称"
+          width="180"
+        >
+        </el-table-column>
+        <el-table-column
+          label="人数"
+        >
+          <template slot-scope="scope">
+              <el-input-number :min="1" :max="roundGroup.number?setPubMax(scope.$index):Infinity" v-model="scope.row.expectedCount" @change="countEdit(scope.row,scope.$index)" ></el-input-number>
+          </template>
+        </el-table-column>
+<!--        <el-table-column-->
+<!--          label="操作"-->
+<!--        >-->
+<!--          <template slot-scope="scope">-->
+<!--            <el-popover-->
+<!--              placement="bottom"-->
+<!--              title="标题"-->
+<!--              width="200"-->
+<!--              trigger="click"-->
+<!--              content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。">-->
+<!--              <el-switch-->
+<!--                v-model="scope.row.active"-->
+<!--                active-color=""-->
+<!--                active-text="按排名"-->
+<!--                inactive-text="随机"-->
+<!--                inactive-color="#ff4949"-->
+<!--                slot="reference">-->
+<!--              </el-switch>-->
+<!--            </el-popover>-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+      </el-table>
+      <el-radio-group class="mt10" v-model="mode">
+        <el-popover
+          placement="bottom-start"
+          width="200"
+          trigger="hover"
+          content="默认分配方式,按成绩平均分配">
+          <el-radio-button class="radio-btn" label="RankBalance" slot="reference">均衡</el-radio-button>
+        </el-popover>
+        <el-popover
+          placement="bottom-start"
+          width="200"
+          trigger="hover"
+          content="按成绩优先分配">
+          <el-radio-button class="radio-btn" label="RankPriority" slot="reference">排名</el-radio-button>
+        </el-popover>
+        <el-popover
+          placement="bottom-start"
+          width="200"
+          trigger="hover"
+          content="随机分配">
+          <el-radio-button class="radio-btn" label="random" slot="reference">随机</el-radio-button>
+        </el-popover>
+      </el-radio-group>
+    </div>
+</template>
+
+<script>
+import MxClassTreeTranslateMixin from '@/components/Cache/modules/mx-classTree-translate-mixin.js'
+import { saveSettings, applySettings } from '@/api/webApi/elective/dispatch'
+export default {
+  name: 'set-classcount',
+  mixins: [MxClassTreeTranslateMixin],
+  data() {
+    return {
+      setShow: false,
+      roundId: '',
+      roundGroup: {},
+      mode: 'RankBalance',
+      // RankBalance, // 按成绩平均分配,默认按这种分配方式,可保证各个班成绩都差不多
+      // RankPriority, // 按成绩优先,会导致成绩好的集中到一起
+      // Random // 随机
+      settingContainer: []
+    }
+  },
+  computed: {
+    // input-number设置公用max
+    setPubMax() {
+      return (data) => {
+        let allSelect = 0
+        let maxQuantit = this.roundGroup.number
+        this.settingContainer.forEach((item, index) => {
+          if (index !== data) {
+            allSelect += item.expectedCount
+          }
+        })
+        return maxQuantit-allSelect
+      }
+    },
+    formatSetting() {
+      return this.settingContainer.map((item,index) => {
+        return {
+          sortIndex: index + 1,
+          expectedCount: item.expectedCount,
+          classId: item.classId,
+          className: this.getClassName(item.classId),
+        }
+      })
+    },
+  },
+  inject: ['refreshData'],
+  methods: {
+    countEdit(newVal,index){
+      this.settingContainer[index].expectedCount = newVal.expectedCount
+    },
+    init(roundGroup,settingContainer) {
+      this.roundGroup = roundGroup
+      this.roundId = roundGroup.roundId
+      if( settingContainer.length > 0) {
+        this.settingContainer = settingContainer.filter(item => item.groupId == roundGroup.groupId)
+        console.log(this.settingContainer)
+        return
+      }
+      // 没有setting就初始化人数
+      const remainder = roundGroup.number % roundGroup.classCount // 余数
+      const divide = Math.floor(roundGroup.number / roundGroup.classCount) // 向下取整的除数
+      this.settingContainer = settingContainer.filter(item => item.groupId == roundGroup.groupId).map((item,index)  => {
+        if (index + 1 <= remainder) {
+          // 余数平分给前面
+          item.expectedCount = divide + 1
+        }else {
+          // 余数分完
+          item.expectedCount = divide
+        }
+        return item
+        console.log(item)
+      })
+    },
+    valid() {
+      // 验证
+      // 累计人数
+      const count =this.formatSetting.reduce((pre, cur) => {
+          return pre + cur.expectedCount
+      }, 0)
+      if(count != this.roundGroup.number) {
+        this.$message.warning('还有学生未分班')
+        return
+      }
+      // 校验通过 保存
+      this.confirm()
+      return true
+    },
+    saveSettings() {
+      saveSettings(
+        {roundId: this.roundId }, this.settingContainer ).then(res => {
+        if (res.code == 200) {
+          this.$message.success(res.msg)
+        }
+      }).finally(res => {
+        this.refreshData()
+      })
+    },
+    confirm() {
+      console.log(this.settingContainer)
+      // 应用分班配置
+      applySettings({
+        roundId:this.roundId,
+        mode:this.mode,
+        groupId:this.roundGroup.groupId,
+      }).then(res => {})
+      // 保存settings
+      this.$nextTick(_ => {
+        this.saveSettings()
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.radio-btn{
+  margin-right: 20px;
+}
+</style>

+ 78 - 0
src/views/elective/dispatch/detail.vue

@@ -0,0 +1,78 @@
+<template>
+  <!-- 分班完成的详情 -->
+  <div class="app-container">
+    <el-card class="box-card" style="margin-bottom: 10px;">
+      <mx-condition :query-params="queryParams" :require-fields="requireFields" :local-data="groupSource"
+                    @query="handleGroupQuery" class="mb10"
+      ></mx-condition>
+    </el-card>
+    <class-table type="2" :list="studentList"></class-table>
+  </div>
+</template>
+<!--:setting-model="settingModel" :group-model-index="groupModelIndex"-->
+<!--:group-model="groupModel" :default-group-id="scoreQueryGroupId"-->
+<script>
+import MxSelectTranslateMixin from '@/components/Cache/modules/mx-select-translate-mixin.js'
+import MxCondition from '@/components/MxCondition/mx-condition'
+import MxTransferMixin from '@/components/mx-transfer-mixin.js'
+import ClassTable from '@/views/elective/dispatch/components/class-table'
+import { classesResult } from '@/api/webApi/elective/dispatch'
+
+export default {
+  mixins: [MxTransferMixin, MxSelectTranslateMixin],
+  components: { ClassTable, MxCondition },
+  name: 'dispatch-detail',
+  data() {
+    return {
+      requireFields: ['localGroupId'],
+      studentList: [],
+      queryParams: null
+    }
+  },
+  computed: {
+    groupSource() {
+      if (!this.listGroupsOptions.length) return {}
+      if (!this.prevData.groupIds?.length) return {}
+      console.log('computed groupSource exec')
+      const source = {
+        groups: this.prevData.groupIds.map(groupId => {
+          return {
+            groupName: this.translateGroup(groupId),
+            groupId: groupId
+          }
+        })
+      }
+      this.$nextTick(_ => this.queryParams = {
+        localGroupId: this.prevData.group.groupId,
+        dispatchClassId: this.prevData.classId || '',
+        dispatchGender: '',
+        dispatchRoundId: this.prevData.group.roundId
+      })
+      return source
+    }
+  },
+  methods: {
+    handleGroupQuery() {
+       this.getDispatchResult()
+    },
+    getDispatchResult() {
+      classesResult({
+        classId: this.queryParams.dispatchClassId,
+        groupId: this.queryParams.localGroupId,
+        roundId: this.queryParams.dispatchRoundId,
+        sex: this.queryParams.dispatchGender || null,
+      }).then(res => {
+        console.log(res)
+        this.studentList = res.rows.map(item => {
+          item.groupName = this.translateGroup(this.queryParams.localGroupId)
+          return item
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 73 - 0
src/views/elective/dispatch/index.vue

@@ -0,0 +1,73 @@
+<template>
+  <div class="app-container">
+    <el-card class="box-card" style="margin-bottom: 10px;">
+      <mx-condition ref="condition" :query-params="queryParams" :require-fields="requireFields" @query="handleQuery"
+                    @invalid="handleInvalidQuery"
+      ></mx-condition>
+    </el-card>
+    <dispatch-table :loading="loading" :round="round" :settings="settings"></dispatch-table>
+  </div>
+</template>
+<script>
+import DispatchTable from "./components/dispatch-table.vue";
+import { getRound,getSettings } from '@/api/webApi/elective/dispatch'
+import MxCondition from '@/components/MxCondition/mx-condition'
+
+export default {
+  components: {
+    DispatchTable,
+    MxCondition
+  },
+  data() {
+    return {
+      requireFields: ['year', 'roundId'],
+      queryParams: {
+        year: '',
+        roundId: ''
+      },
+      loading: false,
+      settings: [],
+      round: { },
+    };
+  },
+  provide() {
+    return {
+      refreshData: this.handleQuery
+    }
+  },
+  methods: {
+    handleQuery() {
+      this.getRound()
+      this.getSettings()
+    },
+    // 获取批次的setting
+    getSettings() {
+      getSettings({
+        roundId:this.queryParams.roundId,
+      }).then(res => {
+        console.log(res)
+        this.settings = res.data
+      })
+    },
+    // 获取批次的组合
+    getRound() {
+      this.loading = true
+      getRound({
+        year:this.queryParams.year,
+        round:this.queryParams.roundId,
+      }).then(res => {
+        this.round = res.data
+        console.log(res)
+      }).finally((res) => {
+        this.loading= false
+      })
+    },
+    handleInvalidQuery() {
+      console.log('query取消')
+      this.round = {}
+    },
+  },
+};
+</script>
+<style scoped>
+</style>

+ 23 - 0
src/views/elective/generation/components/elective-flow-major.vue

@@ -0,0 +1,23 @@
+<template>
+  <el-popover trigger="hover" class="mx-generation-major">
+    <div class="fx-row fx-cen-cen f12 bold i">专业意向符合</div>
+    <div v-for="(group,idx) in matchedMajors" :key="idx">
+      <div class="bold f-666 mt10">{{ group.label }}</div>
+      <div v-for="(opts, index) in group.options" :key="index" class="pl20 f-999">
+        {{ opts.majorCategoryName }}
+      </div>
+    </div>
+    <i slot="reference" class="el-icon-check icon24 bold" :class="iconClasses"></i>
+  </el-popover>
+</template>
+
+<script>
+export default {
+  name: 'elective-flow-major',
+  props: ['matchedMajors', 'iconClasses']
+}
+</script>
+
+<style scoped>
+
+</style>

+ 21 - 0
src/views/elective/generation/components/elective-flow-rank-descriptor.vue

@@ -0,0 +1,21 @@
+<template>
+  <el-popover trigger="hover" class="mx-generation-rank">
+    <div v-for="(desc,idx) in rankDescriptors" :key="idx">
+      <span>{{ desc.description }}</span> <span class="bold">{{ desc.value }}</span>
+    </div>
+    <el-tag slot="reference" size="mini" class="round-y ml3" effect="dark">
+      {{ rankDescriptors.map(d => d.value).join('/') }}
+    </el-tag>
+  </el-popover>
+</template>
+
+<script>
+export default {
+  name: 'elective-flow-rank-descriptor',
+  props: ['rankDescriptors']
+}
+</script>
+
+<style scoped>
+
+</style>

+ 30 - 0
src/views/elective/generation/components/elective-flow-table-style.css

@@ -0,0 +1,30 @@
+.elective-flow-table.el-table td {
+  position: relative;
+}
+
+.elective-flow-table.el-table th > .cell {
+  position: unset;
+  margin-top: 5px;
+  margin-bottom: 5px;
+}
+
+.elective-flow-table.el-table tbody .cell {
+  overflow: visible;
+  margin-top: 10px;
+}
+
+.elective-flow-table .round-y {
+  border-radius: 12px;
+}
+
+.elective-flow-table .mx-generation-rank {
+  position: absolute;
+  right: 3px;
+  top: 2px;
+}
+
+.elective-flow-table .mx-generation-major {
+  position: absolute;
+  left: 3px;
+  top: 2px;
+}

+ 244 - 0
src/views/elective/generation/components/elective-generation-charts.vue

@@ -0,0 +1,244 @@
+<template>
+  <div class="fx-column">
+    <div class="fx-row fx-bet-cen">
+      <div class="fx-1">
+        <mx-chart ref="bar" :options="chartOptions.bar" :height="'450px'"></mx-chart>
+      </div>
+      <div v-if="chartOptions.pie" class="fx-row fx-cen-cen" style="width: 450px">
+        <mx-chart ref="pie" :options="chartOptions.pie" :height="'450px'"></mx-chart>
+      </div>
+    </div>
+    <template v-if="chartOptions.pies&&chartOptions.pies.length">
+      <el-divider></el-divider>
+      <div class="fx-row fx-bet-cen pl30">
+        <div v-for="(pie,idx) in chartOptions.pies" :key="idx" class="fx-1 fx-row fx-cen-cen">
+          <mx-chart ref="pies" :options="pie" :height="'400px'"></mx-chart>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import MxChart from '@/components/MxChart/index'
+import config from '@/common/mx-config'
+
+export default {
+  name: 'elective-generation-charts',
+  components: { MxChart },
+  props: ['chartBinding'],
+  computed: {
+    chartOptions() {
+      const options = this.chartBinding.generation.options
+      let data = this.chartBinding.chartData?.accumulates
+      let generation = this.chartBinding.chartData?.generation
+      let desc = '录取超缺数量'
+      if (this.chartBinding.generation.active == options.primary.value) {
+        generation = options.primary.value // force override primary chart data
+        data = this.chartBinding.generation.summary.find(item => item.generation == generation)?.categories
+        desc = '报名超缺数量'
+      }
+      if (!data?.length) return {}
+      const currentOpt = Object.values(options).find(opt => opt.value == generation)
+
+      // xAxis data - round groups
+      const roundGroups = this.chartBinding.generation.roundGroups
+      const xAxis = roundGroups.map(rg => rg.groupName)
+
+      // yAxis data
+      let series = []
+      let valueNameGroup = null
+      let valueNameGroups = []
+      if (generation == options.primary.value) {
+        // 初选报名需要单独处理
+        const multiplePreference = data.length > 1
+        const preferenceSeries = data.map((single, idx) => {
+          const prefix = multiplePreference ? `第${idx + 1}志愿/` : ''
+          const stackName = 'preference_' + idx
+          const singleGroup = { dimension: !multiplePreference ? '报名人数' : `第${idx + 1}志愿`, values: [] }
+          const singleSeries = this.barSeriesMissingAndOverFactory(
+            roundGroups,
+            rg => rg.expectedCount * 1,
+            rg => {
+              let actualCount = single.find(item => item.category == 'actualCount')?.values
+                .find(item => item.groupId == rg.groupId)?.value || 0
+              actualCount = actualCount * 1
+              singleGroup.values.push({ value: actualCount, name: rg.groupName })
+              return actualCount
+            },
+            stackName,
+            [prefix + '报名人数', , prefix + '缺少人数', prefix + '超出人数']
+          )
+          let unfinishedCount = single.find(item => item.category == 'actualCount')?.values?.first()?.value || 0
+          unfinishedCount = unfinishedCount * 1
+          singleGroup.values.push({ value: unfinishedCount, name: '未报名' })
+          valueNameGroups.push(singleGroup)
+          return singleSeries
+        })
+        series = preferenceSeries.reduce((prev, cur) => prev.concat(cur), [])
+        if (valueNameGroups.length > 1) {
+          valueNameGroup = valueNameGroups.reduce((prev, cur) => {
+            const merge = this.deepClone(cur) // NOTE: DO NOT modify raw item
+            merge.dimension = '报名人次'
+            merge.values.forEach(val => {
+              const match = prev.values.find(i => i.name == val.name)
+              val.value = val.value * 1 + (match?.value || 0) * 1
+            })
+            return merge
+          }, { values: [] })
+        } else {
+          valueNameGroup = valueNameGroups?.first()
+          valueNameGroups = []
+        }
+      } else {
+        valueNameGroup = { dimension: '录取人数', values: [] }
+        series = this.barSeriesMissingAndOverFactory(
+          roundGroups,
+          rg => rg.expectedCount * 1,
+          rg => {
+            const approvedCount = data.find(item => item.category == 'approvedCount')?.values
+              .find(item => item.groupId == rg.groupId)?.value || 0
+            const forcedCount = data.find(item => item.category == 'forcedCount')?.values
+              .find(item => item.groupId == rg.groupId)?.value || 0
+            const enrollCount = approvedCount * 1 + forcedCount * 1
+            valueNameGroup.values.push({ value: enrollCount, name: rg.groupName })
+            return enrollCount
+          },
+          'Indicator',
+          []
+        )
+        valueNameGroup.values.push({ value: this.chartBinding.generation.status.disenrollCount, name: '未录取' })
+      }
+      setTimeout(() => {
+        this.$refs.bar?.resize()
+        this.$refs.pie?.resize()
+      }, 200)
+      return {
+        bar: this.barOptionFactory('组合统计/' + currentOpt.title, desc, xAxis, series),
+        pie: valueNameGroup && this.pieOptionFactory('组合统计/' + currentOpt.title, valueNameGroup.dimension, '', valueNameGroup.values),
+        pies: valueNameGroups.map(g => this.pieOptionFactory('组合统计/' + currentOpt.title, g.dimension, '', g.values))
+      }
+    }
+  },
+  methods: {
+    pieOptionFactory(title, subTitle, dimensionName, valueNamePairs) {
+      return {
+        title: {
+          text: title,
+          subtext: subTitle,
+          left: 'center'
+        },
+        tooltip: {
+          trigger: 'item'
+        },
+        legend: {
+          orient: 'vertical',
+          left: 'left'
+        },
+        series: [
+          {
+            name: dimensionName,
+            type: 'pie',
+            radius: '50%',
+            data: valueNamePairs,
+            emphasis: {
+              itemStyle: {
+                shadowBlur: 10,
+                shadowOffsetX: 0,
+                shadowColor: 'rgba(0, 0, 0, 0.5)'
+              }
+            }
+          }
+        ]
+      }
+    },
+    barOptionFactory(title, subTitle, xAxis, series) {
+      return {
+        title: {
+          text: title,
+          subtext: subTitle,
+          left: 'center'
+        },
+        xAxis: {
+          type: 'category',
+          data: xAxis
+        },
+        yAxis: {
+          type: 'value'
+        },
+        series: series,
+        tooltip: {
+          trigger: 'item'
+        }
+      }
+    },
+    barSeriesMissingAndOverFactory(sourceArr, baseGetter, actualGetter, stackName, lineNames) {
+      // 本来是创建3条线:基础线,缺少线,超出线,但图表的提示展示不方便
+      // 所以这里创建4条线:期望线,实际线,缺少线,超出线。(期望线,实际线保持相同颜色)
+      const series = []
+      const commonDefines = {
+        type: 'bar',
+        stack: stackName,
+        emphasis: {
+          focus: 'series',
+          itemStyle: {
+            shadowBlur: 10,
+            shadowOffsetX: 0,
+            shadowColor: 'rgba(0, 0, 0, 0.5)'
+          }
+        }
+      }
+      const actualLine = {
+        ...commonDefines,
+        name: lineNames[0] || '录取人数',
+        data: []
+      }
+      const expectedLine = {
+        ...commonDefines,
+        name: lineNames[1] || '设置人数',
+        data: []
+      }
+      const missingLine = {
+        ...commonDefines,
+        name: lineNames[2] || '缺少人数',
+        data: []
+      }
+      const overLine = {
+        ...commonDefines,
+        name: lineNames[3] || '超出人数',
+        data: []
+      }
+      sourceArr.forEach(item => {
+        const settingCount = baseGetter(item)
+        const enrollCount = actualGetter(item)
+        if (settingCount <= 0 || settingCount == enrollCount) {
+          expectedLine.data.push(0)
+          actualLine.data.push({ value: enrollCount, itemStyle: { color: config.color.primary_report } })
+          missingLine.data.push(0)
+          overLine.data.push(0)
+        } else {
+          const subCount = Math.abs(settingCount - enrollCount)
+          const baseCount = Math.min(settingCount, enrollCount)
+          if (enrollCount > settingCount) {
+            expectedLine.data.push({ value: baseCount, itemStyle: { color: config.color.primary_report } })
+            actualLine.data.push(0)
+            missingLine.data.push(0)
+            overLine.data.push({ value: subCount, itemStyle: { color: config.color.error } })
+          } else {
+            expectedLine.data.push(0)
+            actualLine.data.push({ value: baseCount, itemStyle: { color: config.color.primary_report } })
+            missingLine.data.push({ value: subCount, itemStyle: { color: config.color.yellow_report } })
+            overLine.data.push(0)
+          }
+        }
+      })
+      series.push(actualLine, expectedLine, missingLine, overLine)
+      return series
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 65 - 0
src/views/elective/generation/components/elective-generation-commands.vue

@@ -0,0 +1,65 @@
+<template>
+  <div class="fx-row fx-bet-cen">
+    <div class="fx-1">
+      <el-button v-if="showDMAlgorithm" type="primary">智能匹配</el-button>
+      <el-button v-if="showDMAlgorithmResults" type="primary">查看匹配明细</el-button>
+      <el-button v-if="showForceAdjust" type="primary">调剂/提醒</el-button>
+    </div>
+    <div>
+      <el-button v-if="showFastPush" type="primary">提前进入{{ nextStepName }}</el-button>
+      <el-button v-if="showSend" type="primary">发送</el-button>
+      <el-button v-if="showRankBalance" type="primary">排名均衡</el-button>
+      <el-button v-if="showClassDispatch" type="primary">选科分班</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'elective-generation-commands',
+  inject: ['refreshData'],
+  props: ['chartBinding', 'disabled'],
+  computed: {
+    generation() {
+      return this.chartBinding.generation || {}
+    },
+    status() {
+      return this.generation?.status || {}
+    },
+    // buttons control
+    showDMAlgorithm() {
+      return this.status.allowDMAlgorithm
+    },
+    showDMAlgorithmResults() {
+      return this.status.doneDMAlgorithm && !this.status.allowForce
+    },
+    showForceAdjust() {
+      return this.status.doneDMAlgorithm && this.status.allowForce
+    },
+    showFastPush() {
+      return this.status.enablePushNextDMGeneration
+    },
+    nextStepName() {
+      const options = this.generation.options
+      const next = this.generation.current + 1
+      return Object.values(options).find(opt => opt.value == next).title
+    },
+    showSend() {
+      return this.status.doneDMAlgorithm && !this.status.allMatched
+    },
+    showRankBalance() {
+      const options = this.generation.options
+      return this.status.allMatched && this.generation.current < options.rankBalance.value
+    },
+    showClassDispatch() {
+      const options = this.generation.options
+      return this.status.allMatched && this.generation.current <= options.rankBalance.value
+    }
+  },
+  methods: {}
+}
+</script>
+
+<style scoped>
+
+</style>

+ 72 - 0
src/views/elective/generation/components/elective-generation-flow-log.vue

@@ -0,0 +1,72 @@
+<template>
+  <mx-table :rows="logTable.rows" :prop-defines="logTable.columns" border class="elective-flow-table">
+    <template #group-header="{key, label}">
+      <elective-flow-major v-if="logTable.majors[key]" :icon-classes="['f-fff']"
+                           :matched-majors="logTable.majors[key]"></elective-flow-major>
+      {{ label }}
+    </template>
+    <template #group-flow="{value}">
+      <span v-if="value.text">{{ value.text }}</span>
+      <span v-else v-html="'&#12288'"></span>
+      <elective-flow-rank-descriptor v-if="value.rankDescriptors"
+                                     :rank-descriptors="value.rankDescriptors"></elective-flow-rank-descriptor>
+    </template>
+  </mx-table>
+</template>
+
+<script>
+import config from '@/common/mx-config'
+import ElectiveFlowMajor from '@/views/elective/generation/components/elective-flow-major'
+import ElectiveFlowRankDescriptor from '@/views/elective/generation/components/elective-flow-rank-descriptor'
+
+export default {
+  name: 'elective-generation-flow-log',
+  components: { ElectiveFlowRankDescriptor, ElectiveFlowMajor },
+  props: ['groups', 'matchedMajors', 'histories'],
+  computed: {
+    logTable() {
+      if (!this.histories.length) return {}
+      const maxGeneration = this.histories.last().generation
+      const options = Object.values(config.electiveGenerationOptions)
+      // columns & rows
+      const rows = []
+      for (let g = 1; g <= maxGeneration; g++) {
+        const opt = options.find(opt => opt.value == g)
+        rows.push({ opt, generation: opt.title })
+      }
+      const columns = { generation: { label: '进程' } }
+      const majors = {}
+      this.groups.forEach(group => {
+        const keyPrefix = 'group_'
+        const key = keyPrefix + group.groupId
+        columns[key] = { label: group.groupName, minWidth: '160px', slot: 'group-flow', slotHeader: 'group-header' }
+        // match major
+        const groupMajors = (this.matchedMajors?.majors
+          ?.filter(m => m['matchedGroupIds'].some(id => id == group.groupId)) || [])
+          .groupBy(m => m.collegeName)
+        if (groupMajors.length) majors[key] = groupMajors
+        // fill rows
+        rows.forEach(row => {
+          const g = row.opt.value
+          const gHistories = this.histories.filter(h => h.generation == g)
+          row[key] = {
+            text: gHistories.map(h => h.description).join('/'),
+            histories: gHistories,
+            rankDescriptors: gHistories.last(i => !!i.rankDescriptors?.length)?.rankDescriptors
+          }
+        })
+      })
+
+      return {
+        columns,
+        rows,
+        majors
+      }
+    }
+  }
+}
+</script>
+
+<style>
+@import url('./elective-flow-table-style.css');
+</style>

+ 96 - 0
src/views/elective/generation/components/elective-generation-master.vue

@@ -0,0 +1,96 @@
+<template>
+  <div class="fx-column">
+    <evaluation-empty v-if="isUnPassedStep" :shadow="false" :title="emptyTitle"></evaluation-empty>
+    <template v-else>
+      <div class="fx-row fx-bet-cen mb15">
+        <div>
+          <slot name="header-prefix"></slot>
+        </div>
+        <div class="fx-1">
+          <slot :name="activeKey+'-header'" v-bind="chartBinding">
+            <elective-generation-commands v-if="!stepDisabled" :chart-binding="chartBinding"/>
+          </slot>
+        </div>
+        <div>
+          <slot name="header-suffix"></slot>
+        </div>
+      </div>
+      <slot :name="activeKey" v-bind="chartBinding">
+        <elective-generation-table :chart-binding="chartBinding"></elective-generation-table>
+      </slot>
+      <slot name="footer-prefix"></slot>
+      <slot :name="activeKey+'-footer'" v-bind="chartBinding">
+        <elective-generation-charts :chart-binding="chartBinding" class="mt40"></elective-generation-charts>
+      </slot>
+      <slot name="footer-suffix"></slot>
+    </template>
+  </div>
+</template>
+
+<script>
+
+import ElectiveGenerationTable from '@/views/elective/generation/components/elective-generation-table'
+import ElectiveGenerationCharts from '@/views/elective/generation/components/elective-generation-charts'
+import ElectiveGenerationCommands from '@/views/elective/generation/components/elective-generation-commands'
+
+export default {
+  name: 'elective-generation-master',
+  components: { ElectiveGenerationCommands, ElectiveGenerationCharts, ElectiveGenerationTable },
+  props: {
+    generation: {
+      type: Object
+    }
+  },
+  data() {
+    return {
+      emptyTitle: ''
+    }
+  },
+  computed: {
+    activeKey() {
+      return this.generation.activeOpt?.key || ''
+    },
+    stepDisabled() {
+      return this.generation.active !== this.generation.current
+    },
+    isUnPassedStep() {
+      if (!this.generation.activeOpt) return false
+      this.emptyTitle = this.generation.currentOpt == this.generation.options.init
+        ? '选科还未开启'
+        : `正在进行${this.generation.currentOpt.title || '{未知进程}'}, 还未进行至${this.generation.activeOpt.title}`
+      return this.generation.active > this.generation.current
+    },
+    categories() {
+      // summary - categories for display in table
+      return this.generation.summary.filter(item => item.generation <= this.generation.active)
+    },
+    accumulates() {
+      // summary - accumulate for display in charts
+      let options = this.generation.options
+      let generation = this.generation.active
+      let generationData = this.generation.summary.find(item => item.generation == generation && item.accumulates?.length)
+      if (!generationData) {
+        generation = generation - 1
+        generationData = this.generation.summary.find(item => item.generation == generation)
+      }
+
+      return generationData?.accumulates?.length ? {
+        generation: generation, // 图表激活的进程代,除初选报名外,一般为决策代数据
+        accumulates: generationData.accumulates
+      } : null
+    },
+    chartBinding() {
+      return {
+        generation: this.generation,
+        tableData: this.categories,
+        chartData: this.accumulates
+      }
+    }
+  },
+  methods: {}
+}
+</script>
+
+<style scoped>
+
+</style>

+ 156 - 0
src/views/elective/generation/components/elective-generation-steps.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="fx-column pl20 pr20">
+    <div class="fx-row fx-cen-cen f18 f-333 bold">
+      {{ title }}
+    </div>
+    <el-steps :active="activeStep" class="mt20">
+      <el-step v-for="(step,idx) in steps" :key="step.key" :title="step.title" :status="step.status">
+        <template #title>
+          <div class="rel"
+               :class="{'bold':activeStep==idx+1, 'f-333':activeStep==idx+1&&step.value>generation.current}">
+            <i v-if="activeStep==idx+1" class="el-icon-thumb current-pointer current-animation abs"></i>
+            <el-popover v-else-if="step.value<generation.current&&step.value<activeStep" trigger="hover"
+                        class="abs current-view">
+              <el-checkbox :value="generation.hiddenGenerations.includes(step.value)"
+                           @input="toggleGenerationTableData($event,step)">
+                隐藏{{ step.title }}表格数据
+              </el-checkbox>
+              <i slot="reference" class="el-icon-view pointer"
+                 :class="{'current-disabled': generation.hiddenGenerations.includes(step.value)}"
+                 @click="toggleGenerationTableData(!generation.hiddenGenerations.includes(step.value), step)"></i>
+            </el-popover>
+            <span class="pointer" @click="handleStepChange(step)">
+              {{ step.title }}</span>
+          </div>
+        </template>
+      </el-step>
+    </el-steps>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+
+export default {
+  name: 'elective-generation-steps',
+  model: {
+    prop: 'modelValue',
+    event: 'change'
+  },
+  props: {
+    generation: {
+      type: Object,
+      default: () => ({})
+    },
+    modelValue: {
+      type: String,
+      default: '' // active step key.
+    }
+  },
+  data() {
+    return {
+      activeStep: 0
+    }
+  },
+  computed: {
+    ...mapGetters(['school']),
+    steps() {
+      const options = this.generation.options
+      const current = this.generation.current
+      const visibleSteps = Object.keys(options)
+        .filter(key => options[key].stepsVisible)
+      return visibleSteps.map(key => {
+        const opt = options[key]
+        return {
+          key: key,
+          value: opt.value,
+          title: opt.title,
+          status: opt.value < current
+            ? 'success'
+            : opt.value > current
+              ? 'wait'
+              : 'process'
+        }
+      })
+    },
+    title() {
+      const year = (this.generation.status.year + '').tailingFix('学年')
+      const schoolName = this.school.schoolName
+      const roundName = this.generation.status.roundName
+
+      const options = this.generation.options
+      let statusTip = this.generation.currentOpt?.title + '进行中...'
+      let defaultOpt = this.generation.currentOpt
+      if (this.generation.current < options.primary.value) {
+        statusTip = '选科未开启'
+        defaultOpt = options.primary
+      } else if (this.generation.current > options.rankBalance.value) {
+        statusTip = '选科结束'
+        defaultOpt = options.rankBalance
+      }
+
+      if (!this.modelValue) {
+        this.activeStep = this.steps.findIndex(item => item.value == defaultOpt.value) + 1
+        this.$emit('change', defaultOpt.key)
+      }
+      return `${year}${schoolName}${roundName}(${statusTip})`
+    }
+  },
+  methods: {
+    handleStepChange(step) {
+      const target = step.value
+      this.generation.hiddenGenerations.remove(target) // force display
+      if (target != this.activeStep) {
+        this.activeStep = target
+        this.$emit('change', step.key)
+      }
+    },
+    toggleGenerationTableData(e, step) {
+      if (e) {
+        if (!this.generation.hiddenGenerations.includes(step.value)) {
+          this.generation.hiddenGenerations.push(step.value)
+        }
+      } else {
+        this.generation.hiddenGenerations.remove(step.value)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.current-pointer {
+  left: -20px;
+  top: 10px;
+}
+
+.current-view {
+  left: -20px;
+}
+
+.current-disabled {
+  color: #999999;
+}
+
+.current-animation {
+  animation: finger infinite 2s;
+}
+
+@keyframes finger {
+  0% {
+    transform: translate(-5px) rotate(90deg)
+  }
+  25% {
+    transform: translate(5px) rotate(90deg)
+  }
+  50% {
+    transform: translate(-5px) rotate(90deg)
+  }
+  75% {
+    transform: translate(5px) rotate(90deg)
+  }
+  100% {
+    transform: translate(-5px) rotate(90deg)
+  }
+}
+</style>

+ 168 - 0
src/views/elective/generation/components/elective-generation-table.vue

@@ -0,0 +1,168 @@
+<template>
+  <mx-table ref="table" :prop-defines="resolvedTable.columns" :rows="resolvedTable.rows" border>
+    <template #elective-cell="{value, label}">
+      <el-popover trigger="hover" :disabled="value&&value.disabled"
+                  popper-class="zero-padding-popover">
+        <div class="fx-column">
+          <el-button plain type="text" @click="goDetails(value, label)">查看名单</el-button>
+        </div>
+        <div slot="reference" :style="getCellStyles(value)">
+          <span v-if="value.star">*</span>
+          <span>{{ value && value.value }}</span>
+        </div>
+      </el-popover>
+    </template>
+  </mx-table>
+</template>
+
+<script>
+import config from '@/common/mx-config'
+import MxTransferMixin from '@/components/mx-transfer-mixin'
+
+export default {
+  mixins: [MxTransferMixin],
+  name: 'elective-generation-table',
+  props: ['chartBinding'],
+  computed: {
+    resolvedTable() {
+      // setting data
+      const columns = { groupName: { label: '组合', fixed: true }, expectedCount: { label: '设置人数', fixed: true } }
+      const rows = this.deepClone(this.chartBinding.generation.roundGroups) // TODO: need clone?
+      // generation data
+      const mergedColumns = []
+      this.chartBinding.tableData.forEach(item => {
+        if (this.chartBinding.generation.hiddenGenerations.includes(item.generation)) return
+        const isPreference = item.categories.every(item => Array.isArray(item))
+        const generationQueryableCategories = []
+        const generationQueryableIgnoreGroupCategories = []
+        const ext = {
+          roundId: item.roundId,
+          generation: item.generation,
+          queryableCategories: generationQueryableCategories, // 本代支持查询的列
+          ignoreGroupCategories: generationQueryableIgnoreGroupCategories // 本代支持查询,且与组合无关的列
+        }
+
+        const subColumns = {}
+        const mergedHeaderKey = 'generation_' + item.generation
+        const options = Object.values(this.chartBinding.generation.options)
+        const mergedHeaderOpt = options.find(opt => opt.value == item.generation)
+        const mergedHeaderDefine = { label: mergedHeaderOpt.title, children: subColumns }
+        if (isPreference) {
+          const cnIndexer = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
+          item.categories.forEach((subItem, idx) => {
+            const cnIndex = idx < cnIndexer.length ? cnIndexer[idx] : (idx + 1)
+            const innerHeaderKey = mergedHeaderKey + '_' + idx
+            const innerHeaderDefine = item.categories.length > 1
+              ? { label: `${cnIndex}志愿`, children: {} }
+              : mergedHeaderDefine
+            const overrideQueryName = item.categories.length > 1 ? innerHeaderDefine.label : ''
+            subItem.forEach(data => this.resolveTableGeneration(ext, data, innerHeaderDefine.children, rows, mergedColumns, innerHeaderKey, false, overrideQueryName))
+            columns[innerHeaderKey] = innerHeaderDefine
+          })
+        } else {
+          item.categories.forEach(data => this.resolveTableGeneration(ext, data, subColumns, rows, mergedColumns))
+          columns[mergedHeaderKey] = mergedHeaderDefine
+        }
+      })
+      // accumulate data
+      if (this.chartBinding.generation.active > this.chartBinding.generation.options.primaryDM.value
+        && this.chartBinding.chartData?.accumulates?.length) {
+        const prefix = 'accumulate_'
+        const ext = {
+          roundId: this.chartBinding.generation.status.roundId,
+          generation: -1, // for detail page special display,
+          queryableCategories: [], // 本代支持查询的列
+          ignoreGroupCategories: [] // 本代支持查询,且与组合无关的列
+        }
+        this.chartBinding.chartData.accumulates.forEach(acc => this.resolveTableGeneration(ext, acc, columns, rows, mergedColumns, prefix, true))
+      }
+      // completed
+      this.$refs.table?.scrollToRight()
+      return {
+        mergedColumns,
+        columns,
+        rows
+      }
+    }
+  },
+  mounted() {
+    window.tableVue = this
+  },
+  provide() {
+    return {
+      mergeTable: this.mergeTable
+    }
+  },
+  methods: {
+    resolveTableGeneration(ext, data, columnsRef, rowsRef, mergedColumnsRef, prefix = '', fixed = false, overrideQueryName = '') {
+      // resolve core
+      const prop = prefix + ext.generation + '_' + data.category
+      const name = data.displayName
+      const shouldMerge = data.values.length != rowsRef.length || data.values.some(v => v.groupId == 0)
+      if (data.queryCode) {
+        let nextName = data.displayName
+        if (overrideQueryName) nextName = overrideQueryName + '/' + nextName
+        ext.queryableCategories.push({ id: data.queryCode, name: nextName, detailName: data.detailName })
+        if (shouldMerge) ext.ignoreGroupCategories.push(data.queryCode)
+      }
+
+      columnsRef[prop] = { label: name, slot: 'elective-cell', fixed: fixed }
+      if (!shouldMerge) {
+        data.values.forEach(val => {
+          const row = rowsRef.find(row => row.groupId == val.groupId)
+          if (row) {
+            row[prop] = { ...ext, ...val, queryCode: data.queryCode }
+          }
+        })
+      } else {
+        // mark for table rows merge
+        mergedColumnsRef.push(prop)
+
+        // keep value in display row
+        const val = data.values.first()
+        const row = rowsRef.first()
+        row[prop] = { ...ext, ...val, queryCode: data.queryCode }
+      }
+    },
+    mergeTable({ row, column, rowIndex }) {
+      if (this.resolvedTable.mergedColumns.includes(column.property)) {
+        return rowIndex === 0 ? [this.resolvedTable.rows.length, 1] : [0, 0]
+      }
+    },
+    getCellStyles(option) {
+      const styles = {}
+      if (option.color) {
+        const map = {
+          R: config.color.error,
+          r: config.color.error,
+          G: config.color.yellow_up,
+          g: config.color.yellow_up,
+          B: config.color.blue_up,
+          b: config.color.blue_up
+        }
+        styles.color = map[option.color] || option.color
+      }
+      return styles
+    },
+    goDetails(option) {
+      const path = '/elective/generation/detail'
+      const nextData = {
+        year: this.chartBinding.generation.status.year,
+        roundId: this.chartBinding.generation.status.roundId,
+        roundName: this.chartBinding.generation.status.roundName,
+        queryGeneration: option.generation,
+        queryGroupId: option.groupId,
+        queryCode: option.queryCode,
+        queryableCategories: option.queryableCategories,
+        ignoreGroupCategories: option.ignoreGroupCategories,
+        groups: this.chartBinding.generation.roundGroups.map(rg => ({ groupId: rg.groupId, groupName: rg.groupName }))
+      }
+      this.transferTo(path, nextData)
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 218 - 0
src/views/elective/generation/detail.vue

@@ -0,0 +1,218 @@
+<template>
+  <div class="app-container" v-loading="loading">
+    <evaluation-title :title="title" :sub-title="subTitle" nav-back-button></evaluation-title>
+    <el-card>
+      <mx-condition :query-params="queryParams" :require-fields="requireFields" :local-data="localData"
+                    @query="handleQuery"></mx-condition>
+    </el-card>
+    <mx-table :rows="detailTable.rows" :prop-defines="detailTable.columns" border class="mt20 elective-flow-table">
+      <template #group-header="{label, key}">
+        <div class="fx-row jc-cen">
+          <span>{{ label }}</span>
+          <el-popover trigger="hover" :disabled="!!!detailTable.groupHeaders[key].text">
+            <div v-for="(desc,idx) in detailTable.groupHeaders[key].descriptions" :key="idx">
+              <span>{{ desc.description }}</span> <span class="bold">{{ desc.value }}</span></div>
+            <el-tag slot="reference" size="mini" class="round-y ml3">{{ detailTable.groupHeaders[key].text }}</el-tag>
+          </el-popover>
+        </div>
+      </template>
+      <template #group-flow="{value}">
+        <elective-flow-major v-if="value.matchedMajors.length" :icon-classes="['f-primary']"
+                             :matched-majors="value.matchedMajors"></elective-flow-major>
+        <div class="fx-row fx-cen-cen">
+          <span v-if="value.text">{{ value.text }}</span>
+          <span v-else v-html="'&#12288'"></span>
+        </div>
+        <elective-flow-rank-descriptor v-if="value.rankDescriptors"
+                                       :rank-descriptors="value.rankDescriptors"></elective-flow-rank-descriptor>
+      </template>
+      <template #flow-log="{row}">
+        <el-link @click="handleFlowLog(row)" :underline="false">查看</el-link>
+      </template>
+    </mx-table>
+    <pagination :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize"
+                @pagination="loadGenerationDetails"></pagination>
+    <el-dialog title="选科历史记录" v-if="logVisible" :visible.sync="logVisible" :width="logDialogWidth">
+      <elective-generation-flow-log :groups="prevData.groups" :histories="logRow.histories"
+                                    :matched-majors="this.majorsMap[logRow['studentId']]"/>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import config from '@/common/mx-config'
+import transferMixin from '@/components/mx-transfer-mixin'
+import { mapGetters } from 'vuex'
+import MxCondition from '@/components/MxCondition/mx-condition'
+import { getElectiveGenerationDetails, getGenerationOptionalMajorsBatch } from '@/api/webApi/elective/generation'
+import ElectiveGenerationFlowLog from '@/views/elective/generation/components/elective-generation-flow-log'
+import ElectiveFlowMajor from '@/views/elective/generation/components/elective-flow-major'
+import ElectiveFlowRankDescriptor from '@/views/elective/generation/components/elective-flow-rank-descriptor'
+
+export default {
+  components: { ElectiveFlowRankDescriptor, ElectiveFlowMajor, ElectiveGenerationFlowLog, MxCondition },
+  mixins: [transferMixin],
+  name: 'generation-detail',
+  computed: {
+    ...mapGetters(['school']),
+    title() {
+      const y = (this.prevData.year + '').tailingFix('学年')
+      const s = this.school.schoolName
+      const n = this.prevData.roundName
+      return y + s + n
+    },
+    subTitle() {
+      const g = Object.values(config.electiveGenerationOptions).find(opt => opt.value == this.prevData.queryGeneration)
+      const hideGeneration = g == config.electiveGenerationOptions.init || g == config.electiveGenerationOptions.terminate
+      return hideGeneration ? '' : g?.title || ''
+    },
+    localData() {
+      this.queryParams.generation = this.prevData.queryGeneration
+      this.queryParams.generationQueryCode = this.prevData.queryCode
+      this.queryParams.generationGroupId = this.prevData.queryGroupId
+      return {
+        ignoreGroupCategories: this.prevData.ignoreGroupCategories,
+        categories: this.prevData.queryableCategories,
+        groups: this.prevData.groups
+      }
+    },
+    detailTable() {
+      if (!this.majorsMap) return {}
+      if (!this.prevData.queryableCategories.length) return {}
+      if (!this.detailWrapper?.groupDescriptors?.length) return {}
+      // fixed columns
+      const queryCategory = this.prevData.queryableCategories.find(i => i.id == this.queryParams.generationQueryCode)
+      const ignoreGroups = this.prevData.ignoreGroupCategories.includes(this.queryParams.generationQueryCode)
+      const columns = {
+        className: { label: '班级' },
+        studentName: { label: '姓名' }
+      }
+      if (!ignoreGroups) {
+        columns.groupName = { label: queryCategory.detailName || '组合' }
+        columns.datetime = { label: '时间', minWidth: '110px' }
+      }
+      // extension generation columns & custom group header
+      const rows = this.detailWrapper.details // todo: need clone?
+      const groupHeaders = {}
+      const groupKeyPrefix = 'group_'
+      if (!ignoreGroups) {
+        this.prevData.groups.forEach(group => {
+          const groupKey = groupKeyPrefix + group.groupId
+          columns[groupKey] = {
+            label: group.groupName,
+            slotHeader: 'group-header',
+            slot: 'group-flow',
+            minWidth: '180px'
+          }
+          groupHeaders[groupKey] = this.getGroupHeaderDescription(group.groupId)
+          rows.forEach(row => {
+            row[groupKey] = this.getGroupDescription(row, group.groupId, this.majorsMap)
+          })
+        })
+      }
+      columns.actions = { label: '查看明细', slot: 'flow-log' }
+      return {
+        rows,
+        columns,
+        groupHeaders
+      }
+    },
+    logDialogWidth() {
+      const expectedWidth = (this.prevData.groups.length + 2) * 160 // 假定elective-generation-flow-log 单格宽160
+      const finalWidth = Math.min(expectedWidth, window.innerWidth * 0.8)
+      return finalWidth + 'px'
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      requireFields: ['generationQueryCode'],
+      queryParams: {
+        pageNo: 1,
+        pageSize: 20,
+        generation: '',
+        generationQueryCode: '',
+        generationGroupId: ''
+      },
+      // query data
+      total: 0,
+      detailWrapper: null,
+      majorsMap: null,
+      // log
+      logVisible: false,
+      logRow: {}
+    }
+  },
+  methods: {
+    handleQuery() {
+      this.queryParams.pageNo = 1
+      this.loadGenerationDetails()
+    },
+    loadGenerationDetails() {
+      const params = {
+        pageNo: this.queryParams.pageNo,
+        pageSize: this.queryParams.pageSize,
+        roundId: this.prevData.roundId,
+        generation: this.queryParams.generation,
+        groupId: this.queryParams.generationGroupId,
+        queryCode: this.queryParams.generationQueryCode
+      }
+      this.loading = true
+      this.majorsMap = null
+      getElectiveGenerationDetails(params).then(res => {
+        this.total = res['total']
+        this.detailWrapper = res.data
+
+        const studentIds = res.data.details?.map(d => d['studentId']).toString()
+        return getGenerationOptionalMajorsBatch({ studentIds })
+      }).then(res => {
+        this.majorsMap = res.data
+      }).finally(() => this.loading = false)
+    },
+    getGroupHeaderDescription(groupId) {
+      const statistic = this.detailWrapper.groupDescriptors.find(d => d.groupId == groupId)
+      if (!statistic?.descriptors?.length) return {}
+      const descriptors = statistic.descriptors.reverse()
+      return {
+        text: descriptors.map(d => d.value).join('/'),
+        descriptions: descriptors
+      }
+    },
+    getGroupDescription(row, groupId) {
+      const matchedMajors = (this.majorsMap[row['studentId']]
+        ?.majors?.filter(m => m['matchedGroupIds'].some(id => id == groupId)) || [])
+        .groupBy(m => m.collegeName)
+      const current = this.prevData.queryGeneration
+      const options = Object.values(config.electiveGenerationOptions)
+      const histories = row['histories'] || []
+      const filterHistories = []
+      for (let g = current; g > 0; g--) {
+        const opt = options.find(opt => opt.value == g)
+        const groupHistories = histories.filter(h => h.generation == g && h.groupId == groupId)
+        if (groupHistories.length) filterHistories.push(groupHistories)
+        if (g < current && opt.decisionMaking) break // TODO: 仅迭代至最近的决策代(可能需要调整)
+      }
+      if (!filterHistories.length) return { matchedMajors }
+      filterHistories.reverse() // 还原顺序
+      const mergedHistories = filterHistories.reduce((prev, cur) => {
+        prev.push(...cur)
+        return prev
+      }, [])
+      return {
+        matchedMajors,
+        text: mergedHistories.map(h => h.description).join('/'),
+        histories: mergedHistories,
+        rankDescriptors: mergedHistories.last(i => !!i.rankDescriptors?.length)?.rankDescriptors
+      }
+    },
+    handleFlowLog(row) {
+      this.logRow = row
+      this.logVisible = true
+    }
+  }
+}
+</script>
+
+<style>
+@import url('./components/elective-flow-table-style.css');
+</style>

+ 119 - 0
src/views/elective/generation/index.vue

@@ -0,0 +1,119 @@
+<template>
+  <div class="app-container" v-loading="loading">
+    <el-card>
+      <mx-condition :query-params="queryParams" :require-fields="requireFields"
+                    @query="handleQuery" @invalid="handleInvalid"></mx-condition>
+    </el-card>
+    <el-card v-if="electiveStatus" class="mt20">
+      <template #header>
+        <elective-generation-steps v-model="activeStep" :generation="generation"></elective-generation-steps>
+      </template>
+      <elective-generation-master :generation="generation">
+        <template #header-prefix>
+          <el-button circle icon="el-icon-refresh" @click="handleQuery" class="mr30"></el-button>
+        </template>
+      </elective-generation-master>
+    </el-card>
+    <evaluation-empty v-else class="mt20"></evaluation-empty>
+  </div>
+</template>
+
+<script>
+import config from '@/common/mx-config'
+import { getElectiveStatus, getElectiveSummary } from '@/api/webApi/elective/generation'
+import MxCondition from '@/components/MxCondition/mx-condition'
+import ElectiveGenerationSteps from '@/views/elective/generation/components/elective-generation-steps'
+import ElectiveGenerationMaster from '@/views/elective/generation/components/elective-generation-master'
+import MxGroupTranslateMixin from '@/components/Cache/modules/mx-select-translate-mixin'
+
+export default {
+  mixins: [MxGroupTranslateMixin],
+  name: 'generation-index',
+  components: { ElectiveGenerationMaster, ElectiveGenerationSteps, MxCondition },
+  data() {
+    return {
+      // query
+      loading: false,
+      queryParams: {
+        year: '',
+        roundId: ''
+      },
+      requireFields: ['year', 'roundId'],
+      // api data
+      electiveStatus: null,
+      electiveSummary: [],
+      // local data
+      activeStep: '',
+      options: config.electiveGenerationOptions,
+      tableHiddenGenerations: []
+    }
+  },
+  computed: {
+    current() {
+      /// 当前进程代,可能没有值
+      return this.electiveStatus?.currentGeneration
+    },
+    currentOpt() {
+      /// 当前进程代对应的配置项,可能没有值
+      return Object.values(this.options).find(opt => opt.value == this.current)
+    },
+    activeOpt() {
+      /// 当前选中的进程代,可能没有值
+      return this.options[this.activeStep]
+    },
+    active() {
+      return this.activeOpt?.value
+    },
+    roundGroups() {
+      return this.electiveStatus?.roundGroups.map(rg => ({
+        groupId: rg.groupId,
+        groupName: this.translateGroup(rg.groupId),
+        expectedCount: rg.personCount
+      }))
+    },
+    generation() {
+      return {
+        // generation key value
+        options: this.options,
+        current: this.current,
+        currentOpt: this.currentOpt,
+        active: this.active,
+        activeOpt: this.activeOpt,
+        /// root data inject
+        summary: this.electiveSummary,
+        status: this.electiveStatus,
+        roundGroups: this.roundGroups,
+        /// local display setting
+        hiddenGenerations: this.tableHiddenGenerations
+      }
+    }
+  },
+  provide() {
+    return {
+      refreshData: this.handleQuery
+    }
+  },
+  methods: {
+    handleInvalid() {
+      this.activeStep = ''
+      this.electiveStatus = null
+      this.electiveSummary = []
+    },
+    async handleQuery() {
+      this.loading = true
+      try {
+        const resStatus = await getElectiveStatus(this.queryParams)
+        this.electiveStatus = resStatus.data
+        const resSummary = await getElectiveSummary(this.queryParams)
+        this.electiveSummary = resSummary.data
+      } finally {
+        this.loading = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 1 - 0
src/views/permission/components/round-score-query.vue

@@ -105,6 +105,7 @@ export default {
   },
   computed: {
     groupSource() {
+      console.log(this.settingModel.roundGroups)
       return { groups: this.settingModel.roundGroups }
     },
     groupQuery() {

+ 6 - 6
src/views/permission/components/steps/fauclty/faculty-forms.vue

@@ -79,37 +79,37 @@ export default {
           label: '原周课时数',
           slot: 'input',
           slotHeader: 'batch',
-          width: '130px'
+          minWidth: '130px'
         },
         teacherCount: {
           label: '单科老师数量',
           slot: 'input',
           slotHeader: 'batch',
-          width: '130px'
+          minWidth: '130px'
         },
         teacherClassesCount: {
           label: '老师周课时数',
           slot: 'input',
           slotHeader: 'batch',
-          width: '130px'
+          minWidth: '130px'
         },
         classesCount: {
           label: '单科总课时数',
           slot: 'input',
-          width: '130px',
+          minWidth: '130px',
           disabled: true
         },
         levelClassesCount: {
           label: '等级考课时数',
           slot: 'input',
           slotHeader: 'batch',
-          width: '130px'
+          minWidth: '130px'
         },
         qualifiedClassesCount: {
           label: '合格考课时数',
           slot: 'input',
           slotHeader: 'batch',
-          width: '130px'
+          minWidth: '130px'
         }
       }
     },

+ 6 - 6
src/views/permission/components/steps/fauclty/faculty-result.vue

@@ -34,30 +34,30 @@ export default {
         },
         levelClassCount: {
           label: '等级考班级数',
-          width: '130px'
+          minWidth: '130px'
         },
         qualifiedClassCount: {
           label: '合格考班级数',
-          width: '130px'
+          minWidth: '130px'
         },
         requiredClassesCount: {
           label: '所需课时数',
-          width: '130px'
+          minWidth: '130px'
         },
         actualClassesCount: {
           label: '实际课时数',
-          width: '130px',
+          minWidth: '130px',
           disabled: true
         },
         missingClassesCount: {
           label: '所缺课时数',
           slot: 'tag',
-          width: '130px'
+          minWidth: '130px'
         },
         missingTeacherCount: {
           label: '所需老师人数',
           slot: 'tag',
-          width: '130px'
+          minWidth: '130px'
         }
       }
     },

+ 2 - 2
src/views/permission/components/steps/round-setting-group.vue

@@ -187,8 +187,8 @@ export default {
     groupLimitDefines() {
       return {
         groupName: { label: '组合', slot: 'replace', width: '100px' },
-        personCount: { label: '人数', slot: 'input', width: '130px' },
-        classCount: { label: '班级数', slot: 'input', width: '130px' },
+        personCount: { label: '人数', slot: 'input', minWidth: '130px' },
+        classCount: { label: '班级数', slot: 'input', minWidth: '130px' },
         limitPerson: { label: '人数限制', slot: 'switch' },
         rankOut: { label: '排名挤出', slot: 'switch', hidden: !this.hasImportWeight },
         scoreQuery: { label: '成绩查询', slot: 'view', hidden: !this.hasImportWeight },

+ 3 - 3
src/views/questioncenter/LearnHelper.vue

@@ -1382,17 +1382,17 @@ export default {
 .el-dialog__header .el-dialog__title {
   color: #47c6a2;
 }
-.el-radio-button__inner {
+.radio_contianer .el-radio-button__inner {
   border-left: 1px solid #dcdfe6;
 }
 .radio_contianer .el-radio-button .el-radio-button__inner {
   border-radius: 16px;
   margin-bottom: 5px;
 }
-.el-radio-button:first-child:last-child .el-radio-button__inner {
+.radio_contianer .el-radio-button:first-child:last-child .el-radio-button__inner {
   border-radius: 16px;
 }
-.el-radio-button {
+.radio_contianer .el-radio-button {
   margin-right: 8px;
 }
 </style>

+ 1 - 1
src/views/questioncenter/bestpaper.vue

@@ -376,7 +376,7 @@ export default {
   margin-right: 16px;
   flex-shrink: 0;
 }
-.el-radio-button {
+.radio_contianer .el-radio-button {
   margin-right: 8px;
 }
 .search_header {

+ 1 - 1
src/views/questioncenter/generating.vue

@@ -965,7 +965,7 @@ export default {
   bottom: 0;
 }
 
-.el-radio-button {
+.radio_contianer .el-radio-button {
   margin-right: 8px;
 }
 

+ 684 - 646
src/views/questioncenter/smart.vue

@@ -1,646 +1,684 @@
-<template >
-  <div class="smart_container">
-    <el-card class="header">
-      <div class="smart_tabs">
-        <div
-          class="tab"
-          :class="tabActive == 0 ? 'tab_active' : ''"
-          @click="switchTab(0)"
-        >
-          同步在线练习
-        </div>
-        <div
-          class="tab"
-          :class="tabActive == 1 ? 'tab_active' : ''"
-          @click="switchTab(1)"
-        >
-          知识点在线练习
-        </div>
-      </div>
-      <div class="radio_contianer">
-        <div style="margin-bottom: 16px">
-          <span>科目</span>
-          <el-radio-group
-            v-model="form.subjectId"
-            size="mini"
-            @change="toggleSub"
-          >
-            <el-radio-button
-              :label="item.subjectid"
-              v-for="item in subjectList"
-              :key="item.subjectid"
-              >{{ item.subjectname }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-        <div style="margin-bottom: 16px" v-show="tabActive == 0">
-          <span>版本</span>
-          <el-radio-group
-            v-model="form.editionId"
-            size="mini"
-            @change="toggleEdi"
-          >
-            <el-radio-button
-              :label="item.id"
-              v-for="(item,i) in editionList"
-              :key="i"
-              >{{ item.name }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-        <div style="margin-bottom: 16px" v-show="tabActive == 0">
-          <span>学册</span>
-          <el-radio-group
-            v-model="form.gradeId"
-            size="mini"
-            @change="toggleGrade"
-          >
-            <el-radio-button
-              :label="item.gradeid"
-              v-for="item in gradeList"
-              :key="item.gradeid"
-              >{{ item.gradename }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-      </div>
-    </el-card>
-    <el-container>
-      <el-aside width="284px" style="padding-bottom: 104px">
-        <div class="aside_header">
-          <span>CONTACT</span>
-          <span>章节目录</span>
-        </div>
-        <div class="aside_content">
-          <el-tree
-            v-show="tabActive == 0"
-            :data="treeList"
-            :props="defaultProps"
-            node-key="$treeNodeId"
-            @node-click="clickChapterNode"
-            highlight-current
-            ref="treeChapter"
-            :expand-on-click-node="false"
-          ></el-tree>
-          <el-tree
-            v-show="tabActive == 1"
-            :data="knowTreeList"
-            :props="knowDefaultProps"
-            node-key="knowledgeId"
-            @node-click="clickKnowNode"
-            :expand-on-click-node="false"
-            highlight-current
-            ref="tree"
-          ></el-tree>
-        </div>
-      </el-aside>
-      <el-main style="padding: 0; background: #fff">
-        <div class="main_tit">
-          <div class="tit_left" v-show="tabActive == 0">{{ chapterTitle }}</div>
-          <div class="tit_left" v-show="tabActive == 1">{{ konwTitle }}</div>
-          <div class="tit_right">
-            <div>
-              <input type="checkbox" />
-              <span>过滤已做题</span>
-            </div>
-            <div
-              class="btn"
-              style="cursor: pointer"
-              @click="httpDiagnosticRecords(1)"
-            >
-              诊断记录
-            </div>
-          </div>
-        </div>
-        <!-- 主题内容 -->
-        <div class="main_con" v-show="tabActive == 0">
-          <div class="con_item" v-for="item in chapterList" :key="item.id">
-            <img src="@/assets/images/img_bg_tongbulianxi.png" alt="" />
-            <div class="con_info">
-              <div class="tit">
-                {{ item.chapterName }}
-              </div>
-              <div class="count">共{{ item.num }}题</div>
-              <div class="btn" @click="toChapterQue(item)">马上做题</div>
-            </div>
-          </div>
-        </div>
-        <!-- 主题内容 -->
-        <div class="main_con" v-show="tabActive == 1">
-          <div
-            class="con_item"
-            v-for="item in knowList"
-            :key="item.knowledgeId"
-          >
-            <img src="@/assets/images/img_bg_tongbulianxi.png" alt="" />
-            <div class="con_info">
-              <p class="tit">{{ item.knowledgeName }}</p>
-              <p class="count">共{{ item.num }}题</p>
-              <p class="btn" @click="toKnowQue(item)">马上做题</p>
-            </div>
-          </div>
-        </div>
-      </el-main>
-    </el-container>
-    <!-- 诊断的弹窗 -->
-    <el-dialog
-      title="同步在线诊断记录"
-      :visible.sync="dialogVisible"
-      width="50%"
-    >
-      <div class="tables">
-        <el-table :data="tableData.rows" stripe style="width: 100%">
-          <el-table-column type="index" width="50" label="序号">
-          </el-table-column>
-          <el-table-column prop="coursename" label="科目"> </el-table-column>
-          <el-table-column prop="day" label="日期"> </el-table-column>
-          <el-table-column label="用时">
-            <template slot-scope="scope">
-              <span>{{ scope.row.seconds + "秒" }}</span>
-            </template>
-          </el-table-column>
-          <el-table-column label="正确率">
-            <template slot-scope="scope">
-              <span>{{ scope.row.rate + "%" }}</span>
-            </template>
-          </el-table-column>
-          <el-table-column label="操作">
-            <template slot-scope="scope">
-              <span
-                style="color: #47c6a2; cursor: pointer"
-                @click="toDetails(scope.row)"
-              >
-                <i class="el-icon-search" style="margin-right: 4px"></i
-                >操作</span
-              >
-            </template>
-          </el-table-column>
-        </el-table>
-        <div class="block">
-          <el-pagination
-            :page-sizes="[tablePageSize]"
-            layout="prev, pager, next"
-            :page-count="tableData.pageNum"
-            style="backgrond: #47c6a2"
-            @current-change="httpDiagnosticRecords"
-          >
-          </el-pagination>
-          <div class="count">
-            共{{ tableData.pageNum }}页 {{ tableData.total }}条
-          </div>
-        </div>
-      </div>
-    </el-dialog>
-  </div>
-</template>
-<script>
-let flag = 1;
-import {
-  treeList,
-  smartSubjectList,
-  smartGradeList,
-  newEditionList,
-  getQuestionsNumByChapter,
-  getQuestionsNumByKnowledge,
-  knowledgeTree,
-  diagnosticRecords,
-} from "@/api/webApi/webQue.js";
-
-export default {
-  data() {
-    return {
-      tabActive: 0,
-      form: {
-        subjectId: 0,
-        editionId: 0,
-        gradeId: 0,
-        chapterId: 0,
-        knowledgeId: 0,
-      },
-      tablePageSize: 5,
-      chapterTitle: "",
-      konwTitle: "",
-      dialogVisible: false,
-      tableData: [],
-      // 章节树的prop
-      defaultProps: {
-        children: "children",
-        label: "name",
-      },
-      // 知识树的prop
-      knowDefaultProps: {
-        children: "children",
-        label: "name",
-      },
-      subjectList: [],
-      editionList: {},
-      gradeList: [],
-      treeList: [],
-      knowTreeList: [],
-      knowList: [],
-      chapterList: [],
-    };
-  },
-  created() {
-    // 设置默认高亮
-    this.tabActive = this.$route.query.tabActive
-      ? this.$route.query.tabActive
-      : 0;
-    this.getSubjects();
-  },
-  methods: {
-    // 获取所有课程
-    getSubjects() {
-      smartSubjectList().then((res) => {
-        console.log(res);
-        this.form.subjectId = res.rows[0].subjectid;
-        this.subjectList = res.rows;
-        this.getEidtion();
-      });
-    },
-    getEidtion() {
-      newEditionList({ subjectId: this.form.subjectId }).then((res) => {
-        this.form.editionId = res.data[0].id;
-        this.editionList = res.data;
-        this.getGrade();
-      });
-    },
-    httpDiagnosticRecords(pageNum) {
-      diagnosticRecords({
-        pageNum: pageNum,
-        pageSize: this.tablePageSize,
-        type: 1,
-      }).then((res) => {
-        this.dialogVisible = true;
-        this.tableData = res;
-        this.tableData["pageNum"] = Math.ceil(res.total / this.tablePageSize);
-        console.log(res);
-      });
-    },
-    // 获取所有的学册
-    getGrade() {
-      smartGradeList({ ...this.form }).then((res) => {
-        console.log(res);
-        if (res.data.length > 0) {
-          this.form.gradeId = res.data[0].gradeid;
-        }
-        this.gradeList = res.data;
-        if (this.tabActive == 0) {
-          this.getTree();
-        } else {
-          this.getTreeByKnowledge();
-        }
-      });
-    },
-    // 获取章节树
-    getTree() {
-      treeList({ ...this.form }).then((res) => {
-        console.log("gettree", res);
-        this.treeList = res.data;
-        this.$nextTick(() => {
-          if (res.data.length > 0) {
-            this.chapterTitle = res.data[0].name;
-            this.$refs.treeChapter.setCurrentKey(res.data[0].$treeNodeId);
-            this.form.chapterId = res.data[0].id;
-          }
-          this.getQueByChapter();
-        });
-      });
-    },
-    // 获取知识点树
-    getTreeByKnowledge() {
-      knowledgeTree({ pharseId: 3, subjectId: this.form.subjectId }).then(
-        (res) => {
-          this.knowTreeList = res.data;
-          console.log(res);
-          this.$nextTick(() => {
-            this.konwTitle = res.data[0].name;
-            this.form.knowledgeId = res.data[0].id;
-            this.getQueByKnowledge();
-          });
-        }
-      );
-    },
-    toDetails(item) {
-      this.$router.push({
-        path: "/question-center/smartExercise/answerDetailas",
-        query: {
-          batchno: item.batchno,
-          info: encodeURIComponent(JSON.stringify(item)),
-        },
-      });
-    },
-    // 根据章节来获取题目
-    getQueByChapter() {
-      console.log("-------获取题目-------")
-      getQuestionsNumByChapter({
-        chapterId: this.form.chapterId,
-        subjectId: this.form.subjectId,
-        gradeId: this.form.gradeId,
-        editionId: this.form.editionId,
-      }).then((res) => {
-        console.log(res);
-        this.chapterList = res.data;
-      });
-    },
-    // 根据知识点来获取题目
-    getQueByKnowledge() {
-      getQuestionsNumByKnowledge({
-        knowledgeId: this.form.knowledgeId,
-        subjectId: this.form.subjectId,
-      }).then((res) => {
-        this.knowList = res.data;
-        console.log(res);
-      });
-    },
-    toggleSub(e) {
-      this.form.subjectId = e;
-      this.getEidtion();
-      if (this.tabActive == 1) {
-        this.getTreeByKnowledge();
-      }
-    },
-    toggleEdi(e) {
-      this.form.editionId = e;
-      this.getGrade();
-    },
-    toggleGrade(e) {
-      this.form.gradeId = e;
-      this.getTree();
-    },
-    clickChapterNode(item) {
-      console.log(item);
-      this.chapterTitle = item.name;
-      this.form.chapterId = item.id;
-      this.getQueByChapter();
-    },
-    clickKnowNode(item) {
-      console.log(item);
-      this.form.knowledgeId = item.id;
-      this.konwTitle = item.name;
-      this.getQueByKnowledge();
-    },
-    switchTab(index) {
-      this.tabActive = index;
-      if (index == 1) {
-        console.log(111111111111);
-        this.getTreeByKnowledge();
-      } else {
-        console.log(22222222);
-        this.getTree();
-      }
-    },
-    // 做章节题目
-    toChapterQue(item) {
-      console.log(item);
-      this.$router.push({
-        path: "/question-center/smartExercise/practice",
-        query: {
-          chapterId: item.chapterId,
-          title: item.chapterName,
-          subjectId: this.form.subjectId,
-        },
-      });
-    },
-    // 做知识点题目
-    toKnowQue(item) {
-      console.log(item);
-      this.$router.push({
-        path: "/question-center/smartExercise/practice",
-        query: {
-          knowledgeId: item.knowledgeId,
-          title: item.knowledgeName,
-          subjectId: this.form.subjectId,
-        },
-      });
-    },
-  },
-};
-</script>
-<style scoped>
-.smart_container {
-  padding: 20px;
-  background: #f7f7f7;
-}
-.smart_tabs {
-  display: flex;
-  align-items: center;
-  margin-bottom: 12px;
-}
-.smart_tabs .tab {
-  text-align: center;
-  flex: 1;
-  padding: 8px 0 24px 0;
-  border-bottom: 4px solid transparent;
-}
-.smart_tabs .tab_active {
-  color: #47c6a2;
-  border-bottom: 4px solid #47c6a2;
-}
-.radio_contianer span {
-  font-size: 14px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #232323;
-  line-height: 20px;
-  height: 20px;
-  margin-right: 16px;
-}
-.el-radio-button {
-  margin-right: 8px;
-}
-.aside_header {
-  background: white;
-  display: flex;
-  flex-direction: column;
-  justify-content: flex-end;
-  padding: 24px 16px 0 16px;
-  margin-bottom: 16px;
-}
-.el-aside {
-  background: white;
-  padding: 0;
-  margin-bottom: 0;
-  margin-right: 16px;
-}
-.aside_header span {
-  margin-top: 5px;
-  width: 84px;
-  height: 22px;
-  font-size: 16px;
-  font-family: PingFangSC-Medium, PingFang SC;
-  font-weight: 500;
-  color: #343434;
-  line-height: 22px;
-}
-.aside_header span:first-child {
-  width: 173px;
-  height: 27px;
-  background: linear-gradient(180deg, #ffffff 0%, #47c6a2 100%);
-  opacity: 0.5;
-}
-.main_tit {
-  display: flex;
-  justify-content: space-between;
-  padding-top: 52px;
-  padding-bottom: 19px;
-  border-bottom: 1px solid #eee;
-  padding-left: 19px;
-  margin-bottom: 23px;
-}
-.main_tit .tit_right {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-
-.main_tit .tit_right .btn {
-  background: #47c6a2;
-  border-radius: 16px;
-  padding: 6px 19px;
-  font-size: 14px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #ffffff;
-  margin-left: 32px;
-  margin-right: 57px;
-}
-.tit_right input {
-  background: #d8d8d8;
-  margin: 0;
-}
-.tit_right span {
-  margin-left: 8px;
-  font-size: 14px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #47c6a2;
-}
-.main_con {
-  display: flex;
-  flex-wrap: wrap;
-  padding: 0 21px 0 16px;
-}
-.main_con .con_item {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  flex: 0 0 33%;
-  padding-right: 15px;
-  position: relative;
-  margin-bottom: 20px;
-}
-.main_con .con_item img {
-  width: 100%;
-  height: 100%;
-}
-.main_con .con_item .con_info {
-  position: absolute;
-  top: 24px;
-  left: 24px;
-}
-.main_con .con_item .con_info p {
-  margin: 0;
-}
-.main_con .con_item .con_info .tit {
-  height: 22px;
-  font-size: 16px;
-  font-family: PingFangSC-Medium, PingFang SC;
-  font-weight: 500;
-  color: #343434;
-  line-height: 22px;
-}
-.main_con .con_item .con_info .count {
-  height: 16px;
-  font-size: 12px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #717171;
-  line-height: 17px;
-  margin-top: 8px;
-}
-.main_con .con_item .con_info .btn {
-  cursor: pointer;
-  display: inline-block;
-  font-size: 12px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #ffffff;
-  line-height: 17px;
-  padding: 4px 12px;
-  background: #47c6a2;
-  margin-top: 20px;
-  text-align: center;
-}
-.block {
-  text-align: center;
-  margin-top: 20px;
-  position: relative;
-  display: flex;
-  justify-content: center;
-}
-.block > .count {
-  top: 10px;
-  left: 0;
-  position: absolute;
-}
-</style>
-<style >
-.el-radio-button__inner {
-  border-left: 1px solid #dcdfe6;
-}
-.radio_contianer .el-radio-button .el-radio-button__inner {
-  border-radius: 16px;
-}
-.split_page .el-pager > li {
-  border-radius: 50%;
-}
-.header {
-  margin-bottom: 10px;
-}
-</style>
-<style >
-.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
-  color: #47c6a2;
-  background: #ffffff;
-}
-.el-tree-node__content {
-  padding-top: 6px;
-  padding-bottom: 6px;
-  height: 32px;
-  font-size: 14px;
-  font-family: PingFangSC-Medium, PingFang SC;
-  font-weight: 500;
-  color: #343434;
-  line-height: 20px;
-}
-.el-tree-node {
-  padding-left: 16px;
-}
-.el-tree-node__expand-icon.expanded {
-  content: "";
-}
-.el-tree-node__content > .el-tree-node__expand-icon {
-  padding: 6px;
-  /* position: absolute; */
-  right: 16px;
-}
-.el-table .el-table__header-wrapper th,
-.el-table .el-table__fixed-header-wrapper th {
-  background: #47c6a2;
-  color: white;
-}
-.el-dialog__header .el-dialog__title {
-  color: #47c6a2;
-}
-.el-radio-button:first-child:last-child .el-radio-button__inner {
-  border-radius: 16px;
-}
-.el-tree-node__content {
-  white-space: break-space !important;
-}
-.el-tree-node__content {
-  padding-right: 25px;
-}
-</style>
+<template>
+  <div class="smart_container">
+    <el-card class="header">
+      <div class="smart_tabs">
+        <div
+          class="tab"
+          :class="tabActive == 0 ? 'tab_active' : ''"
+          @click="switchTab(0)"
+        >
+          同步在线练习
+        </div>
+        <div
+          class="tab"
+          :class="tabActive == 1 ? 'tab_active' : ''"
+          @click="switchTab(1)"
+        >
+          知识点在线练习
+        </div>
+      </div>
+      <div class="radio_contianer">
+        <div style="margin-bottom: 16px">
+          <span>科目</span>
+          <el-radio-group
+            v-model="form.subjectId"
+            size="mini"
+            @change="toggleSub"
+          >
+            <el-radio-button
+              :label="item.subjectid"
+              v-for="item in subjectList"
+              :key="item.subjectid"
+            >{{ item.subjectname }}
+            </el-radio-button
+            >
+          </el-radio-group>
+        </div>
+        <div style="margin-bottom: 16px" v-show="tabActive == 0">
+          <span>版本</span>
+          <el-radio-group
+            v-model="form.editionId"
+            size="mini"
+            @change="toggleEdi"
+          >
+            <el-radio-button
+              :label="item.id"
+              v-for="(item,i) in editionList"
+              :key="i"
+            >{{ item.name }}
+            </el-radio-button
+            >
+          </el-radio-group>
+        </div>
+        <div style="margin-bottom: 16px" v-show="tabActive == 0">
+          <span>学册</span>
+          <el-radio-group
+            v-model="form.gradeId"
+            size="mini"
+            @change="toggleGrade"
+          >
+            <el-radio-button
+              :label="item.gradeid"
+              v-for="item in gradeList"
+              :key="item.gradeid"
+            >{{ item.gradename }}
+            </el-radio-button
+            >
+          </el-radio-group>
+        </div>
+      </div>
+    </el-card>
+    <el-container>
+      <el-aside width="284px" style="padding-bottom: 104px">
+        <div class="aside_header">
+          <span>CONTACT</span>
+          <span>章节目录</span>
+        </div>
+        <div class="aside_content">
+          <el-tree
+            v-show="tabActive == 0"
+            :data="treeList"
+            :props="defaultProps"
+            node-key="$treeNodeId"
+            @node-click="clickChapterNode"
+            highlight-current
+            ref="treeChapter"
+            :expand-on-click-node="false"
+          ></el-tree>
+          <el-tree
+            v-show="tabActive == 1"
+            :data="knowTreeList"
+            :props="knowDefaultProps"
+            node-key="knowledgeId"
+            @node-click="clickKnowNode"
+            :expand-on-click-node="false"
+            highlight-current
+            ref="tree"
+          ></el-tree>
+        </div>
+      </el-aside>
+      <el-main style="padding: 0; background: #fff">
+        <div class="main_tit">
+          <div class="tit_left" v-show="tabActive == 0">{{ chapterTitle }}</div>
+          <div class="tit_left" v-show="tabActive == 1">{{ konwTitle }}</div>
+          <div class="tit_right">
+            <div>
+              <input type="checkbox"/>
+              <span>过滤已做题</span>
+            </div>
+            <div
+              class="btn"
+              style="cursor: pointer"
+              @click="httpDiagnosticRecords(1)"
+            >
+              诊断记录
+            </div>
+          </div>
+        </div>
+        <!-- 主题内容 -->
+        <div class="main_con" v-show="tabActive == 0">
+          <div class="con_item" v-for="item in chapterList" :key="item.id">
+            <img src="@/assets/images/img_bg_tongbulianxi.png" alt=""/>
+            <div class="con_info">
+              <div class="tit">
+                {{ item.chapterName }}
+              </div>
+              <div class="count">共{{ item.num }}题</div>
+              <div class="btn" @click="toChapterQue(item)">马上做题</div>
+            </div>
+          </div>
+        </div>
+        <!-- 主题内容 -->
+        <div class="main_con" v-show="tabActive == 1">
+          <div
+            class="con_item"
+            v-for="item in knowList"
+            :key="item.knowledgeId"
+          >
+            <img src="@/assets/images/img_bg_tongbulianxi.png" alt=""/>
+            <div class="con_info">
+              <p class="tit">{{ item.knowledgeName }}</p>
+              <p class="count">共{{ item.num }}题</p>
+              <p class="btn" @click="toKnowQue(item)">马上做题</p>
+            </div>
+          </div>
+        </div>
+      </el-main>
+    </el-container>
+    <!-- 诊断的弹窗 -->
+    <el-dialog
+      title="同步在线诊断记录"
+      :visible.sync="dialogVisible"
+      width="50%"
+    >
+      <div class="tables">
+        <el-table :data="tableData.rows" stripe style="width: 100%">
+          <el-table-column type="index" width="50" label="序号">
+          </el-table-column>
+          <el-table-column prop="coursename" label="科目"></el-table-column>
+          <el-table-column prop="day" label="日期"></el-table-column>
+          <el-table-column label="用时">
+            <template slot-scope="scope">
+              <span>{{ scope.row.seconds + '秒' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="正确率">
+            <template slot-scope="scope">
+              <span>{{ scope.row.rate + '%' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作">
+            <template slot-scope="scope">
+              <span
+                style="color: #47c6a2; cursor: pointer"
+                @click="toDetails(scope.row)"
+              >
+                <i class="el-icon-search" style="margin-right: 4px"></i
+                >操作</span
+              >
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="block">
+          <el-pagination
+            :page-sizes="[tablePageSize]"
+            layout="prev, pager, next"
+            :page-count="tableData.pageNum"
+            style="backgrond: #47c6a2"
+            @current-change="httpDiagnosticRecords"
+          >
+          </el-pagination>
+          <div class="count">
+            共{{ tableData.pageNum }}页 {{ tableData.total }}条
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+<script>
+let flag = 1
+import {
+  treeList,
+  smartSubjectList,
+  smartGradeList,
+  newEditionList,
+  getQuestionsNumByChapter,
+  getQuestionsNumByKnowledge,
+  knowledgeTree,
+  diagnosticRecords
+} from '@/api/webApi/webQue.js'
+
+export default {
+  data() {
+    return {
+      tabActive: 0,
+      form: {
+        subjectId: 0,
+        editionId: 0,
+        gradeId: 0,
+        chapterId: 0,
+        knowledgeId: 0
+      },
+      tablePageSize: 5,
+      chapterTitle: '',
+      konwTitle: '',
+      dialogVisible: false,
+      tableData: [],
+      // 章节树的prop
+      defaultProps: {
+        children: 'children',
+        label: 'name'
+      },
+      // 知识树的prop
+      knowDefaultProps: {
+        children: 'children',
+        label: 'name'
+      },
+      subjectList: [],
+      editionList: {},
+      gradeList: [],
+      treeList: [],
+      knowTreeList: [],
+      knowList: [],
+      chapterList: []
+    }
+  },
+  created() {
+    // 设置默认高亮
+    this.tabActive = this.$route.query.tabActive
+      ? this.$route.query.tabActive
+      : 0
+    this.getSubjects()
+  },
+  methods: {
+    // 获取所有课程
+    getSubjects() {
+      smartSubjectList().then((res) => {
+        console.log(res)
+        this.form.subjectId = res.rows[0].subjectid
+        this.subjectList = res.rows
+        this.getEidtion()
+      })
+    },
+    getEidtion() {
+      newEditionList({ subjectId: this.form.subjectId }).then((res) => {
+        this.form.editionId = res.data[0].id
+        this.editionList = res.data
+        this.getGrade()
+      })
+    },
+    httpDiagnosticRecords(pageNum) {
+      diagnosticRecords({
+        pageNum: pageNum,
+        pageSize: this.tablePageSize,
+        type: 1
+      }).then((res) => {
+        this.dialogVisible = true
+        this.tableData = res
+        this.tableData['pageNum'] = Math.ceil(res.total / this.tablePageSize)
+        console.log(res)
+      })
+    },
+    // 获取所有的学册
+    getGrade() {
+      smartGradeList({ ...this.form }).then((res) => {
+        console.log(res)
+        if (res.data.length > 0) {
+          this.form.gradeId = res.data[0].gradeid
+        }
+        this.gradeList = res.data
+        if (this.tabActive == 0) {
+          this.getTree()
+        } else {
+          this.getTreeByKnowledge()
+        }
+      })
+    },
+    // 获取章节树
+    getTree() {
+      treeList({ ...this.form }).then((res) => {
+        console.log('gettree', res)
+        this.treeList = res.data
+        this.$nextTick(() => {
+          if (res.data.length > 0) {
+            this.chapterTitle = res.data[0].name
+            this.$refs.treeChapter.setCurrentKey(res.data[0].$treeNodeId)
+            this.form.chapterId = res.data[0].id
+          }
+          this.getQueByChapter()
+        })
+      })
+    },
+    // 获取知识点树
+    getTreeByKnowledge() {
+      knowledgeTree({ pharseId: 3, subjectId: this.form.subjectId }).then(
+        (res) => {
+          this.knowTreeList = res.data
+          console.log(res)
+          this.$nextTick(() => {
+            this.konwTitle = res.data[0].name
+            this.form.knowledgeId = res.data[0].id
+            this.getQueByKnowledge()
+          })
+        }
+      )
+    },
+    toDetails(item) {
+      this.$router.push({
+        path: '/question-center/smartExercise/answerDetailas',
+        query: {
+          batchno: item.batchno,
+          info: encodeURIComponent(JSON.stringify(item))
+        }
+      })
+    },
+    // 根据章节来获取题目
+    getQueByChapter() {
+      console.log('-------获取题目-------')
+      getQuestionsNumByChapter({
+        chapterId: this.form.chapterId,
+        subjectId: this.form.subjectId,
+        gradeId: this.form.gradeId,
+        editionId: this.form.editionId
+      }).then((res) => {
+        console.log(res)
+        this.chapterList = res.data
+      })
+    },
+    // 根据知识点来获取题目
+    getQueByKnowledge() {
+      getQuestionsNumByKnowledge({
+        knowledgeId: this.form.knowledgeId,
+        subjectId: this.form.subjectId
+      }).then((res) => {
+        this.knowList = res.data
+        console.log(res)
+      })
+    },
+    toggleSub(e) {
+      this.form.subjectId = e
+      this.getEidtion()
+      if (this.tabActive == 1) {
+        this.getTreeByKnowledge()
+      }
+    },
+    toggleEdi(e) {
+      this.form.editionId = e
+      this.getGrade()
+    },
+    toggleGrade(e) {
+      this.form.gradeId = e
+      this.getTree()
+    },
+    clickChapterNode(item) {
+      console.log(item)
+      this.chapterTitle = item.name
+      this.form.chapterId = item.id
+      this.getQueByChapter()
+    },
+    clickKnowNode(item) {
+      console.log(item)
+      this.form.knowledgeId = item.id
+      this.konwTitle = item.name
+      this.getQueByKnowledge()
+    },
+    switchTab(index) {
+      this.tabActive = index
+      if (index == 1) {
+        console.log(111111111111)
+        this.getTreeByKnowledge()
+      } else {
+        console.log(22222222)
+        this.getTree()
+      }
+    },
+    // 做章节题目
+    toChapterQue(item) {
+      console.log(item)
+      this.$router.push({
+        path: '/question-center/smartExercise/practice',
+        query: {
+          chapterId: item.chapterId,
+          title: item.chapterName,
+          subjectId: this.form.subjectId
+        }
+      })
+    },
+    // 做知识点题目
+    toKnowQue(item) {
+      console.log(item)
+      this.$router.push({
+        path: '/question-center/smartExercise/practice',
+        query: {
+          knowledgeId: item.knowledgeId,
+          title: item.knowledgeName,
+          subjectId: this.form.subjectId
+        }
+      })
+    }
+  }
+}
+</script>
+<style scoped>
+.smart_container {
+  padding: 20px;
+  background: #f7f7f7;
+}
+
+.smart_tabs {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.smart_tabs .tab {
+  text-align: center;
+  flex: 1;
+  padding: 8px 0 24px 0;
+  border-bottom: 4px solid transparent;
+}
+
+.smart_tabs .tab_active {
+  color: #47c6a2;
+  border-bottom: 4px solid #47c6a2;
+}
+
+.radio_contianer span {
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #232323;
+  line-height: 20px;
+  height: 20px;
+  margin-right: 16px;
+}
+
+.radio_contianer .el-radio-button {
+  margin-right: 8px;
+}
+
+.aside_header {
+  background: white;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+  padding: 24px 16px 0 16px;
+  margin-bottom: 16px;
+}
+
+.el-aside {
+  background: white;
+  padding: 0;
+  margin-bottom: 0;
+  margin-right: 16px;
+}
+
+.aside_header span {
+  margin-top: 5px;
+  width: 84px;
+  height: 22px;
+  font-size: 16px;
+  font-family: PingFangSC-Medium, PingFang SC;
+  font-weight: 500;
+  color: #343434;
+  line-height: 22px;
+}
+
+.aside_header span:first-child {
+  width: 173px;
+  height: 27px;
+  background: linear-gradient(180deg, #ffffff 0%, #47c6a2 100%);
+  opacity: 0.5;
+}
+
+.main_tit {
+  display: flex;
+  justify-content: space-between;
+  padding-top: 52px;
+  padding-bottom: 19px;
+  border-bottom: 1px solid #eee;
+  padding-left: 19px;
+  margin-bottom: 23px;
+}
+
+.main_tit .tit_right {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.main_tit .tit_right .btn {
+  background: #47c6a2;
+  border-radius: 16px;
+  padding: 6px 19px;
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #ffffff;
+  margin-left: 32px;
+  margin-right: 57px;
+}
+
+.tit_right input {
+  background: #d8d8d8;
+  margin: 0;
+}
+
+.tit_right span {
+  margin-left: 8px;
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #47c6a2;
+}
+
+.main_con {
+  display: flex;
+  flex-wrap: wrap;
+  padding: 0 21px 0 16px;
+}
+
+.main_con .con_item {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  flex: 0 0 33%;
+  padding-right: 15px;
+  position: relative;
+  margin-bottom: 20px;
+}
+
+.main_con .con_item img {
+  width: 100%;
+  height: 100%;
+}
+
+.main_con .con_item .con_info {
+  position: absolute;
+  top: 24px;
+  left: 24px;
+}
+
+.main_con .con_item .con_info p {
+  margin: 0;
+}
+
+.main_con .con_item .con_info .tit {
+  height: 22px;
+  font-size: 16px;
+  font-family: PingFangSC-Medium, PingFang SC;
+  font-weight: 500;
+  color: #343434;
+  line-height: 22px;
+}
+
+.main_con .con_item .con_info .count {
+  height: 16px;
+  font-size: 12px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #717171;
+  line-height: 17px;
+  margin-top: 8px;
+}
+
+.main_con .con_item .con_info .btn {
+  cursor: pointer;
+  display: inline-block;
+  font-size: 12px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #ffffff;
+  line-height: 17px;
+  padding: 4px 12px;
+  background: #47c6a2;
+  margin-top: 20px;
+  text-align: center;
+}
+
+.block {
+  text-align: center;
+  margin-top: 20px;
+  position: relative;
+  display: flex;
+  justify-content: center;
+}
+
+.block > .count {
+  top: 10px;
+  left: 0;
+  position: absolute;
+}
+</style>
+<style>
+.el-radio-button__inner {
+  border-left: 1px solid #dcdfe6;
+}
+
+.radio_contianer .el-radio-button .el-radio-button__inner {
+  border-radius: 16px;
+}
+
+.split_page .el-pager > li {
+  border-radius: 50%;
+}
+
+.header {
+  margin-bottom: 10px;
+}
+</style>
+<style>
+.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
+  color: #47c6a2;
+  background: #ffffff;
+}
+
+.el-tree-node__content {
+  padding-top: 6px;
+  padding-bottom: 6px;
+  height: 32px;
+  font-size: 14px;
+  font-family: PingFangSC-Medium, PingFang SC;
+  font-weight: 500;
+  color: #343434;
+  line-height: 20px;
+}
+
+.el-tree-node {
+  padding-left: 16px;
+}
+
+.el-tree-node__expand-icon.expanded {
+  content: "";
+}
+
+.el-tree-node__content > .el-tree-node__expand-icon {
+  padding: 6px;
+  /* position: absolute; */
+  right: 16px;
+}
+
+.el-table .el-table__header-wrapper th,
+.el-table .el-table__fixed-header-wrapper th {
+  background: #47c6a2;
+  color: white;
+}
+
+.el-dialog__header .el-dialog__title {
+  color: #47c6a2;
+}
+
+.radio_contianer .el-radio-button:first-child:last-child .el-radio-button__inner {
+  border-radius: 16px;
+}
+
+.el-tree-node__content {
+  white-space: break-space !important;
+}
+
+.el-tree-node__content {
+  padding-right: 25px;
+}
+</style>

+ 376 - 376
src/views/videocourse/video_course.vue

@@ -1,376 +1,376 @@
-<template >
-  <div class="video_contianer">
-    <el-card>
-      <div slot="header" class="header">
-        <div class="spans">
-          <span
-            v-for="item in videoType"
-            :key="item.code"
-            :class="typeActive == item.code ? 'active_text' : ''"
-            @click="toggleType(item.code)"
-            >{{ item.label }}</span
-          >
-        </div>
-        <div class="search_btn" style="float: right; overflow: auto">
-          <input
-            v-model="form.sectionName"
-            @keyup.enter="searchVideo"
-            placeholder="请输入搜索内容"
-          />
-          <img src="@/assets/images/icon_search.png" @click="searchVideo" />
-        </div>
-      </div>
-      <div class="radio_contianer">
-        <div style="margin-bottom: 16px">
-          <span>科目</span>
-          <el-radio-group v-model="form.course" @change="getGrade" size="mini">
-            <el-radio-button
-              :label="item.code"
-              v-for="item in subjects"
-              :key="item.code"
-              >{{ item.label }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-        <div style="margin-bottom: 16px">
-          <span>年级</span>
-          <el-radio-group v-model="form.grade" @change="getVersion" size="mini">
-            <el-radio-button
-              :label="item.code"
-              v-for="item in grade"
-              :key="item.code"
-              >{{ item.label }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-        <div style="margin-bottom: 16px" v-if="version.length > 0">
-          <span>版本</span>
-          <el-radio-group
-            v-model="form.version"
-            size="mini"
-            @change="toggleCondition"
-          >
-            <el-radio-button
-              :label="item.code"
-              v-for="item in version"
-              :key="item.code"
-              >{{ item.label }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-        <div style="margin-bottom: 16px" v-if="packList.length > 0">
-          <span>分类</span>
-          <el-radio-group
-            v-model="form.pack"
-            size="mini"
-            @change="togglePack($event)"
-          >
-            <el-radio-button
-              :label="item.value"
-              v-for="item in packList"
-              :key="item.code"
-              >{{ item.label }}</el-radio-button
-            >
-          </el-radio-group>
-        </div>
-      </div>
-    </el-card>
-    <!-- 视频主体 -->
-    <el-card class="video_content" v-if="videoList.length > 0">
-      <el-row :span="24">
-        <el-col
-          :span="6"
-          class="video_item"
-          v-for="item in videoList"
-          :key="item.id"
-        >
-          <img
-            :src="item.img"
-            alt=""
-            @click="
-              toVideoDetail(
-                item.pack_id,
-                item.chapter_id,
-                item.id,
-                item.section_aliId,
-                item.aliIdType
-              )
-            "
-          />
-          <p>{{ item.section_name }}</p>
-        </el-col>
-      </el-row>
-      <!-- 分页 -->
-      <div class="split_page">
-        <el-pagination
-          background
-          layout="prev, pager, next"
-          :total="total"
-          :page-size="this.form.pageSize"
-          :current-page="this.form.pageNum"
-          @next-click="next"
-          @prev-click="prev"
-          @current-change="togglePage"
-        >
-        </el-pagination>
-      </div>
-    </el-card>
-    <evaluation-empty v-if="videoList.length == 0" />
-  </div>
-</template>
-<script>
-import {
-  videoSubjects,
-  videoType,
-  videoGrades,
-  videoVersions,
-  packList,
-  videoList,
-  videoInfo,
-} from "@/api/webApi/webVideo";
-import FormSearch from "@/components/formSearch";
-export default {
-  components: { FormSearch },
-  data() {
-    return {
-      input: "",
-      videoList: [],
-      grade: [],
-      typeActive: 0,
-      videoType: [],
-      input: "",
-      form: {
-        course: "", // 科目
-        subject: "", // 大类
-        grade: "", // 年级
-        version: "", // 版本
-        pageNum: 1,
-        pageSize: 16,
-        pack: 0,
-        sectionName: "",
-      },
-      subjects: [], // 课程
-      grade: [], // 年级
-      version: [], //  版本
-      packList: [], // 分类
-      total: 0,
-      packNewList: [],
-      videoList: [], // 视频列表
-    };
-  },
-  methods: {
-    tohandle(code) {
-      console.log(code);
-    },
-    toVideoDetail(id, chapter_id, childrenId, section_aliId, aliIdType) {
-      this.$router.push({
-        path: "/video_course/detail",
-        query: {
-          packId: id,
-          chapter_id: chapter_id,
-          childrenId: childrenId,
-          section_aliId,
-          aliIdType: aliIdType
-        },
-      });
-    },
-    searchVideo() {
-      this.getVideoList();
-    },
-    // 获取视频课程大类
-    getVideoType() {
-      videoType().then((res) => {
-        this.videoType = res.rows;
-        if (res.rows.length > 0) {
-          this.toggleType(res.rows[0].code);
-        }
-      });
-    },
-    //  切换大类获取科目
-    toggleType(params) {
-      this.typeActive = params;
-      this.form.subject = params;
-      let { subject } = this.form;
-      // 获取科目
-      videoSubjects({ subject }).then((res) => {
-        this.subjects = res.rows;
-        if (res.rows.length > 0) {
-          this.form.course = res.rows[0].code;
-        }
-        // 获取年级
-        this.getGrade();
-      });
-    },
-    // 获取年级
-    getGrade() {
-      let { subject, course } = this.form;
-      videoGrades({ subject, course }).then((res) => {
-        console.log(JSON.stringify(res.rows));
-        this.grade = res.rows;
-        if (res.rows.length > 0) {
-          this.form.grade = res.rows[0].code;
-        }
-        this.getVersion();
-      });
-    },
-    // 获取版本
-    getVersion() {
-      let { subject, course, grade } = this.form;
-      videoVersions({ subject, course, grade }).then((res) => {
-        let resArr = res.rows.filter((item) => {
-          return item != null;
-        });
-        if (resArr.length > 0) {
-          this.version = resArr;
-          this.form.version = this.version[0].code;
-          this.getPack();
-        } else {
-          this.version = []
-          this.form.version = ''
-          this.videoList = []
-        }
-      });
-    },
-    // 获取包分类
-    getPack() {
-      packList(this.form).then((res) => {
-        if (res.rows.length > 0) {
-          this.form.pack = res.rows[0].value;
-          this.packList = res.rows;
-          this.getVideoList();
-        } else {
-          this.packList = []
-          this.form.pack = ''
-          this.videoList = []
-        }
-      });
-    },
-    // 获取视频列表
-    getVideoList() {
-      videoList(this.form).then((res) => {
-        console.log(res);
-        this.total = res.total;
-        this.videoList = res.rows;
-      });
-    },
-    togglePack(pack) {
-      this.form.pack = pack;
-      this.getVideoList();
-    },
-    toggleCondition(code) {
-      this.form.version = code;
-      this.getPack();
-    },
-    next() {
-      this.form.pageNum++, this.getVideoList();
-    },
-    prev() {
-      this.form.pageNum--, this.getVideoList();
-    },
-    togglePage(e) {
-      this.form.pageNum = e;
-      this.getVideoList();
-    },
-  },
-  created() {
-    this.getVideoType();
-  },
-};
-</script>
-<style scoped>
-.el-card {
-  margin-bottom: 32px;
-}
-.header {
-  padding-bottom: 17px;
-}
-.video_contianer {
-  padding: 20px;
-}
-.spans {
-  display: inline-block;
-}
-.spans > span {
-  padding: 6px 19px;
-  border-radius: 16px;
-  font-size: 14px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  margin-right: 8px;
-  border: 1px solid #eee;
-}
-.active_text {
-  background: #47c6a2;
-  color: white;
-}
-.search_btn {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-}
-.search_btn input {
-  border: 0;
-  height: 100%;
-}
-.search_btn input:focus {
-  outline: 0;
-}
-.search_btn img {
-  cursor: pointer;
-}
-.radio_contianer {
-  margin-top: 24px;
-}
-.radio_contianer span {
-  font-size: 14px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #232323;
-  line-height: 20px;
-  height: 20px;
-  margin-right: 16px;
-}
-.radio_contianer .el-radio-button {
-  margin-right: 8px;
-}
-.video_content p {
-  margin: 0;
-  padding: 0;
-  text-align: left;
-  height: 27px;
-  font-size: 14px;
-  font-family: PingFangSC-Regular, PingFang SC;
-  font-weight: 400;
-  color: #343434;
-  line-height: 20px;
-  margin-top: 11px;
-}
-.video_item > img {
-  cursor: pointer;
-}
-.video_content .el-col {
-  margin-bottom: 42px;
-}
-.video_item > img {
-  width: 100%;
-  padding-right: 22px;
-}
-.split_page {
-  display: flex;
-  justify-content: center;
-}
-</style>
-<style >
-.el-radio-button__inner {
-  border-left: 1px solid #dcdfe6;
-}
-.el-radio-button:first-child:last-child .el-radio-button__inner {
-  border-radius: 16px;
-}
-.radio_contianer .el-radio-button .el-radio-button__inner {
-  border-radius: 16px;
-}
-.split_page .el-pager > li {
-  border-radius: 50%;
-}
-</style>
+<template >
+  <div class="video_contianer">
+    <el-card>
+      <div slot="header" class="header">
+        <div class="spans">
+          <span
+            v-for="item in videoType"
+            :key="item.code"
+            :class="typeActive == item.code ? 'active_text' : ''"
+            @click="toggleType(item.code)"
+            >{{ item.label }}</span
+          >
+        </div>
+        <div class="search_btn" style="float: right; overflow: auto">
+          <input
+            v-model="form.sectionName"
+            @keyup.enter="searchVideo"
+            placeholder="请输入搜索内容"
+          />
+          <img src="@/assets/images/icon_search.png" @click="searchVideo" />
+        </div>
+      </div>
+      <div class="radio_contianer">
+        <div style="margin-bottom: 16px">
+          <span>科目</span>
+          <el-radio-group v-model="form.course" @change="getGrade" size="mini">
+            <el-radio-button
+              :label="item.code"
+              v-for="item in subjects"
+              :key="item.code"
+              >{{ item.label }}</el-radio-button
+            >
+          </el-radio-group>
+        </div>
+        <div style="margin-bottom: 16px">
+          <span>年级</span>
+          <el-radio-group v-model="form.grade" @change="getVersion" size="mini">
+            <el-radio-button
+              :label="item.code"
+              v-for="item in grade"
+              :key="item.code"
+              >{{ item.label }}</el-radio-button
+            >
+          </el-radio-group>
+        </div>
+        <div style="margin-bottom: 16px" v-if="version.length > 0">
+          <span>版本</span>
+          <el-radio-group
+            v-model="form.version"
+            size="mini"
+            @change="toggleCondition"
+          >
+            <el-radio-button
+              :label="item.code"
+              v-for="item in version"
+              :key="item.code"
+              >{{ item.label }}</el-radio-button
+            >
+          </el-radio-group>
+        </div>
+        <div style="margin-bottom: 16px" v-if="packList.length > 0">
+          <span>分类</span>
+          <el-radio-group
+            v-model="form.pack"
+            size="mini"
+            @change="togglePack($event)"
+          >
+            <el-radio-button
+              :label="item.value"
+              v-for="item in packList"
+              :key="item.code"
+              >{{ item.label }}</el-radio-button
+            >
+          </el-radio-group>
+        </div>
+      </div>
+    </el-card>
+    <!-- 视频主体 -->
+    <el-card class="video_content" v-if="videoList.length > 0">
+      <el-row :span="24">
+        <el-col
+          :span="6"
+          class="video_item"
+          v-for="item in videoList"
+          :key="item.id"
+        >
+          <img
+            :src="item.img"
+            alt=""
+            @click="
+              toVideoDetail(
+                item.pack_id,
+                item.chapter_id,
+                item.id,
+                item.section_aliId,
+                item.aliIdType
+              )
+            "
+          />
+          <p>{{ item.section_name }}</p>
+        </el-col>
+      </el-row>
+      <!-- 分页 -->
+      <div class="split_page">
+        <el-pagination
+          background
+          layout="prev, pager, next"
+          :total="total"
+          :page-size="this.form.pageSize"
+          :current-page="this.form.pageNum"
+          @next-click="next"
+          @prev-click="prev"
+          @current-change="togglePage"
+        >
+        </el-pagination>
+      </div>
+    </el-card>
+    <evaluation-empty v-if="videoList.length == 0" />
+  </div>
+</template>
+<script>
+import {
+  videoSubjects,
+  videoType,
+  videoGrades,
+  videoVersions,
+  packList,
+  videoList,
+  videoInfo,
+} from "@/api/webApi/webVideo";
+import FormSearch from "@/components/formSearch";
+export default {
+  components: { FormSearch },
+  data() {
+    return {
+      input: "",
+      videoList: [],
+      grade: [],
+      typeActive: 0,
+      videoType: [],
+      input: "",
+      form: {
+        course: "", // 科目
+        subject: "", // 大类
+        grade: "", // 年级
+        version: "", // 版本
+        pageNum: 1,
+        pageSize: 16,
+        pack: 0,
+        sectionName: "",
+      },
+      subjects: [], // 课程
+      grade: [], // 年级
+      version: [], //  版本
+      packList: [], // 分类
+      total: 0,
+      packNewList: [],
+      videoList: [], // 视频列表
+    };
+  },
+  methods: {
+    tohandle(code) {
+      console.log(code);
+    },
+    toVideoDetail(id, chapter_id, childrenId, section_aliId, aliIdType) {
+      this.$router.push({
+        path: "/video_course/detail",
+        query: {
+          packId: id,
+          chapter_id: chapter_id,
+          childrenId: childrenId,
+          section_aliId,
+          aliIdType: aliIdType
+        },
+      });
+    },
+    searchVideo() {
+      this.getVideoList();
+    },
+    // 获取视频课程大类
+    getVideoType() {
+      videoType().then((res) => {
+        this.videoType = res.rows;
+        if (res.rows.length > 0) {
+          this.toggleType(res.rows[0].code);
+        }
+      });
+    },
+    //  切换大类获取科目
+    toggleType(params) {
+      this.typeActive = params;
+      this.form.subject = params;
+      let { subject } = this.form;
+      // 获取科目
+      videoSubjects({ subject }).then((res) => {
+        this.subjects = res.rows;
+        if (res.rows.length > 0) {
+          this.form.course = res.rows[0].code;
+        }
+        // 获取年级
+        this.getGrade();
+      });
+    },
+    // 获取年级
+    getGrade() {
+      let { subject, course } = this.form;
+      videoGrades({ subject, course }).then((res) => {
+        console.log(JSON.stringify(res.rows));
+        this.grade = res.rows;
+        if (res.rows.length > 0) {
+          this.form.grade = res.rows[0].code;
+        }
+        this.getVersion();
+      });
+    },
+    // 获取版本
+    getVersion() {
+      let { subject, course, grade } = this.form;
+      videoVersions({ subject, course, grade }).then((res) => {
+        let resArr = res.rows.filter((item) => {
+          return item != null;
+        });
+        if (resArr.length > 0) {
+          this.version = resArr;
+          this.form.version = this.version[0].code;
+          this.getPack();
+        } else {
+          this.version = []
+          this.form.version = ''
+          this.videoList = []
+        }
+      });
+    },
+    // 获取包分类
+    getPack() {
+      packList(this.form).then((res) => {
+        if (res.rows.length > 0) {
+          this.form.pack = res.rows[0].value;
+          this.packList = res.rows;
+          this.getVideoList();
+        } else {
+          this.packList = []
+          this.form.pack = ''
+          this.videoList = []
+        }
+      });
+    },
+    // 获取视频列表
+    getVideoList() {
+      videoList(this.form).then((res) => {
+        console.log(res);
+        this.total = res.total;
+        this.videoList = res.rows;
+      });
+    },
+    togglePack(pack) {
+      this.form.pack = pack;
+      this.getVideoList();
+    },
+    toggleCondition(code) {
+      this.form.version = code;
+      this.getPack();
+    },
+    next() {
+      this.form.pageNum++, this.getVideoList();
+    },
+    prev() {
+      this.form.pageNum--, this.getVideoList();
+    },
+    togglePage(e) {
+      this.form.pageNum = e;
+      this.getVideoList();
+    },
+  },
+  created() {
+    this.getVideoType();
+  },
+};
+</script>
+<style scoped>
+.el-card {
+  margin-bottom: 32px;
+}
+.header {
+  padding-bottom: 17px;
+}
+.video_contianer {
+  padding: 20px;
+}
+.spans {
+  display: inline-block;
+}
+.spans > span {
+  padding: 6px 19px;
+  border-radius: 16px;
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  margin-right: 8px;
+  border: 1px solid #eee;
+}
+.active_text {
+  background: #47c6a2;
+  color: white;
+}
+.search_btn {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.search_btn input {
+  border: 0;
+  height: 100%;
+}
+.search_btn input:focus {
+  outline: 0;
+}
+.search_btn img {
+  cursor: pointer;
+}
+.radio_contianer {
+  margin-top: 24px;
+}
+.radio_contianer span {
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #232323;
+  line-height: 20px;
+  height: 20px;
+  margin-right: 16px;
+}
+.radio_contianer .el-radio-button {
+  margin-right: 8px;
+}
+.video_content p {
+  margin: 0;
+  padding: 0;
+  text-align: left;
+  height: 27px;
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #343434;
+  line-height: 20px;
+  margin-top: 11px;
+}
+.video_item > img {
+  cursor: pointer;
+}
+.video_content .el-col {
+  margin-bottom: 42px;
+}
+.video_item > img {
+  width: 100%;
+  padding-right: 22px;
+}
+.split_page {
+  display: flex;
+  justify-content: center;
+}
+</style>
+<style >
+.radio_contianer .el-radio-button__inner {
+  border-left: 1px solid #dcdfe6;
+}
+.radio_contianer .el-radio-button:first-child:last-child .el-radio-button__inner {
+  border-radius: 16px;
+}
+.radio_contianer .el-radio-button .el-radio-button__inner {
+  border-radius: 16px;
+}
+.split_page .el-pager > li {
+  border-radius: 50%;
+}
+</style>

+ 29 - 28
vue.config.js

@@ -18,7 +18,7 @@ module.exports = {
   // 默认情况下,Vue CLI 会假设你的应用是被部署在一个域名的根路径上
   // 例如 https://www.ruoyi.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.ruoyi.vip/admin/,则设置 baseUrl 为 /admin/。
   // publicPath: process.env.NODE_ENV === "production" ? "/front" : "/front",
-  publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
+  publicPath: process.env.NODE_ENV === 'production' ? '/' : '/',
   // 在npm run build 或 yarn build 时 ,生成文件的目录名称(要和baseUrl的生产环境路径一致)(默认dist)
   outputDir: 'dist',
   // 用于放置生成的静态资源 (js、css、img、fonts) 的;(项目打包之后,静态资源会放在这个文件夹下)
@@ -42,7 +42,8 @@ module.exports = {
         }
       }
     },
-    disableHostCheck: true
+    disableHostCheck: true,
+    before: require('./mock/mock-server.js')
   },
   configureWebpack: {
     name: name,
@@ -80,39 +81,39 @@ module.exports = {
             .plugin('ScriptExtHtmlWebpackPlugin')
             .after('html')
             .use('script-ext-html-webpack-plugin', [{
-            // `runtime` must same as runtimeChunk name. default is `runtime`
+              // `runtime` must same as runtimeChunk name. default is `runtime`
               inline: /runtime\..*\.js$/
             }])
             .end()
           config
             .optimization.splitChunks({
-              chunks: 'all',
-              cacheGroups: {
-                libs: {
-                  name: 'chunk-libs',
-                  test: /[\\/]node_modules[\\/]/,
-                  priority: 10,
-                  chunks: 'initial' // only package third parties that are initially dependent
-                },
-                elementUI: {
-                  name: 'chunk-elementUI', // split elementUI into a single package
-                  priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
-                  test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
-                },
-                commons: {
-                  name: 'chunk-commons',
-                  test: resolve('src/components'), // can customize your rules
-                  minChunks: 3, //  minimum common number
-                  priority: 5,
-                  reuseExistingChunk: true
-                }
+            chunks: 'all',
+            cacheGroups: {
+              libs: {
+                name: 'chunk-libs',
+                test: /[\\/]node_modules[\\/]/,
+                priority: 10,
+                chunks: 'initial' // only package third parties that are initially dependent
+              },
+              elementUI: {
+                name: 'chunk-elementUI', // split elementUI into a single package
+                priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
+                test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
+              },
+              commons: {
+                name: 'chunk-commons',
+                test: resolve('src/components'), // can customize your rules
+                minChunks: 3, //  minimum common number
+                priority: 5,
+                reuseExistingChunk: true
               }
-            })
+            }
+          })
           config.optimization.runtimeChunk('single'),
-          {
-             from: path.resolve(__dirname, './public/robots.txt'), //防爬虫文件
-             to: './', //到根目录下
-          }
+            {
+              from: path.resolve(__dirname, './public/robots.txt'), //防爬虫文件
+              to: './' //到根目录下
+            }
         }
       )
   }