Browse Source

安装插件-答题考试系统1.5.9-超航网络

lizhen_gitee 8 months ago
parent
commit
7059b2a527
100 changed files with 22680 additions and 0 deletions
  1. 0 0
      addons/exam/.addonrc
  2. 456 0
      addons/exam/Exam.php
  3. 30 0
      addons/exam/README.md
  4. 4 0
      addons/exam/config.php
  5. 88 0
      addons/exam/controller/Base.php
  6. 128 0
      addons/exam/controller/Cate.php
  7. 152 0
      addons/exam/controller/Common.php
  8. 74 0
      addons/exam/controller/Correction.php
  9. 89 0
      addons/exam/controller/Grade.php
  10. 15 0
      addons/exam/controller/Index.php
  11. 44 0
      addons/exam/controller/Notice.php
  12. 157 0
      addons/exam/controller/Paper.php
  13. 240 0
      addons/exam/controller/Question.php
  14. 192 0
      addons/exam/controller/Room.php
  15. 49 0
      addons/exam/controller/RoomGrade.php
  16. 48 0
      addons/exam/controller/RoomSignup.php
  17. 254 0
      addons/exam/controller/User.php
  18. 78 0
      addons/exam/enum/BaseEnum.php
  19. 13 0
      addons/exam/enum/CateKind.php
  20. 11 0
      addons/exam/enum/CommonStatus.php
  21. 11 0
      addons/exam/enum/ExamMode.php
  22. 11 0
      addons/exam/enum/PaperMode.php
  23. 15 0
      addons/exam/enum/RoomMode.php
  24. 13 0
      addons/exam/enum/RoomSignupStatus.php
  25. 11 0
      addons/exam/enum/UserStatus.php
  26. 67 0
      addons/exam/enum/UserType.php
  27. 294 0
      addons/exam/helper.php
  28. 10 0
      addons/exam/info.ini
  29. 507 0
      addons/exam/install.sql
  30. 48 0
      addons/exam/library/CacheService.php
  31. 453 0
      addons/exam/library/ExamService.php
  32. 256 0
      addons/exam/library/FrontService.php
  33. 24 0
      addons/exam/library/ValidateService.php
  34. 141 0
      addons/exam/library/WechatService.php
  35. 65 0
      addons/exam/model/BaseModel.php
  36. 28 0
      addons/exam/model/CorrectionQuestionModel.php
  37. 41 0
      addons/exam/model/GradeModel.php
  38. 11 0
      addons/exam/model/NoticeModel.php
  39. 38 0
      addons/exam/model/PaperModel.php
  40. 12 0
      addons/exam/model/QuestionCollectModel.php
  41. 280 0
      addons/exam/model/QuestionModel.php
  42. 53 0
      addons/exam/model/RoomGradeModel.php
  43. 17 0
      addons/exam/model/RoomModel.php
  44. 12 0
      addons/exam/model/RoomSignupModel.php
  45. 45 0
      addons/exam/model/UserInfoModel.php
  46. 113 0
      addons/exam/model/UserModel.php
  47. 27 0
      addons/exam/testdata.sql
  48. 115 0
      addons/exam/traits/ModelExtend.php
  49. 707 0
      addons/exam/tuniao-bak/tn-calendar/tn-calendar.vue
  50. 645 0
      addons/exam/tuniao-bak/tn-image-upload/tn-image-upload.vue
  51. 401 0
      addons/exam/tuniao-bak/tn-number-box/tn-number-box.vue
  52. 182 0
      addons/exam/tuniao-bak/tn-number-keyboard/tn-number-keyboard.vue
  53. 334 0
      addons/exam/tuniao-bak/tn-rate/tn-rate.vue
  54. 71 0
      addons/exam/tuniao-bak/tn-time-line-item/tn-time-line-item.vue
  55. 71 0
      addons/exam/tuniao-bak/tn-time-line-item/tn-time-line-item.vue_bk
  56. 39 0
      addons/exam/tuniao-bak/tn-time-line/tn-time-line.vue
  57. 39 0
      addons/exam/tuniao-bak/tn-time-line/tn-time-line.vue_bk
  58. 324 0
      addons/exam/tuniao-bak/tn-verification-code-input/tn-verification-code-input.vue
  59. 149 0
      addons/exam/tuniao-bak/tn-verification-code/tn-verification-code.vue
  60. 2 0
      addons/exam/uniapp/.gitignore
  61. 244 0
      addons/exam/uniapp/App.vue
  62. 184 0
      addons/exam/uniapp/colorui/animation.css
  63. 65 0
      addons/exam/uniapp/colorui/components/cu-custom.vue
  64. 36 0
      addons/exam/uniapp/colorui/icon.css
  65. 3936 0
      addons/exam/uniapp/colorui/main.css
  66. 48 0
      addons/exam/uniapp/common/api/common.js
  67. 31 0
      addons/exam/uniapp/common/api/correction.js
  68. 23 0
      addons/exam/uniapp/common/api/notice.js
  69. 79 0
      addons/exam/uniapp/common/api/user.js
  70. 60 0
      addons/exam/uniapp/common/js/ad.js
  71. 14 0
      addons/exam/uniapp/common/js/lodash.min.js
  72. 27 0
      addons/exam/uniapp/common/js/page.js
  73. 405 0
      addons/exam/uniapp/common/js/picker.city.js
  74. 308 0
      addons/exam/uniapp/common/js/utils.js
  75. 62 0
      addons/exam/uniapp/common/mixins/share.js
  76. 794 0
      addons/exam/uniapp/components/HM-filterDropdown/HM-filterDropdown.vue
  77. 70 0
      addons/exam/uniapp/components/headline/headline.vue
  78. 81 0
      addons/exam/uniapp/components/kz-ad/kz-ad.vue
  79. 100 0
      addons/exam/uniapp/components/kz-follow-mp/kz-follow-mp.vue
  80. 193 0
      addons/exam/uniapp/components/kz-grade-result/kz-grade-result.vue
  81. 729 0
      addons/exam/uniapp/components/kz-page-index-color/kz-page-index-color.vue
  82. 400 0
      addons/exam/uniapp/components/kz-page-index-simple/kz-page-index-simple.vue
  83. 867 0
      addons/exam/uniapp/components/kz-page-my-color/kz-page-my-color.vue
  84. 218 0
      addons/exam/uniapp/components/kz-page-my-simple/kz-page-my-simple.vue
  85. 2177 0
      addons/exam/uniapp/components/kz-question/kz-question.vue
  86. 104 0
      addons/exam/uniapp/components/kz-skeleton/kz-skeleton.vue
  87. 152 0
      addons/exam/uniapp/components/kz-wx-privacy-check/kz-wx-privacy-check.vue
  88. 344 0
      addons/exam/uniapp/components/login/login.vue
  89. 453 0
      addons/exam/uniapp/components/monster/monster.vue
  90. 105 0
      addons/exam/uniapp/components/struggler-uniapp-add-tip/struggler-uniapp-add-tip.vue
  91. 325 0
      addons/exam/uniapp/components/tabbar/tabbar.vue
  92. 69 0
      addons/exam/uniapp/components/topbar/topbar.vue
  93. 519 0
      addons/exam/uniapp/components/tui-button/tui-button.vue
  94. 211 0
      addons/exam/uniapp/components/tui-card/tui-card.vue
  95. 567 0
      addons/exam/uniapp/components/tui-cascade-selection/tui-cascade-selection.vue
  96. 264 0
      addons/exam/uniapp/components/tui-circular-progress/tui-circular-progress.vue
  97. 335 0
      addons/exam/uniapp/components/tui-countdown/tui-countdown.vue
  98. 103 0
      addons/exam/uniapp/components/tui-divider/tui-divider.vue
  99. 55 0
      addons/exam/uniapp/components/tui-icon/tui-icon.vue
  100. 119 0
      addons/exam/uniapp/components/tui-loading/tui-loading.vue

File diff suppressed because it is too large
+ 0 - 0
addons/exam/.addonrc


+ 456 - 0
addons/exam/Exam.php

@@ -0,0 +1,456 @@
+<?php
+
+namespace addons\exam;
+
+use app\common\library\Menu;
+use think\Addons;
+
+/**
+ * 插件
+ */
+class Exam extends Addons
+{
+    protected $menu
+        = [
+            [
+                "name"    => "exam",
+                "title"   => "考试系统",
+                "icon"    => "fa fa-columns",
+                "sublist" => [
+                    [
+                        "name"    => "exam/config_info",
+                        "title"   => "参数配置",
+                        "icon"    => "fa fa-cogs",
+                        "sublist" => [
+                            [
+                                "name"  => "exam/config_info/index",
+                                "title" => "查看"
+                            ],
+                            [
+                                "name"  => "exam/config_info/add",
+                                "title" => "添加"
+                            ],
+                            [
+                                "name"  => "exam/config_info/edit",
+                                "title" => "编辑"
+                            ],
+                            [
+                                "name"  => "exam/config_info/del",
+                                "title" => "删除"
+                            ],
+                            [
+                                "name"  => "exam/config_info/multi",
+                                "title" => "批量更新"
+                            ]
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/grade",
+                        "title"   => "考卷成绩",
+                        "icon"    => "fa fa-list",
+                        "sublist" => [
+                            [
+                                "name"  => "exam/grade/index",
+                                "title" => "查看"
+                            ],
+                            [
+                                "name"  => "exam/grade/add",
+                                "title" => "添加"
+                            ],
+                            [
+                                "name"  => "exam/grade/edit",
+                                "title" => "编辑"
+                            ],
+                            [
+                                "name"  => "exam/grade/del",
+                                "title" => "删除"
+                            ],
+                            [
+                                "name"  => "exam/grade/multi",
+                                "title" => "批量更新"
+                            ],
+                            [
+                                "name"  => "exam/grade/detail",
+                                "title" => "详情"
+                            ],
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/paper",
+                        "title"   => "试卷管理",
+                        "icon"    => "fa fa-newspaper-o",
+                        "sublist" => [
+                            [
+                                "name"  => "exam/paper/index",
+                                "title" => "查看"
+                            ],
+                            [
+                                "name"  => "exam/paper/add",
+                                "title" => "添加"
+                            ],
+                            [
+                                "name"  => "exam/paper/edit",
+                                "title" => "编辑"
+                            ],
+                            [
+                                "name"  => "exam/paper/del",
+                                "title" => "删除"
+                            ],
+                            [
+                                "name"  => "exam/paper/multi",
+                                "title" => "批量更新"
+                            ],
+                            [
+                                "name"  => "exam/paper/recyclebin",
+                                "title" => "回收站"
+                            ],
+                            [
+                                "name"  => "exam/paper/destroy",
+                                "title" => "真实删除"
+                            ],
+                            [
+                                "name"  => "exam/paper/restore",
+                                "title" => "还原"
+                            ],
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/question",
+                        "title"   => "试题管理",
+                        "icon"    => "fa fa-question-circle-o",
+                        "sublist" => [
+                            [
+                                "name"  => "exam/question/index",
+                                "title" => "查看"
+                            ],
+                            [
+                                "name"  => "exam/question/add",
+                                "title" => "添加"
+                            ],
+                            [
+                                "name"  => "exam/question/edit",
+                                "title" => "编辑"
+                            ],
+                            [
+                                "name"  => "exam/question/del",
+                                "title" => "删除"
+                            ],
+                            [
+                                "name"  => "exam/question/multi",
+                                "title" => "批量更新"
+                            ],
+                            [
+                                "name"  => "exam/question/recyclebin",
+                                "title" => "回收站"
+                            ],
+                            [
+                                "name"  => "exam/question/destroy",
+                                "title" => "真实删除"
+                            ],
+                            [
+                                "name"  => "exam/question/restore",
+                                "title" => "还原"
+                            ],
+                            [
+                                "name"  => "exam/question/import",
+                                "title" => "导入"
+                            ],
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/cate",
+                        "title"   => "分类管理",
+                        "icon"    => "fa fa-align-left",
+                        "sublist" => [
+                            [
+                                "name"  => "exam/cate/import",
+                                "title" => "Import"
+                            ],
+                            [
+                                "name"  => "exam/cate/index",
+                                "title" => "查看"
+                            ],
+                            [
+                                "name"  => "exam/cate/add",
+                                "title" => "添加"
+                            ],
+                            [
+                                "name"  => "exam/cate/edit",
+                                "title" => "编辑"
+                            ],
+                            [
+                                "name"  => "exam/cate/del",
+                                "title" => "删除"
+                            ],
+                            [
+                                "name"  => "exam/cate/multi",
+                                "title" => "批量更新"
+                            ],
+                            [
+                                "name"  => "exam/cate/recyclebin",
+                                "title" => "回收站"
+                            ],
+                            [
+                                "name"  => "exam/cate/destroy",
+                                "title" => "真实删除"
+                            ],
+                            [
+                                "name"  => "exam/cate/restore",
+                                "title" => "还原"
+                            ],
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/notice",
+                        "title"   => "系统公告",
+                        "icon"    => "fa fa-volume-up",
+                        "sublist" => [
+                            [
+                                "name"  => "exam/notice/index",
+                                "title" => "查看"
+                            ],
+                            [
+                                "name"  => "exam/notice/add",
+                                "title" => "添加"
+                            ],
+                            [
+                                "name"  => "exam/notice/edit",
+                                "title" => "编辑"
+                            ],
+                            [
+                                "name"  => "exam/notice/del",
+                                "title" => "删除"
+                            ],
+                            [
+                                "name"  => "exam/notice/multi",
+                                "title" => "批量更新"
+                            ]
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/roommanage",
+                        "title"   => "考场功能",
+                        "icon"    => "fa fa-home",
+                        "sublist" => [
+                            [
+                                "name"    => "exam/room",
+                                "title"   => "考场管理",
+                                "icon"    => "fa fa-home",
+                                "sublist" => [
+                                    [
+                                        "name"  => "exam/room/index",
+                                        "title" => "查看"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/add",
+                                        "title" => "添加"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/edit",
+                                        "title" => "编辑"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/del",
+                                        "title" => "删除"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/multi",
+                                        "title" => "批量更新"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/recyclebin",
+                                        "title" => "回收站"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/destroy",
+                                        "title" => "真实删除"
+                                    ],
+                                    [
+                                        "name"  => "exam/room/restore",
+                                        "title" => "还原"
+                                    ]
+                                ]
+                            ],
+                            [
+                                "name"    => "exam/room_signup",
+                                "title"   => "考场报名",
+                                "icon"    => "fa fa-pencil",
+                                "sublist" => [
+                                    [
+                                        "name"  => "exam/room_signup/index",
+                                        "title" => "查看"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_signup/add",
+                                        "title" => "添加"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_signup/edit",
+                                        "title" => "编辑"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_signup/del",
+                                        "title" => "删除"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_signup/multi",
+                                        "title" => "批量更新"
+                                    ]
+                                ]
+                            ],
+                            [
+                                "name"    => "exam/room_grade",
+                                "title"   => "考场考试成绩",
+                                "icon"    => "fa fa-list-ol",
+                                "sublist" => [
+                                    [
+                                        "name"  => "exam/room_grade/index",
+                                        "title" => "查看"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_grade/add",
+                                        "title" => "添加"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_grade/edit",
+                                        "title" => "编辑"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_grade/del",
+                                        "title" => "删除"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_grade/multi",
+                                        "title" => "批量更新"
+                                    ],
+                                    [
+                                        "name"  => "exam/room_grade/detail",
+                                        "title" => "详情"
+                                    ],
+                                ]
+                            ]
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/correction",
+                        "title"   => "纠错反馈",
+                        "icon"    => "fa fa-wpforms",
+                        "sublist" => [
+                            [
+                                "name"    => "exam/correction_type",
+                                "title"   => "纠错反馈类型",
+                                "icon"    => "fa fa-dot-circle-o",
+                                "sublist" => [
+                                    [
+                                        "name"  => "exam/correction_type/index",
+                                        "title" => "查看"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_type/add",
+                                        "title" => "添加"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_type/edit",
+                                        "title" => "编辑"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_type/del",
+                                        "title" => "删除"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_type/multi",
+                                        "title" => "批量更新"
+                                    ],
+                                ]
+                            ],
+                            [
+                                "name"    => "exam/correction_question",
+                                "title"   => "纠错反馈试题",
+                                "icon"    => "fa fa-pencil",
+                                "sublist" => [
+                                    [
+                                        "name"  => "exam/correction_question/index",
+                                        "title" => "查看"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_question/add",
+                                        "title" => "添加"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_question/edit",
+                                        "title" => "编辑"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_question/del",
+                                        "title" => "删除"
+                                    ],
+                                    [
+                                        "name"  => "exam/correction_question/multi",
+                                        "title" => "批量更新"
+                                    ]
+                                ]
+                            ],
+                        ]
+                    ],
+                    [
+                        "name"    => "exam/dashboard/index",
+                        "title"   => "控制台",
+                        "icon"    => "fa fa-dashboard",
+                        "sublist" => []
+                    ]
+                ]
+            ]
+        ];
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        Menu::create($this->menu);
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        Menu::delete('exam');
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        Menu::enable('exam');
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        Menu::disable('exam');
+        return true;
+    }
+
+    /**
+     * 插件升级方法
+     * @return bool
+     */
+    public function upgrade()
+    {
+        // 如果菜单有变更则升级菜单
+        Menu::upgrade('exam', $this->menu);
+        return true;
+    }
+
+}

+ 30 - 0
addons/exam/README.md

@@ -0,0 +1,30 @@
+## 更新日志
+
+### 1.0.4
+
+小程序端重磅更新
+
+- 小程序端首页、用户中心页面,新增简约、多彩风格组件,并由后台控制页面风格(后续高级版会择机新增多种风格);
+- 首页新增去广告开关、分享、回到顶部悬浮按钮;
+
+推荐更新本次版本(另外:标准版下版本考虑涨价,想买的同学不要犹豫,赶紧的啦!)
+
+### 1.0.3
+
+- 新增看题模式功能;
+- 新增练题模式功能;
+
+### 1.0.2
+
+- 新增考卷每日限制考试次数功能;
+- 解决前端查询试题数据没清空的问题;
+
+### 1.0.1
+
+- 解决后台考卷分类、考场列表查看考场成绩列表无权限问题;
+- 解决后台考卷配置时未填数量导致NAN提示问题;
+- 新增【首页】、【我的】、【题目搜索】、【排行榜】页面流量主开关及插屏、Banner、视频、视频贴片类型的广告;
+
+### 1.0.0
+
+正式发布

+ 4 - 0
addons/exam/config.php

@@ -0,0 +1,4 @@
+<?php
+
+return [
+];

+ 88 - 0
addons/exam/controller/Base.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace addons\exam\controller;
+
+use app\common\controller\Api;
+use think\Lang;
+
+class Base extends Api
+{
+    public function _initialize()
+    {
+        parent::_initialize();
+
+        $this->loadCommonFile();
+
+        $controller = strtolower($this->request->controller());
+        $this->loadlang($controller);
+
+        $this->getAppVersion();
+    }
+
+    /**
+     * 加载公共函数库文件
+     */
+    protected function loadCommonFile()
+    {
+        require_once ROOT_PATH . 'addons/exam/helper.php';
+    }
+
+    /**
+     * 加载后台语言包
+     * @param string $name
+     */
+    protected function loadlang($name)
+    {
+        $lang = $this->request->langset();
+        $lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
+        Lang::load(APP_PATH . '/admin/lang/' . $lang . '/exam/' . str_replace('.', '/', $name) . '.php');
+    }
+
+    /**
+     * 加载用户信息
+     */
+    protected function loadUserData()
+    {
+        if (!$this->auth->isLogin()) {
+            return;
+        }
+
+    }
+
+    /**
+     * 接口执行后统一的返回格式
+     * @param Closure $closure
+     * @param string  $error_msg
+     * @param array   $success_data 带return_result时返回结果给前端
+     * @return array|void
+     */
+    protected function operateResult(\Closure $closure, string $error_msg = '操作失败,请重试', array $success_data = [])
+    {
+        if ($result = $closure()) {
+            if ($success_data && isset($success_data['return_result'])) {
+                succ($result);
+            }
+            succ($success_data);
+        }
+
+        fail($error_msg);
+    }
+
+    /**
+     * 获取前端版本号
+     * @return int
+     */
+    protected function getAppVersion()
+    {
+        $app_version = $this->request->header('app-version', '1.0.0');
+        $app_version = str_replace('.', '', $app_version);
+        $app_version = is_numeric($app_version) ? intval($app_version) : 100;
+
+        if (!defined('APP_VERSION')) {
+            define('APP_VERSION', $app_version);
+        }
+
+        return $app_version;
+    }
+
+}

+ 128 - 0
addons/exam/controller/Cate.php

@@ -0,0 +1,128 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\enum\CateKind;
+use app\admin\model\exam\CateModel;
+
+/**
+ * 分类接口
+ */
+class Cate extends Base
+{
+    protected $noNeedLogin = ['*'];
+    protected $noNeedRight = ['*'];
+
+    /**
+     * 按种类查询分类
+     */
+    public function index()
+    {
+        if (!$kind = input('kind/s')) {
+            fail('缺少分类种类参数');
+        }
+
+        $model = new CateModel();
+
+        $data['level1'] = $model->where('status', '1')->where('kind', $kind)->where('level', 1)->order('sort desc')->select();
+        $data['level2'] = $model->where('status', '1')->where('kind', $kind)->where('level', 2)->order('sort desc')->select();
+        $data['level3'] = $model->where('status', '1')->where('kind', $kind)->where('level', 3)->order('sort desc')->select();
+
+        $this->success('', $data);
+    }
+
+    /**
+     * 3级分类筛选
+     */
+    public function filter()
+    {
+        $kind = input('kind/s');
+
+        if (!$kind || !in_array($kind, [CateKind::PAPER, CateKind::QUESTION, CateKind::ROOM])) {
+            fail('筛选参数错误');
+        }
+
+        $submenu = [];
+        switch ($kind) {
+            case CateKind::QUESTION:
+            case CateKind::PAPER:
+                $submenu = [
+                    [
+                        'name'  => '默认排序',
+                        'value' => '',
+                    ],
+                    [
+                        'name'  => '按参与人数从低到高',
+                        'value' => "join_count|asc",
+                    ],
+                    [
+                        'name'  => '按参与人数从高到低',
+                        'value' => "join_count|desc",
+                    ],
+                ];
+                break;
+            case CateKind::ROOM:
+                $submenu = [
+                    [
+                        'name'  => '默认排序',
+                        'value' => '',
+                    ],
+                    [
+                        'name'  => '按报考人数从低到高',
+                        'value' => "signup_count|asc",
+                    ],
+                    [
+                        'name'  => '按报考人数从高到低',
+                        'value' => "signup_count|desc",
+                    ],
+                    [
+                        'name'  => '按考试人数从低到高',
+                        'value' => "grade_count|asc",
+                    ],
+                    [
+                        'name'  => '按考试人数从高到低',
+                        'value' => "grade_count|desc",
+                    ],
+                    [
+                        'name'  => '按及格人数从低到高',
+                        'value' => "pass_count|asc",
+                    ],
+                    [
+                        'name'  => '按及格人数从高到低',
+                        'value' => "pass_count|desc",
+                    ],
+                ];
+                break;
+        }
+
+        $filter = [
+            [
+                'name'    => '筛选分类',
+                'type'    => 'hierarchy',
+                'submenu' => CateModel::threeLevel($kind)
+            ],
+            [
+                'name'    => '排序',
+                'type'    => 'hierarchy',
+                'submenu' => $submenu
+            ]
+        ];
+
+        $this->success('', $filter);
+    }
+
+    /**
+     * 3级分类获取
+     */
+    public function getThree()
+    {
+        $kind = input('kind/s');
+
+        if (!$kind || !in_array($kind, [CateKind::PAPER, CateKind::QUESTION, CateKind::ROOM])) {
+            fail('筛选参数错误');
+        }
+
+        $cates = CateModel::threeLevel2($kind);
+        $this->success('', $cates);
+    }
+}

+ 152 - 0
addons/exam/controller/Common.php

@@ -0,0 +1,152 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\enum\CommonStatus;
+use addons\exam\model\PaperModel;
+use addons\exam\model\RoomGradeModel;
+use addons\exam\model\RoomModel;
+use app\admin\model\exam\NoticeModel;
+use app\common\exception\UploadException;
+use app\common\library\Upload;
+use think\Config;
+
+/**
+ * 公共接口
+ */
+class Common extends Base
+{
+    protected $noNeedLogin = ['index', 'login'];
+    protected $noNeedRight = ['*'];
+
+    /**
+     * 读取配置
+     */
+    public function index()
+    {
+        $data['system']  = getConfig('system_config');
+        $data['page']    = getConfig('page_config');
+        $data['ad']      = getConfig('ad_config');
+        $data['notice']  = NoticeModel::where('status', CommonStatus::NORMAL)->order('weigh desc')->column('name');
+        $data['notices'] = NoticeModel::where('status', CommonStatus::NORMAL)->order('weigh desc')->field('id,name')->limit(5)->select();
+        $data['papers']  = $this->indexPaperList();
+        $data['rooms']   = $this->indexRoomList();
+        // $data['version'] = $this->getAppVersion();
+        $this->success('请求成功', $data);
+    }
+
+    /**
+     * 上传文件
+     */
+    public function upload()
+    {
+        Config::set('default_return_type', 'json');
+        //必须设定cdnurl为空,否则cdnurl函数计算错误
+        Config::set('upload.cdnurl', '');
+        $chunkid = $this->request->post("chunkid");
+        if ($chunkid) {
+            if (!Config::get('upload.chunking')) {
+                $this->error(__('Chunk file disabled'));
+            }
+            $action     = $this->request->post("action");
+            $chunkindex = $this->request->post("chunkindex/d");
+            $chunkcount = $this->request->post("chunkcount/d");
+            $filename   = $this->request->post("filename");
+            $method     = $this->request->method(true);
+            if ($action == 'merge') {
+                $attachment = null;
+                //合并分片文件
+                try {
+                    $upload     = new Upload();
+                    $attachment = $upload->merge($chunkid, $chunkcount, $filename);
+                } catch (UploadException $e) {
+                    $this->error($e->getMessage());
+                }
+                $this->success(__('Uploaded successful'), ['url' => $attachment->url, 'fullurl' => cdnurl($attachment->url, true)]);
+            } elseif ($method == 'clean') {
+                //删除冗余的分片文件
+                try {
+                    $upload = new Upload();
+                    $upload->clean($chunkid);
+                } catch (UploadException $e) {
+                    $this->error($e->getMessage());
+                }
+                $this->success();
+            } else {
+                //上传分片文件
+                //默认普通上传文件
+                $file = $this->request->file('file');
+                try {
+                    $upload = new Upload($file);
+                    $upload->chunk($chunkid, $chunkindex, $chunkcount);
+                } catch (UploadException $e) {
+                    $this->error($e->getMessage());
+                }
+                $this->success();
+            }
+        } else {
+            $attachment = null;
+            //默认普通上传文件
+            $file = $this->request->file('file');
+            try {
+                $upload     = new Upload($file);
+                $attachment = $upload->upload();
+            } catch (UploadException $e) {
+                $this->error($e->getMessage());
+            }
+
+            $this->success(__('Uploaded successful'), ['url' => $attachment->url, 'fullurl' => cdnurl($attachment->url, true)]);
+        }
+    }
+
+    /**
+     * 首页试卷列表
+     * @return array
+     */
+    protected function indexPaperList()
+    {
+        $now    = time();
+        $papers = PaperModel::where('status', CommonStatus::NORMAL)
+            ->where('is_only_room', 0)
+            ->whereRaw("((start_time = 0 and end_time = 0) or (start_time < {$now} and end_time > {$now}))")
+            ->order('join_count', 'desc')
+            ->limit(6)->select();
+        foreach ($papers as &$paper) {
+            // 试卷参与人员
+            $users = PaperModel::getJoinUsers($paper['id'], -4);
+            // 参与人员头像
+            $user_avatars = [];
+            foreach ($users as $user) {
+                $user_avatars[] = [
+                    'src' => $user['avatar']
+                ];
+            }
+            $paper['users'] = $user_avatars;
+        }
+
+        return $papers;
+    }
+
+    /**
+     * 首页考场列表
+     * @return array
+     */
+    protected function indexRoomList()
+    {
+        $rooms = RoomModel::where('status', CommonStatus::NORMAL)->order('grade_count', 'desc')->limit(6)->select();
+        foreach ($rooms as &$room) {
+            // 考场考试人员
+            $users = RoomGradeModel::getJoinUsers($room['id'], -4);
+            // 参与人员头像
+            $user_avatars = [];
+            foreach ($users as $user) {
+                $user_avatars[] = [
+                    'src' => $user['avatar']
+                ];
+            }
+            $room['users'] = $user_avatars;
+        }
+
+        return $rooms;
+    }
+}

+ 74 - 0
addons/exam/controller/Correction.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\model\CorrectionQuestionModel;
+use app\admin\model\exam\CorrectionTypeModel;
+
+
+/**
+ * 纠错接口
+ */
+class Correction extends Base
+{
+    protected $noNeedLogin = [''];
+    protected $noNeedRight = ['*'];
+    protected $user;
+
+    /**
+     * 纠错类型
+     */
+    public function types()
+    {
+        $types = CorrectionTypeModel::all();
+        $this->success('', ['types' => $types]);
+    }
+
+    /**
+     * 提交纠错
+     */
+    public function submit()
+    {
+        $question_id = input('question_id/d');
+        // $type_ids    = input('type_ids/a', []);
+        $type_names = input('type_names/a', []);
+        $remark     = input('remark/s', '', 'trim,strip_tags,htmlspecialchars,xss_clean');
+
+        if (!$question_id) {
+            $this->error(__('缺少题目ID参数'));
+        }
+        // if (!$type_ids) {
+        //     $this->error(__('请选择纠错类型'));
+        // }
+        if (!$type_names) {
+            $this->error(__('请选择纠错类型'));
+        }
+
+        CorrectionQuestionModel::create([
+            'user_id'     => $this->auth->id,
+            'question_id' => $question_id,
+            'type_ids'    => '',//implode(',', $type_ids),
+            'type_names'  => implode(',', $type_names),
+            'remark'      => $remark,
+        ]);
+
+        $this->success('提交成功,感谢您的反馈');
+    }
+
+    /**
+     * 纠错反馈列表
+     */
+    public function list()
+    {
+        $list = CorrectionQuestionModel::with([
+            'question' => function ($query) {
+                $query->with([
+                    'cates' => function ($query) {
+                        $query->field('id,name');
+                    },
+                ])->field('id,cate_id,kind,title');
+            }
+        ])->where('user_id', $this->auth->id)->order('id', 'desc')->paginate(15, true);
+        $this->success('', $list);
+    }
+}

+ 89 - 0
addons/exam/controller/Grade.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\model\BaseModel;
+use addons\exam\model\GradeModel;
+use think\Db;
+
+
+/**
+ * 试卷考试成绩接口
+ */
+class Grade extends Base
+{
+    protected $noNeedLogin = ['*'];
+    protected $noNeedRight = ['*'];
+
+    /**
+     * 获取成绩列表
+     */
+    public function index()
+    {
+        $list = GradeModel::with(
+            [
+                // 'user'  => BaseModel::withSimpleUser(),
+                'cate'  => BaseModel::withSimpleCate(),
+                'paper' => BaseModel::withSimplePaper(),
+            ]
+        )
+            ->where('user_id', $this->auth->id)
+            ->order('id desc')
+            ->paginate(15, true);
+
+        $this->success('', compact('list'));
+    }
+
+    /**
+     * 排行榜
+     */
+    public function rank()
+    {
+        if (!$paper_id = input('paper_id/d', '0')) {
+            $this->error('缺少试卷信息');
+        }
+
+        $result = cache_data("rank:paper-{$paper_id}", function () use ($paper_id) {
+            $grade_count = GradeModel::where('paper_id', $paper_id)->group('user_id')->count();
+            $pass_count  = GradeModel::where('paper_id', $paper_id)->where('is_pass', 1)->group('user_id')->count();
+            $pass_rate   = round(($pass_count / $grade_count) * 100, 2) . '%';
+            // $pass_rate   = bcmul(bcdiv($pass_count, $grade_count, 4), 100, 2) . '%';
+
+            if ($grade_count) {
+                // 子查询,先取出最新的成绩
+                $subQuery = Db::name('exam_grade')
+                    ->field('id,user_id,cate_id,paper_id,mode,MAX(score) AS score,is_pass,grade_time')
+                    ->where('paper_id', $paper_id)
+                    ->group('user_id')
+                    ->order('id desc')
+                    ->buildSql();
+
+                // 再根据成绩、考试时间排序,取出前10名
+                $list = GradeModel::with(
+                    [
+                        'user' => BaseModel::withSimpleUser(),
+                    ]
+                )->table($subQuery . ' exam_grade')
+                    // ->where('paper_id', $paper_id)
+                    // ->group('user_id')
+                    ->order('score desc, grade_time asc')
+                    ->limit(10)
+                    ->select();
+            } else {
+                $list = [];
+            }
+
+            return [
+                'summary' => [
+                    'grade_count' => $grade_count,
+                    'pass_count'  => $pass_count,
+                    'pass_rate'   => $pass_rate,
+                    'cache_time'  => datetime(time()),
+                ],
+                'list'    => $list,
+            ];
+        }, 3600, true);
+
+        $this->success('', json_decode($result, true));
+    }
+}

+ 15 - 0
addons/exam/controller/Index.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace addons\exam\controller;
+
+use think\addons\Controller;
+
+class Index extends Controller
+{
+
+    public function index()
+    {
+        $this->error("当前插件暂无前台页面");
+    }
+
+}

+ 44 - 0
addons/exam/controller/Notice.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\enum\CommonStatus;
+use addons\exam\model\NoticeModel;
+
+
+/**
+ * 公告接口
+ */
+class Notice extends Base
+{
+    protected $noNeedLogin = ['*'];
+    protected $noNeedRight = ['*'];
+    protected $user;
+
+    /**
+     * 列表
+     */
+    public function index()
+    {
+        $list = NoticeModel::where('status', CommonStatus::NORMAL)
+            ->field('id, name, createtime')
+            ->order('weigh desc')
+            ->paginate(15, true);
+        $this->success('', ['list' => $list]);
+    }
+
+    /**
+     * 详情
+     */
+    public function detail()
+    {
+        if (!$id = input('id/d', '0')) {
+            $this->error('缺少公告ID');
+        }
+        if (!$notice = NoticeModel::where('id', $id)->where('status', CommonStatus::NORMAL)->find()) {
+            $this->error('公告信息不存在');
+        }
+
+        $this->success('', $notice);
+    }
+}

+ 157 - 0
addons/exam/controller/Paper.php

@@ -0,0 +1,157 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\enum\CommonStatus;
+use addons\exam\enum\ExamMode;
+use addons\exam\library\ExamService;
+use addons\exam\model\PaperModel;
+use addons\exam\model\QuestionModel;
+use addons\exam\model\UserModel;
+use app\admin\model\exam\CateModel;
+use app\admin\model\exam\GradeModel;
+use think\Request;
+
+
+/**
+ * 试卷接口
+ */
+class Paper extends Base
+{
+    protected $noNeedLogin = ['index'];
+    protected $noNeedRight = ['*'];
+    protected $user;
+
+    /**
+     * 查询出分类下的试卷
+     */
+    public function index()
+    {
+        $cate_id = input('cate_id/d', '0');
+        $sort    = input('sort/s', '');
+        $now     = time();
+
+        $query = PaperModel::with([
+            'cates' => function ($query) {
+                $query->withField('id, name');
+            }
+        ])
+            ->where('status', CommonStatus::NORMAL)
+            ->where('is_only_room', 0)// 过滤仅考场使用的试卷
+            ->whereRaw("((start_time = 0 and end_time = 0) or (start_time < {$now} and end_time > {$now}))");
+
+        // 分类
+        if ($cate_id) {
+            $child_cate_ids = CateModel::getChildId($cate_id);
+            array_push($child_cate_ids, $cate_id);
+            $query->whereIn('cate_id', $child_cate_ids);
+        }
+        // 排序
+        if ($sort && $sort != 'null') {
+            $sort     = explode('|', $sort);
+            $field    = $sort[0];
+            $order_by = $sort[1];
+
+            $field    = in_array($field, ['join_count']) ? $field : 'join_count';
+            $order_by = $order_by == 'desc' ? 'desc' : 'asc';
+
+            $query->order("{$field} $order_by");
+        }
+
+        $list = $query->paginate();
+        $this->success('', ['list' => $list]);
+    }
+
+    /**
+     * 试卷取题接口
+     */
+    public function getExamQuestion()
+    {
+        $paper_id = input('paper_id/d', 0);
+        $room_id  = input('room_id/d', 0);
+
+        // 验证是否需要绑定手机号
+        UserModel::isMustBindMobile($this->auth->getUser());
+
+        // 预创建考场考试记录
+        $room_grade_id = ExamService::preRoomGrade($room_id, $this->auth->id);
+
+        // 获取试卷题目
+        $question_data = ExamService::getExamQuestion($paper_id, $room_id);
+
+        $this->success('', array_merge($question_data, ['room_grade_id' => $room_grade_id]));
+    }
+
+    /**
+     * 交卷
+     */
+    public function submit()
+    {
+        $request       = Request::instance();
+        $user_id       = $this->auth->id;
+        $paper_id      = $request->post('paper_id/d', 0);
+        $questions     = $request->post('questions/a', []);
+        $start_time    = $request->post('start_time/d', time());
+        $room_id       = $request->post('room_id/d', 0);
+        $room_grade_id = $request->post('room_grade_id/d', 0);
+
+        if (!$user_id || !$paper_id || !$questions) {
+            $this->error('提交数据有误' . $user_id);
+        }
+
+        // 考场考试
+        if ($room_id) {
+            if (!$room_grade_id) {
+                $this->error('提交数据不合法');
+            }
+
+            // 考场考试
+            $result = ExamService::roomExam($user_id, $room_id, $room_grade_id, $questions, $start_time, $paper, $room, $is_makeup, $room_grade_log);
+
+            // 记录考场考试成绩
+            $room_grade_log->allowField(true)->save(
+                array_merge(
+                    $result,
+                    [
+                        // 'cate_id'   => $paper['cate_id'],
+                        'user_id'   => $user_id,
+                        'paper_id'  => $paper_id,
+                        'is_makeup' => $is_makeup,
+                        'is_pre'    => 0, // 提交成绩后不再为预创建标记
+                    ],
+                    [
+                        'exam_mode' => ExamMode::ROOM,
+                    ]
+                )
+            );
+        } else {
+            $result = ExamService::paperExam($user_id, $paper_id, $questions, $start_time, $paper);
+
+            // 记录考试成绩
+            GradeModel::create(array_merge(
+                $result,
+                [
+                    'cate_id'  => $paper['cate_id'],
+                    'user_id'  => $user_id,
+                    'paper_id' => $paper_id,
+                ],
+                [
+                    // 'exam_mode' => ExamMode::PAPER,
+                    'date' => date('Y-m-d'),
+                ]), true);
+        }
+        return json($result);
+    }
+
+    /*
+     * 查看错题
+     * Robin
+     * @param $ids
+     * */
+    public function error_ids($ids)
+    {
+        $questions = QuestionModel::whereIn('id', ($ids))->select();
+        $this->success('', $questions);
+    }
+
+}

+ 240 - 0
addons/exam/controller/Question.php

@@ -0,0 +1,240 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\enum\CommonStatus;
+use addons\exam\model\QuestionCollectModel;
+use addons\exam\model\QuestionModel;
+use addons\exam\model\UserModel;
+use app\admin\model\exam\QuestionWrongModel;
+
+
+/**
+ * 试题接口
+ */
+class Question extends Base
+{
+    protected $noNeedLogin = [];
+    protected $noNeedRight = ['*'];
+
+    /**
+     * 看题模式
+     */
+    public function lookPage()
+    {
+        $model = new QuestionModel();
+        $total = $model->where('cate_id', $this->request->param('cate_id'))->count('id');
+        $this->success('', compact('total'));
+    }
+
+    /**
+     * 练习模式
+     */
+    public function train()
+    {
+        $param            = $this->request->param();
+        $param['user_id'] = $this->auth->id;
+
+        // 验证是否需要绑定手机号
+        UserModel::isMustBindMobile($this->auth->getUser());
+
+        $list = QuestionModel::getList($param);
+        // $total     = $list['total'];
+
+        $this->success('', $list);
+    }
+
+    /**
+     * 根据关键词模糊查询10条题目
+     */
+    public function search()
+    {
+        $query = QuestionModel::with(
+            [
+                'cates'          => function ($query) {
+                    $query->field('id,name');
+                },
+                'materialParent' => function ($query) {
+                    $query->field('id,title');
+                },
+            ]
+        )->where('status', CommonStatus::NORMAL);
+
+        if ($keyword = input('keyword/s', '', 'trim,strip_tags,htmlspecialchars,xss_clean')) {
+            if (mb_strlen($keyword) < 2) {
+                $this->error('请输入不少于2个字的关键词进行搜索');
+            }
+
+            $query->where('title', 'like', '%' . $keyword . '%');
+        }
+
+        if ($sort_type = input('sort_type/s')) {
+            $query->order($sort_type);
+        }
+
+        if (input('sort_rand/d', 0)) {
+            $query->orderRaw('rand()');
+        }
+
+        $list = $query->paginate(15, true)->toArray();
+        // 最多搜索5页
+        if (input('page/d') >= 5) {
+            $list['has_more'] = false;
+        }
+
+        $this->success('', ['list' => $list]);
+    }
+
+    /**
+     * 试题详情
+     */
+    public function detail($id)
+    {
+        $this->success('', (new QuestionModel)->get($id));
+    }
+
+    /**
+     * 收藏列表
+     */
+    public function collectList()
+    {
+        $user_id         = $this->auth->id;
+        $collectQuestion = new QuestionCollectModel();
+
+        $list  = $collectQuestion::with('question')->where('user_id', $user_id)->order('id desc')->paginate(999, true);
+        $total = $collectQuestion::where('user_id', $user_id)->count();
+
+        $this->success('', compact('list', 'total'));
+
+        // $list  = $collectQuestion::with([
+        //     'question.materialQuestions.question'
+        // ])->where('user_id', $user_id)->order('id desc')->paginate(999, true);
+        // $total = $list->count();
+        //
+        // $list = $list->toArray();
+        // ddd($list);
+        // // 合并材料题子题目
+        // $list['data'] = QuestionModel::mergeMaterialQuestions($list['data']);
+
+        // $this->success('', compact('list', 'total'));
+    }
+
+    /**
+     * 添加收藏
+     */
+    public function collectAdd()
+    {
+        if (!$question_id = input('question_id/d', 0)) {
+            $this->error('缺少题目ID');
+        }
+        if (!QuestionModel::where('id', $question_id)->count()) {
+            $this->error('题目数据不存在');
+        }
+
+        if ($res = QuestionCollectModel::updateOrCreate(
+            [
+                'user_id'     => $this->auth->id,
+                'question_id' => $question_id
+            ],
+            [
+                'user_id'     => $this->auth->id,
+                'question_id' => $question_id
+            ])
+        ) {
+            $this->success('收藏成功', $res);
+        }
+        $this->error('收藏失败');
+    }
+
+    /**
+     * 取消收藏
+     */
+    public function collectCancel()
+    {
+        if (!$question_id = input('question_id/d', 0)) {
+            $this->error('缺少题目ID');
+        }
+        $result = QuestionCollectModel::where('question_id', $question_id)->where('user_id', $this->auth->id)->delete();
+
+        if ($result) {
+            $this->success('取消成功');
+        }
+        $this->error('取消失败');
+    }
+
+    /**
+     * 获取错题列表
+     */
+    public function wrongList()
+    {
+        $user_id = $this->auth->id;
+
+        if ($ids = input('question_ids')) {
+            $total = QuestionWrongModel::where('user_id', $user_id)->whereIn('question_id', $ids)->count();
+            $list  = $total ? QuestionWrongModel::with('question')
+                ->whereIn('question_id', $ids)
+                ->where('user_id', $user_id)
+                ->order('id desc')
+                ->paginate(999, true)->toArray() : [];
+        } else {
+            $total = QuestionWrongModel::where('user_id', $user_id)->count();
+            $list  = $total ? QuestionWrongModel::with('question')
+                ->where('user_id', $user_id)
+                ->order('id desc')
+                ->paginate(999, true)->toArray() : [];
+        }
+
+        if (isset($list['data']) && $list['data']) {
+            $questions = [];
+            foreach ($list['data'] as $item) {
+                $questions[] = array_merge($item['question'], ['wrong_id' => $item['id'], 'user_answer' => $item['user_answer']]);
+            }
+            $list['data'] = \addons\exam\model\QuestionModel::isCollected($user_id, $questions);
+        } else {
+            $list['data'] = [];
+        }
+
+        $this->success('', compact('list', 'total'));
+    }
+
+    /*
+     * 记录错题
+     */
+    public function wrongAdd()
+    {
+        if (!$question_id = input('question_id/d', 0)) {
+            $this->error('缺少题目ID');
+        }
+
+        if (QuestionWrongModel::add($this->auth->id, $question_id))
+            $this->success('记录成功');
+        else
+            $this->error('记录失败');
+    }
+
+    /**
+     * 删除错题
+     */
+    public function wrongDelete()
+    {
+        if (!$question_id = input('question_id/d', 0)) {
+            $this->success('缺少错题ID');
+        }
+
+        if (QuestionWrongModel::where('question_id', $question_id)->where('user_id', $this->auth->id)->delete()) {
+            $this->success('删除成功');
+        }
+        $this->error('删除失败');
+    }
+
+    /**
+     * 清空所有错题
+     */
+    public function wrongClear()
+    {
+        if (QuestionWrongModel::where('user_id', $this->auth->id)->delete()) {
+            $this->success('删除成功');
+        }
+        $this->error('删除失败');
+    }
+}

+ 192 - 0
addons/exam/controller/Room.php

@@ -0,0 +1,192 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\enum\CommonStatus;
+use addons\exam\enum\RoomMode;
+use addons\exam\enum\RoomSignupStatus;
+use addons\exam\library\ValidateService;
+use addons\exam\model\BaseModel;
+use addons\exam\model\RoomGradeModel;
+use addons\exam\model\RoomModel;
+use addons\exam\model\RoomSignupModel;
+use app\admin\model\exam\CateModel;
+
+
+/**
+ * 考场接口
+ */
+class Room extends Base
+{
+    protected $noNeedLogin = ['index'];
+    protected $noNeedRight = ['*'];
+    protected $user;
+
+    /**
+     * 查询出分类下的考场
+     */
+    public function index()
+    {
+        $cate_id = input('cate_id', '0');
+        $sort    = input('sort/s', '');
+
+        $query = RoomModel::with(
+            [
+                'cates' => BaseModel::withSimpleCate(),
+                'paper' => function ($query) {
+                    $query->field('id, cate_id, title, mode, quantity, total_score, pass_score, limit_time');
+                },
+            ]
+        )->where('status', CommonStatus::NORMAL);
+
+        // 分类
+        if ($cate_id && is_numeric($cate_id)) {
+            $child_cate_ids = CateModel::getChildId($cate_id);
+            array_push($child_cate_ids, $cate_id);
+            $query->whereIn('cate_id', $child_cate_ids);
+        }
+
+        // 排序
+        if ($sort && $sort != 'null') {
+            $sort     = explode('|', $sort);
+            $field    = $sort[0];
+            $order_by = $sort[1];
+
+            $field    = in_array($field, ['signup_count', 'grade_count', 'pass_count']) ? $field : 'signup_count';
+            $order_by = $order_by == 'desc' ? 'desc' : 'asc';
+
+            $query->order("{$field} $order_by");
+        }
+
+        $list = $query->order('weigh desc')->paginate();
+        $this->success('', ['list' => $list]);
+    }
+
+    /**
+     * 考场详情
+     */
+    public function detail()
+    {
+        if (!$room_id = input('room_id/d', '0')) {
+            $this->error('缺少考场ID');
+        }
+        if (!$room = RoomModel::with(
+            [
+                'cates' => BaseModel::withSimpleCate(),
+                'paper'
+            ])
+            ->where('id', $room_id)->find()
+        ) {
+            $this->error('考场信息不存在');
+        }
+        if ($room['status'] != CommonStatus::NORMAL) {
+            $this->error('考场已关闭');
+        }
+
+        // 报名记录
+        $signup_log = RoomSignupModel::where('room_id', $room_id)
+            ->where('user_id', $this->auth->id)
+            ->order('id desc')
+            ->find();
+
+        // 考试记录
+        $room_grade_logs = $signup_log ? RoomGradeModel::where('room_id', $room_id)
+            ->where('user_id', $this->auth->id)
+            ->order('id desc')
+            ->select() : [];
+
+        // 符合开始考试条件(0:不允许,1:开始开始,2:开始补考)
+        if ($signup_log) {
+            // 已报名成功、在考试时间内
+            $can_start = ($signup_log['status'] == RoomSignupStatus::ACCEPT && ($room['start_time'] < time() && $room['end_time'] > time())) ? 1 : 0;
+            if ($can_start) {
+                // 允许补考
+                if ($room['is_makeup'] == 1) {
+                    // 未超出补考次数限制
+                    $room_grade_log_count = count($room_grade_logs);
+                    if ($room_grade_log_count > 0) {
+                        $can_start = $room_grade_log_count - 1 < $room['makeup_count'] ? 2 : 0;
+                    }
+                } else {
+                    // 非补考模式只能考一次
+                    $can_start = count($room_grade_logs) == 0;
+                }
+            }
+            $signup_log['can_start'] = $can_start;
+        }
+
+        $this->success('', [
+            'room'       => $room,
+            'signup_log' => $signup_log,
+            'exam_logs'  => $room_grade_logs,
+        ]);
+    }
+
+    /**
+     * 考场报名
+     */
+    public function signup()
+    {
+        switch (true) {
+            case !$room_id = input('room_id/d', '0'):
+                $this->error('缺少考场ID');
+            case !$real_name = input('real_name/s', ''):
+                $this->error('请填写您的姓名');
+            case !$phone = input('phone/s', ''):
+                $this->error('请填写手机号码');
+            case !ValidateService::phone($phone):
+                $this->error('手机号码格式不正确');
+            case !$room = RoomModel::get($room_id):
+                $this->error('考场信息不存在');
+            case $room['signup_mode'] == RoomMode::PASSWORD && $room['password'] != input('password/s', ''):
+                $this->error('考场密码不正确');
+            case $room['status'] != CommonStatus::NORMAL:
+                $this->error('考场状态异常');
+            case $room['start_time'] > time() || $room['end_time'] < time():
+                $this->error('考场未开始或已过期');
+            // case $room['signup_count'] >= $room['people_count']:
+            //     $this->error('考场人数已满,无法报名参加考试');
+        }
+
+        // 已报名、被拒绝
+        if ($signupLog = RoomSignupModel::where('room_id', $room_id)->where('user_id', $this->auth->id)->find()) {
+            if ($signupLog->status != RoomSignupStatus::REJECT) {
+                $this->error('该考场您已报过名了,请勿重复报名');
+            }
+
+            $signupLog->real_name = $real_name;
+            $signupLog->phone     = $phone;
+            $signupLog->status    = RoomSignupStatus::WAIT;
+
+            if ($signupLog->save()) {
+                $this->success('重新提交报名成功');
+            }
+        } else {
+            // 非审核模式
+            if ($room['signup_mode'] != RoomMode::AUDIT) {
+                if ($room['people_count'] != 0 && $room['signup_count'] >= $room['people_count']) {
+                    $this->error('考场人数已满,无法报名参加考试');
+                }
+            }
+
+            // 创建报名记录
+            if (RoomSignupModel::create([
+                'user_id'   => $this->auth->id,
+                'room_id'   => $room_id,
+                'real_name' => $real_name,
+                'phone'     => $phone,
+                'status'    => $room['signup_mode'] == RoomMode::AUDIT ? RoomSignupStatus::WAIT : RoomSignupStatus::ACCEPT,
+            ])) {
+                // 非审核模式
+                if ($room['signup_mode'] != RoomMode::AUDIT) {
+                    // 递增报名成功人数
+                    $room->setInc('signup_count');
+                }
+
+                $this->success($room['signup_mode'] == RoomMode::AUDIT ? '报名成功,请等待审核' : '报名成功');
+            }
+        }
+
+        $this->error('报名失败,请重试');
+    }
+}

+ 49 - 0
addons/exam/controller/RoomGrade.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\model\BaseModel;
+use addons\exam\model\RoomGradeModel;
+
+
+/**
+ * 考场考试成绩接口
+ */
+class RoomGrade extends Base
+{
+    protected $noNeedLogin = [''];
+    protected $noNeedRight = ['*'];
+
+    /**
+     * 获取成绩列表
+     */
+    public function index()
+    {
+        $list = RoomGradeModel::with(
+            [
+                // 'user'  => BaseModel::withSimpleUser(),
+                'cate'  => BaseModel::withSimpleCate(),
+                'paper' => BaseModel::withSimplePaper(),
+                'room'  => BaseModel::withSimpleRoom(),
+            ]
+        )
+            ->where('user_id', $this->auth->id)
+            ->order('id desc')
+            ->paginate(15, true);
+
+        $this->success('', compact('list'));
+    }
+
+    /**
+     * 排行榜
+     */
+    public function rank()
+    {
+        if (!$room_id = input('room_id/d', '0')) {
+            $this->error('缺少考场信息');
+        }
+
+        $result = RoomGradeModel::rankData($room_id);
+        $this->success('', $result);
+    }
+}

+ 48 - 0
addons/exam/controller/RoomSignup.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\model\RoomSignupModel;
+
+
+/**
+ * 考场接口
+ */
+class RoomSignup extends Base
+{
+    protected $noNeedLogin = [''];
+    protected $noNeedRight = ['*'];
+    protected $user;
+
+    /**
+     * 我的报名记录
+     */
+    public function index()
+    {
+        $query = RoomSignupModel::with(
+            [
+                'room' => function ($query) {
+                    $query->with(
+                        [
+                            // 'cates' => function ($query) {
+                            //     $query->withField('id, name');
+                            // },
+                            'paper' => function ($query) {
+                                $query->withField('id, title');
+                            },
+                        ]
+                    );//->field('id,name,contents,cate_id,paper_id,');
+                }
+            ]
+        )->where('user_id', $this->auth->id);
+
+        // 状态查询
+        $status = input('status', '');
+        if (is_numeric($status)) {
+            $query->where('status', $status);
+        }
+
+        $list = $query->order('id desc')->paginate();
+        $this->success('', ['list' => $list]);
+    }
+}

+ 254 - 0
addons/exam/controller/User.php

@@ -0,0 +1,254 @@
+<?php
+
+namespace addons\exam\controller;
+
+use addons\exam\library\CacheService;
+use addons\exam\library\WechatService;
+use addons\exam\model\UserInfoModel;
+use addons\exam\model\UserModel;
+use app\common\library\Token;
+use think\Validate;
+
+/**
+ * 会员接口
+ */
+class User extends Base
+{
+    protected $noNeedLogin = ['login', 'userLogin', 'register'];
+    protected $noNeedRight = '*';
+    protected $visibleFields = ['id', 'avatar', 'gender', 'nickname', 'mobile', 'birthday', 'status', 'createtime', 'logintime'];
+
+    /**
+     * 授权登录
+     * @ApiMethod   (POST)
+     * @param string $code     授权code
+     * @param string $userInfo 授权后拿到的用户信息
+     */
+    public function login()
+    {
+        $user_info    = input('userInfo/a', []);
+        $code         = input('code/s', '');
+        $from_user_id = input('from_user_id/d', 0);
+
+        if (!$code) {
+            fail('缺少小程序参数code');
+        }
+        if (!$user_info) {
+            fail('缺少小程序参数userInfo');
+        }
+
+        $service     = new WechatService();
+        $wechat_user = $service->miniLogin($code);
+        if (!isset($wechat_user['openid'])) {
+            fail('获取小程序用户信息失败');
+        }
+
+        $open_id     = $wechat_user['openid'];
+        $session_key = $wechat_user['session_key'] ?? '';
+
+        $user = UserModel::get(['username' => $open_id]);
+        if (empty($user)) {
+            $user = UserModel::fastRegister($open_id, $user_info['nickName'] ?? '', $user_info['avatarUrl'] ?? '', $user_info['gender'] ?? 0);
+            if (!$user) {
+                fail('注册用户失败');
+            }
+        } else {
+            $data = [
+                // 'nickname'  => $user_info['nickName'],
+                // 'avatar'    => $user_info['avatarUrl'],
+                'logintime' => time(),
+            ];
+
+            // if (!$user->parent_id) {
+            //     $data['parent_id'] = $from_user_id;
+            // }
+
+            $user->isUpdate(true)->save($data);
+        }
+
+        // 记录session_key,用于后续获取手机号码等功能
+        CacheService::setWechatUserSessionKey($user->id, $session_key);
+
+        // 清除之前的token
+        Token::clear($user->id);
+
+        // 直接登录
+        $this->auth->direct($user->id);
+
+        // 用户扩展信息
+        $info = UserInfoModel::getUserInfo($user->id);
+
+        $this->success('', [
+            'token' => $this->auth->getToken(),
+            'user'  => array_merge($user->only($this->visibleFields), ['info' => $info->toArray()]),
+        ]);
+    }
+
+    /**
+     * 用户信息
+     */
+    public function info()
+    {
+        $user         = $this->auth->getUser()->visible($this->visibleFields)->toArray();
+        $user['info'] = UserInfoModel::getUserInfo($this->auth->id);
+        $this->success('', $user);
+    }
+
+    /**
+     * 获取微信绑定的手机号码
+     */
+    public function getWechatPhone()
+    {
+        $iv            = input('iv/s', '');
+        $encryptedData = input('encryptedData/s', '');
+
+        if (!$iv) {
+            fail('缺少小程序参数iv');
+        }
+        if (!$encryptedData) {
+            fail('缺少小程序参数encryptedData');
+        }
+        if (!$session_key = CacheService::getWechatUserSessionKey($this->auth->id)) {
+            fail('微信sessionKey丢失,请重新登录再试');
+        }
+
+        // try {
+        $service = new WechatService();
+        $data    = $service->decryptedData($session_key, $iv, $encryptedData);
+        succ($data);
+        // } catch (\Exception $exception) {
+        //     fail('sessionKey失效,请重新登录再试:' . $exception->getMessage());
+        // }
+    }
+
+    /**
+     * 保存个人信息
+     */
+    public function save()
+    {
+        $update_fields = ['avatar', 'nickname', 'mobile', 'gender', 'birthday'];
+        $data          = ['updatetime' => time()];
+        foreach ($update_fields as $field) {
+            $value = input("{$field}/s", '');
+            if ($value !== '') {
+                $data[$field] = $value;
+            }
+        }
+
+        $user = $this->auth->getUser();
+        if ($user->save($data)) {
+            succ(['user' => $user->visible($this->visibleFields)]);
+        }
+
+        fail('保存失败,请重试');
+    }
+
+    /**
+     * 账号密码注册
+     */
+    public function register()
+    {
+        if (!$username = input('username/s')) {
+            fail('请填写登录账号');
+        }
+        if (!$password = input('password/s')) {
+            fail('请填写登录密码');
+        }
+        if (!$nickname = input('nickname/s')) {
+            fail('请填写昵称');
+        }
+        if (!$mobile = input('mobile/s')) {
+            fail('请填写手机号码');
+        }
+        if ($mobile && !Validate::regex($mobile, "^1\d{10}$")) {
+            fail(__('Mobile is incorrect'));
+        }
+        $gender = input('gender/d', 1);
+
+        // 注册
+        $user = UserModel::fastRegister($username, $nickname, '', $gender, $password, $mobile);
+        // 用户扩展信息
+        $info = UserInfoModel::getUserInfo($user->id);
+        // 接口层登录
+        $this->auth->direct($user->id);
+        succ([
+            'user'  => array_merge($user->only($this->visibleFields), ['info' => $info->toArray()]),
+            'token' => $this->auth->getToken()
+        ]);
+    }
+
+    /**
+     * 账号密码登录
+     */
+    public function userLogin()
+    {
+        if (!$username = input('username/s')) {
+            fail('请填写登录账号');
+        }
+        if (!$password = input('password/s')) {
+            fail('请填写登录密码');
+        }
+
+        if (!$username || !$password) {
+            $this->error(__('Invalid parameters'));
+        }
+
+        $user = UserModel::get(['username' => $username]);
+        if (!$user) {
+            fail('登录失败,账号或密码错误');
+        }
+        if ($user->password != $this->auth->getEncryptPassword($password, $user->salt)) {
+            fail('登录失败,账号或密码错误');
+        }
+        if ($user->status != 'normal') {
+            fail('登录失败,账号已被禁用登录');
+        }
+
+        // 用户扩展信息
+        $info = UserInfoModel::getUserInfo($user->id);
+        // 接口层登录
+        $this->auth->direct($user->id);
+        succ([
+            'user'  => array_merge($user->only($this->visibleFields), ['info' => $info->toArray()]),
+            'token' => $this->auth->getToken()
+        ]);
+    }
+
+    /**
+     * 保存用户常用题库设置
+     */
+    // public function saveMyCate()
+    // {
+    //     if (!$cate_id = input('cate_id/d', 0)) {
+    //         fail('请选择常用题库类型');
+    //     }
+    //     if (!$cate = CateModel::get($cate_id)) {
+    //         fail('题库类型不存在,请重新选择');
+    //     }
+    //
+    //     $cate_ids   = [$cate['id']];
+    //     $cate_names = [$cate['name']];
+    //
+    //     // 上级
+    //     if ($cateParent1 = CateModel::where('id', $cate['parent_id'])->find()) {
+    //         array_unshift($cate_ids, $cateParent1['id']);
+    //         array_unshift($cate_names, $cateParent1['name']);
+    //
+    //         // 上上级
+    //         if ($cateParent2 = CateModel::where('id', $cateParent1['parent_id'])->find()) {
+    //             array_unshift($cate_ids, $cateParent2['id']);
+    //             array_unshift($cate_names, $cateParent2['name']);
+    //         }
+    //     }
+    //
+    //     $info                     = UserInfoModel::getUserInfo($this->auth->id);
+    //     $info->default_cate_ids   = $cate_ids;
+    //     $info->default_cate_names = $cate_names;
+    //
+    //     if ($info->save()) {
+    //         succ($info->toArray());
+    //     }
+    //
+    //     fail('保存失败,请重试');
+    // }
+}

+ 78 - 0
addons/exam/enum/BaseEnum.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace addons\exam\enum;
+
+use ReflectionClassConstant;
+
+class BaseEnum
+{
+    /**
+     * 获取所有常量
+     * @return array
+     */
+    public static function getConst(): array
+    {
+        $objClass = new \ReflectionClass(get_called_class());
+        return $objClass->getConstants();
+    }
+
+    /**
+     * 获取所有常量名
+     * @return array
+     */
+    public static function getConstantsKeys(): array
+    {
+        return array_keys(self::getConst());
+    }
+
+    /**
+     * 获取所有常量值
+     * @return array
+     */
+    public static function getConstantsValues(): array
+    {
+        return array_values(self::getConst());
+    }
+
+    /**
+     * 获取常量注释
+     * @param string $key 常量名
+     * @return string
+     */
+    public static function getDescription(string $key): string
+    {
+        return preg_replace('#[\*\s]*(^/|/$)[\*\s]*#', '', (new ReflectionClassConstant(static::class, $key))->getDocComment());
+    }
+
+    /**
+     * 获取常量名和注释列表
+     * @return array
+     */
+    public static function getKeyDescription(): array
+    {
+        $keys   = self::getConstantsKeys();
+        $result = [];
+
+        foreach ($keys as $key => $key_name) {
+            $result[$key_name] = self::getDescription($key_name);
+        }
+
+        return $result;
+    }
+
+    /**
+     * 获取常量值和注释列表
+     * @return array
+     */
+    public static function getValueDescription(): array
+    {
+        $const  = self::getConst();
+        $result = [];
+
+        foreach ($const as $key => $value) {
+            $result[$value] = self::getDescription($key);
+        }
+
+        return $result;
+    }
+}

+ 13 - 0
addons/exam/enum/CateKind.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace addons\exam\enum;
+
+class CateKind extends BaseEnum
+{
+    /** 题库 */
+    const QUESTION = 'QUESTION';
+    /** 试卷 */
+    const PAPER = 'PAPER';
+    /** 考场 */
+    const ROOM = 'ROOM';
+}

+ 11 - 0
addons/exam/enum/CommonStatus.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace addons\exam\enum;
+
+class CommonStatus extends BaseEnum
+{
+    /** 正常 */
+    const NORMAL = 'NORMAL';
+    /** 隐藏 */
+    const HIDDEN = 'HIDDEN';
+}

+ 11 - 0
addons/exam/enum/ExamMode.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace addons\exam\enum;
+
+class ExamMode extends BaseEnum
+{
+    /** 试卷考试 */
+    const PAPER = 'PAPER';
+    /** 考场考试 */
+    const ROOM = 'ROOM';
+}

+ 11 - 0
addons/exam/enum/PaperMode.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace addons\exam\enum;
+
+class PaperMode extends BaseEnum
+{
+    /** 随机 */
+    const RANDOM = 'RANDOM';
+    /** 固定 */
+    const FIX = 'FIX';
+}

+ 15 - 0
addons/exam/enum/RoomMode.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace addons\exam\enum;
+
+class RoomMode extends BaseEnum
+{
+    /** 常规模式 */
+    const GENERAL = 'GENERAL';
+    /** 密码模式 */
+    const PASSWORD = 'PASSWORD';
+    /** 审核模式 */
+    const AUDIT = 'AUDIT';
+    // /** 付费 */
+    // const PAY = 'PAY';
+}

+ 13 - 0
addons/exam/enum/RoomSignupStatus.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace addons\exam\enum;
+
+class RoomSignupStatus extends BaseEnum
+{
+    /** 待审核 */
+    const WAIT = 0;
+    /** 报名成功 */
+    const ACCEPT = 1;
+    /** 报名被拒绝 */
+    const REJECT = 2;
+}

+ 11 - 0
addons/exam/enum/UserStatus.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace addons\exam\enum;
+
+class UserStatus extends BaseEnum
+{
+    /** 正常 */
+    const NORMAL = 'normal';
+    /** 隐藏 */
+    const HIDDEN = 'hidden';
+}

+ 67 - 0
addons/exam/enum/UserType.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace addons\exam\enum;
+
+/**
+ * 会员类型
+ */
+class UserType extends BaseEnum
+{
+    /** 普通用户 */
+    const NORMAL = 'NORMAL';
+    /** 月卡会员 */
+    const VIP_MONTH = 'VIP_MONTH';
+    /** 年卡会员 */
+    const VIP_YEAR = 'VIP_YEAR';
+    /** 终身会员 */
+    const VIP_LIFE = 'VIP_LIFE';
+
+    /**
+     * 获取会员类型时限说明
+     * @param $type
+     * @return string
+     */
+    public static function getDurationDesc($type)
+    {
+        switch ($type) {
+            case self::VIP_MONTH:
+                return '1个月';
+
+            case self::VIP_YEAR:
+                return '12个月';
+
+            case self::VIP_LIFE:
+                return '终身会员';
+
+            default:
+                return '非会员';
+        }
+    }
+
+    /**
+     * 获取会员类型时限值
+     * @param $type
+     * @return int
+     */
+    public static function getDurationVal($type)
+    {
+        switch ($type) {
+            case self::VIP_MONTH:
+                return intval(date("t", strtotime(date('Y-m-d'))));
+
+            case self::VIP_YEAR:
+                $year = date('Y');
+                $days = 0;
+                for ($month = 1; $month <= 12; $month++) {
+                    $days = $days + date("t", strtotime("{$year}-{$month}"));
+                }
+                return $days;
+
+            case self::VIP_LIFE:
+                return 365 * 100;
+
+            default:
+                return 0;
+        }
+    }
+}

+ 294 - 0
addons/exam/helper.php

@@ -0,0 +1,294 @@
+<?php
+
+if (!function_exists('dd')) {
+    function dd($obj)
+    {
+        halt($obj);
+    }
+}
+
+if (!function_exists('fail')) {
+    /**
+     * 主动抛错
+     */
+    function fail($message, $data = [], $code = 0)
+    {
+        $result = [
+            'code' => $code,
+            'data' => $data,
+            'msg'  => is_array($message) ? json_encode($message) : $message,
+        ];
+
+        // 如果未设置类型则自动判断
+        $type     = 'json';
+        $response = \think\Response::create($result, $type, 200);
+
+        throw new \think\exception\HttpResponseException($response);
+    }
+}
+
+if (!function_exists('succ')) {
+    /**
+     * 成功返回
+     */
+    function succ($data = [], $message = '')
+    {
+        $result = [
+            'code' => 1,
+            'data' => $data,
+            'msg'  => $message,
+        ];
+
+        // 如果未设置类型则自动判断
+        $type     = 'json';
+        $response = \think\Response::create($result, $type, 200);
+
+        throw new \think\exception\HttpResponseException($response);
+    }
+}
+
+if (!function_exists('getConfig')) {
+    /**
+     * 获取配置
+     * @param string  $field   配置组名
+     * @param string  $key     字段
+     * @param string  $default 字段默认值
+     * @param boolean $refresh 是否刷新缓存
+     * @return mixed
+     */
+    function getConfig(string $field, $key = '', $default = '', $refresh = true)
+    {
+        $config = \think\Cache::get($field);
+        if (!$config || $refresh) {
+            $config = \think\Db::name('exam_config_info')->order('id')->limit(1)->value($field);
+            if (!$config) {
+                return null;
+            }
+
+            $config = json_decode($config, true);
+            //存入缓存
+            \think\Cache::set($field, $config);
+        }
+
+        if ($key) {
+            return $config[$key] ?? $default;
+        }
+
+        return $config;
+    }
+}
+
+if (!function_exists('getCurl')) {
+    /**
+     * get请求
+     * @param $url
+     * @return bool|string
+     */
+    function getCurl($url)
+    {
+        $ch = curl_init();
+        curl_setopt($ch, CURLOPT_URL, $url);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        $data = curl_exec($ch);
+        curl_close($ch);
+
+        return $data;
+    }
+}
+
+if (!function_exists('postCurl')) {
+    /**
+     * post请求
+     * @param        $url
+     * @param string $data
+     * @param string $type
+     * @return bool|string
+     */
+    function postCurl($url, $data = '', $type = 'json')
+    {
+        if ($type == 'json') {
+            $data   = json_encode($data); //对数组进行json编码
+            $header = array("Content-type: application/json;charset=UTF-8", "Accept: application/json", "Cache-Control: no-cache", "Pragma: no-cache");
+        }
+        $curl = curl_init();
+        curl_setopt($curl, CURLOPT_URL, $url);
+        curl_setopt($curl, CURLOPT_POST, 1);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
+        if (!empty($data)) {
+            curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
+        }
+        curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
+        curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
+        $res = curl_exec($curl);
+        if (curl_errno($curl)) {
+            echo 'Error+' . curl_error($curl);
+        }
+        curl_close($curl);
+        return $res;
+    }
+}
+
+if (!function_exists('only_keys')) {
+    /**
+     * 只取数组部分key数据
+     * @param array $array
+     * @param array $keys
+     * @return array
+     */
+    function only_keys(array $array, array $keys)
+    {
+        $result = [];
+        foreach ($array as $k => $value) {
+            if (in_array($k, $keys)) {
+                $result[$k] = $value;
+            }
+        }
+        return $result;
+    }
+}
+
+if (!function_exists('hidden_keys')) {
+    /**
+     * 隐藏数组部分key数据
+     * @param array $array
+     * @param array $keys
+     * @return array
+     */
+    function hidden_keys(array $array, array $keys)
+    {
+        $result = [];
+        foreach ($array as $k => $value) {
+            if (in_array($k, $keys)) {
+                unset($value[$k]);
+                $result[$k] = $value;
+            }
+        }
+        return $result;
+    }
+}
+
+if (!function_exists('hidden_list_keys')) {
+    /**
+     * 隐藏数组部分key数据
+     * @param array $list
+     * @param array $keys
+     * @return array
+     */
+    function hidden_list_keys(array $list, array $keys)
+    {
+        $list   = collection($list)->toArray();
+        $result = [];
+        foreach ($list as $i => $item) {
+            foreach ($item as $k => $value) {
+                if (in_array($k, $keys)) {
+                    unset($item[$k]);
+                }
+            }
+            $result[$i] = $item;
+        }
+        return $result;
+    }
+}
+
+if (!function_exists('is_empty_in_array')) {
+    /**
+     * 数组内是否包含且存在字段值
+     * @param $array
+     * @param $field
+     * @return bool
+     */
+    function is_empty_in_array($array, $field)
+    {
+        if (!isset($array[$field]) || !$array[$field]) {
+            return true;
+        }
+
+        return false;
+    }
+}
+
+if (!function_exists('cache_data')) {
+    /**
+     * 获取/设置缓存数据
+     * @param string  $cache_key   缓存key名
+     * @param Closure $fun         用户函数,获取并返回数据
+     * @param int     $expire_time 缓存过期时间
+     * @return mixed
+     */
+    function cache_data(string $cache_key, Closure $fun, int $expire_time = 0, bool $refresh = false)
+    {
+        // 固定前缀
+        $cache_key = "exam:{$cache_key}";
+
+        // 存在缓存,返回缓存
+        if (!$refresh && $cache = cache($cache_key)) {
+            return $cache;
+        }
+
+        // 执行数据获取
+        $data = $fun();
+        $data = is_array($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : $data;
+
+        // 设置缓存
+        cache($cache_key, $data, $expire_time);
+        return $data;
+    }
+}
+
+if (!function_exists('getUser')) {
+    /**
+     * 获取Api用户信息
+     * @return mixed
+     */
+    function getUser()
+    {
+        if (\app\common\library\Auth::instance()->isLogin()) {
+            return \app\common\library\Auth::instance();
+        }
+
+        return null;
+    }
+}
+
+if (!function_exists('getUserId')) {
+    /**
+     * 获取Api用户ID
+     * @return mixed
+     */
+    function getUserId()
+    {
+        if ($user = getUser()) {
+            return $user->id;
+        }
+
+        return 0;
+    }
+}
+
+if (!function_exists('str_trim')) {
+    /**
+     * 字符串去除空格
+     * @return string
+     */
+    function str_trim($str)
+    {
+        return str_replace(' ', '', $str);
+    }
+}
+
+if (!function_exists('generate_no')) {
+    /**
+     * 根据时间生成编号
+     * @return string
+     */
+    function generate_no($pre = '')
+    {
+        $date         = date('YmdHis', time());
+        $u_timestamp  = microtime(true);
+        $timestamp    = floor($u_timestamp);
+        $milliseconds = round(($u_timestamp - $timestamp) * 100); // 改这里的数值控制毫秒位数
+        return $pre . $date . date(preg_replace('`(?<!\\\\)u`', $milliseconds, 'u'), $timestamp);
+    }
+}

+ 10 - 0
addons/exam/info.ini

@@ -0,0 +1,10 @@
+name = exam
+title = 答题考试系统
+intro = 试卷答题、考场考试系统
+author = coder
+website = https://exam.jykezhi.com/master.php
+version = 1.5.9
+state = 1
+license = regular
+licenseto = 19079
+url = /addons/exam

+ 507 - 0
addons/exam/install.sql

@@ -0,0 +1,507 @@
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_cate` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `kind` enum('QUESTION','PAPER','ROOM','COURSE') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'QUESTION' COMMENT '种类',
+    `level` enum('1','2','3') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1' COMMENT '类型',
+    `name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名称',
+--     `icon` VARCHAR(200) NULL DEFAULT '' COMMENT '图标' COLLATE 'utf8mb4_unicode_ci',
+    `parent_id` int(11) NOT NULL DEFAULT '0' COMMENT '父级',
+    `sort` int(11) NOT NULL DEFAULT '1' COMMENT '排序',
+    `remark` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '简介',
+    `deletetime` bigint(16) DEFAULT NULL COMMENT '删除时间',
+    `status` ENUM('0','1') NOT NULL DEFAULT '1' COMMENT '状态:0=禁用,1=启用' COLLATE 'utf8_general_ci',
+    PRIMARY KEY (`id`),
+    KEY `parent_id` (`parent_id`),
+    KEY `kind` (`kind`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试题分类';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_config_info` (
+    `id` int(11) NOT NULL AUTO_INCREMENT,
+    `ad_config` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '广告位配置',
+    `system_config` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '系统配置',
+    `wx_config` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '微信配置',
+    `page_config` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '页面配置',
+    PRIMARY KEY (`id`) USING BTREE
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='参数配置';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_grade` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `cate_id` int(11) unsigned NOT NULL COMMENT '所属分类',
+    `user_id` int(11) unsigned NOT NULL COMMENT '考试用户',
+    `paper_id` int(11) unsigned NOT NULL COMMENT '所属试卷',
+    `mode` ENUM('RANDOM','FIX') NOT NULL DEFAULT 'RANDOM' COMMENT '选题模式' COLLATE 'utf8mb4_unicode_ci',
+    `score` tinyint(3) unsigned NOT NULL COMMENT '考试分数',
+    `system_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '系统得分',
+    `manual_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '人工判分',
+    `is_pass` tinyint(3) unsigned NOT NULL COMMENT '是否及格',
+    `total_score` tinyint(3) unsigned NOT NULL COMMENT '总分数',
+    `total_count` tinyint(3) unsigned NOT NULL COMMENT '总题数',
+    `right_count` tinyint(3) unsigned NOT NULL COMMENT '答对数',
+    `error_count` tinyint(3) unsigned NOT NULL COMMENT '答错数',
+    `grade_time` int(10) unsigned NOT NULL COMMENT '考试用时',
+    `date` CHAR(10) NOT NULL DEFAULT '' COMMENT '考试日期',
+    `question_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '试卷ID集合' COLLATE 'utf8mb4_unicode_ci',
+    `error_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '错题ID集合' COLLATE 'utf8mb4_unicode_ci',
+    `user_answers` TEXT NULL DEFAULT NULL COMMENT '用户答案集合' COLLATE 'utf8mb4_unicode_ci',
+    `configs` TEXT NULL DEFAULT NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `user_id` (`user_id`),
+    KEY `paper_id` (`paper_id`),
+    KEY `work_type_id` (`cate_id`) USING BTREE
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试成绩';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_notice` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '标题',
+    `contents` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '内容',
+    `weigh` int(11) NOT NULL DEFAULT '1' COMMENT '排序',
+    `status` enum('NORMAL','HIDDEN') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NORMAL' COMMENT '状态',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统公告';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_paper` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `cate_id` int(11) unsigned NOT NULL COMMENT '试卷分类',
+    `mode` enum('RANDOM','FIX') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RANDOM' COMMENT '选题模式',
+    `title` varchar(3000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '试卷名称',
+    `configs` varchar(3000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '选题配置',
+    `quantity` int(10) unsigned NOT NULL COMMENT '题目数量',
+    `total_score` tinyint(3) unsigned NOT NULL COMMENT '试卷总分',
+    `pass_score` tinyint(3) unsigned NOT NULL COMMENT '及格线',
+    `limit_time` int(10) unsigned NOT NULL COMMENT '考试限时',
+    `join_count` int(10) NOT NULL DEFAULT '0' COMMENT '参与人次',
+    `day_limit_count` INT(10) NOT NULL DEFAULT '0' COMMENT '每日限制考试次数',
+    `start_time` BIGINT(16) NOT NULL DEFAULT '0' COMMENT '开始时间',
+    `end_time` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '过期时间',
+    `is_only_room` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '仅用于考场',
+    `status` enum('NORMAL','HIDDEN') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NORMAL' COMMENT '状态',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    `deletetime` bigint(16) DEFAULT NULL COMMENT '删除时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `cate_id` (`cate_id`,`status`) USING BTREE
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_paper_question` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `paper_id` int(11) unsigned NOT NULL COMMENT '所属试卷',
+    `question_id` int(11) unsigned NOT NULL COMMENT '试题',
+    `score` int(10) unsigned NOT NULL COMMENT '分数',
+    `sort` int(10) unsigned NOT NULL DEFAULT '1' COMMENT '排序',
+    `answer_config` TEXT NULL DEFAULT NULL COMMENT '正确答案配置' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    `deletetime` bigint(16) DEFAULT NULL COMMENT '删除时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `paper_id` (`paper_id`),
+    KEY `question_id` (`question_id`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷试题';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_question` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `cate_id` int(11) unsigned NOT NULL COMMENT '分类',
+    `kind` enum('JUDGE','SINGLE','MULTI','FILL','SHORT','MATERIAL') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'JUDGE' COMMENT '试题类型',
+    `title` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '题目',
+    `explain` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '解析',
+    `difficulty` enum('EASY','GENERAL','HARD') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'GENERAL' COMMENT '难度',
+    `options_json` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '选项',
+    `options_img` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '选项图片',
+    `options_extend` TEXT NULL DEFAULT NULL COMMENT '选项扩展' COLLATE 'utf8mb4_unicode_ci',
+    `answer` TEXT NOT NULL COMMENT '正确答案' COLLATE 'utf8mb4_unicode_ci',
+    `status` enum('NORMAL','HIDDEN') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NORMAL' COMMENT '状态',
+    `is_material_child` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '属于材料题子题:0=否,1=是',
+    `material_question_id` INT(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '所属材料题',
+    `material_score` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '材料子题分数',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    `deletetime` bigint(16) DEFAULT NULL COMMENT '删除时间',
+    PRIMARY KEY (`id`),
+    KEY `kind` (`kind`,`status`) USING BTREE,
+    KEY `cate_id` (`cate_id`,`kind`,`status`) USING BTREE
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试题';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_question_collect` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `user_id` int(11) unsigned NOT NULL COMMENT '用户',
+    `question_id` int(11) unsigned NOT NULL COMMENT '试题',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `question_id` (`question_id`) USING BTREE,
+    KEY `user_id` (`user_id`) USING BTREE
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目收藏';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_question_wrong` (
+    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+    `user_id` INT(11) UNSIGNED NOT NULL COMMENT '用户',
+    `question_id` INT(11) UNSIGNED NOT NULL COMMENT '试题',
+    `user_answer` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '用户答案' COLLATE 'utf8mb4_unicode_ci',
+    `kind` ENUM('PAPER','ROOM','TRAINING') NULL DEFAULT 'PAPER' COMMENT '来源:PAPER=试卷,ROOM=考场,TRAINING=练题' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` BIGINT(16) UNSIGNED NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX `question_id` (`question_id`) USING BTREE,
+    INDEX `kind` (`kind`) USING BTREE,
+    INDEX `user_id` (`user_id`, `kind`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='错题记录';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_room` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '考场标题',
+    `contents` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '考场说明',
+    `cate_id` int(11) NOT NULL COMMENT '考场分类',
+    `paper_id` int(11) NOT NULL COMMENT '考试试卷',
+    `people_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '限制考场人数',
+    `start_time` bigint(16) NOT NULL DEFAULT '0' COMMENT '考试开始时间',
+    `end_time` bigint(16) NOT NULL DEFAULT '0' COMMENT '考试结束时间',
+    `weigh` int(11) NOT NULL DEFAULT '1' COMMENT '排序',
+    `status` enum('NORMAL','HIDDEN') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NORMAL' COMMENT '状态',
+    `signup_mode` enum('NORMAL','PASSWORD','AUDIT') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NORMAL' COMMENT '报名方式:NORMAL=普通模式,PASSWORD=密码模式,AUDIT=审核模式',
+    `password` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '考场密码',
+    `is_makeup` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否允许补考:0=关闭,1=开启',
+    `makeup_count` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '补考次数',
+    `is_rank` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否已排名',
+    `signup_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '报考人数',
+    `grade_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '考试人数',
+    `pass_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '及格人数',
+    `pass_rate` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '及格率',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    `deletetime` bigint(16) DEFAULT NULL COMMENT '删除时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `paper_id` (`paper_id`),
+    KEY `status` (`status`) USING BTREE,
+    KEY `cate_id` (`status`,`cate_id`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试考场';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_room_grade` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `user_id` int(11) NOT NULL COMMENT '考试用户',
+    `cate_id` int(11) unsigned NOT NULL COMMENT '所属分类',
+    `room_id` int(11) unsigned NOT NULL COMMENT '所属考场',
+    `paper_id` int(11) unsigned NOT NULL COMMENT '所属试卷',
+    `mode` ENUM('RANDOM','FIX') NOT NULL DEFAULT 'RANDOM' COMMENT '选题模式' COLLATE 'utf8mb4_unicode_ci',
+    `score` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '考试分数',
+    `system_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '系统得分',
+    `manual_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '人工判分',
+    `is_pass` tinyint(3) NOT NULL DEFAULT '0' COMMENT '是否及格:0=不及格,1=及格',
+    `is_makeup` tinyint(3) NOT NULL DEFAULT '0' COMMENT '是否是补考:0=否,1=是',
+    `total_score` tinyint(3) NOT NULL DEFAULT '0' COMMENT '总分数',
+    `total_count` tinyint(3) NOT NULL DEFAULT '0' COMMENT '总题数',
+    `right_count` tinyint(3) NOT NULL DEFAULT '0' COMMENT '答对数',
+    `error_count` tinyint(3) NOT NULL DEFAULT '0' COMMENT '答错数',
+    `rank` tinyint(3) NOT NULL DEFAULT '0' COMMENT '本次考试排名',
+    `is_pre` tinyint(3) NOT NULL DEFAULT '0' COMMENT '是否为预载入数据',
+    `grade_time` int(10) unsigned NOT NULL COMMENT '考试用时',
+    `question_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '试卷ID集合' COLLATE 'utf8mb4_unicode_ci',
+    `error_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '错题ID集合' COLLATE 'utf8mb4_unicode_ci',
+    `user_answers` TEXT NULL DEFAULT NULL COMMENT '用户答案集合' COLLATE 'utf8mb4_unicode_ci',
+    `configs` TEXT NULL DEFAULT NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `user_id` (`user_id`) USING BTREE,
+    KEY `paper_id` (`paper_id`) USING BTREE,
+    KEY `cate_id` (`cate_id`) USING BTREE,
+    KEY `FK2_exam_room_grade_with_exam_room` (`room_id`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考场考试成绩';
+
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_room_signup` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+    `room_id` int(11) unsigned NOT NULL COMMENT '所属考场',
+    `user_id` int(11) unsigned NOT NULL COMMENT '报名用户',
+    `real_name` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '真实姓名',
+    `phone` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '手机号码',
+    `message` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '审核说明',
+    `status` enum('0','1','2') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '状态:0=未审核,1=报名成功,2=报名被拒绝',
+    `createtime` bigint(16) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `room_id` (`room_id`,`status`),
+    KEY `user_id` (`user_id`,`status`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考场报名';
+
+-- 1.0.2版本,新增考卷每日限制考试次数
+ALTER TABLE `__PREFIX__exam_paper` ADD COLUMN `day_limit_count` INT(10) NOT NULL DEFAULT '0' COMMENT '每日限制考试次数' AFTER `join_count`;
+ALTER TABLE `__PREFIX__exam_grade` ADD COLUMN `date` CHAR(10) NOT NULL DEFAULT '' COMMENT '考试日期' AFTER `grade_time`;
+
+-- 1.0.4版本,新增系统配置 - 页面配置
+ALTER TABLE `__PREFIX__exam_config_info` ADD COLUMN `page_config` MEDIUMTEXT NULL DEFAULT NULL COMMENT '页面配置' COLLATE 'utf8mb4_unicode_ci' AFTER `wx_config`;
+
+
+-- 1.0.6版本
+-- 加大选项图片字段长度
+ALTER TABLE `__PREFIX__exam_question` CHANGE COLUMN `options_img` `options_img` VARCHAR(1000) NULL DEFAULT NULL COMMENT '选项图片' COLLATE 'utf8mb4_unicode_ci' AFTER `options_json`;
+
+
+-- 1.0.9版本
+-- 题库新增图标
+-- ALTER TABLE `__PREFIX__exam_cate` ADD COLUMN `icon` VARCHAR(200) NULL DEFAULT '' COMMENT '图标' AFTER `name`;
+
+-- 用户信息
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_user_info` (
+    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+    `type` ENUM('NORMAL','VIP_MONTH','VIP_YEAR','VIP_LIFE') NOT NULL DEFAULT 'NORMAL' COMMENT '用户类型:NORMAL=普通用户,VIP_MONTH=月卡会员,VIP_YEAR=年卡会员,VIP_LIFE=终身会员' COLLATE 'utf8mb4_unicode_ci',
+    `member_config_id` INT(10) NOT NULL COMMENT '开通会员类型',
+    `user_id` INT(11) NOT NULL DEFAULT '0' COMMENT '用户ID',
+    `score` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '积分',
+    `score_inc` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '累计获得积分',
+    `score_dec` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '累计支出积分',
+    `expire_time` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '过期时间',
+    `createtime` BIGINT(16) NULL DEFAULT NULL COMMENT '创建时间',
+    `updatetime` BIGINT(16) NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX `user_id` (`user_id`) USING BTREE,
+    INDEX `type` (`type`) USING BTREE,
+    INDEX `member_config_id` (`member_config_id`, `expire_time`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息';
+
+-- 1.0.11
+-- 试卷加开始、过期时间,仅用于考场字段
+ALTER TABLE `__PREFIX__exam_paper` ADD COLUMN `end_time` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '过期时间' AFTER `member_price`;
+ALTER TABLE `__PREFIX__exam_paper`
+    ADD COLUMN `start_time` BIGINT(16) NOT NULL DEFAULT '0' COMMENT '开始时间' AFTER `member_price`,
+	ADD COLUMN `is_only_room` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '仅用于考场' AFTER `end_time`,
+	ADD INDEX `is_only_room` (`is_only_room`),
+	ADD INDEX `start_time` (`start_time`);
+
+-- 试题新增【填空题】类型
+ALTER TABLE `__PREFIX__exam_question` ADD COLUMN `options_extend` VARCHAR(1000) NULL DEFAULT NULL COMMENT '选项扩展' COLLATE 'utf8mb4_unicode_ci' AFTER `options_img`;
+ALTER TABLE `__PREFIX__exam_question` CHANGE COLUMN `kind` `kind` ENUM('JUDGE','SINGLE','MULTI','FILL') NOT NULL DEFAULT 'JUDGE' COMMENT '试题类型' COLLATE 'utf8mb4_unicode_ci';
+ALTER TABLE `__PREFIX__exam_question` CHANGE COLUMN `answer` `answer` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '正确答案' COLLATE 'utf8mb4_unicode_ci';
+
+-- 1.0.12
+-- 考卷成绩新增答题信息
+ALTER TABLE `__PREFIX__exam_grade`
+    ADD COLUMN `question_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '试卷ID集合' COLLATE 'utf8mb4_unicode_ci' AFTER `date`,
+	ADD COLUMN `error_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '错题ID集合' COLLATE 'utf8mb4_unicode_ci' AFTER `question_ids`,
+	ADD COLUMN `user_answers` TEXT NULL DEFAULT NULL COMMENT '用户答案集合' COLLATE 'utf8mb4_unicode_ci' AFTER `error_ids`;
+
+-- 考场成绩新增答题信息
+ALTER TABLE `__PREFIX__exam_room_grade`
+    ADD COLUMN `question_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '试卷ID集合' COLLATE 'utf8mb4_unicode_ci' AFTER `grade_time`,
+	ADD COLUMN `error_ids` VARCHAR(2000) NULL DEFAULT '' COMMENT '错题ID集合' COLLATE 'utf8mb4_unicode_ci' AFTER `question_ids`,
+	ADD COLUMN `user_answers` TEXT NULL DEFAULT NULL COMMENT '用户答案集合' COLLATE 'utf8mb4_unicode_ci' AFTER `error_ids`;
+
+-- 1.0.13
+-- 考卷成绩新增考卷选题配置
+ALTER TABLE `__PREFIX__exam_grade`
+    ADD COLUMN `configs` VARCHAR(1000) NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `user_answers`;
+
+-- 考场成绩新增考卷选题配置
+ALTER TABLE `__PREFIX__exam_room_grade`
+    ADD COLUMN `configs` VARCHAR(1000) NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `user_answers`;
+
+
+-- 1.1.0
+-- 错题新增用户答案
+ALTER TABLE `__PREFIX__exam_question_wrong`
+    ADD COLUMN `user_answer` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '用户答案' COLLATE 'utf8mb4_unicode_ci' AFTER `question_id`;
+
+ALTER TABLE `__PREFIX__exam_grade`
+    ADD COLUMN `mode` ENUM('RANDOM','FIX') NOT NULL DEFAULT 'RANDOM' COMMENT '选题模式' COLLATE 'utf8mb4_unicode_ci' AFTER `paper_id`;
+
+ALTER TABLE `__PREFIX__exam_room_grade`
+    ADD COLUMN `mode` ENUM('RANDOM','FIX') NOT NULL DEFAULT 'RANDOM' COMMENT '选题模式' COLLATE 'utf8mb4_unicode_ci' AFTER `paper_id`;
+
+-- 1.2.1
+-- 新增公告前端跳转信息
+ALTER TABLE `__PREFIX__exam_notice`
+    ADD COLUMN `front_info` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '前端跳转信息' AFTER `status`;
+
+-- 1.3.0
+-- 新增试题类型【简答题】、【材料题】
+ALTER TABLE `__PREFIX__exam_question`
+    CHANGE COLUMN `kind` `kind` ENUM('JUDGE','SINGLE','MULTI','FILL','SHORT','MATERIAL') NOT NULL DEFAULT 'JUDGE' COMMENT '试题类型' COLLATE 'utf8mb4_unicode_ci' AFTER `cate_id`;
+ALTER TABLE `__PREFIX__exam_question`
+    CHANGE COLUMN `title` `title` VARCHAR(2000) NOT NULL COMMENT '题目' COLLATE 'utf8mb4_unicode_ci' AFTER `kind`;
+ALTER TABLE `__PREFIX__exam_question`
+    CHANGE COLUMN `answer` `answer` TEXT NOT NULL COMMENT '正确答案' COLLATE 'utf8mb4_unicode_ci' AFTER `options_extend`;
+ALTER TABLE `__PREFIX__exam_question`
+    CHANGE COLUMN `options_extend` `options_extend` TEXT NULL COMMENT '选项扩展' COLLATE 'utf8mb4_unicode_ci' AFTER `options_img`;
+
+-- 试卷标题、配置字段扩展长度
+ALTER TABLE `__PREFIX__exam_paper`
+    CHANGE COLUMN `title` `title` VARCHAR(3000) NOT NULL COMMENT '试卷名称' COLLATE 'utf8mb4_unicode_ci' AFTER `mode`,
+    CHANGE COLUMN `configs` `configs` VARCHAR(3000) NOT NULL COMMENT '选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `title`;
+
+-- 试卷固定选题新增试题答案
+ALTER TABLE `__PREFIX__exam_paper_question`
+    ADD COLUMN `answer_config` TEXT NULL COMMENT '正确答案配置' COLLATE 'utf8mb4_unicode_ci' AFTER `sort`;
+
+-- 考试成绩新增系统得分、人工判分
+ALTER TABLE `__PREFIX__exam_grade`
+    ADD COLUMN `system_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '系统得分' AFTER `score`,
+	ADD COLUMN `manual_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '人工判分' AFTER `system_score`;
+
+-- 考场成绩新增系统得分、人工判分
+ALTER TABLE `__PREFIX__exam_room_grade`
+    ADD COLUMN `system_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '系统得分' AFTER `score`,
+	ADD COLUMN `manual_score` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0' COMMENT '人工判分' AFTER `system_score`;
+
+-- 错题记录新增来源
+ALTER TABLE `__PREFIX__exam_question_wrong`
+    ADD COLUMN `kind` ENUM('PAPER','ROOM','TRAINING') NULL DEFAULT 'PAPER' COMMENT '来源:PAPER=试卷,ROOM=考场,TRAINING=练题' AFTER `user_answer`,
+    DROP INDEX `user_id`,
+	ADD INDEX `user_id` (`user_id`, `kind`) USING BTREE;
+
+-- 新增材料题关联表
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_material_question` (
+    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+    `parent_question_id` INT(11) UNSIGNED NOT NULL COMMENT '材料题主题目',
+    `question_id` INT(11) UNSIGNED NOT NULL COMMENT '材料题子题目',
+    `score` INT(11) UNSIGNED NOT NULL COMMENT '分数',
+    `weigh` INT(10) UNSIGNED NOT NULL DEFAULT '1' COMMENT '排序',
+    `answer` TEXT NULL DEFAULT NULL COMMENT '正确答案配置' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` BIGINT(16) UNSIGNED NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    UNIQUE INDEX `from_question_id` (`parent_question_id`, `question_id`) USING BTREE
+) COMMENT='材料题关联表'
+COLLATE='utf8mb4_unicode_ci'
+ENGINE=InnoDB
+ROW_FORMAT=DYNAMIC;
+
+-- 试卷成绩手动判题记录表
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_manual_grade_log` (
+    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+    `admin_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '操作管理员',
+    `user_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '考试用户',
+    `paper_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '试卷ID',
+    `grade_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '考试成绩ID',
+    `question_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '试题ID',
+    `before_score` INT(11) UNSIGNED NOT NULL COMMENT '修改前分数',
+    `after_score` INT(11) UNSIGNED NOT NULL COMMENT '修改后分数',
+    `status` ENUM('0','1') NOT NULL DEFAULT '0' COMMENT '状态:0=未生效,1=已生效' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` BIGINT(16) UNSIGNED NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX `admin_id` (`admin_id`) USING BTREE,
+    INDEX `user_id` (`user_id`) USING BTREE,
+    INDEX `status` (`status`) USING BTREE,
+    INDEX `grade_id` (`grade_id`, `status`) USING BTREE,
+    INDEX `paper_id` (`paper_id`, `status`) USING BTREE,
+    INDEX `question_id` (`question_id`) USING BTREE
+) COMMENT='试卷成绩手动判题记录表'
+COLLATE='utf8mb4_unicode_ci'
+ENGINE=InnoDB
+ROW_FORMAT=DYNAMIC
+;
+
+-- 考场成绩手动判题记录表
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_manual_room_grade_log` (
+    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+    `admin_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '操作管理员',
+    `user_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '考试用户',
+    `paper_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '试卷ID',
+    `room_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '考场ID',
+    `grade_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '考试成绩ID',
+    `question_id` INT(11) UNSIGNED NOT NULL DEFAULT '0' COMMENT '试题ID',
+    `before_score` INT(11) UNSIGNED NOT NULL COMMENT '修改前分数',
+    `after_score` INT(11) UNSIGNED NOT NULL COMMENT '修改后分数',
+    `status` ENUM('0','1') NOT NULL DEFAULT '0' COMMENT '状态:0=未生效,1=已生效' COLLATE 'utf8mb4_unicode_ci',
+    `createtime` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建时间',
+    `updatetime` BIGINT(16) UNSIGNED NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX `admin_id` (`admin_id`) USING BTREE,
+    INDEX `user_id` (`user_id`) USING BTREE,
+    INDEX `status` (`status`) USING BTREE,
+    INDEX `grade_id` (`grade_id`, `status`) USING BTREE,
+    INDEX `paper_id` (`paper_id`, `status`) USING BTREE,
+    INDEX `question_id` (`question_id`) USING BTREE,
+    INDEX `room_id` (`room_id`) USING BTREE
+) COMMENT='考场成绩手动判题记录表'
+COLLATE='utf8mb4_unicode_ci'
+ENGINE=InnoDB
+ROW_FORMAT=DYNAMIC
+;
+
+-- 1.4.0
+-- 成绩记录表扩展配置长度
+ALTER TABLE `__PREFIX__exam_grade`
+    CHANGE COLUMN `configs` `configs` TEXT NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `user_answers`;
+ALTER TABLE `__PREFIX__exam_room_grade`
+    CHANGE COLUMN `configs` `configs` TEXT NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `user_answers`;
+
+-- 材料题关联表新增分数字段
+ALTER TABLE `__PREFIX__exam_material_question`
+    ADD COLUMN `score` INT(11) UNSIGNED NOT NULL COMMENT '分数' AFTER `question_id`,
+    ADD COLUMN `weigh` INT(10) UNSIGNED NOT NULL DEFAULT '1' COMMENT '排序' AFTER `score`,
+	ADD COLUMN `answer` TEXT NULL DEFAULT NULL COMMENT '正确答案配置' COLLATE 'utf8mb4_unicode_ci' AFTER `weigh`;
+
+-- 题目新增材料题子题字段
+ALTER TABLE `__PREFIX__exam_question`
+    ADD COLUMN `is_material_child` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '属于材料题子题:0=否,1=是' AFTER `status`,
+    DROP INDEX `kind`,
+	ADD INDEX `kind` (`kind`, `status`, `is_material_child`) USING BTREE,
+    DROP INDEX `cate_id`,
+	ADD INDEX `cate_id` (`cate_id`, `kind`, `status`, `is_material_child`) USING BTREE,
+	ADD INDEX `is_material_child` (`is_material_child`),
+    ADD COLUMN `material_question_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属材料题' AFTER `is_material_child`,
+	ADD INDEX `material_question_id` (`material_question_id`),
+    ADD COLUMN `material_score` BIGINT(16) UNSIGNED NOT NULL DEFAULT '0' COMMENT '材料子题分数' AFTER `material_question_id`;
+
+-- 1.4.5
+-- 试题新增题目视频字段
+ALTER TABLE `__PREFIX__exam_question`
+    ADD COLUMN `title_video` VARCHAR(200) NULL COMMENT '题目视频' AFTER `title`,
+	ADD COLUMN `explain_video` VARCHAR(200) NULL COMMENT '解析视频' AFTER `explain`;
+
+-- 错题记录用户答案字段扩展长度(某些旧版本没有更新表结构)
+ALTER TABLE `__PREFIX__exam_question_wrong`
+    CHANGE COLUMN `user_answer` `user_answer` TEXT NOT NULL COMMENT '用户答案' COLLATE 'utf8mb4_unicode_ci' AFTER `cate_id`;
+ALTER TABLE `__PREFIX__exam_room_grade`
+    CHANGE COLUMN `configs` `configs` TEXT NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `user_answers`,
+    CHANGE COLUMN `user_answers` `user_answers` TEXT NULL COMMENT '用户答案集合' COLLATE 'utf8mb4_unicode_ci' AFTER `error_ids`;
+ALTER TABLE `__PREFIX__exam_grade`
+    CHANGE COLUMN `configs` `configs` TEXT NULL COMMENT '试卷选题配置' COLLATE 'utf8mb4_unicode_ci' AFTER `user_answers`,
+    CHANGE COLUMN `user_answers` `user_answers` TEXT NULL COMMENT '用户答案集合' COLLATE 'utf8mb4_unicode_ci' AFTER `error_ids`;
+
+-- 1.5.2
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_correction_type` (
+    `id` INT(11) NOT NULL AUTO_INCREMENT,
+    `name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '类型名称' COLLATE 'utf8_general_ci',
+    `createtime` BIGINT(20) NULL DEFAULT NULL COMMENT '创建时间',
+    `updatetime` BIGINT(20) NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE
+) COMMENT='纠错反馈类型' COLLATE='utf8_general_ci' ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__exam_correction_question` (
+    `id` INT(11) NOT NULL AUTO_INCREMENT,
+    `user_id` INT(11) NOT NULL DEFAULT '0' COMMENT '反馈人',
+    `question_id` INT(11) NOT NULL DEFAULT '0' COMMENT '反馈题目',
+    `type_ids` INT(11) NOT NULL DEFAULT '0' COMMENT '纠错类型',
+    `type_names` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '类型名称' COLLATE 'utf8_general_ci',
+    `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '其他说明' COLLATE 'utf8_general_ci',
+    `status` ENUM('0','1','2') NOT NULL DEFAULT '0' COMMENT '状态:0=未处理,1=已处理,2=忽略' COLLATE 'utf8_general_ci',
+    `message` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '处理说明' COLLATE 'utf8_general_ci',
+    `createtime` BIGINT(20) NULL DEFAULT NULL COMMENT '创建时间',
+    `updatetime` BIGINT(20) NULL DEFAULT NULL COMMENT '修改时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX `question_id` (`question_id`) USING BTREE,
+    INDEX `user_id` (`user_id`) USING BTREE
+) COMMENT='纠错反馈试题' COLLATE='utf8_general_ci' ENGINE=InnoDB;
+
+
+ALTER TABLE `__PREFIX__exam_cate`
+    ADD COLUMN `status` ENUM('0','1') NOT NULL DEFAULT '1' COMMENT '状态:0=禁用,1=启用' COLLATE 'utf8_general_ci';
+
+
+-- 1.5.8
+ALTER TABLE `__PREFIX__exam_paper`
+    CHANGE COLUMN `quantity` `quantity` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '题目数量' AFTER `configs`;

+ 48 - 0
addons/exam/library/CacheService.php

@@ -0,0 +1,48 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User         : zgcLives
+ * CreateTime   : 2022/10/20 11:57
+ */
+
+namespace addons\exam\library;
+
+/**
+ * 缓存服务
+ */
+class CacheService
+{
+    /**
+     * 缓存Key:微信用户sessionKey
+     * @param $user_id
+     * @return string
+     */
+    public static function cacheKeyWechatUserSessionKey($user_id)
+    {
+        return "exam:wechat_user:session_key-{$user_id}";
+    }
+
+    /**
+     * 设置微信用户sessionKey缓存
+     * @param $user_id
+     * @param $session_key
+     * @return void
+     */
+    public static function setWechatUserSessionKey($user_id, $session_key)
+    {
+        $cache_key = self::cacheKeyWechatUserSessionKey($user_id);
+        cache($cache_key, $session_key);
+    }
+
+    /**
+     * 获取微信用户sessionKey缓存
+     * @param $user_id
+     * @return string
+     */
+    public static function getWechatUserSessionKey($user_id)
+    {
+        $cache_key = self::cacheKeyWechatUserSessionKey($user_id);
+        return cache($cache_key);
+    }
+    
+}

+ 453 - 0
addons/exam/library/ExamService.php

@@ -0,0 +1,453 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User         : zgcLives
+ * CreateTime   : 2022/4/16 16:21
+ */
+
+namespace addons\exam\library;
+
+use addons\exam\enum\CommonStatus;
+use addons\exam\enum\PaperMode;
+use addons\exam\enum\RoomSignupStatus;
+use addons\exam\model\GradeModel;
+use addons\exam\model\PaperModel;
+use addons\exam\model\QuestionModel;
+use addons\exam\model\RoomGradeModel;
+use addons\exam\model\RoomModel;
+use addons\exam\model\RoomSignupModel;
+use app\admin\model\exam\MaterialQuestionModel;
+
+/**
+ * 考试相关服务
+ */
+class ExamService
+{
+    /**
+     * 获取试卷题目
+     * @param     $paper_id
+     * @param int $room_id
+     * @return array`
+     */
+    public static function getExamQuestion($paper_id, $room_id = 0)
+    {
+        if (!$paper_id) {
+            fail('缺少试卷ID');
+        }
+
+        $paper = self::validPaper($paper_id, $room_id);
+        switch ($paper['mode']) {
+            case PaperMode::RANDOM:
+                $questions = self::getRandomQuestions($paper);
+                break;
+            case PaperMode::FIX:
+                $questions = self::getFixQuestions($paper);
+                break;
+            default:
+                fail('试卷取题模式有误');
+        }
+
+        return [
+            'paper'      => $paper,
+            'questions'  => $questions,
+            'start_time' => time(),
+        ];
+    }
+
+    /**
+     * 获取试卷随机题
+     * @param $paper
+     * @return array
+     */
+    public static function getRandomQuestions($paper)
+    {
+        $configs   = $paper->configs;
+        $questions = [];
+
+        if (!isset($configs['cate_ids'])) {
+            fail('试卷随机取题配置有误');
+        }
+
+        foreach (QuestionModel::kindList as $kind) {
+            if (!isset($configs[strtolower($kind)])) {
+                continue;
+            }
+
+            $kind_config = $configs[strtolower($kind)];
+            // 使用难度选题
+            if ($kind_config['use_difficulty']) {
+
+                foreach ($kind_config['difficulty'] as $difficulty => $value) {
+                    if ($value['count']) {
+                        $question  = QuestionModel::getListByCateAndKind($configs['cate_ids'], $kind, ['materialQuestions.question']);
+                        $questions = array_merge(
+                            $questions,
+                            $question->where('difficulty', $difficulty)->limit($value['count'])->select()
+                        // hidden_list_keys($question->where('difficulty', $difficulty)->limit($value['count'])->select(), ['answer', 'explain'])
+                        );
+                    }
+                }
+
+            } else {
+                if ($kind_config['count']) {
+                    $question = QuestionModel::getListByCateAndKind($configs['cate_ids'], $kind, ['materialQuestions.question']);
+                    // dd(collection($question->limit($kind_config['count'])->select())->toArray());
+                    $questions = array_merge(
+                        $questions,
+                        $question->limit($kind_config['count'])->select()
+                    // hidden_list_keys($question->limit($kind_config['count'])->select(), ['answer', 'explain'])
+                    );
+                }
+            }
+        }
+
+        // 合并材料题子题目
+        $questions = QuestionModel::mergeMaterialQuestions($questions);
+
+        return hidden_list_keys($questions, ['answer', 'explain', 'origin_answer']);
+    }
+
+    /**
+     * 获取试卷固定题
+     * @param $paper
+     * @return array
+     */
+    public static function getFixQuestions($paper, $hidden = true)
+    {
+        $questions = QuestionModel::getFixListByPaper($paper['id'], ['materialQuestions.question']);
+        // 合并材料题子题目
+        $questions = QuestionModel::mergeMaterialQuestions($questions);
+        if ($hidden) {
+            return hidden_list_keys($questions, ['answer', 'explain', 'origin_answer']);
+        }
+        return $questions;
+    }
+
+    /**
+     * 试卷考试
+     * @param $user_id
+     * @param $paper_id
+     * @param $user_questions
+     * @param $start_time
+     * @param $paper
+     * @return array
+     */
+    public static function paperExam($user_id, $paper_id, $user_questions, $start_time, &$paper, $from_room = false)
+    {
+        // 验证试卷
+        $paper = self::validPaper($paper_id, $from_room ? 1 : 0);
+        if (!$questions_ids = array_column($user_questions, 'id')) {
+            fail('提交的题目数据有误');
+        }
+
+        $answers      = array_column($user_questions, 'answer');        // 用户答案
+        $material_ids = array_column($user_questions, 'material_id');   // 材料题id
+        $total_score  = 0;                                              // 试卷总分
+        $error_count  = 0;                                              // 错误题目数量
+        $error_ids    = [];                                             //错误题目id
+
+        if ($paper['mode'] == PaperMode::RANDOM) {
+            $questions = QuestionModel::whereIn('id', $questions_ids)->orderRaw("find_in_set(id, '" . implode(',', $questions_ids) . "')")->select();
+        } else {
+            $questions = self::getFixQuestions($paper, false);
+        }
+
+        // 材料题分数
+        $material_score = [];
+        foreach ($questions as $key => $question) {
+            $score = 0;
+            // 随机取题
+            if ($paper['mode'] == PaperMode::RANDOM) {
+                $kind       = $question['kind'];
+                $difficulty = $question['difficulty'];
+
+                // 属于材料题子题
+                if (isset($material_ids[$key]) && $material_ids[$key]) {
+                    if ($material_question = QuestionModel::where('id', $material_ids[$key])->cache(60)->find()) {
+                        $kind       = 'MATERIAL';
+                        $difficulty = $material_question['difficulty'];
+                        // $score = PaperModel::getSingleScore($paper['configs'], strtolower($kind), strtolower($difficulty));    // 每题分数
+                        // 材料题子题目设定的分数
+                        $score = MaterialQuestionModel::where('parent_question_id', $material_ids[$key])->where('question_id', $question['id'])->cache(60)->value('score');
+                    }
+                } else {
+                    $score = PaperModel::getSingleScore($paper['configs'], strtolower($kind), strtolower($difficulty));    // 每题分数
+                }
+            } else {
+                // 固定取题
+                $score = $question['score'];
+
+                if ($question['id'] == 764) {
+                    // dd([$score, $question, isset($material_ids[$key]), $material_ids[$key]]);
+                }
+            }
+
+            switch ($question['kind']) {
+                case 'JUDGE':   // 判断题
+                case 'SINGLE':  // 单选题
+                case 'MULTI':   // 多选题
+
+                    // 答题正确
+                    if (strtoupper($answers[$key]) == $question['answer']) {
+                        $total_score                      += $score;
+                        $user_questions[$key]['is_right'] = true;
+                    } else {
+                        array_push($error_ids, $question['id']);
+                        $error_count++;
+                        $user_questions[$key]['is_right'] = false;
+
+                        // 记录错题
+                        QuestionModel::recordWrong($question['id'], $user_id, $answers[$key]);
+                        // $question->logWrong($user_id, $answers[$key]);
+                    }
+                    break;
+
+                case 'FILL':    // 填空题
+                    $user_answers       = $answers[$key];
+                    $fill_right_count   = 0;
+                    $question['answer'] = is_array($question['answer']) ? $question['answer'] : json_decode($question['answer'], true);
+                    foreach ($question['answer'] as $fill_key => $fill_answer) {
+                        foreach ($fill_answer['answers'] as $answer) {
+                            if (isset($user_answers[$fill_key]) && str_trim($user_answers[$fill_key]) == str_trim($answer)) {
+                                $fill_right_count++;
+                                break;
+                            }
+                        }
+                    }
+
+                    // 所有填空项全对
+                    if ($fill_right_count == count($question['answer'])) {
+                        $user_questions[$key]['is_right'] = true;
+                        $total_score                      += $score;
+                    } else {
+                        $user_questions[$key]['is_right'] = false;
+                        array_push($error_ids, $question['id']);
+                        $error_count++;
+
+                        // 记录错题
+                        QuestionModel::recordWrong($question['id'], $user_id, $answers[$key]);
+                        // $question->logWrong($user_id, $answers[$key]);
+                    }
+                    break;
+
+                case 'SHORT':   // 简答题
+                    // 答案得分配置
+                    $answer_config = is_string($question['answer']) ? json_decode($question['answer'], true) : $question['answer'];
+                    $user_answers  = $answers[$key];
+                    $right_score   = 0;
+                    $answer_score  = [];
+                    foreach ($answer_config['config'] as $answer_item) {
+                        if ($right_score < $score) {
+                            // 匹配答案关键词
+                            if (strpos($user_answers, $answer_item['answer']) !== false) {
+                                $right_score += $answer_item['score'];
+                                // 得分情况
+                                $answer_score[] = [
+                                    'answer'        => $answer_item['answer'],
+                                    'score'         => min($score, $answer_item['score']),
+                                    'keyword_score' => $answer_item['score'],
+                                    'max_score'     => $score,
+                                ];
+                            }
+                        }
+                    }
+
+                    // 最高得分不能超过题目分数
+                    $right_score = min($right_score, $score);
+
+                    // 有得分
+                    if ($right_score > 0) {
+                        $user_questions[$key]['is_right'] = true;
+                        $total_score                      += $right_score;
+                    } else {
+                        $user_questions[$key]['is_right'] = false;
+                        array_push($error_ids, $question['id']);
+                        $error_count++;
+
+                        // 记录错题
+                        QuestionModel::recordWrong($question['id'], $user_id, $answers[$key]);
+                    }
+
+                    $user_questions[$key]['answer_score'] = $answer_score;
+                    break;
+            }
+        }
+
+        // 递增参与人次
+        $paper->setInc('join_count');
+
+        return [
+            'total_score'  => $paper['total_score'],                                                                        // 试卷总分
+            'score'        => $total_score,                                                                                 // 考试分数
+            'is_pass'      => $total_score >= $paper['pass_score'],                                                         // 是否及格
+            'pass_score'   => $paper['pass_score'],                                                                         // 及格分数
+            'total_count'  => count($questions),                                                                            // 题目数量
+            'right_count'  => count($questions) - $error_count,                                                             // 答对数量
+            'error_count'  => $error_count,                                                                                 // 答错数量
+            'start_time'   => $start_time,                                                                                  // 开始时间
+            'grade_time'   => $paper['limit_time'] ? min(time() - $start_time, $paper['limit_time']) : time() - $start_time,// 考试用时
+            'error_ids'    => implode(',', $error_ids),                                                                     // 错误题目id
+            'question_ids' => implode(',', $questions_ids),                                                                 // 试题ID集合
+            'user_answers' => json_encode($user_questions, JSON_UNESCAPED_UNICODE),                                         // 用户答案集合
+            'configs'      => json_encode($paper['configs']),                                                               // 试卷配置
+            'mode'         => $paper['mode'],                                                                               // 试卷选题模式
+        ];
+    }
+
+    /**
+     * 考场考试
+     * @param                     $user_id
+     * @param                     $room_id
+     * @param                     $room_grade_id
+     * @param                     $questions
+     * @param                     $start_time
+     * @param                     $paper
+     * @param                     $room
+     * @param                     $is_makeup
+     * @param RoomGradeModel|null $room_grade_log
+     * @return array
+     */
+    public static function roomExam($user_id, $room_id, $room_grade_id, $questions, $start_time, &$paper, &$room, &$is_makeup, &$room_grade_log)
+    {
+        // 验证考场信息
+        $room = self::validRoom($user_id, $room_id, $room_grade_id, $is_makeup, $room_grade_log);
+
+        return self::paperExam($user_id, $room['paper_id'], $questions, $start_time, $paper, true);
+    }
+
+    /**
+     * 预创建考场考试记录(消耗一次考试记录,避免重复进入考场看题)
+     * @param $room_id
+     * @param $user_id
+     * @return int
+     */
+    public static function preRoomGrade($room_id, $user_id)
+    {
+        if (!$room_id) {
+            return 0;
+        }
+
+        // 验证考场信息
+        $room = self::validRoom($user_id, $room_id, 0, $is_makeup);
+
+        // 创建考场考试记录
+        $grade = RoomGradeModel::create([
+            'user_id'     => $user_id,
+            'room_id'     => $room_id,
+            'cate_id'     => $room['cate_id'],
+            'paper_id'    => $room['paper_id'],
+            'score'       => 0,
+            'is_pass'     => 0,
+            'is_makeup'   => $is_makeup,
+            'total_score' => $room['paper']['total_score'],
+            'total_count' => $room['paper']['quantity'],
+            'right_count' => 0,
+            'error_count' => $room['paper']['quantity'],
+            'rank'        => 0,
+            'is_pre'      => 1,// 标记为预载入,提交成绩时须改为0
+            'grade_time'  => 0,
+        ]);
+
+        return $grade['id'];
+    }
+
+    /**
+     * 验证试卷
+     * @param int $paper_id 试卷ID
+     * @param int $room_id  考场ID
+     * @return PaperModel|null
+     */
+    private static function validPaper($paper_id, $room_id = 0)
+    {
+        $paper   = PaperModel::get($paper_id);
+        $user_id = getUserId();
+
+        switch (true) {
+            case !$paper:
+                fail('试卷信息不存在');
+            case $paper->status != CommonStatus::NORMAL:
+                fail('试卷未开启');
+            case $paper->mode == PaperMode::RANDOM && !$paper->configs:
+                fail('试卷未配置');
+        }
+
+        // 普通考试
+        if (!$room_id) {
+            if ($user_id && $paper['day_limit_count'] > 0 && GradeModel::getUserDateGradeCount($paper_id, $user_id) >= $paper['day_limit_count']) {
+                fail('当前试卷考试次数已达今日上限,明天再来吧~');
+            }
+
+            if ($paper['end_time'] > 0 && $paper['end_time'] < time()) {
+                fail('该试卷已失效,不能参与考试了');
+            }
+        }
+
+        return $paper;
+    }
+
+    /**
+     * 验证考场
+     * @param int                 $user_id        考试用户
+     * @param int                 $room_id        试卷ID
+     * @param int                 $room_grade_id  考场预创建成绩ID
+     * @param int                 $is_makeup      返回是否是补考
+     * @param RoomGradeModel|null $room_grade_log 预创建的成绩记录
+     * @return RoomModel|null
+     */
+    private static function validRoom($user_id, $room_id, $room_grade_id, &$is_makeup, &$room_grade_log = null)
+    {
+        $room = RoomModel::get($room_id);
+
+        switch (true) {
+            case !$room:
+                fail('考场信息不存在');
+            case $room['status'] != CommonStatus::NORMAL:
+                fail('考场未开启');
+            case time() < $room['start_time'] || time() > $room['end_time']:
+                fail('考场时间未开始或已结束');
+            case !$roomSignup = RoomSignupModel::where('room_id', $room_id)->where('user_id', $user_id)->find():
+                fail('您尚未报名此考场');
+            case $roomSignup['status'] != RoomSignupStatus::ACCEPT:
+                fail('您的考场报名信息状态有误');
+        }
+
+        // 考场允许补考
+        if ($room['is_makeup'] == 1 && $room['makeup_count'] > 0) {
+            // $query = RoomGradeModel::where('room_id', $room_id)->where('paper_id', $room['paper_id'])->where('user_id', $user_id);
+            // 考试次数
+            $room_exam_count = RoomGradeModel::where('room_id', $room_id)->where('paper_id', $room['paper_id'])->where('user_id', $user_id)->count();
+            // 补考次数
+            $makeup_count     = RoomGradeModel::where('room_id', $room_id)->where('paper_id', $room['paper_id'])->where('user_id', $user_id)->where('is_makeup', 1)->count();
+            $min_makeup_count = $makeup_count - ($room_grade_id ? 1 : 0);
+            if ($min_makeup_count >= $room['makeup_count']) {
+                fail("已超过补考次数");
+            }
+
+            $last_exam_log = RoomGradeModel::where('room_id', $room_id)->where('paper_id', $room['paper_id'])->where('user_id', $user_id)->order('id desc')->find();
+            if ($last_exam_log && $last_exam_log['is_pass'] != 0) {
+                fail('最后一次考试已及格,不需要补考了');
+            }
+
+            // 考试次数大于0视为补考
+            $is_makeup = $room_exam_count > 1 ? 1 : 0;
+        } else {
+            if (RoomGradeModel::where('room_id', $room_id)->where('user_id', $user_id)->where('is_pre', 0)->count() > 0) {
+                fail('您已参加过该考场考试了');
+            }
+
+            $is_makeup = 0;
+        }
+
+
+        // 考场预创建记录验证
+        if ($room_grade_id) {
+            if (!$room_grade_log = RoomGradeModel::where('id', $room_grade_id)->where('user_id', $user_id)->find()) {
+                fail('考场成绩错误');
+            } else if ($room_grade_log['is_pre'] == 0) {
+                fail('本次考场考试已提交过成绩了,请勿重复提交');
+            }
+        }
+
+        return $room;
+    }
+}

+ 256 - 0
addons/exam/library/FrontService.php

@@ -0,0 +1,256 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User         : zgcLives
+ * CreateTime   : 2023/5/3 10:54
+ */
+
+namespace addons\exam\library;
+
+class FrontService
+{
+    /** 小程序页面列表 */
+    const PAGES
+        = [
+            [
+                'path'   => '/pages/index/index',
+                'name'   => '首页',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/search/index',
+                'name'   => '题目搜索',
+                'params' => [
+                    [
+                        'field'   => 'keyword',
+                        'name'    => '搜索关键词',
+                        'type'    => 'string',
+                        'require' => false,
+                        'value'   => '',
+                    ],
+                ],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/paper/index',
+                'name'   => '试卷列表',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/paper/paper',
+                'name'   => '考试试卷',
+                'params' => [
+                    [
+                        'field'      => 'id',
+                        'name'       => '试卷ID',
+                        'type'       => 'selectpage',
+                        'require'    => true,
+                        'value'      => '',
+                        'selectpage' => [
+                            'source' => 'exam/paper/index',
+                            'field'  => 'title',
+                            'params' => [],
+                        ],
+                    ],
+                    [
+                        'field'      => 'room_id',
+                        'name'       => '考场ID',
+                        'type'       => 'selectpage',
+                        'require'    => false,
+                        'value'      => '',
+                        'selectpage' => [
+                            'source' => 'exam/room/index',
+                            'field'  => 'name',
+                            'params' => [],
+                        ],
+                    ],
+                ],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/paper/grade',
+                'name'   => '考试记录',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/paper/rank',
+                'name'   => '考试排行榜',
+                'params' => [
+                    [
+                        'field'      => 'paper_id',
+                        'name'       => '试卷ID',
+                        'type'       => 'selectpage',
+                        'require'    => true,
+                        'value'      => '',
+                        'selectpage' => [
+                            'source' => 'exam/paper/index',
+                            'field'  => 'title',
+                            'params' => [],
+                        ],
+                    ],
+                ],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/room/index',
+                'name'   => '考场列表',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/room/detail',
+                'name'   => '考场详情',
+                'params' => [
+                    [
+                        'field'      => 'id',
+                        'name'       => '考场ID',
+                        'type'       => 'selectpage',
+                        'require'    => true,
+                        'value'      => '',
+                        'selectpage' => [
+                            'source' => 'exam/room/index',
+                            'field'  => 'name',
+                            'params' => [],
+                        ],
+                    ],
+                ],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/room/grade',
+                'name'   => '考场成绩',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/room/rank',
+                'name'   => '考场排行榜',
+                'params' => [
+                    [
+                        'field'      => 'paper_id',
+                        'name'       => '试卷ID',
+                        'type'       => 'selectpage',
+                        'require'    => true,
+                        'value'      => '',
+                        'selectpage' => [
+                            'source' => 'exam/paper/index',
+                            'field'  => 'title',
+                            'params' => [],
+                        ],
+                    ],
+                    [
+                        'field'      => 'room_id',
+                        'name'       => '考场ID',
+                        'type'       => 'selectpage',
+                        'require'    => true,
+                        'value'      => '',
+                        'selectpage' => [
+                            'source' => 'exam/room/index',
+                            'field'  => 'name',
+                            'params' => [],
+                        ],
+                    ],
+                ],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/room/signup-index',
+                'name'   => '考场报名记录',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/user/user',
+                'name'   => '用户中心',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/user/set',
+                'name'   => '个人设置',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/user/my-cate',
+                'name'   => '常用题库设置',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/user/login-reg',
+                'name'   => '登录注册',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/collect/index',
+                'name'   => '我的收藏',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/wrong/index',
+                'name'   => '错题记录',
+                'params' => [],
+                'module' => '',
+            ],
+            [
+                'path'   => '/pages/webview/webview',
+                'name'   => '跳转网页',
+                'params' => [
+                    [
+                        'field'   => 'url',
+                        'name'    => '网页地址',
+                        'type'    => 'string',
+                        'require' => true,
+                        'value'   => '',
+                    ],
+                ],
+                'module' => '',
+            ],
+        ];
+
+    /**
+     * 获取小程序跳转路径及参数
+     * @return string
+     */
+    public static function buildUrl($path, $params = [])
+    {
+        return $path . '?' . http_build_query($params);
+    }
+
+    /**
+     * 替换内容里的图片CDN链接
+     * @param $title
+     * @return string
+     */
+    public static function replaceImgUrl($title)
+    {
+        $pattern = '/<img.*?src="(.*?)".*?>/i';
+        $title   = preg_replace_callback($pattern, function ($matches) {
+            $full = $matches[0];
+            if (!empty($matches[1])) {
+                return str_replace($matches[1], cdnurl($matches[1], true), $full);
+            }
+
+            $url      = $matches[1];
+            $host     = parse_url($url, PHP_URL_HOST);
+            $cdn_host = parse_url(cdnurl('', true), PHP_URL_HOST);
+            if ($host) {
+                if ($host != $cdn_host) {
+                    $url = str_replace($host, $cdn_host, $url);
+                }
+            } else {
+                $url = cdnurl($url, true);
+            }
+
+            // ddd($matches);
+            return '<img src="' . $url . '">';
+        }, $title);
+        return $title;
+    }
+}

+ 24 - 0
addons/exam/library/ValidateService.php

@@ -0,0 +1,24 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User         : zgcLives
+ * CreateTime   : 2022/6/1 11:56
+ */
+
+namespace addons\exam\library;
+
+class ValidateService
+{
+    /**
+     * 验证手机号
+     * @param $phone
+     * @return false|int
+     */
+    public static function phone($phone)
+    {
+        if (!$phone || !\think\Validate::regex($phone, "^1\d{10}$")) {
+            return false;
+        }
+        return true;
+    }
+}

+ 141 - 0
addons/exam/library/WechatService.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace addons\exam\library;
+
+use EasyWeChat\Factory;
+
+/**
+ * 微信公众号、小程序服务
+ */
+class WechatService
+{
+
+    public static function getConfig(): array
+    {
+        $wx_config = getConfig('wx_config');
+        switch (true) {
+            case is_empty_in_array($wx_config, 'appid'):
+                fail('缺少app_id配置');
+            case is_empty_in_array($wx_config, 'secret'):
+                fail('缺少secret配置');
+            // case is_empty_in_array($pay_config, 'mchid'):
+            //     fail('缺少mchid配置');
+            // case is_empty_in_array($pay_config, 'key'):
+            //     fail('缺少key配置');
+            // case is_empty_in_array($pay_config, 'notify_url'):
+            //     fail('缺少支付回调配置');
+        }
+
+        return [
+            'app_id' => $wx_config['appid'],
+            'secret' => $wx_config['secret'],
+            // 'mch_id'     => $pay_config['mchid'],
+            // 'key'        => $pay_config['key'],
+            // 'notify_url' => $pay_config['notify_url'],
+        ];
+    }
+
+    /**
+     * 公众号实例
+     * @return \EasyWeChat\OfficialAccount\Application
+     */
+    public static function getApp(): \EasyWeChat\Payment\Application
+    {
+        return Factory::payment(self::getConfig());
+    }
+
+    /**
+     * 小程序实例
+     * @return \EasyWeChat\MiniProgram\Application
+     */
+    public static function getMiniApp(): \EasyWeChat\MiniProgram\Application
+    {
+        return Factory::miniProgram(self::getConfig());
+    }
+
+    /**
+     * 支付实例
+     * @return \EasyWeChat\Payment\Application
+     */
+    public static function getPayment(): \EasyWeChat\Payment\Application
+    {
+        $config = Factory::payment(self::getConfig());
+        // $config['cert_path'] = APP_PATH . '/common/certs/apiclient_cert.pem';
+        // $config['key_path']  = APP_PATH . '/common/certs/apiclient_key.pem';
+
+        return $config;
+    }
+
+    /**
+     * H5发起登录
+     * @param array $params 回调参数
+     * @param int   $type   授权方式
+     * @return \Symfony\Component\HttpFoundation\RedirectResponse|void
+     */
+    public function login(array $params = [], int $type = 0)
+    {
+        $scopes = $type ? 'snsapi_userinfo' : 'snsapi_base';
+
+        $callback_url = self::CALLBACK_URL;
+        $callback_url = $params ? $callback_url . '?' . http_build_query($params) : $callback_url;
+
+        $this->getApp()->oauth->withRedirectUrl($callback_url)->scopes([$scopes])->redirect()->send();
+    }
+
+    /**
+     * 小程序登录
+     * @param string $code 前端js code
+     * @return array|\EasyWeChat\Kernel\Support\Collection|object|\Psr\Http\Message\ResponseInterface|string
+     * @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
+     */
+    public function miniLogin(string $code)
+    {
+        $app = self::getMiniApp();
+        return $app->auth->session($code);
+    }
+
+    /**
+     * 统一支付
+     * @param string $openid       用户open id
+     * @param string $out_trade_no 订单编号
+     * @param int    $fee          订单金额
+     * @param string $body         订单说明
+     * @return array|\EasyWeChat\Kernel\Support\Collection|object|\Psr\Http\Message\ResponseInterface|string
+     * @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
+     * @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
+     * @throws \GuzzleHttp\Exception\GuzzleException
+     */
+    public function unifyPay(string $openid, string $out_trade_no, int $fee, string $body)
+    {
+        $payment = self::getPayment();
+
+        $result = $payment->order->unify([
+            'body'         => $body,
+            'out_trade_no' => $out_trade_no,
+            'total_fee'    => $fee,
+            'trade_type'   => 'JSAPI',
+            'openid'       => $openid,
+        ]);
+
+        if ($result['return_code'] == 'FAIL') {
+            fail('发起支付失败:' . $result['return_msg']);
+        }
+
+        return $payment->jssdk->bridgeConfig($result['prepay_id'], false);
+    }
+
+    /**
+     * 微信小程序消息解密
+     * 比如获取电话等功能,信息是加密的,需要解密
+     * @param $session
+     * @param $iv
+     * @param $encryptedData
+     * @return mixed
+     */
+    public function decryptedData($session, $iv, $encryptedData)
+    {
+        $app = self::getMiniApp();
+        return $app->encryptor->decryptData($session, $iv, $encryptedData);
+    }
+    
+}

+ 65 - 0
addons/exam/model/BaseModel.php

@@ -0,0 +1,65 @@
+<?php
+
+
+namespace addons\exam\model;
+
+use addons\exam\traits\ModelExtend;
+use think\Model;
+
+/** 基础模型 */
+class BaseModel extends Model
+{
+    use ModelExtend;
+
+    protected static function init()
+    {
+        parent::init();
+        self::loadCommonFile();
+    }
+
+    /**
+     * 加载公共函数库文件
+     */
+    protected static function loadCommonFile()
+    {
+        require_once ROOT_PATH . 'addons/exam/helper.php';
+    }
+
+    public function getCreateTimeTextAttr($value, $data)
+    {
+        $value = $value ?: ($data['createtime'] ?? '');
+        return is_numeric($value) ? date('Y-m-d H:i:s', $value) : $value;
+    }
+
+    // +----------------------------------------------------------------------
+    // 预加载模型关联(仅返回部分数据)
+    // +----------------------------------------------------------------------
+
+    public static function withSimpleUser()
+    {
+        return function ($query) {
+            return $query->field('id,nickname,avatar');
+        };
+    }
+
+    public static function withSimpleCate()
+    {
+        return function ($query) {
+            return $query->field('id,name');
+        };
+    }
+
+    public static function withSimplePaper()
+    {
+        return function ($query) {
+            return $query->field('id,title');
+        };
+    }
+
+    public static function withSimpleRoom()
+    {
+        return function ($query) {
+            return $query->field('id,name');
+        };
+    }
+}

+ 28 - 0
addons/exam/model/CorrectionQuestionModel.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace addons\exam\model;
+
+
+class CorrectionQuestionModel extends \app\admin\model\exam\CorrectionQuestionModel
+{
+    // 追加属性
+    protected $append = [
+        'createtime_text',
+        'status_text'
+    ];
+
+    public function getStatusList()
+    {
+        return ['0' => __('Status 0'), '1' => __('Status 1'), '2' => __('Status 2')];
+    }
+
+    public function user()
+    {
+        return $this->belongsTo(UserModel::class, 'user_id', 'id');
+    }
+
+    public function question()
+    {
+        return $this->belongsTo(QuestionModel::class, 'question_id', 'id');
+    }
+}

+ 41 - 0
addons/exam/model/GradeModel.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace addons\exam\model;
+
+
+use app\admin\model\exam\CateModel;
+
+class GradeModel extends \app\admin\model\exam\GradeModel
+{
+    public function user()
+    {
+        return $this->belongsTo(UserModel::class, 'user_id', 'id');
+    }
+
+    public function cate()
+    {
+        return $this->belongsTo(CateModel::class, 'cate_id', 'id');
+    }
+
+    public function paper()
+    {
+        return $this->belongsTo(PaperModel::class, 'paper_id', 'id');
+    }
+
+    /**
+     * 获取用户某日考试次数
+     * @param int    $paper_id 试卷ID
+     * @param int    $user_id  用户ID
+     * @param string $date     日期
+     * @return int|string
+     */
+    public static function getUserDateGradeCount($paper_id, $user_id, $date = '')
+    {
+        if (!$user_id) {
+            return 0;
+        }
+
+        $date = $date ?: date('Y-m-d');
+        return self::where('user_id', $user_id)->where('date', $date)->count();
+    }
+}

+ 11 - 0
addons/exam/model/NoticeModel.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace addons\exam\model;
+
+
+class NoticeModel extends \app\admin\model\exam\NoticeModel
+{
+    protected $type = [
+        'front_info' => 'json',
+    ];
+}

+ 38 - 0
addons/exam/model/PaperModel.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace addons\exam\model;
+
+
+use think\Db;
+
+class PaperModel extends \app\admin\model\exam\PaperModel
+{
+    // 追加属性
+    protected $type
+        = [
+            'configs' => 'array'
+        ];
+    // public function getConfigsAttr($value, $data)
+    // {
+    //     $value = $value ? $value : (isset($data['configs']) ? $data['configs'] : '');
+    //     return $value ? json_decode($value, true) : [];
+    // }
+
+    /**
+     * 获取试卷参与人员
+     * @param $paper_id
+     * @param $slice
+     * @return array
+     */
+    public static function getJoinUsers($paper_id, $slice = 0)
+    {
+        $user_ids = Db::name('exam_grade')->where('paper_id', $paper_id)->group('user_id')->column('user_id');
+        if ($user_ids) {
+            // 截取数组
+            $user_ids = $slice ? array_slice($user_ids, $slice) : $user_ids;
+            return Db::name('user')->whereIn('id', $user_ids)->select();
+        }
+
+        return [];
+    }
+}

+ 12 - 0
addons/exam/model/QuestionCollectModel.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace addons\exam\model;
+
+
+class QuestionCollectModel extends \app\admin\model\exam\QuestionCollectModel
+{
+    public function question()
+    {
+        return $this->belongsTo(QuestionModel::class, 'question_id', 'id');
+    }
+}

+ 280 - 0
addons/exam/model/QuestionModel.php

@@ -0,0 +1,280 @@
+<?php
+
+namespace addons\exam\model;
+
+
+use addons\exam\enum\CommonStatus;
+use app\admin\model\exam\QuestionCollectModel;
+
+class QuestionModel extends \app\admin\model\exam\QuestionModel
+{
+    // 字段类型
+    protected $type
+        = [
+            'options_img' => 'array',
+            // 'options_extend' => 'array',
+            // 'options_json' => 'array'
+        ];
+
+    // 隐藏字段
+    // protected $hidden = [
+    //     'answer', 'explain'
+    // ];
+
+    public function getKindList()
+    {
+        return [
+            'JUDGE'    => '判断题',
+            'SINGLE'   => '单选题',
+            'MULTI'    => '多选题',
+            'FILL'     => '填空题',
+            'SHORT'    => '简答题',
+            'MATERIAL' => '材料题',
+        ];
+    }
+
+    public function getDifficultyList()
+    {
+        return ['EASY' => '简单', 'GENERAL' => '普通', 'HARD' => '困难'];
+    }
+
+    public function getStatusList()
+    {
+        return ['NORMAL' => '正常', 'HIDDEN' => '隐藏'];
+    }
+
+    public function getOptionsJsonAttr($value)
+    {
+        if ($value = json_decode($value, true)) {
+            $data = [];
+            foreach ($value as $key => $row) {
+                $arr['key']         = $key;
+                $arr['value']       = $row;
+                $arr['click_index'] = false;
+                array_push($data, $arr);
+            }
+            return $data;
+        }
+        return [];
+    }
+
+    public function getAnswerAttr($value, $data)
+    {
+        if (is_array($value)) {
+            return $value;
+        }
+        if (in_array($data['kind'], ['FILL', 'SHORT'])) {
+            return json_decode($value, true);
+        }
+        return $value;
+    }
+
+    // public function getOptionsExtendAttr($value)
+    // {
+    //     return json_decode($value, true);
+    // }
+
+    /**
+     * 试题是否已收藏过
+     * @param $user_id
+     * @param $questions
+     * @return mixed
+     */
+    public static function isCollected($user_id, $questions)
+    {
+        $ids         = array_column($questions, 'id');
+        $collects    = QuestionCollectModel::where('user_id', $user_id)->whereIn('question_id', $ids)->select();
+        $collect_ids = array_column(collection($collects)->toArray(), 'question_id');
+
+        foreach ($questions as &$question) {
+            $question['collected'] = in_array($question['id'], $collect_ids);
+        }
+
+        return $questions;
+    }
+
+    /**
+     * 获取试题列表
+     * @param $params
+     * @return array|int
+     */
+    public static function getList($params)
+    {
+        $param = array_merge([
+            'cate_id'        => 0,                            // 按分类查询
+            'kind'           => '',                           // 试题类型查询
+            'status'         => CommonStatus::NORMAL,         // 状态查询
+            'keyword'        => '',                           // 搜索关键词
+            'user_id'        => 0,                            // 传入用户ID时,查询收藏状态
+            'page_count'     => 20,                           // 每页数量
+            'sort'           => '',                           // 排序类型
+            'just_get_count' => 0,                            // 仅获取题数
+            'mode'           => 'normal',                     // normal=普通模式,memory=记忆模式,random=随机查询
+            'memory_index'   => 0,                            // 记忆模式 - 上次做题题标
+        ], $params);
+
+        $model = new static();
+        $model->with(['materialQuestions.question'])->where('is_material_child', 0);
+
+        // 查询条件
+        if ($param['cate_id']) {
+            $model->where('cate_id', $param['cate_id']);
+        }
+        if ($param['kind']) {
+            $model->where('kind', $param['kind']);
+        }
+        if ($param['status']) {
+            $model->where('status', $param['status']);
+        }
+        if ($param['keyword']) {
+            $model->where('title', 'like', '%' . $param['keyword'] . '%');
+        }
+
+        // 仅获取题数
+        if ($param['just_get_count']) {
+            return ['total' => $model->count()];
+        }
+
+        switch ($param['mode']) {
+            // 记忆模式
+            case 'memory':
+                // 排序
+                if ($param['sort']) {
+                    $model->order($param['sort']);
+                } else {
+                    $model->order('id asc');
+                }
+                $list['data'] = $model->select();
+                break;
+
+            // 随机模式
+            case 'random':
+                // 限制最多500条
+                $list['data'] = $model->orderRaw('rand()')->limit(500)->select();
+                break;
+
+            // 普通模式
+            default:
+                // 排序
+                if ($param['sort']) {
+                    $model->order($param['sort']);
+                } else {
+                    $model->order('id asc');
+                }
+                $list = $model->paginate($param['page_count'])->toArray();
+                break;
+        }
+
+        if ($list['data']) {
+            // 题目是否已收藏
+            if ($param['user_id']) {
+                $list['data'] = self::isCollected($param['user_id'], $list['data']);
+            }
+
+            // 合并材料题子题目
+            $list['data'] = self::mergeMaterialQuestions($list['data']);
+        }
+
+        return $list;
+    }
+
+    /**
+     * 合并材料题子题目
+     * @param $questions
+     * @return mixed
+     */
+    public static function mergeMaterialQuestions($questions)
+    {
+        $questions = collection($questions)->toArray();
+        // dd($questions);
+        $material_questions = [];
+        foreach ($questions as $key => $question) {
+            if ($question['kind'] == 'MATERIAL') {
+                foreach ($question['material_questions'] as $material_question) {
+                    // dd([$question]);
+                    $new_question = $material_question['question'];
+
+                    $new_question['material_id']    = $question['id'];
+                    $new_question['material_title'] = $question['title'];
+                    $new_question['material_score'] = $question['score'] ?? 1;
+                    $new_question['origin_answer']  = $material_question['question']['answer'];
+                    $new_question['score']          = $material_question['score'];
+                    $new_question['answer']         = $material_question['answer'];
+
+                    // with查询导致的数据格式问题,需要特殊处理
+                    if (in_array($new_question['kind'], ['FILL', 'SHORT']) && $new_question['answer'] && is_string($new_question['answer'])) {
+                        $new_question['answer'] = json_decode($new_question['answer'], true);
+                    }
+                    if ($new_question['options_img'] && is_string($new_question['options_img'])) {
+                        $new_question['options_img'] = json_decode($new_question['options_img'], true);
+                    }
+                    if ($new_question['options_json'] && is_string($new_question['options_json'])) {
+                        $new_question['options_json'] = json_decode($new_question['options_json'], true);
+
+                        // 特殊格式处理
+                        $keys = array_keys($new_question['options_json']);
+                        if (isset($keys[0]) && $keys[0] && !is_numeric($keys[0])) {
+                            $options_json = [];
+                            foreach ($new_question['options_json'] as $option_key => $option_val) {
+                                $options_json[] = [
+                                    'key'         => $option_key,
+                                    'value'       => $option_val,
+                                    'click_index' => false,
+                                ];
+                            }
+                            $new_question['options_json'] = $options_json;
+                        }
+                    }
+                    $new_question['show_full'] = false;
+
+                    $material_questions[] = $new_question;
+
+                    // $material_question['question']['material_id']    = $question['id'];
+                    // $material_question['question']['material_title'] = $question['title'];
+                    // $material_question['question']['material_score'] = $question['score'] ?? 1;
+                    // $material_question['question']['origin_answer']  = $material_question['question']['answer'];
+                    // $material_question['question']['answer']         = $material_question['answer'];
+                    // $material_question['question']['score']          = $material_question['score'];
+                    // $material_question['question']['show_full']      = false;
+                    // if ($material_question['question']['options_img'] && is_string($material_question['question']['options_img'])) {
+                    //     $material_question['question']['options_img'] = json_decode($material_question['question']['options_img'], true);
+                    // }
+                    // if ($material_question['question']['options_json'] && is_string($material_question['question']['options_json'])) {
+                    //     $material_question['question']['options_json'] = json_decode($material_question['question']['options_json'], true);
+                    //
+                    //     // 特殊格式处理
+                    //     $keys = array_keys($material_question['question']['options_json']);
+                    //     if (isset($keys[0]) && $keys[0] && !is_numeric($keys[0])) {
+                    //         $options_json = [];
+                    //         foreach ($material_question['question']['options_json'] as $option_key => $option_val) {
+                    //             $options_json[] = [
+                    //                 'key'         => $option_key,
+                    //                 'value'       => $option_val,
+                    //                 'click_index' => false,
+                    //             ];
+                    //         }
+                    //         $material_question['question']['options_json'] = $options_json;
+                    //     }
+                    // }
+                    //
+                    // $material_questions[] = $material_question['question'];
+                }
+
+                // 删除材料题
+                unset($questions[$key]);
+                // array_splice($questions, $key, 1);
+                // dd($questions);
+                // array_slice($questions, $key, 1);
+            }
+            // dd(collection($material_questions)->toArray());
+        }
+        // dd(collection($questions)->toArray());
+        // dd(collection($material_questions)->toArray());
+
+        if ($material_questions) {
+            $questions = array_merge(array_values($questions), $material_questions);
+        }
+
+        return $questions;
+    }
+}

+ 53 - 0
addons/exam/model/RoomGradeModel.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace addons\exam\model;
+
+
+use app\admin\model\exam\CateModel;
+use think\Db;
+
+class RoomGradeModel extends \app\admin\model\exam\RoomGradeModel
+{
+    public function user()
+    {
+        return $this->belongsTo(UserModel::class, 'user_id', 'id');
+    }
+
+    public function cate()
+    {
+        return $this->belongsTo(CateModel::class, 'cate_id', 'id');
+    }
+
+    public function paper()
+    {
+        return $this->belongsTo(PaperModel::class, 'paper_id', 'id');
+    }
+
+    public function room()
+    {
+        return $this->belongsTo(RoomModel::class, 'room_id', 'id');
+    }
+
+    public static function getRank($room_id)
+    {
+
+    }
+
+    /**
+     * 获取试卷参与人员
+     * @param $room_id
+     * @param $slice
+     * @return array
+     */
+    public static function getJoinUsers($room_id, $slice = 0)
+    {
+        $user_ids = Db::name('exam_room_grade')->where('room_id', $room_id)->group('user_id')->column('user_id');
+        if ($user_ids) {
+            // 截取数组
+            $user_ids = $slice ? array_slice($user_ids, $slice) : $user_ids;
+            return Db::name('user')->whereIn('id', $user_ids)->select();
+        }
+
+        return [];
+    }
+}

+ 17 - 0
addons/exam/model/RoomModel.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace addons\exam\model;
+
+
+class RoomModel extends \app\admin\model\exam\RoomModel
+{
+    protected $hidden = [
+        'password',
+    ];
+
+
+    public function paper()
+    {
+        return $this->belongsTo(PaperModel::class, 'paper_id');
+    }
+}

+ 12 - 0
addons/exam/model/RoomSignupModel.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace addons\exam\model;
+
+
+class RoomSignupModel extends \app\admin\model\exam\RoomSignupModel
+{
+    public function room()
+    {
+        return $this->belongsTo(RoomModel::class, 'room_id', 'id');
+    }
+}

+ 45 - 0
addons/exam/model/UserInfoModel.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace addons\exam\model;
+
+use addons\exam\enum\UserType;
+
+class UserInfoModel extends \app\admin\model\exam\UserInfoModel
+{
+
+    public function user()
+    {
+        return $this->belongsTo(UserModel::class, 'user_id');
+    }
+
+    /**
+     * 初始化用户扩展信息
+     * @param $user_id
+     * @param $type
+     * @return UserInfoModel
+     */
+    public static function initInfo($user_id, $type = UserType::NORMAL)
+    {
+        return self::create([
+            'user_id'          => $user_id,
+            'type'             => $type,
+            'score'            => 0,
+            'expire_time'      => 0,
+            'member_config_id' => 0,
+        ]);
+    }
+
+    /**
+     * 获取用户扩展信息
+     * @param $user_id
+     * @return UserInfoModel
+     */
+    public static function getUserInfo($user_id)
+    {
+        if ($info = self::where('user_id', $user_id)->find()) {
+            return $info;
+        }
+
+        return self::initInfo($user_id);
+    }
+}

+ 113 - 0
addons/exam/model/UserModel.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace addons\exam\model;
+
+use addons\exam\enum\UserStatus;
+use app\common\library\Auth;
+use fast\Random;
+
+class UserModel extends BaseModel
+{
+    // 表名
+    protected $name = 'user';
+
+    // 开启自动写入时间戳字段
+    protected $autoWriteTimestamp = 'int';
+    // 定义时间戳字段名
+    protected $createTime = 'createtime';
+    protected $updateTime = 'updatetime';
+
+    // /**
+    //  * 登录并返回token
+    //  * @param $username
+    //  * @param $password
+    //  * @param $user_type
+    //  * @return string
+    //  */
+    // public static function login($username, $password, $user_type)
+    // {
+    //     $username = "{$user_type}-$username";
+    //     if (!$user = self::where('username', $username)->find()) {
+    //         api_fail('登录账号或密码错误');
+    //     }
+    //     if ($user['password'] != Auth::instance()->getEncryptPassword($password, $user['salt'])) {
+    //         api_fail('登录账号或密码错误');
+    //     }
+    //
+    //     Auth::instance()->direct($user['id']);
+    //     return Auth::instance()->getToken();
+    // }
+
+    /**
+     * 当前登录用户信息
+     * @return Auth|null
+     */
+    public static function info()
+    {
+        if (Auth::instance()->isLogin()) {
+            return Auth::instance();
+        }
+        return null;
+    }
+
+    /**
+     * 快速注册用户
+     * @param string $username
+     * @param string $nickname
+     * @param string $avatar
+     * @param int $gender
+     * @param string $password
+     * @param string $mobile
+     * @return UserModel
+     */
+    public static function fastRegister(string $username, string $nickname = '', string $avatar = '', int $gender = 0, string $password = '', string $mobile = '')
+    {
+        if (self::where('username', $username)->count()) {
+            fail('该账号已被注册');
+        }
+        // 不严格要求可以去除
+        // if ($mobile && self::where('mobile', $mobile)->count()) {
+        //     fail('该手机号码已被绑定');
+        // }
+
+        $salt = Random::alnum();
+        return self::create([
+            'username'  => $username,
+            'mobile'    => $mobile,
+            'email'     => $mobile . '@qq.com',
+            'salt'      => $salt,
+            'password'  => Auth::instance()->getEncryptPassword($password ?: $username, $salt),
+            'nickname'  => $nickname,
+            'avatar'    => $avatar,
+            'gender'    => $gender,
+            'status'    => UserStatus::NORMAL,
+            'logintime' => time(),
+        ]);
+    }
+
+    /**
+     * 是否必须绑定手机号
+     * @param $user
+     * @return void
+     */
+    public static function isMustBindMobile($user)
+    {
+        $system_config = getConfig('system_config');
+        $bind_mobile   = $system_config['bind_mobile'] ?? 0;
+        if ($bind_mobile == 2 && (!isset($user['mobile']) || !$user['mobile'])) {
+            fail('请先绑定手机号');
+        }
+    }
+
+    /************************** 关联关系 **************************/
+
+    public function agent()
+    {
+        return $this->hasOne(\app\admin\model\juhepay\AgentModel::class, 'user_id', 'id');
+    }
+
+    public function shop()
+    {
+        return $this->hasOne(\app\admin\model\juhepay\ShopModel::class, 'user_id', 'id');
+    }
+}

+ 27 - 0
addons/exam/testdata.sql

@@ -0,0 +1,27 @@
+
+INSERT INTO `__PREFIX__exam_cate` (`id`, `kind`, `level`, `name`, `parent_id`, `sort`, `remark`, `deletetime`) VALUES
+(1, 'QUESTION', '1', '消防', 0, 1, '', NULL),
+(2, 'ROOM', '1', '消防知识考场', 0, 1, '', NULL),
+(3, 'PAPER', '1', '消防知识试卷', 0, 1, '', NULL);
+
+INSERT INTO `__PREFIX__exam_config_info` (`id`, `ad_config`, `system_config`, `wx_config`) VALUES (1, NULL, NULL, NULL);
+
+INSERT INTO `__PREFIX__exam_notice` (`id`, `name`, `contents`, `weigh`, `status`, `createtime`, `updatetime`) VALUES
+(1, '测试公告很长很长很长很长很长很长很长的内容', '', 1, 'NORMAL', 1654088122, 1654088122),
+(2, '测试公告很短的内容', '', 2, 'NORMAL', 1654088131, 1654088131);
+
+INSERT INTO `__PREFIX__exam_paper` (`id`, `cate_id`, `mode`, `title`, `configs`, `quantity`, `total_score`, `pass_score`, `limit_time`, `join_count`, `status`, `createtime`, `updatetime`, `deletetime`) VALUES
+    (1, 3, 'RANDOM', '消防知识考卷', '{"cate_ids":"1","all":{"total_total":5,"quantity":5,"total_score":100},"judge":{"difficulty":{"easy":{"count":0,"score":0,"total_score":0},"general":{"count":0,"score":0,"total_score":0},"hard":{"count":0,"score":0,"total_score":0}},"count":0,"score":0,"total_score":0,"use_difficulty":false},"single":{"difficulty":{"easy":{"count":0,"score":0,"total_score":0},"general":{"count":0,"score":0,"total_score":0},"hard":{"count":0,"score":0,"total_score":0}},"count":5,"score":20,"total_score":100,"use_difficulty":false},"multi":{"difficulty":{"easy":{"count":0,"score":0,"total_score":0},"general":{"count":0,"score":0,"total_score":0},"hard":{"count":0,"score":0,"total_score":0}},"count":0,"score":0,"total_score":0,"use_difficulty":false}}', 5, 100, 60, 3600, 1, 'NORMAL', 1654085409, 1654088972, NULL);
+
+INSERT INTO `__PREFIX__exam_question` (`id`, `cate_id`, `kind`, `title`, `explain`, `difficulty`, `options_json`, `options_img`, `answer`, `status`, `createtime`, `updatetime`, `deletetime`) VALUES
+(1, 1, 'SINGLE', '消防工作应当坚持( )的原则。', '', 'GENERAL', '{"A":"隐患险于明火,防范胜于救灾","B":"预防为主,防消结合","C":"专门机关与群众相结合","D":"政府统一领导、部门依法监管、单位全面负责、公民积极参与"}', NULL, 'D', 'NORMAL', 1653487790, 1653487790, NULL),
+(2, 1, 'SINGLE', '( )应当根据经济和社会发展的需要,建立各种形式的消防组织,加强消防组织建设,增强扑救火灾的能力。', '', 'GENERAL', '{"A":"各级消防机构","B":"城市人民政府","C":"县以上人民政府","D":"各级人民政府"}', NULL, 'D', 'NORMAL', 1653487790, 1653487790, NULL),
+(3, 1, 'SINGLE', '碳水化合物是指 ', '', 'GENERAL', '{"A":"二氧化碳","B":"糖","C":"蛋白质","D":"酒精"}', NULL, 'A', 'NORMAL', 1653487790, 1653487790, NULL),
+(4, 1, 'SINGLE', '( )级以上地方各级人民政府消防机构应当将发生火灾可能性较大以及一旦发生火灾可能造成人身重大伤亡或者财产重大损失的单位,确定为本行政区域内的消防安全重点单位,报本级人民政府备案。', '', 'GENERAL', '{"A":"省","B":"市","C":"县","D":"乡"}', NULL, 'C', 'NORMAL', 1653487790, 1653487790, NULL),
+(5, 1, 'SINGLE', '专职消防队建立以后,应当报( )消防机构验收。', '', 'GENERAL', '{"A":"本地","B":"省级人民政府","C":"市级人民政府","D":"当地"}', NULL, 'D', 'NORMAL', 1653487790, 1653487790, NULL);
+
+INSERT INTO `__PREFIX__exam_room` (`id`, `name`, `contents`, `cate_id`, `paper_id`, `people_count`, `start_time`, `end_time`, `weigh`, `status`, `signup_mode`, `password`, `is_makeup`, `makeup_count`, `is_rank`, `signup_count`, `grade_count`, `pass_count`, `pass_rate`, `createtime`, `updatetime`, `deletetime`) VALUES
+(1, '消防知识考场', '这是消防知识考场的说明', 2, 1, 0, 1654088194, 1656593815, 1, 'NORMAL', 'NORMAL', '', 0, 0, 0, 0, 0, 0, 0.00, 1654088231, 1654089042, NULL);
+
+
+

+ 115 - 0
addons/exam/traits/ModelExtend.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace addons\exam\traits;
+
+use think\Db;
+
+/**
+ * Trait ModelExtend
+ * 自封装的模型扩展
+ * 基本来源于Laravel操作
+ * by zgc
+ */
+trait ModelExtend
+{
+    /**
+     * 获取对象,空直接抛错
+     *
+     * @param integer $pk 主键
+     * @param string $message 提示的错误信息
+     * @param array $with 预加载
+     */
+    public static function findOrFail(int $pk, string $message = '数据不存在', $with = []): self
+    {
+        if (!$pk) {
+            fail('缺少主键信息');
+        }
+
+        $model = $with ? self::with($with)->find($pk) : self::get($pk);
+
+        if (!$model) {
+            fail($message);
+        }
+
+        return $model;
+    }
+
+    /**
+     * 更新或创建
+     * @param array $attributes 条件
+     * @param array $values 值
+     * @return mixed
+     */
+    public static function updateOrCreate(array $attributes, array $values = [], string $type = 'count')
+    {
+        $self = new static();
+        // $model = $self->where($attributes)->find();
+        $model = $self::get($attributes);
+
+        if ($model) {
+            $model->data($values, true);
+        } else {
+            $model = new static();
+            $model->data($values);
+        }
+
+        $count = $model->allowField(true)->save();
+        if ($type == 'count') {
+            return $count;
+        }
+
+        return $model;
+    }
+
+    /**
+     * 批量插入或更新
+     * @param array $values
+     */
+    public static function upsert(array $values, $pk = 'id')
+    {
+        Db::transaction(function () use ($values, $pk) {
+            collection($values)->each(function ($item) use ($pk) {
+                $id = isset($item[$pk]) ? $item[$pk] : 0;
+                self::updateOrCreate(
+                    [$pk => $id],
+                    $item
+                );
+            });
+        });
+    }
+
+    /**
+     * 只取模型部分key数据
+     * @param array $keys
+     * @return array
+     */
+    public function only(array $keys): array
+    {
+        // return only_keys($this->toArray(), $keys);
+        $result = [];
+        foreach ($this->toArray() as $k => $value) {
+            if (in_array($k, $keys)) {
+                $result[$k] = $value;
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * 隐藏模型部分key数据
+     * @param array $keys
+     * @return array
+     */
+    public function makeHidden(array $keys): array
+    {
+        // return only_keys($this->toArray(), $keys);
+        $result = [];
+        foreach ($this->toArray() as $k => $value) {
+            if (in_array($k, $keys)) {
+                unset($value[$k]);
+                $result[$k] = $value;
+            }
+        }
+        return $result;
+    }
+}

+ 707 - 0
addons/exam/tuniao-bak/tn-calendar/tn-calendar.vue

@@ -0,0 +1,707 @@
+<template>
+  <tn-popup
+    v-model="value"
+    mode="bottom"
+    :popup="false"
+    length="auto"
+    :borderRadius="borderRadius"
+    :safeAreaInsetBottom="safeAreaInsetBottom"
+    :maskCloseable="maskCloseable"
+    :closeBtn="closeBtn"
+    :zIndex="elIndex"
+    @close="close"
+  >
+    <view class="tn-calendar-class tn-calendar">
+      <!-- 头部 -->
+      <view class="tn-calendar__header">
+        <view v-if="!$slots.tooltip || !$slots.$tooltip" class="tn-calendar__header__text">
+          {{ toolTips }}
+        </view>
+        <view v-else>
+          <slot name="tooltip"></slot>
+        </view>
+      </view>
+      
+      <!-- 操作提示信息 -->
+      <view class="tn-calendar__action">
+        <view v-if="changeYear" class="tn-calendar__action__icon" :style="{backgroundColor: yearArrowColor}" @tap.stop="changeYearHandler(false)">
+          <view><text class="tn-icon-left"></text></view>
+        </view>
+        <view v-if="changeMonth" class="tn-calendar__action__icon" :style="{backgroundColor: monthArrowColor}" @tap.stop="changeMonthHandler(false)">
+          <view><text class="tn-icon-left"></text></view>
+        </view>
+        <view class="tn-calendar__action__text">{{ dateTitle }}</view>
+        <view v-if="changeMonth" class="tn-calendar__action__icon" :style="{backgroundColor: monthArrowColor}" @tap.stop="changeMonthHandler(true)">
+          <view><text class="tn-icon-right"></text></view>
+        </view>
+        <view v-if="changeYear" class="tn-calendar__action__icon" :style="{backgroundColor: yearArrowColor}" @tap.stop="changeYearHandler(true)">
+          <view><text class="tn-icon-right"></text></view>
+        </view>
+      </view>
+      
+      <!-- 星期中文标识 -->
+      <view class="tn-calendar__week-day-zh">
+        <view v-for="(item,index) in weekDayZh" :key="index" class="tn-calendar__week-day-zh__text">{{ item }}</view>
+      </view>
+      
+      <!-- 日历主体 -->
+      <view class="tn-calendar__content">
+        <!-- 前置空白部分 -->
+        <block v-for="(item, index) in weekdayArr" :key="index">
+          <view class="tn-calendar__content__item"></view>
+        </block>
+        <view
+          v-for="(item, index) in daysArr"
+          :key="index"
+          class="tn-calendar__content__item"
+          :class="{
+            'tn-hover': disabledChoose(year, month, index + 1),
+            'tn-calendar__content--start-date': (mode === 'range' && startDate == `${year}-${month}-${index+1}`) || mode === 'date',
+            'tn-calendar__content--end-date': (mode === 'range' && endDate == `${year}-${month}-${index+1}`) || mode === 'date'
+          }"
+          :style="{
+            backgroundColor: colorValue(index, 'bg')
+          }"
+          @tap.stop="dateClick(index)"
+        >
+          <view class="tn-calendar__content__item__text" :style="{color: colorValue(index, 'text')}">
+            <view>{{ item.day }}</view>
+          </view>
+          <view class="tn-calendar__content__item__tips" :style="{color: item.color}">
+            {{ item.bottomInfo }}
+          </view>
+        </view>
+        
+        <view class="tn-calendar__content__month--bg">{{ month }}</view>
+      </view>
+      
+      <!-- 底部 -->
+      <view class="tn-calendar__bottom">
+        <view class="tn-calendar__bottom__choose">
+          <text>{{ mode === 'date' ? activeDate : startDate }}</text>
+          <text v-if="endDate">至{{ endDate }}</text>
+        </view>
+        <view class="tn-calendar__bottom__btn" :style="{backgroundColor: btnColor}" @click="handleBtnClick(false)">
+          <view class="tn-calendar__bottom__btn--text">确定</view>
+        </view>
+      </view>
+    </view>
+  </tn-popup>
+</template>
+
+<script>
+  import Calendar from '../../libs/utils/calendar.js'
+  
+  export default {
+    name: 'tn-calendar',
+    props: {
+      // 双向绑定控制组件弹出与收起
+      value: {
+        type: Boolean,
+        default: false
+      },
+      // 模式
+      // date -> 单日期 range -> 日期范围
+      mode: {
+        type: String,
+        default: 'date'
+      },
+      // 是否允许切换年份
+      changeYear: {
+        type: Boolean,
+        default: true
+      },
+      // 是否允许切换月份
+      changeMonth: {
+        type: Boolean,
+        default: true
+      },
+      // 可切换的最大年份
+      maxYear: {
+        type: [Number, String],
+        default: 2100
+      },
+      // 可切换的最小年份
+      minYear: {
+        type: [Number, String],
+        default: 1970
+      },
+      // 最小日期(不在范围被不允许选择)
+      minDate: {
+        type: String,
+        default: '1970-01-01'
+      },
+      // 最大日期,如果为空则默认为今天
+      maxDate: {
+        type: String,
+        default: ''
+      },
+      // 切换月份按钮的颜色
+      monthArrowColor: {
+        type: String,
+        default: '#AAAAAA'
+      },
+      // 切换年份按钮的颜色
+      yearArrowColor: {
+        type: String,
+        default: '#C8C8C8'
+      },
+      // 默认字体颜色
+      color: {
+        type: String,
+        default: '#080808'
+      },
+      // 选中|起始结束日期背景颜色
+      activeBgColor: {
+        type: String,
+        default: '#01BEFF'
+      },
+      // 选中|起始结束日期文字颜色
+      activeColor: {
+        type: String,
+        default: '#FFFFFF'
+      },
+      // 范围日期内的背景颜色
+      rangeBgColor: {
+        type: String,
+        default: '#E6E6E655'
+      },
+      // 范围日期内的文字颜色
+      rangeColor: {
+        type: String,
+        default: '#01BEFF'
+      },
+      // 起始日期显示的文字,mode=range时生效
+      startText: {
+        type: String,
+        default: '开始'
+      },
+      // 结束日期显示的文字,mode=range时生效
+      endText: {
+        type: String,
+        default: '结束'
+      },
+      // 按钮背景颜色
+      btnColor: {
+        type: String,
+        default: '#01BEFF'
+      },
+      // 农历文字的颜色
+      lunarColor: {
+        type: String,
+        default: '#AAAAAA'
+      },
+      // 选中日期是否有选中效果
+      isActiveCurrent: {
+        type: Boolean,
+        default: true
+      },
+      // 切换年月是否触发事件,mode=date时生效
+      isChange: {
+        type: Boolean,
+        default: false
+      },
+      // 是否显示农历
+      showLunar: {
+        type: Boolean,
+        default: true
+      },
+      // 顶部提示文字
+      toolTips: {
+        type: String,
+        default: '请选择日期'
+      },
+      // 显示圆角的大小
+      borderRadius: {
+        type: Number,
+        default: 8
+      },
+      // 是否开启底部安全区适配,开启的话,会在iPhoneX机型底部添加一定的内边距
+      safeAreaInsetBottom: {
+      	type: Boolean,
+      	default: false
+      },
+      // 是否可以通过点击遮罩进行关闭
+      maskCloseable: {
+      	type: Boolean,
+      	default: true
+      },
+      // zIndex
+      zIndex: {
+        type: Number,
+        default: 0
+      },
+      // 是否显示关闭按钮
+      closeBtn: {
+        type: Boolean,
+        default: false
+      },
+    },
+    computed: {
+      dateChange() {
+        return `${this.mode}-${this.minDate}-${this.maxDate}`
+      },
+      elIndex() {
+        return this.zIndex ? this.zIndex : this.$t.zIndex.popup
+      },
+      colorValue() {
+        return (index, type) => {
+          let color = type === 'bg' ? '' : this.color
+          let day = index + 1
+          let date = `${this.year}-${this.month}-${day}`
+          let timestamp = new Date(date.replace(/\-/g,'/')).getTime()
+          let start = this.startDate.replace(/\-/g,'/')
+          let end = this.endDate.replace(/\-/g,'/')
+          if ((this.mode === 'date' && this.isActiveCurrent && this.activeDate == date) || this.startDate == date || this.endDate == date) {
+            color = type === 'bg' ? this.activeBgColor : this.activeColor
+          } else if (this.endDate && timestamp > new Date(start).getTime() && timestamp < new Date(end).getTime()) {
+            color = type === 'bg' ? this.rangeBgColor : this.rangeColor
+          }
+          return color
+        }
+      }
+    },
+    data() {
+      return {
+        // 星期几,1-7
+        weekday: 1,
+        weekdayArr: [],
+        // 星期对应的中文
+        weekDayZh: ['日','一','二','三','四','五','六'],
+        // 当前月有多少天
+        days: 0,
+        daysArr: [],
+        year: 2021,
+        month: 0,
+        day: 0,
+        startYear: 0,
+        startMonth: 0,
+        startDay: 0,
+        endYear: 0,
+        endMonth: 0,
+        endDay: 0,
+        today: '',
+        activeDate: '',
+        startDate: '',
+        endDate: '',
+        min: null,
+        max: null,
+        // 日期标题
+        dateTitle: '',
+        // 标记是否已经选择了开始日期
+        chooseStart: false
+      }
+    },
+    watch: {
+      dateChange() {
+        this.init()
+      }
+    },
+    created() {
+      this.init()
+    },
+    methods: {
+      // 初始化
+      init() {
+        let now = new Date()
+        this.year = now.getFullYear()
+        this.month = now.getMonth() + 1
+        this.day = now.getDate()
+        this.today = `${this.year}-${this.month}-${this.day}`
+        this.activeDate = this.today
+        this.min = this.initDate(this.minDate)
+        this.max = this.initDate(this.maxDate || this.today)
+        this.startDate = ''
+        this.startYear = 0
+        this.startMonth = 0
+        this.startDay = 0
+        this.endDate = ''
+        this.endYear = 0
+        this.endMonth = 0
+        this.endDay = 0
+        this.chooseStart = false
+        this.changeData()
+      },
+      // 切换月份
+      changeMonthHandler(add) {
+        if (add) {
+          let month = this.month + 1
+          let year = month > 12 ? this.year + 1 : this.year
+          if (!this.checkRange(year)) {
+            this.month = month > 12 ? 1 : month
+            this.year = year
+            this.changeData()
+          }
+        } else {
+          let month = this.month - 1
+          let year = month < 1 ? this.year - 1 : this.year
+          if (!this.checkRange(year)) {
+            this.month = month < 1 ? 12 : month
+            this.year = year
+            this.changeData()
+          }
+        }
+      },
+      // 切换年份
+      changeYearHandler(add) {
+        let year = add ? this.year + 1 : this.year - 1
+        if (!this.checkRange(year)) {
+          this.year = year
+          this.changeData()
+        }
+      },
+      // 日期点击事件
+      dateClick(day) {
+        day += 1
+        if (!this.disabledChoose(this.year, this.month, day)) {
+          this.day = day
+          let date = `${this.year}-${this.month}-${day}`
+          if (this.mode === 'date') {
+            this.activeDate = date
+          } else {
+            let startTimeCompare = new Date(date.replace(/\-/g,'/')).getTime() < new Date(this.startDate.replace(/\-/g,'/')).getTime()
+            if (!this.chooseStart || startTimeCompare) {
+              this.startDate = date
+              this.startYear = this.year
+              this.startMonth = this.month
+              this.startDay = this.day
+              this.endYear = 0
+              this.endMonth = 0
+              this.endDay = 0
+              this.endDate = ''
+              this.activeDate = ''
+              this.chooseStart = true
+            } else {
+              this.endDate = date
+              this.endYear = this.year
+              this.endMonth = this.month
+              this.endDay = this.day
+              this.chooseStart = false
+            }
+          }
+          this.daysArr = this.handleDaysArr()
+        }
+      },
+      // 修改日期数据
+      changeData() {
+        this.days = this.getMonthDay(this.year, this.month)
+        this.daysArr = this.handleDaysArr()
+        this.weekday = this.getMonthFirstWeekDay(this.year, this.month)
+        this.weekdayArr = this.generateArray(1, this.weekday)
+        this.dateTitle = `${this.year}年${this.month}月`
+        if (this.isChange && this.mode === 'date') {
+          this.handleBtnClick(true)
+        }
+      },
+      // 处理按钮点击
+      handleBtnClick(show) {
+        if (!show) {
+          this.close()
+        }
+        if (this.mode === 'date') {
+          let arr = this.activeDate.split('-')
+          let year = this.isChange ? this.year : Number(arr[0])
+          let month = this.isChange ? this.month : Number(arr[1])
+          let day = this.isChange ? this.day : Number(arr[2])
+          let days = this.getMonthDay(year, month)
+          let result = `${year}-${this.formatNumber(month)}-${this.formatNumber(day)}`
+          let weekText = this.getWeekText(result)
+          let isToday = false
+          if (`${year}-${month}-${day}` === this.today) {
+            isToday = true
+          }
+          this.$emit('change', {
+            year,
+            month,
+            day,
+            days,
+            week: weekText,
+            isToday,
+            date: result,
+            // 是否为切换年月操作
+            switch: show
+          })
+        } else {
+          if (!this.startDate || !this.endDate) return
+          
+          let startMonth = this.formatNumber(this.startMonth)
+          let startDay = this.formatNumber(this.startDay)
+          let startDate = `${this.startYear}-${startMonth}-${startDay}`
+          let startWeek = this.getWeekText(startDate)
+          
+          let endMonth = this.formatNumber(this.endMonth)
+          let endDay = this.formatNumber(this.endDay)
+          let endDate = `${this.endYear}-${endMonth}-${endDay}`
+          let endWeek = this.getWeekText(endDate)
+          
+          this.$emit('change', {
+            startYear: this.startYear,
+            startMonth: this.startMonth,
+            startDay: this.startDay,
+            startDate,
+            startWeek,
+            endYear: this.endYear,
+            endMonth: this.endMonth,
+            endDay: this.endDay,
+            endDate,
+            endWeek
+          })
+        }
+      },
+      // 判断是否允许选择
+      disabledChoose(year, month, day) {
+        let flag = true
+        let date = `${year}/${month}/${day}`
+        let min = `${this.min.year}/${this.min.month}/${this.min.day}`
+        let max = `${this.max.year}/${this.max.month}/${this.max.day}`
+        let timestamp = new Date(date).getTime()
+        if (timestamp >= new Date(min).getTime() && timestamp <= new Date(max).getTime()) {
+          flag = false
+        }
+        return flag
+      },
+      // 检查是否在日期范围内
+      checkRange(year) {
+        let overstep = false
+        if (year < this.minYear || year > this.maxYear) {
+          uni.showToast({
+            title: '所选日期超出范围',
+            icon: 'none'
+          })
+          overstep = true
+        }
+        return overstep
+      },
+      // 处理日期
+      initDate(date) {
+        let fdate = date.split('-')
+        return {
+          year: Number(fdate[0] || 1970),
+          month: Number(fdate[1] || 1),
+          day: Number(fdate[2] || 1)
+        }
+      },
+      // 处理日期数组
+      handleDaysArr() {
+        let days = this.generateArray(1, this.days)
+        let daysArr = days.map((item) => {
+          let bottomInfo = this.showLunar ? Calendar.solar2lunar(this.year, this.month, item).IDayCn : ''
+          let color = this.showLunar ? this.lunarColor : this.activeColor
+          if (
+            (this.mode === 'date' && this.day == item) || 
+            (this.mode === 'range' && (this.startDay == item || this.endDay == item))
+          ) {
+            color = this.activeColor
+          }
+          if (this.mode === 'range') {
+            if (this.startDay == item && this.startDay != this.endDay) {
+              bottomInfo = this.startText
+            }
+            if (this.endDay == item) {
+              bottomInfo = this.endText
+            }
+          }
+          
+          return {
+            day: item,
+            color: color,
+            bottomInfo: bottomInfo
+          }
+        })
+        return daysArr
+      },
+      // 获取对应月有多少天
+      getMonthDay(year, month) {
+        return new Date(year, month, 0).getDate()
+      },
+      // 获取对应月的第一天时星期几
+      getMonthFirstWeekDay(year, month) {
+        return new Date(`${year}/${month}/01 00:00:00`).getDay()
+      },
+      // 获取对应星期的文本
+      getWeekText(date) {
+        date = new Date(`${date.replace(/\-/g, '/')} 00:00:00`)
+        let week = date.getDay()
+        return '星期' + this.weekDayZh[week]
+      },
+      // 生成日期天数数组
+      generateArray(start, end) {
+        return Array.from(new Array(end + 1).keys()).slice(start)
+      },
+      // 格式化数字
+      formatNumber(num) {
+        return num < 10 ? '0' + num : num + ''
+      },
+      // 关闭窗口
+      close() {
+        this.$emit('input', false)
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-calendar {
+    color: $tn-font-color;
+    
+    &__header {
+      width: 100%;
+      box-sizing: border-box;
+      font-size: 30rpx;
+      background-color: #FFFFFF;
+      color: $tn-main-color;
+      
+      &__text {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+        justify-content: center;
+        margin-top: 30rpx;
+        padding: 0 60rpx;
+      }
+    }
+    
+    &__action {
+      display: flex;
+      flex-direction: row;
+      justify-content: center;
+      align-items: center;
+      padding: 40rpx 0 40rpx 0;
+      
+      &__icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        margin: 0 16rpx;
+        width: 32rpx;
+        height: 32rpx;
+        font-size: 20rpx;
+        // line-height: 32rpx;
+        border-radius: 50%;
+        color: #FFFFFF;
+      }
+      
+      &__text {
+        padding: 0 16rpx;
+        color: $tn-font-color;
+        font-size: 32rpx;
+        font-weight: bold;
+      }
+    }
+    
+    &__week-day-zh {
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: center;
+      padding: 12rpx 0;
+      overflow: hidden;
+      box-shadow: 16rpx 6rpx 8rpx 0 #E6E6E6;
+      margin-bottom: 2rpx;
+      
+      &__text {
+        flex: 1;
+        text-align: center;
+      }
+    }
+    
+    &__content {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+      width: 100%;
+      padding: 12rpx 0;
+      box-sizing: border-box;
+      background-color: #F7F7F7;
+      position: relative;
+      
+      &__item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        width: 14.2857%;
+        padding: 12rpx 0;
+        margin: 6rpx 0;
+        overflow: hidden;
+        position: relative;
+        z-index: 2;
+        // box-shadow: inset 0rpx 0rpx 22rpx 4rpx rgba(255,255,255, 0.52);
+        
+        &__text {
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          justify-content: center;
+          height: 80rpx;
+          font-size: 32rpx;
+          position: relative;
+        }
+        
+        &__tips {
+          position: absolute;
+          width: 100%;
+          line-height: 24rpx;
+          left: 0;
+          bottom: 8rpx;
+          text-align: center;
+          z-index: 2;
+          transform-origin: center center;
+          transform: scale(0.8);
+        }
+      }
+      
+      &--start-date {
+        border-top-left-radius: 8rpx;
+        border-bottom-left-radius: 8rpx;
+      }
+      
+      &--end-date {
+        border-top-right-radius: 8rpx;
+        border-bottom-right-radius: 8rpx;
+      }
+      
+      &__month {
+        &--bg {
+          position: absolute;
+          font-size: 200rpx;
+          line-height: 200rpx;
+          left: 50%;
+          top: 50%;
+          transform: translate(-50%, -50%);
+          color: $tn-font-holder-color;
+          z-index: 1;
+        }
+      }
+    }
+    
+    &__bottom {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      background-color: #F7F7F7;
+      padding: 0 40rpx 30rpx;
+      box-sizing: border-box;
+      font-size: 24rpx;
+      color: $tn-font-sub-color;
+      
+      &__choose {
+        height: 50rpx;
+      }
+      
+      &__btn {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        height: 60rpx;
+        border-radius: 40rpx;
+        color: #FFFFFF;
+        font-size: 28rpx;
+      }
+    }
+  }
+</style>

+ 645 - 0
addons/exam/tuniao-bak/tn-image-upload/tn-image-upload.vue

@@ -0,0 +1,645 @@
+<template>
+  <view v-if="!disabled" class="tn-image-upload-class tn-image-upload">
+    <block v-if="showUploadList">
+      <view
+        v-for="(item, index) in lists"
+        :key="index"
+        class="tn-image-upload__item tn-image-upload__item-preview"
+        :style="{
+          width: $t.string.getLengthUnitValue(width),
+          height: $t.string.getLengthUnitValue(height)
+        }"
+      >
+        <!-- 删除按钮 -->
+        <view
+          v-if="deleteable"
+          class="tn-image-upload__item-preview__delete"
+          @tap.stop="deleteItem(index)"
+          :style="{
+            borderTopColor: deleteBackgroundColor
+          }"
+        >
+          <view
+            class="tn-image-upload__item-preview__delete--icon"
+            :class="[`tn-icon-${deleteIcon}`]"
+            :style="{
+              color: deleteColor
+            }"
+          ></view>
+        </view>
+        <!-- 进度条 -->
+        <tn-line-progress
+          v-if="showProgress && item.progress > 0 && !item.error"
+          class="tn-image-upload__item-preview__progress"
+          :percent="item.progress"
+          :showPercent="false"
+          :round="false"
+          :height="8"
+        ></tn-line-progress>
+        <!-- 重试按钮 -->
+        <view v-if="item.error" class="tn-image-upload__item-preview__error-btn" @tap.stop="retry(index)">点击重试</view>
+        <!-- 图片信息 -->
+        <image
+          class="tn-image-upload__item-preview__image"
+          :src="item.url || item.path"
+          :mode="imageMode"
+          @tap.stop="doPreviewImage(item.url || item.path, index)"
+        ></image>
+      </view>
+    </block>
+    <!-- <view v-if="$slots.file || $slots.$file" style="width: 100%;">
+      
+    </view> -->
+    <!-- 自定义图片展示列表 -->
+    <slot name="file" :file="lists"></slot>
+    
+    <!-- 添加按钮 -->
+    <view v-if="maxCount > lists.length" class="tn-image-upload__add" :class="{'tn-image-upload__add--custom': customBtn}" @tap="selectFile">
+      <!-- 添加按钮 -->
+      <view
+        v-if="!customBtn"
+        class="tn-image-upload__item tn-image-upload__item-add"
+        hover-class="tn-hover-class"
+        hover-stay-time="150"
+        :style="{
+          width: $t.string.getLengthUnitValue(width),
+          height: $t.string.getLengthUnitValue(height)
+        }"
+      >
+        <view class="tn-image-upload__item-add--icon tn-icon-add"></view>
+        <view class="tn-image-upload__item-add__tips">{{ uploadText }}</view>
+      </view>
+      <!-- 自定义添加按钮 -->
+      <view>
+        <slot name="addBtn"></slot>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-image-upload',
+    props: {
+      // 已上传的文件列表
+      fileList: {
+        type: Array,
+        default() {
+          return []
+        }
+      },
+      // 上传图片地址
+      action: {
+        type: String,
+        default: ''
+      },
+      // 上传文件的字段名称
+      name: {
+        type: String,
+        default: 'file'
+      },
+      // 头部信息
+      header: {
+        type: Object,
+        default() {
+          return {}
+        }
+      },
+      // 携带的参数
+      formData: {
+        type: Object,
+        default() {
+          return {}
+        }
+      },
+      // 是否禁用
+      disabled: {
+        type: Boolean,
+        default: false
+      },
+      // 是否自动上传
+      autoUpload: {
+        type: Boolean,
+        default: true
+      },
+      // 最大上传数量
+      maxCount: {
+        type: Number,
+        default: 9
+      },
+      // 是否显示组件自带的图片预览
+      showUploadList: {
+        type: Boolean,
+        default: true
+      },
+      // 预览上传图片的裁剪模式
+      imageMode: {
+        type: String,
+        default: 'aspectFill'
+      },
+      // 点击图片是否全屏预览
+      previewFullImage: {
+        type: Boolean,
+        default: true
+      },
+      // 是否显示进度条
+      showProgress: {
+        type: Boolean,
+        default: true
+      },
+      // 是否显示删除按钮
+      deleteable: {
+        type: Boolean,
+        default: true
+      },
+      // 删除按钮图标
+      deleteIcon: {
+        type: String,
+        default: 'close'
+      },
+      // 删除按钮的背景颜色
+      deleteBackgroundColor: {
+        type: String,
+        default: ''
+      },
+      // 删除按钮的颜色
+      deleteColor: {
+        type: String,
+        default: ''
+      },
+      // 上传区域提示文字
+      uploadText: {
+        type: String,
+        default: '选择图片'
+      },
+      // 显示toast提示
+      showTips: {
+        type: Boolean,
+        default: true
+      },
+      // 自定义选择图标按钮
+      customBtn: {
+        type: Boolean,
+        default: false
+      },
+      // 预览图片和选择图片区域的宽度
+      width: {
+        type: Number,
+        default: 200
+      },
+      // 预览图片和选择图片区域的高度
+      height: {
+        type: Number,
+        default: 200
+      },
+      // 选择图片的尺寸
+      // 参考上传文档 https://uniapp.dcloud.io/api/media/image
+      sizeType: {
+        type: Array,
+        default() {
+          return ['original', 'compressed']
+        }
+      },
+      // 图片来源
+      sourceType: {
+        type: Array,
+        default() {
+          return ['album', 'camera']
+        }
+      },
+      // 是否可以多选
+      multiple: {
+        type: Boolean,
+        default: true
+      },
+      // 文件大小(byte)
+      maxSize: {
+        type: Number,
+        default: 10 * 1024 * 1024
+      },
+      // 允许上传的类型
+      limitType: {
+        type: Array,
+        default() {
+          return ['png','jpg','jpeg','webp','gif','image']
+        }
+      },
+      // 是否自定转换为json
+      toJson: {
+        type: Boolean,
+        default: true
+      },
+      // 上传前钩子函数,每个文件上传前都会执行
+      beforeUpload: {
+        type: Function,
+        default: null
+      },
+      // 删除文件前钩子函数
+      beforeRemove: {
+        type: Function,
+        default: null
+      },
+      index: {
+        type: [Number, String],
+        default: ''
+      }
+    },
+    computed: {
+      
+    },
+    data() {
+      return {
+        lists: [],
+        uploading: false
+      }
+    },
+    watch: {
+      fileList: {
+        handler(val) {
+          val.map(value => {
+            // 首先检查内部是否已经添加过这张图片,因为外部绑定了一个对象给fileList的话(对象引用),进行修改外部fileList时,
+            // 会触发watch,导致重新把原来的图片再次添加到this.lists
+            // 数组的some方法意思是,只要数组元素有任意一个元素条件符合,就返回true,而另一个数组的every方法的意思是数组所有元素都符合条件才返回true
+            let tmp = this.lists.some(listVal => {
+              return listVal.url === value.url
+            })
+            // 如果内部没有这张图片,则添加到内部
+            !tmp && this.lists.push({ url: value.url, error: false, progress: 100 })
+          })
+        },
+        immediate: true
+      },
+      lists(val) {
+        this.$emit('on-list-change', val, this.index)
+      }
+    },
+    methods: {
+      // 清除列表
+      clear() {
+        this.lists = []
+      },
+      // 重新上传队列中上传失败所有文件
+      reUpload() {
+        this.uploadFile()
+      },
+      // 选择图片
+      selectFile() {
+        if (this.disabled) return
+        const {
+          name = '',
+          maxCount,
+          multiple,
+          maxSize,
+          sizeType,
+          lists,
+          camera,
+          compressed,
+          sourceType
+        } = this
+        let chooseFile = null
+        const newMaxCount = maxCount - lists.length
+        // 只选择图片的时候使用 chooseImage 来实现
+        chooseFile = new Promise((resolve, reject) => {
+          uni.chooseImage({
+            count: multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
+            sourceType,
+            sizeType,
+            success: resolve,
+            fail: reject
+          })
+        })
+        chooseFile.then(res => {
+          let file = null
+          let listOldLength = lists.length
+          res.tempFiles.map((val, index) => {
+            if (!this.checkFileExt(val)) return
+            
+            // 是否超出最大限制数量
+            if (!multiple && index >= 1) return
+            if (val.size > maxSize) {
+              this.$emit('on-oversize', val, lists, this.index)
+              this.showToast('超出可允许文件大小')
+            } else {
+              if (maxCount <= lists.length) {
+                this.$emit('on-exceed', val, lists, this.index)
+                this.showToast('超出最大允许的文件数')
+                return
+              }
+              lists.push({
+                url: val.path,
+                progress: 0,
+                error: false,
+                file: val
+              })
+            }
+          })
+          this.$emit('on-choose-complete', this.lists, this.index)
+          if (this.autoUpload) this.uploadFile(listOldLength)
+        }).catch(err => {
+          this.$emit('on-choose-fail', err)
+        })
+      },
+      // 提示用户信息
+      showToast(message, force = false) {
+        if (this.showTips || force) {
+          this.$t.message.toast(message)
+        }
+      },
+      // 手动上传,通过ref进行调用
+      upload() {
+        this.uploadFile()
+      },
+      // 对失败图片进行再次上传
+      retry(index) {
+        this.lists[index].progress = 0
+        this.lists[index].error = false
+        this.lists[index].response = null
+        this.$t.message.loading('重新上传')
+        this.uploadFile(index)
+      },
+      // 上传文件
+      async uploadFile(index = 0) {
+        if (this.disabled) return
+        if (this.uploading) return
+        // 全部上传完成
+        if (index >= this.lists.length) {
+          this.$emit('on-uploaded', this.lists, this.index)
+          return
+        }
+        // 检查是否已经全部上传或者上传中
+        if (this.lists[index].progress === 100) {
+          this.lists[index].uploadTask = null
+          if (this.autoUpload) this.uploadFile(index + 1)
+          return
+        }
+        // 执行before-upload钩子
+        if (this.beforeUpload && typeof(this.beforeUpload) === 'function') {
+          // 在微信,支付宝等环境(H5正常),会导致父组件定义的函数体中的this变成子组件的this
+          // 通过bind()方法,绑定父组件的this,让this的this为父组件的上下文
+          // 因为upload组件可能会被嵌套在其他组件内,比如tn-form,这时this.$parent其实为tn-form的this,
+          // 非页面的this,所以这里需要往上历遍,一直寻找到最顶端的$parent,这里用了this.$u.$parent.call(this)
+          let beforeResponse = this.beforeUpload.bind(this.$t.$parent.call(this))(index, this.lists)
+          // 判断是否返回了Promise
+          if (!!beforeResponse && typeof beforeResponse.then === 'function') {
+            await beforeResponse.then(res => {
+              // promise返回成功,不进行操作继续
+            }).catch(err => {
+              // 进入catch回调的话,继续下一张
+              return this.uploadFile(index + 1)
+            })
+          } else if (beforeResponse === false) {
+            // 如果返回flase,继续下一张图片上传
+            return this.uploadFile(index + 1)
+          } else {
+            // 为true的情况,不进行操作
+          }
+        }
+        // 检查上传地址
+        if (!this.action) {
+          this.showToast('请配置上传地址', true)
+          return
+        }
+        this.lists[index].error = false
+        this.uploading = true
+        // 创建上传对象
+        const task = uni.uploadFile({
+          url: this.action,
+          filePath: this.lists[index].url,
+          name: this.name,
+          formData: this.formData,
+          header: this.header,
+          success: res => {
+            // 判断啊是否为json字符串,将其转换为json格式
+            let data = this.toJson && this.$t.test.jsonString(res.data) ? JSON.parse(res.data) : res.data
+            if (![200, 201, 204].includes(res.statusCode)) {
+              this.uploadError(index, data)
+            } else {
+              this.lists[index].response = data
+              this.lists[index].progress = 100
+              this.lists[index].error = false
+              this.$emit('on-success', data, index, this.lists, this.index)
+            }
+          },
+          fail: err => {
+            this.uploadError(index, err)
+          },
+          complete: res => {
+            this.$t.message.closeLoading()
+            this.uploading = false
+            this.uploadFile(index + 1)
+            this.$emit('on-change', res, index, this.lists, this.index)
+          }
+        })
+        this.lists[index].uploadTask = task
+        task.onProgressUpdate(res => {
+          if (res.progress > 0) {
+            this.lists[index].progress = res.progress
+            this.$emit('on-progress', res, index, this.lists, this.index)
+          }
+        })
+      },
+      // 上传失败
+      uploadError(index, err) {
+        this.lists[index].progress = 0
+        this.lists[index].error = true
+        this.lists[index].response = null
+        this.showToast('上传失败,请重试')
+        this.$emit('on-error', err, index, this.lists, this.index)
+      },
+      // 删除一个图片
+      deleteItem(index) {
+        if (!this.deleteable) return
+        this.$t.message.modal(
+          '提示',
+          '您确定要删除吗?',
+          async () => {
+            // 先检查是否有定义before-remove移除前钩子
+            // 执行before-remove钩子
+            if (this.beforeRemove && typeof(this.beforeRemove) === 'function') {
+              let beforeResponse = this.beforeRemove.bind(this.$t.$parent.call(this))(index, this.lists)
+              // 判断是否返回promise 
+              if (!!beforeResponse && typeof beforeResponse.then === 'function') {
+                await beforeResponse.then(res => {
+                  // promise返回成功不进行操作
+                  this.handlerDeleteItem(index)
+                }).catch(err => {
+                  this.showToast('删除操作被中断')
+                })
+              } else if (beforeResponse === false) {
+                this.showToast('删除操作被中断')
+              } else {
+                this.handlerDeleteItem(index)
+              }
+            } else {
+              this.handlerDeleteItem(index)
+            }
+          }, true)
+      },
+      // 移除文件操作
+      handlerDeleteItem(index) {
+        // 如果文件正在上传中,终止上传任务
+        if (this.lists[index].progress < 100 && this.lists[index].progress > 0) {
+          typeof this.lists[index].uploadTask !== 'undefined' && this.lists[index].uploadTask.abort()
+        }
+        this.lists.splice(index, 1)
+        this.$forceUpdate()
+        this.$emit('on-remove', index, this.lists, this.index)
+        this.showToast('删除成功')
+      },
+      // 移除文件,通过ref手动形式进行调用
+      remove(index) {
+        if (!this.deleteable) return
+        // 判断索引合法
+        if (index >= 0 && index < this.lists.length) {
+          this.lists.splice(index, 1)
+        }
+      },
+      // 预览图片
+      doPreviewImage(url, index) {
+        if (!this.previewFullImage) return
+        const images = this.lists.map(item => item.url || item.path)
+        uni.previewImage({
+          urls: images,
+          current: url,
+          success: () => {
+            this.$emit('on-preview', url, this.lists, this.index)
+          },
+          fail: () => {
+            this.showToast('预览图片失败')
+          }
+        })
+      },
+      // 检查文件后缀是否合法
+      checkFileExt(file) {
+        // 是否为合法后缀
+        let noArrowExt = false
+        // 后缀名
+        let fileExt = ''
+        const reg = /.+\./
+        
+        // #ifdef H5
+        fileExt = file.name.replace(reg, '').toLowerCase()
+        // #endif
+        // #ifndef H5
+        fileExt = file.path.replace(reg, '').toLowerCase()
+        // #endif
+        noArrowExt = this.limitType.some(ext => {
+          return ext.toLowerCase() === fileExt
+        })
+        if (!noArrowExt) this.showToast(`不支持${fileExt}格式的文件`)
+        return noArrowExt
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-image-upload {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    align-items: center;
+    
+    &__item {
+      /* #ifndef APP-NVUE */
+      display: flex;
+      /* #endif */
+      align-items: center;
+      justify-content: center;
+      width: 200rpx;
+      height: 200rpx;
+      overflow: hidden;
+      margin: 12rpx;
+      margin-left: 0;
+      background-color: $tn-font-holder-color;
+      position: relative;
+      border-radius: 10rpx;
+      
+      &-preview {
+        border: 1rpx solid $tn-border-solid-color;
+        
+        &__delete {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          position: absolute;
+          top: 0;
+          right: 0;
+          z-index: 10;
+          border-top: 60rpx solid;
+          border-left: 60rpx solid transparent;
+          border-top-color: $tn-color-red;
+          width: 0rpx;
+          height: 0rpx;
+          
+          &--icon {
+            position: absolute;
+            top: -50rpx;
+            right: 6rpx;
+            color: #FFFFFF;
+            font-size: 24rpx;
+            line-height: 1;
+          }
+        }
+        
+        &__progress {
+          position: absolute;
+          width: auto;
+          bottom: 0rpx;
+          left: 0rpx;
+          right: 0rpx;
+          z-index: 9;
+          /* #ifdef MP-WEIXIN */
+          display: inline-flex;
+          /* #endif */
+        }
+        
+        &__error-btn {
+          position: absolute;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          background-color: $tn-color-red;
+          color: #FFFFFF;
+          font-size: 20rpx;
+          padding: 8rpx 0;
+          text-align: center;
+          z-index: 9;
+          line-height: 1;
+        }
+        
+        &__image {
+          display: block;
+          width: 100%;
+          height: 100%;
+          border-radius: 10rpx;
+        }
+      }
+      
+      &-add {
+        flex-direction: column;
+        color: $tn-content-color;
+        font-size: 26rpx;
+        
+        &--icon {
+          font-size: 40rpx;
+        }
+        
+        &__tips {
+          margin-top: 20rpx;
+          line-height: 40rpx;
+        }
+      }
+    }
+    
+    &__add {
+      width: auto;
+      display: inline-block;
+      
+      &--custom {
+        width: 100%;
+      }
+    }
+  }
+</style>

+ 401 - 0
addons/exam/tuniao-bak/tn-number-box/tn-number-box.vue

@@ -0,0 +1,401 @@
+<template>
+  <view class="tn-number-box-class tn-number-box">
+    <!-- 减 -->
+    <view
+      class="tn-number-box__btn__minus"
+      :class="[
+        backgroundColorClass,
+        fontColorClass,
+        {'tn-number-box__btn--disabled': disabled || inputValue <= min}
+      ]"
+      :style="{
+        backgroundColor: backgroundColorStyle,
+        height: $t.string.getLengthUnitValue(inputHeight),
+        color: fontColorStyle,
+        fontSize: fontSizeStyle
+      }"
+      @touchstart.stop.prevent="touchStart('minus')"
+      @touchend.stop.prevent="clearTimer"
+    >
+      <view class="tn-icon-reduce"></view>
+    </view>
+    
+    <!-- 输入框 -->
+    <input
+      v-model="inputValue"
+      :disabled="disabledInput || disabled"
+      :cursor-spacing="getCursorSpacing"
+      class="tn-number-box__input"
+      :class="[
+        fontColorClass,
+        {'tn-number-box__input--disabled': disabledInput || disabled}
+      ]"
+      :style="{
+        width: $t.string.getLengthUnitValue(inputWidth),
+        height: $t.string.getLengthUnitValue(inputHeight),
+        color: fontColorStyle,
+        fontSize: fontSizeStyle,
+        backgroundColor: backgroundColorStyle
+      }"
+      @blur="blurInput"
+      @focus="focusInput"
+    />
+    
+    <!-- 加 -->
+    <view
+      class="tn-number-box__btn__plus"
+      :class="[
+        backgroundColorClass,
+        fontColorClass,
+        {'tn-number-box__btn--disabled': disabled || inputValue >= max}
+      ]"
+      :style="{
+        backgroundColor: backgroundColorStyle,
+        height: $t.string.getLengthUnitValue(inputHeight),
+        color: fontColorStyle,
+        fontSize: fontSizeStyle
+      }"
+      @touchstart.stop.prevent="touchStart('plus')"
+      @touchend.stop.prevent="clearTimer"
+    >
+      <view class="tn-icon-add"></view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import componentsColor from '../../libs/mixin/components_color.js'
+  
+  export default {
+    mixins: [componentsColor],
+    name: 'tn-number-box',
+    props: {
+      value: {
+        type: Number,
+        default: 1
+      },
+      // 索引
+      index: {
+        type: [Number, String],
+        default: ''
+      },
+      // 最小值
+      min: {
+        type: Number,
+        default: 0
+      },
+      // 最大值
+      max: {
+        type: Number,
+        default: 99999
+      },
+      // 步进值
+      step: {
+        type: Number,
+        default: 1
+      },
+      // 禁用
+      disabled: {
+        type: Boolean,
+        default: false
+      },
+      // 是否禁用输入
+      disabledInput: {
+        type: Boolean,
+        default: false
+      },
+      // 输入框的宽度
+      inputWidth: {
+        type: Number,
+        default: 88
+      },
+      // 输入框的高度
+      inputHeight: {
+        type: Number,
+        default: 50
+      },
+      // 输入框和键盘之间的距离
+      cursorSpacing: {
+        type: Number,
+        default: 100
+      },
+      // 是否开启长按进行连续递增减
+      longPress: {
+        type: Boolean,
+        default: true
+      },
+      // 长按触发间隔
+      longPressTime: {
+        type: Number,
+        default: 250
+      },
+      // 是否只能输入正整数
+      positiveInteger: {
+        type: Boolean,
+        default: true
+      }
+    },
+    computed: {
+      getCursorSpacing() {
+        return Number(uni.upx2px(this.cursorSpacing))
+      }
+    },
+    data() {
+      return {
+        // 输入框的值
+        inputValue: 1,
+        // 长按定时器
+        longPressTimer: null,
+        // 标记值的改变是来自外部还是内部
+        changeFromInner: false,
+        // 内部定时器
+        innerChangeTimer: null
+      }
+    },
+    watch: {
+      value(val) {
+        // 只有value的改变是来自外部的时候,才去同步inputValue的值,否则会造成循环错误
+        if (!this.changeFromInner) {
+          this.updateInputValue()
+          // 因为inputValue变化后,会触发this.handleChange(),在其中changeFromInner会再次被设置为true,
+          // 造成外面修改值,也导致被认为是内部修改的混乱,这里进行this.$nextTick延时,保证在运行周期的最后处
+          // 将changeFromInner设置为false
+          this.$nextTick(() => {
+          	this.changeFromInner = false
+          })
+        }
+      },
+      inputValue(newVal, oldVal) {
+        // 为了让用户能够删除所有输入值,重新输入内容,删除所有值后,内容为空字符串
+        if (newVal === '') return
+        let value = 0
+        // 首先判断是否数值,并且在min和max之间,如果不是,使用原来值
+        let isNumber = this.$t.test.number(newVal)
+        if (isNumber && newVal >= this.min && newVal <= this.max) value = newVal
+        else value = oldVal
+        
+        // 判断是否只能输入大于等于0的整数
+        if (this.positiveInteger) {
+          // 小于0或者带有小数点
+          if (newVal < 0 || String(newVal).indexOf('.') !== -1) {
+            value = Math.floor(newVal)
+            // 双向绑定input的值,必须要使用$nextTick修改显示的值
+            this.$nextTick(() => {
+            	this.inputValue = value
+            })
+          }
+        }
+        this.handleChange(value, 'change')
+      },
+      min() {
+        this.updateInputValue()
+      },
+      max() {
+        this.updateInputValue()
+      }
+    },
+    created() {
+      this.updateInputValue()
+    },
+    methods: {
+      // 开始点击按钮
+      touchStart(func) {
+        // 先执行一遍方法,否则会造成松开手时,就执行了clearTimer,导致无法实现功能
+        this[func]()
+        // 如果没有开启长按功能,直接返回
+        if (!this.longPress) return
+        // 清空长按定时器,防止重复注册
+        if (this.longPressTimer) {
+          clearInterval(this.longPressTimer)
+          this.longPressTimer = null
+        }
+        this.longPressTimer = setInterval(() => {
+          // 执行加减操作
+          this[func]()
+        }, this.longPressTime)
+      },
+      // 清除定时器
+      clearTimer() {
+        this.$nextTick(() => {
+          if (this.longPressTimer) {
+            clearInterval(this.longPressTimer)
+            this.longPressTimer = null
+          }
+        })
+      },
+      // 减
+      minus() {
+        this.computeValue('minus')
+      },
+      // 加
+      plus() {
+        this.computeValue('plus')
+      },
+      // 处理小数相加减出现溢出问题
+      calcPlus(num1, num2) {
+        let baseNum = 0, baseNum1 = 0, baseNum2 = 0
+        try {
+          baseNum1 = num1.toString().split('.')[1].length
+        } catch(e) {
+          baseNum1 = 0
+        }
+        try {
+          baseNum2 = num2.toString().split('.')[1].length
+        } catch(e) {
+          baseNum2 = 0
+        }
+        
+        baseNum = Math.pow(10, Math.max(baseNum1, baseNum2))
+        // 精度
+        let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2
+        return ((num1 * baseNum + num2 * baseNum) / baseNum).toFixed(precision)
+      },
+      calcMinus(num1, num2) {
+        let baseNum = 0, baseNum1 = 0, baseNum2 = 0
+        try {
+          baseNum1 = num1.toString().split('.')[1].length
+        } catch(e) {
+          baseNum1 = 0
+        }
+        try {
+          baseNum2 = num2.toString().split('.')[1].length
+        } catch(e) {
+          baseNum2 = 0
+        }
+        
+        baseNum = Math.pow(10, Math.max(baseNum1, baseNum2))
+        // 精度
+        let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2
+        return ((num1 * baseNum - num2 * baseNum) / baseNum).toFixed(precision)
+      },
+      // 处理操作后的值
+      computeValue(type) {
+        uni.hideKeyboard()
+        if (this.disabled) return
+        let value = 0
+        
+        if (type === 'minus') {
+          // 减
+          value = this.calcMinus(this.inputValue, this.step)
+        } else if (type === 'plus') {
+          // 加
+          value = this.calcPlus(this.inputValue, this.step)
+        }
+        // 判断是否比最小值小和操作最大值
+        if (value < this.min || value > this.max) return
+        
+        this.inputValue = value
+        this.handleChange(value, type)
+      },
+      // 处理用户手动输入
+      blurInput(event) {
+        let val = 0,
+            value = event.detail.value
+        // 如果为非0-9数字组成,或者其第一位数值为0,直接让其等于min值
+        // 这里不直接判断是否正整数,是因为用户传递的props min值可能为0
+        if (!/(^\d+$)/.test(value) || value[0] == 0) {
+          val = this.min
+        } else {
+          val = +value
+        }
+        
+        if (val > this.max) {
+          val = this.max
+        } else if (val < this.min) {
+          val = this.min
+        }
+        this.$nextTick(() => {
+          this.inputValue = val
+        })
+        this.handleChange(val, 'blur')
+      },
+      // 获取焦点
+      focusInput() {
+        this.$emit('focus')
+      },
+      // 初始化inputValue
+      updateInputValue() {
+        let value = this.value
+        if (value <= this.min) {
+          value = this.min
+        } else if (value >= this.max) {
+          value = this.max
+        }
+        
+        this.inputValue = Number(value)
+      },
+      // 处理值改变状态
+      handleChange(value, type) {
+        if (this.disabled) return
+        // 清除定时器,防止混乱
+        if (this.innerChangeTimer) {
+          clearTimeout(this.innerChangeTimer)
+          this.innerChangeTimer = null
+        }
+        
+        // 内部修改值
+        this.changeFromInner = true
+        // 一定时间内,清除changeFromInner标记,否则内部值改变后
+        // 外部通过程序修改value值,将会无效
+        this.innerChangeTimer = setTimeout(() => {
+          this.changeFromInner = false
+        }, 150)
+        this.$emit('input', Number(value))
+        this.$emit(type, {
+          value: Number(value),
+          index: this.index
+        })
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-number-box {
+    display: inline-flex;
+    align-items: center;
+    
+    &__btn {
+      &__plus,&__minus {
+        width: 60rpx;
+        display: flex;
+        flex-direction: row;
+        justify-content: center;
+        align-items: center;
+        background-color: $tn-font-holder-color;
+      }
+      
+      &__plus {
+        border-radius: 0 8rpx 8rpx 0;
+      }
+      
+      &__minus {
+        border-radius: 8rpx 0 0 8rpx;
+      }
+      
+      &--disabled {
+        color: $tn-font-sub-color !important;
+        background: $tn-font-holder-color !important;
+      }
+    }
+    
+    &__input {
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: center;
+      position: relative;
+      text-align: center;
+      box-sizing: border-box;
+      padding: 0 4rpx;
+      margin: 0 6rpx;
+      background-color: $tn-font-holder-color;
+      
+      &--disabled {
+        color: $tn-font-sub-color !important;
+        background: $tn-font-holder-color !important;
+      }
+    }
+  }
+</style>

+ 182 - 0
addons/exam/tuniao-bak/tn-number-keyboard/tn-number-keyboard.vue

@@ -0,0 +1,182 @@
+<template>
+  <view class="tn-number-keyboard-class tn-number-keyboard" @touchmove.stop.prevent="() => {}">
+    <view class="tn-number-keyboard__grids">
+      <view
+        v-for="(item, index) in dataList"
+        :key="index"
+        class="tn-number-keyboard__grids__item"
+        :class="{
+          'tn-bg-gray--light': showGaryBg(index),
+          'tn-border-solid-top': index <= 2,
+          'tn-border-solid-bottom': index < 9,
+          'tn-border-solid-right': (index + 1) % 3 != 0
+        }"
+        :hover-class="hoverClass(index)"
+        :hover-stay-time="150"
+        @tap="keyboardClick(item)"
+      >
+        <view class="tn-number-keyboard__grids__btn">{{ item }}</view>
+      </view>
+      
+      <view
+        class="tn-number-keyboard__grids__item tn-bg-gray--light"
+        hover-class="tn-hover"
+        :hover-stay-time="150"
+        @touchstart.stop="backspaceClick"
+        @touchend="clearTimer"
+      >
+        <view class="tn-number-keyboard__grids__btn tn-number-keyboard__back">
+          <view class="tn-icon-left-arrow tn-number-keyboard__back__icon"></view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-number-keyboard',
+    props: {
+      // 键盘类型
+      // number -> 数字键盘 card -> 身份证键盘
+      mode: {
+        type: String,
+        default: 'number'
+      },
+      // 是否显示键盘的'.'符号
+      dotEnabled: {
+        type: Boolean,
+        default: true
+      },
+      // 是否为乱序键盘
+      randomEnabled: {
+        type: Boolean,
+        default: false
+      }
+    },
+    computed: {
+      // 键盘显示的内容
+      dataList() {
+        let tmp = []
+        if (!this.dotEnabled && this.mode === 'number') {
+          if (!this.randomEnabled) {
+            return [1, 2, 3, 4, 5, 6, 7, 8, 9, '', 0]
+          } else {
+            let data = this.$t.array.random([1, 2, 3, 4, 5, 6, 7, 8, 9, 0])
+            data.splice(-1, 0, '')
+            return data
+          }
+        } else if (this.dotEnabled && this.mode === 'number') {
+          if (!this.randomEnabled) {
+            return [1, 2, 3, 4, 5, 6, 7, 8, 9, this.dot, 0]
+          } else {
+            let data = this.$t.array.random([1, 2, 3, 4, 5, 6, 7, 8, 9, 0])
+            data.splice(-1, 0, this.dot)
+            return data
+          }
+        } else if (this.mode === 'card') {
+          if (!this.randomEnabled) {
+            return [1, 2, 3, 4, 5, 6, 7, 8, 9, this.cardX, 0]
+          } else {
+            let data = this.$t.array.random([1, 2, 3, 4, 5, 6, 7, 8, 9, 0])
+            data.splice(-1, 0, this.cardX)
+            return data
+          }
+        }
+      },
+      // 按键的样式
+      keyStyle() {
+        return index => {
+          let style = {}
+          if (this.mode === 'number' && !this.dotEnabled && index === 9) style.flex = '0 0 66.6666666666%'
+          return style
+        }
+      },
+      // 是否让按键显示灰色,只在数字键盘和非乱序且在点击时
+      showGaryBg() {
+        return index => {
+          if (!this.randomEnabled && index === 9 && (this.mode !== 'number' || (this.mode === 'number' && this.dotEnabled))) return true
+          else return false
+        }
+      },
+      // 手指停留的class
+      hoverClass() {
+        return index => {
+          if (this.mode === 'number' && !this.dotEnabled && index === 9) return ''
+          if (!this.randomEnabled && index === 9 && (this.mode === 'number' && this.dotEnabled || this.mode === 'card')) return 'tn-hover'
+          else return 'tn-number-keyboard--hover'
+        }
+      }
+    },
+    data() {
+      return {
+        // 退格键内容
+        backspace: 'backspace',
+        // 点内容
+        dot: '.',
+        // 长按多次删除事件监听
+        longPressDeleteTimer: null,
+        // 身份证的X符号
+        cardX: 'X'
+      }
+    },
+    methods: {
+      // 点击退格键
+      backspaceClick() {
+        this.$emit('backspace')
+        this.clearTimer()
+        this.longPressDeleteTimer = setInterval(() => {
+          this.$emit('backspace')
+        }, 250)
+      },
+      // 获取键盘显示的内容
+      keyboardClick(value) {
+        if (this.mode === 'number' && !this.dotEnabled && value === '') return
+        // 允许键盘显示点模式和触发非点按键时,将内容转换为数字类型
+        if (this.dotEnabled && value != this.dot && value != this.cardX) value = Number(value)
+        this.$emit('change', value)
+      },
+      // 清除定时器
+      clearTimer() {
+        if (this.longPressDeleteTimer) {
+          clearInterval(this.longPressDeleteTimer)
+          this.longPressDeleteTimer = null
+        }
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .tn-number-keyboard {
+    position: relative;
+    
+    &__grids {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+      justify-content: flex-end;
+      
+      &__item {
+        display: flex;
+        flex-direction: row;
+        flex: 0 0 33.3333333333%;
+        align-items: center;
+        justify-content: center;
+        height: 110rpx;
+        text-align: center;
+        font-size: 50rpx;
+        color: $tn-font-color;
+        font-weight: 500;
+      }
+    }
+    
+    &__back {
+      font-size: 38rpx;
+    }
+    
+    &--hover {
+      background-color: $tn-font-holder-color;
+    }
+  }
+</style>

+ 334 - 0
addons/exam/tuniao-bak/tn-rate/tn-rate.vue

@@ -0,0 +1,334 @@
+<template>
+  <view
+    :id="elId"
+    class="tn-rate-class tn-rate"
+    @touchmove.stop.prevent="touchMove"
+  >
+    <view class="tn-rate__wrap" :class="[elClass]" v-for="(item, index) in count" :key="index">
+      <view
+        class="tn-rate__wrap__icon"
+        :class="[`tn-icon-${(allowHalf && halfIcon ? activeIndex > index + 1 : activeIndex > index) ? elActionIcon : elInactionIcon}`]"
+        :style="[iconStyle(index)]"
+        @tap="click(index + 1, $event)"
+      >
+        <!-- 半图标 -->
+        <view
+          v-if="showHalfIcon(index)"
+          class="tn-rate__wrap__icon--half"
+          :class="[`tn-icon-${elActionIcon}`]"
+          :style="[halfIconStyle]"
+        ></view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-rate',
+    props: {
+      // 选中星星的数量
+      value: {
+        type: Number,
+        default: 0
+      },
+      // 显示的星星数
+      count: {
+        type: Number,
+        default: 5
+      },
+      // 最小能选择的星星数
+      minCount: {
+        type: Number,
+        default: 0
+      },
+      // 禁用状态
+      disabled: {
+        type: Boolean,
+        default: false
+      },
+      // 是否可以选择半星
+      allowHalf: {
+        type: Boolean,
+        default: false
+      },
+      // 星星大小
+      size: {
+        type: Number,
+        default: 32
+      },
+      // 被选中的图标
+      activeIcon: {
+        type: String,
+        default: 'star-fill'
+      },
+      // 未被选中的图标
+      inactiveIcon: {
+        type: String,
+        default: 'star'
+      },
+      // 被选中的颜色
+      activeColor: {
+        type: String,
+        default: '#01BEFF'
+      },
+      // 默认颜色
+      inactiveColor: {
+        type: String,
+        default: '#AAAAAA'
+      },
+      // 星星之间的距离
+      gutter: {
+        type: Number,
+        default: 10
+      },
+      // 自定义颜色
+      colors: {
+        type: Array,
+        default() {
+          return []
+        }
+      },
+      // 自定义图标
+      icons: {
+        type: Array,
+        default() {
+          return []
+        }
+      }
+    },
+    computed: {
+      // 图标显示的比例
+      showHalfIcon(index) {
+        return index => {
+          return this.allowHalf && Math.ceil(this.activeIndex) === index + 1 && this.halfIcon
+        }
+      },
+      // 被激活的图标
+      elActionIcon() {
+        const len = this.icons.length
+        // icons参数传递了3个图标,当选中2个时,用第一个图标,4个时,用第二个图标
+        // 5个时,用第三个图标作为激活的图标
+        if (len && len <= this.count) {
+          const step = Math.round(Math.ceil(this.activeIndex) / Math.round(this.count / len))
+          if (step < 1) return this.icons[0]
+          if (step > len) return this.icons[len - 1]
+          return this.icons[step - 1]
+        }
+        return this.activeIcon
+      },
+      // 未被激活的图标
+      elInactionIcon() {
+        const len = this.icons.length
+        // icons参数传递了3个图标,当选中2个时,用第一个图标,4个时,用第二个图标
+        // 5个时,用第三个图标作为激活的图标
+        if (len && len <= this.count) {
+          const step = Math.round(Math.ceil(this.activeIndex) / Math.round(this.count / len))
+          if (step < 1) return this.icons[0]
+          if (step > len) return this.icons[len - 1]
+          return this.icons[step - 1]
+        }
+        return this.inactiveIcon
+      },
+      // 被激活的颜色
+      elActionColor() {
+        const len = this.colors.length
+        // 如果有设置colors参数(此参数用于将图标分段,比如一共5颗星,colors传3个颜色值,那么根据一定的规则,2颗星可能为第一个颜色
+        // 4颗星为第二个颜色值,5颗星为第三个颜色值)
+        if (len && len <= this.count) {
+          const step = Math.round(Math.ceil(this.activeIndex) / Math.round(this.count / len))
+          if (step < 1) return this.colors[0]
+          if (step > len) return this.colors[len - 1]
+          return this.colors[step - 1]
+        }
+        return this.activeColor
+      },
+      // 图标的样式
+      iconStyle() {
+        return index => {
+          let style = {}
+          
+          style.fontSize = this.$t.string.getLengthUnitValue(this.size)
+          style.padding = `0 ${this.$t.string.getLengthUnitValue(this.gutter / 2)}`
+          // 当前图标的颜色
+          if (this.allowHalf && this.halfIcon) {
+            style.color = this.activeIndex > index + 1 ? this.elActionColor : this.inactiveColor
+          } else {
+            style.color = this.activeIndex > index ? this.elActionColor : this.inactiveColor
+          }
+          return style
+        }
+      },
+      // 半图标样式
+      halfIconStyle() {
+        let style = {}
+        
+        style.fontSize = this.$t.string.getLengthUnitValue(this.size)
+        style.padding = `0 ${this.$t.string.getLengthUnitValue(this.gutter / 2)}`
+        style.color = this.elActionColor
+        return style
+      }
+    },
+    data() {
+      return {
+        // 保证控件的唯一性
+        elId: this.$t.uuid(),
+        elClass: this.$t.uuid(),
+        // 评分盒子左边到屏幕左边的距离,用于滑动选择时计算距离
+        starBoxLeft: 0,
+        // 当前激活的星星的序号
+        activeIndex: this.value,
+        // 每个星星的宽度
+        starWidth: 0,
+        // 每个星星最右边到盒子组件最左边的距离
+        starWidthArr: [],
+        // 标记是否为半图标
+        halfIcon: false,
+      }
+    },
+    watch: {
+      value(val) {
+        this.activeIndex = val
+        if (this.allowHalf && (val % 1 === 0.5)) {
+          this.halfIcon = true
+        } else {
+          this.halfIcon = false
+        }
+      },
+      size() {
+        // 当尺寸修改的时候重新获取布局尺寸信息
+        this.$nextTick(() => {
+          this.getElRectById()
+          this.getElRectByClass()
+        })
+      }
+    },
+    mounted() {
+      this.getElRectById()
+      this.getElRectByClass()
+    },
+    methods: {
+      // 获取评分组件盒子的布局信息
+      getElRectById() {
+        this._tGetRect('#'+this.elId).then(res => {
+          this.starBoxLeft = res.left
+        })
+      },
+      // 获取单个星星的尺寸
+      getElRectByClass() {
+        this._tGetRect('.'+this.elClass).then(res => {
+          this.starWidth = res.width
+          // 把每个星星最右边到盒子最左边的距离
+          for (let i = 0; i < this.count; i++) {
+            this.starWidthArr[i] = (i + 1) * this.starWidth
+          }
+        })
+      },
+      // 手指滑动
+      touchMove(e) {
+        if (this.disabled) return
+        if (!e.changedTouches[0]) return
+        
+        const movePageX = e.changedTouches[0].pageX
+        // 滑动点相对于评分盒子左边的距离
+        const distance = movePageX - this.starBoxLeft
+        
+        // 如果滑动到了评分盒子的左边界,设置为0星
+        if (distance <= 0) {
+          this.activeIndex = 0
+        }
+        
+        // 计算滑动的距离相当于点击多少颗星星
+        let index = Math.ceil(distance / this.starWidth)
+        if (this.allowHalf) {
+          const iconHalfWidth = this.starWidthArr[index - 1] - (this.starWidth / 2)
+          if (distance < iconHalfWidth) {
+            this.halfIcon = true
+            index -= 0.5
+          } else {
+            this.halfIcon = false
+          }
+        }
+        this.activeIndex = index > this.count ? this.count : index
+        
+        if (this.activeIndex < this.minCount) this.activeIndex = this.minCount
+        
+        this.emitEvent()
+      },
+      // 通过点击直接选中
+      click(index, e) {
+        if (this.disabled) return
+        // 半星选择
+        if (this.allowHalf) {
+          if (!e.changedTouches[0]) return
+          const movePageX = e.changedTouches[0].pageX
+          // 点击点相对于当前图标左边的距离
+          const distance = movePageX - this.starBoxLeft
+          const iconHalfWidth = this.starWidthArr[index - 1] - (this.starWidth / 2)
+          if (distance < iconHalfWidth) {
+            this.halfIcon = true
+          } else {
+            this.halfIcon = false
+          }
+        }
+        
+        // 对第一个星星特殊处理,只有一个的时候,点击可以取消,否则无法作0星评价
+        if (index == 1) {
+          if (this.allowHalf && this.allowHalf) {
+            if ((this.activeIndex === 0.5 && this.halfIcon) || 
+                (this.activeIndex === 1 && !this.halfIcon)) {
+              this.activeIndex = 0
+            } else {
+              this.activeIndex = this.halfIcon ? 0.5 : 1
+            }
+          } else {
+            if (this.activeIndex == 1) {
+              this.activeIndex = 0
+            } else {
+              this.activeIndex = 1
+            }
+          }
+        } else {
+          this.activeIndex = (this.allowHalf && this.halfIcon) ? index - 0.5 : index
+        }
+        
+        if (this.activeIndex < this.minCount) this.activeIndex = this.minCount
+        
+        this.emitEvent()
+      },
+      // 发送事件
+      emitEvent() {
+        this.$emit('change', this.activeIndex)
+        // 修改v-model的值
+        this.$emit('input', this.activeIndex)
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-rate {
+    display: inline-flex;
+    align-items: center;
+    margin: 0;
+    padding: 0;
+    
+    &__wrap {
+      
+      &__icon {
+        position: relative;
+        box-sizing: border-box;
+        
+        &--half {
+          position: absolute;
+          top: 0;
+          left: 0;
+          display: inline-block;
+          overflow: hidden;
+          width: 50%;
+        }
+      }
+    }
+  }
+</style>

+ 71 - 0
addons/exam/tuniao-bak/tn-time-line-item/tn-time-line-item.vue

@@ -0,0 +1,71 @@
+<template>
+  <view class="tn-time-line-item-class tn-time-line-item">
+    <view>
+      <slot name="content"></slot>
+    </view>
+    <view class="tn-time-line-item__node" :style="[nodeStyle]">
+      <slot name="node">
+        <view class="tn-time-line-item__node--dot"></view>
+      </slot>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-time-line-item',
+    props: {
+      // 节点左边图标的绝对定位top值
+      top: {
+        type: [String, Number],
+        default: ''
+      }
+    },
+    computed: {
+      nodeStyle() {
+        let style = {}
+        if (this.top !== '') style.top = this.top + 'rpx'
+        
+        return style
+      }
+    },
+    data() {
+      return {
+        
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-time-line-item {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    position: relative;
+    margin-bottom: 32rpx;
+    
+    &__node {
+      display: flex;
+      flex-direction: row;
+      position: absolute;
+      top: 12rpx;
+      left: -40rpx;
+      align-items: center;
+      justify-content: center;
+      font-size: 24rpx;
+      transform-origin: 0;
+      transform: translateX(-50%);
+      z-index: 1;
+      background-color: transparent;
+      
+      &--dot {
+        width: 16rpx;
+        height: 16rpx;
+        border-radius: 100rpx;
+        background-color: #AAAAAA;
+      }
+    }
+  }
+</style>

+ 71 - 0
addons/exam/tuniao-bak/tn-time-line-item/tn-time-line-item.vue_bk

@@ -0,0 +1,71 @@
+<template>
+  <view class="tn-time-line-item-class tn-time-line-item">
+    <view>
+      <slot name="content"></slot>
+    </view>
+    <view class="tn-time-line-item__node" :style="[nodeStyle]">
+      <slot name="node">
+        <view class="tn-time-line-item__node--dot"></view>
+      </slot>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-time-line-item',
+    props: {
+      // 节点左边图标的绝对定位top值
+      top: {
+        type: [String, Number],
+        default: ''
+      }
+    },
+    computed: {
+      nodeStyle() {
+        let style = {}
+        if (this.top !== '') style.top = this.top + 'rpx'
+        
+        return style
+      }
+    },
+    data() {
+      return {
+        
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-time-line-item {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    position: relative;
+    margin-bottom: 32rpx;
+    
+    &__node {
+      display: flex;
+      flex-direction: row;
+      position: absolute;
+      top: 12rpx;
+      left: -40rpx;
+      align-items: center;
+      justify-content: center;
+      font-size: 24rpx;
+      transform-origin: 0;
+      transform: translateX(-50%);
+      z-index: 1;
+      background-color: transparent;
+      
+      &--dot {
+        width: 16rpx;
+        height: 16rpx;
+        border-radius: 100rpx;
+        background-color: #AAAAAA;
+      }
+    }
+  }
+</style>

+ 39 - 0
addons/exam/tuniao-bak/tn-time-line/tn-time-line.vue

@@ -0,0 +1,39 @@
+<template>
+  <view class="tn-time-line-class tn-time-line">
+    <slot></slot>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-time-line',
+    props: {
+      
+    },
+    data() {
+      return {
+        
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-time-line {
+    padding-left: 40rpx;
+    position: relative;
+    
+    &::before {
+      content: '';
+      position: absolute;
+      width: 1px;
+      left: 0;
+      top: 12rpx;
+      bottom: 0;
+      border-left: 1px solid #AAAAAA;
+      transform-origin: 0 0;
+      transform: scaleX(0.5);
+    }
+  }
+</style>

+ 39 - 0
addons/exam/tuniao-bak/tn-time-line/tn-time-line.vue_bk

@@ -0,0 +1,39 @@
+<template>
+  <view class="tn-time-line-class tn-time-line">
+    <slot></slot>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-time-line',
+    props: {
+      
+    },
+    data() {
+      return {
+        
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-time-line {
+    padding-left: 40rpx;
+    position: relative;
+    
+    &::before {
+      content: '';
+      position: absolute;
+      width: 1px;
+      left: 0;
+      top: 12rpx;
+      bottom: 0;
+      border-left: 1px solid #AAAAAA;
+      transform-origin: 0 0;
+      transform: scaleX(0.5);
+    }
+  }
+</style>

+ 324 - 0
addons/exam/tuniao-bak/tn-verification-code-input/tn-verification-code-input.vue

@@ -0,0 +1,324 @@
+<template>
+  <view class="tn-verification-code-class tn-verification-code">
+    <view class="tn-code__container">
+      <input class="tn-code__input" :disabled="disabledKeyboard" :value="valueModel" type="number" :focus="focus" :maxlength="maxLength" @input="getValue" />
+      <view v-for="(item, index) in loopCharArr" :key="index">
+        <view
+          class="tn-code__item"
+          :class="[{
+            'tn-code__item--breathe': breathe && charArrLength === index,
+            'tn-code__item__box': mode === 'box',
+            'tn-code__item__box--active': mode === 'box' && charArrLength === index
+          }]"
+          :style="[itemStyle(index)]"
+        >
+          <view
+            v-if="mode !== 'middleLine'"
+            class="tn-code__item__line tn-code__item__line--placeholder"
+            :style="[placeholderLineStyle(index)]"
+          ></view>
+          <view
+            v-if="mode === 'middleLine' && charArrLength <= index"
+            class="tn-code__item__line tn-code__item__line--middle"
+            :class="[{
+              'tn-code__item__line--bold': bold,
+              'tn-code__item--breathe': breathe && charArrLength === index,
+              'tn-code__item__line--active': charArrLength === index
+            }]"
+            :style="[lineStyle(index)]"
+          ></view>
+          <view
+            v-if="mode === 'bottomLine'"
+            class="tn-code__item__line tn-code__item__line--bottom"
+            :class="[{
+              'tn-code__item__line--bold': bold,
+              'tn-code__item--breathe': breathe && charArrLength === index,
+              'tn-code__item__line--active': charArrLength === index
+            }]"
+            :style="[lineStyle(index)]"
+          ></view>
+          <block v-if="!dotFill">
+            <text>{{ charArr[index] ? charArr[index] : '' }}</text>
+          </block>
+          <block v-else>
+            <text class="tn-code__item__dot">{{ charArr[index] ? '●' : '' }}</text>
+          </block>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-verification-code',
+    props: {
+      // 验证码的值
+      value: {
+        type: [String, Number],
+        default: ''
+      },
+      // 最大输入长度
+      maxLength: {
+        type: Number,
+        default: 4
+      },
+      // 显示模式
+      // box -> 盒子 bottomLine -> 底部横线 middleLine -> 中间横线
+      mode: {
+        type: String,
+        default: 'box'
+      },
+      // 用圆点填充空白位置
+      dotFill: {
+        type: Boolean,
+        default: false
+      },
+      // 字体加粗
+      bold: {
+        type: Boolean,
+        default: false
+      },
+      // 字体大小
+      fontSize: {
+        type: [String, Number],
+        default: ''
+      },
+      // 激活时颜色
+      activeColor: {
+        type: String,
+        default: ''
+      },
+      // 未激活时颜色
+      inactiveColor: {
+        type: String,
+        default: ''
+      },
+      // 输入框宽度,单位rpx
+      inputWidth: {
+        type: Number,
+        default: 80
+      },
+      // 当前激活的item带呼吸效果
+      breathe: {
+        type: Boolean,
+        default: true
+      },
+      // 自动获取焦点
+      focus: {
+        type: Boolean,
+        default: false
+      },
+      // 隐藏原生键盘,当使用自定义键盘的时候设置该参数未true即可
+      disabledKeyboard: {
+        type: Boolean,
+        default: false
+      }
+    },
+    computed: {
+      // 拆分要显示的字符
+      charArr() {
+        return this.valueModel.split('')
+      },
+      // 当前输入字符的长度
+      charArrLength() {
+        return this.charArr.length
+      },
+      // 输入框的个数
+      loopCharArr() {
+        return new Array(this.maxLength)
+      },
+      itemStyle() {
+        return (index) => {
+          let style = {}
+          style.fontWeight = this.bold ? 'bold' : 'normal'
+          if (this.fontSize) {
+            style.fontSize = this.fontSize + 'rpx'
+          }
+          if (this.inputWidth) {
+            style.width = this.inputWidth + 'rpx'
+            style.height = this.inputWidth + 'rpx'
+            style.lineHeight = this.inputWidth + 'rpx'
+          }
+          if (this.inactiveColor) {
+            style.color = this.inactiveColor
+            style.borderColor = this.inactiveColor
+          }
+          if (this.mode === 'box' && this.charArrLength === index) {
+            style.borderColor = this.activeColor
+          }
+          return style
+        }
+      },
+      placeholderLineStyle() {
+        return (index) => {
+          let style = {}
+          style.display = this.charArrLength === index ? 'block' : 'none'
+          if (this.inputWidth) {
+            style.height = (this.inputWidth * 0.5) + 'rpx'
+          }
+          return style
+        }
+      },
+      lineStyle() {
+        return (index) => {
+          let style = {}
+          if (this.inactiveColor) {
+            style.backgroundColor = this.inactiveColor
+          }
+          if (this.charArrLength === index && this.activeColor) {
+            style.backgroundColor = this.activeColor
+          }
+          return style
+        }
+      }
+    },
+    watch: {
+      value: {
+        handler(val) {
+          // 转换为字符串
+          val = String(val)
+          // 截掉超出的部分
+          this.valueModel = val.substring(0, this.maxLength)
+        },
+        immediate: true
+      }
+    },
+    data() {
+      return {
+        valueModel: ''
+      }
+    },
+    methods: {
+      // 获取填写的值
+      getValue(e) {
+        const {
+          value
+        } = e.detail
+        this.valueModel = value
+        // 判断输入的长度是否超出了maxlength的值
+        if (String(value).length > this.maxLength) return
+        // 未达到maxlength之前,触发change事件,否则触发finish事件
+        this.$emit('change', value)
+        this.$emit('input', value)
+        if (String(value).length == this.maxLength) {
+          this.$emit('finish', value)
+        }
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  
+  .tn-verification-code {
+    text-align: center;
+    
+    .tn-code {
+      &__container {
+        display: flex;
+        flex-direction: row;
+        justify-content: center;
+        flex-wrap: wrap;
+        position: relative;
+      }
+      
+      &__input {
+        position: absolute;
+        top: 0;
+        left: -100%;
+        width: 200%;
+        height: 100%;
+        text-align: left;
+        z-index: 9;
+        opacity: 0;
+        background: none;
+      }
+      
+      &__item {
+        position: relative;
+        width: 80rpx;
+        height: 80rpx;
+        line-height: 80rpx;
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+        justify-content: center;
+        margin: 10rpx 10rpx;
+        font-size: 60rpx;
+        font-weight: bold;
+        color: #838383;
+        
+        &--breathe {
+          animation: breathe 2s infinite ease;
+        }
+        
+        &__box {
+          border: 2rpx solid #AAAAAA;
+          border-radius: 6rpx;
+          
+          &--active {
+            animation-timing-function: ease-in-out;
+            animation-duration: 1500ms;
+            animation-iteration-count: infinite;
+            animation-direction: alternate;
+            overflow: hidden;
+            border: 2rpx solid #01BEFF;
+          }
+        }
+        
+        &__line {
+          position: absolute;
+          top: 50%;
+          left: 50%;
+          transform: translate(-50%, -50%);
+          background-color: #AAAAAA;
+          
+          &--bold {
+            height: 4px !important;
+          }
+          
+          &--placeholder {
+            display: none;
+            width: 2rpx;
+            height: 40rpx;
+          }
+          
+          &--middle, &--bottom {
+            width: 80%;
+            height: 2px;
+            border-radius: 2px;
+          }
+          &--bottom {
+            top: auto !important;
+            bottom: 0;
+            transform: translateX(-50%) !important;
+          }
+          
+          &--active {
+            background-color: #01BEFF !important;
+          }
+        }
+        
+        &__dot {
+          font-size: 34rpx;
+          line-height: 34rpx;
+        }
+      }
+    }
+  }
+  
+  @keyframes breathe {
+    0% {
+      opacity: 0.3;
+    }
+    
+    50% {
+      opacity: 1;
+    }
+    
+    100% {
+      opacity: 0.3;
+    }
+  }
+</style>

+ 149 - 0
addons/exam/tuniao-bak/tn-verification-code/tn-verification-code.vue

@@ -0,0 +1,149 @@
+<template>
+  <view class="tn-code-class tn-code">
+    
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'tn-verification-code',
+    props: {
+      // 倒计时总秒数
+      seconds: {
+        type: Number,
+        default: 60
+      },
+      // 开始时提示文字
+      startText: {
+        type: String,
+        default: '获取验证码'
+      },
+      // 倒计时提示文字
+      countDownText: {
+        type: String,
+        default: 's秒后重新获取'
+      },
+      // 结束时提示文字
+      endText: {
+        type: String,
+        default: '重新获取'
+      },
+      // 是否在H5刷新或各端返回再进入时继续倒计时
+      keepRunning: {
+      	type: Boolean,
+      	default: false
+      },
+      // 为了区分多个页面,或者一个页面多个倒计时组件本地存储的继续倒计时变了
+      uniqueKey: {
+      	type: String,
+      	default: ''
+      }
+    },
+    data() {
+      return {
+        timer: null,
+        secNum: this.seconds,
+        // 是否可以执行验证码操作
+        canGetCode: true
+      }
+    },
+    watch: {
+      seconds: {
+        handler(n) {
+          this.secNum = n
+        },
+        immediate: true
+      }
+    },
+    mounted() {
+      this.checkKeepRunning()
+    },
+    beforeDestroy() {
+      this.setTimeToStorage()
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    methods: {
+      // 检查是否继续运行
+      checkKeepRunning() {
+        // 获取上一次退出页面时的时间戳,如果没有上次保存,该值为空
+        let lastTimestamp = Number(uni.getStorageSync(this.uniqueKey + '_$tCountDownTimestamp'))
+        if (!lastTimestamp) return this.changeEvent(this.startText)
+        // 当前秒的时间戳
+        // + new Date() 相当于 new Date().getTime()
+        let nowTimestamp = Math.floor((+ new Date()) / 1000)
+        // 判断当前的时间戳,是否小于上一次的设定结束的时间,提前于结束的时间戳
+        if (this.keepRunning && lastTimestamp && lastTimestamp > nowTimestamp) {
+          // 剩余尚未执行完倒计时秒数
+          this.secNum = lastTimestamp - nowTimestamp
+          // 清除本地保存的变量
+          uni.removeStorageSync(this.uniqueKey + '_$tCountDownTimestamp')
+          // 开始倒计时
+          this.start()
+        } else {
+          // 如果不存在需要继续上一次的倒计时,执行正常的逻辑
+          this.changeEvent(this.startText);
+        }
+      },
+      // 开始倒计时
+      start() {
+        // 防止快速点击获取验证码按钮导致产生多个定时器导致混乱
+        if (this.timer) {
+          clearInterval(this.timer)
+          this.timer = null
+        }
+        this.$emit('start')
+        this.canGetCode = false
+        
+        this.changeEvent(this.countDownText.replace(/s|S/, this.secNum))
+        this.setTimeToStorage()
+        this.timer = setInterval(() => {
+          if (--this.secNum) {
+            this.changeEvent(this.countDownText.replace(/s|S/, this.secNum))
+          } else {
+            // 倒计时结束,清空定时器、重置提示信息
+            this.reset()
+            this.$emit('end')
+          }
+        }, 1000)
+      },
+      // 重置倒计时
+      reset() {
+        this.canGetCode = true
+        if (this.timer) {
+          clearInterval(this.timer)
+          this.timer = null
+        }
+        this.secNum = this.seconds
+        this.changeEvent(this.endText)
+      },
+      // 倒计时改变事件
+      changeEvent(text) {
+        this.$emit('change', text)
+      },
+      // 保存当前时间戳
+      // 防止倒计时尚未结束,H5刷新或者各端的右上角返回上一页再进来
+      setTimeToStorage() {
+        if (!this.keepRunning ||!this.timer) return
+        // 记录当前的时间戳,为了下次进入页面,如果还在倒计时内的话,继续倒计时
+        // 倒计时尚未结束,结果大于0;倒计时已经开始,就会小于初始值,如果等于初始值,说明没有开始倒计时,无需处理
+        if (this.secNum > 0 && this.secNum <= this.seconds) {
+          let nowTimestamp = Math.floor((+ new Date()) / 1000)
+          // 保存本次倒计时结束时候的时间戳
+          uni.setStorageSync(this.uniqueKey + '_$tCountDownTimestamp', nowTimestamp + this.secNum)
+        }
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .tn-code {
+    width: 0;
+    height: 0;
+    position: fixed;
+    z-index: -1;
+  }
+</style>

+ 2 - 0
addons/exam/uniapp/.gitignore

@@ -0,0 +1,2 @@
+/unpackage
+/.hbuilderx

+ 244 - 0
addons/exam/uniapp/App.vue

@@ -0,0 +1,244 @@
+<script>
+	import Vue from 'vue'
+	export default {
+		onLaunch: function() {
+			uni.getSystemInfo({
+				success: function(e) {
+					// #ifndef MP
+					Vue.prototype.StatusBar = e.statusBarHeight;
+					if (e.platform == 'android') {
+						Vue.prototype.CustomBar = e.statusBarHeight + 50;
+					} else {
+						Vue.prototype.CustomBar = e.statusBarHeight + 45;
+					};
+					// #endif
+
+					// #ifdef MP-WEIXIN
+					Vue.prototype.StatusBar = e.statusBarHeight;
+					let custom = wx.getMenuButtonBoundingClientRect();
+					Vue.prototype.Custom = custom;
+					Vue.prototype.CustomBar = custom.bottom + custom.top - e.statusBarHeight;
+					// #endif		
+
+					// #ifdef MP-ALIPAY
+					Vue.prototype.StatusBar = e.statusBarHeight;
+					Vue.prototype.CustomBar = e.statusBarHeight + e.titleBarHeight;
+					// #endif
+				}
+			})
+
+		},
+		onShow: function() {
+			console.log('App Show')
+		},
+		onHide: function() {
+			console.log('App Hide')
+		}
+
+	}
+</script>
+
+<style lang="scss">
+	@import "colorui/main.css";
+	@import "colorui/icon.css";
+	@import 'thorui/app.css';
+	@import 'tuniao-ui/index.scss';
+	@import 'tuniao-ui/iconfont.css';
+	
+	page {
+		background: #EDEDED;
+	}
+
+	.nav-list {
+		display: flex;
+		flex-wrap: wrap;
+		padding: 0px 40upx 0px;
+		justify-content: space-between;
+	}
+
+	.nav-li {
+		padding: 30upx;
+		border-radius: 12upx;
+		width: 45%;
+		margin: 0 2.5% 40upx;
+		background-image: url(https://cdn.nlark.com/yuque/0/2019/png/280374/1552996358352-assets/web-upload/cc3b1807-c684-4b83-8f80-80e5b8a6b975.png);
+		background-size: cover;
+		background-position: center;
+		position: relative;
+		z-index: 1;
+	}
+
+	.nav-li::after {
+		content: "";
+		position: absolute;
+		z-index: -1;
+		background-color: inherit;
+		width: 100%;
+		height: 100%;
+		left: 0;
+		bottom: -10%;
+		border-radius: 10upx;
+		opacity: 0.2;
+		transform: scale(0.9, 0.9);
+	}
+
+	.nav-li.cur {
+		color: #fff;
+		background: rgb(94, 185, 94);
+		box-shadow: 4upx 4upx 6upx rgba(94, 185, 94, 0.4);
+	}
+
+	.nav-title {
+		font-size: 32upx;
+		font-weight: 300;
+	}
+
+	.nav-title::first-letter {
+		font-size: 40upx;
+		margin-right: 4upx;
+	}
+
+	.nav-name {
+		font-size: 28upx;
+		text-transform: Capitalize;
+		margin-top: 20upx;
+		position: relative;
+	}
+
+	.nav-name::before {
+		content: "";
+		position: absolute;
+		display: block;
+		width: 40upx;
+		height: 6upx;
+		background: #fff;
+		bottom: 0;
+		right: 0;
+		opacity: 0.5;
+	}
+
+	.nav-name::after {
+		content: "";
+		position: absolute;
+		display: block;
+		width: 100upx;
+		height: 1px;
+		background: #fff;
+		bottom: 0;
+		right: 40upx;
+		opacity: 0.3;
+	}
+
+	.nav-name::first-letter {
+		font-weight: bold;
+		font-size: 36upx;
+		margin-right: 1px;
+	}
+
+	.nav-li text {
+		position: absolute;
+		right: 30upx;
+		top: 30upx;
+		font-size: 52upx;
+		width: 60upx;
+		height: 60upx;
+		text-align: center;
+		line-height: 60upx;
+	}
+
+	.text-light {
+		font-weight: 300;
+	}
+
+	@keyframes show {
+		0% {
+			transform: translateY(-50px);
+		}
+
+		60% {
+			transform: translateY(40upx);
+		}
+
+		100% {
+			transform: translateY(0px);
+		}
+	}
+
+	@-webkit-keyframes show {
+		0% {
+			transform: translateY(-50px);
+		}
+
+		60% {
+			transform: translateY(40upx);
+		}
+
+		100% {
+			transform: translateY(0px);
+		}
+	}
+	
+	.fix-bottom {
+		width: 100%;
+		height: 100rpx;
+	}
+	
+	.m-t-20 {
+		margin-top: 20rpx;
+	}
+	
+	.m-lr-10 {
+		margin: 0 10rpx;
+	}
+	
+	.m-lr-20 {
+		margin: 0 20rpx;
+	}
+	
+	.m-l-30 {
+		margin: 30rpx;
+	}
+	
+	.text-primary {
+		color: #5677fc;
+	}
+	
+	.bg-primary {
+	    background-color: #5677fc;
+	    color: #ffffff;
+	}
+	
+	.line-primary::after,
+	.lines-primary::after {
+		border-color: #5677fc;
+	}
+	
+	.card-view {
+		width: 90%;
+		padding: 20rpx;
+		background: #fff;
+		margin: 30rpx auto;
+		border-radius: 15rpx;
+		box-shadow: 0rpx 0rpx 50rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+	
+	.card-shadow {
+	    border-radius: 15rpx;
+	    box-shadow: 0rpx 0rpx 50rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+	
+	.about-shadow {
+		border-radius: 15rpx;
+		box-shadow: 0rpx 0rpx 50rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+	
+	.about {
+	
+		&__wrap {
+			position: relative;
+			z-index: 1;
+			margin: 20rpx 30rpx;
+			margin-top: -230rpx;
+		}
+	}
+</style>

+ 184 - 0
addons/exam/uniapp/colorui/animation.css

@@ -0,0 +1,184 @@
+/* 
+  Animation 微动画  
+  基于ColorUI组建库的动画模块 by 文晓港 2019年3月26日19:52:28
+ */
+
+/* css 滤镜 控制黑白底色gif的 */
+.gif-black{  
+  mix-blend-mode: screen;  
+}
+.gif-white{  
+  mix-blend-mode: multiply; 
+}
+
+
+/* Animation css */
+[class*=animation-] {
+    animation-duration: .5s;
+    animation-timing-function: ease-out;
+    animation-fill-mode: both
+}
+
+.animation-fade {
+    animation-name: fade;
+    animation-duration: .8s;
+    animation-timing-function: linear
+}
+
+.animation-scale-up {
+    animation-name: scale-up
+}
+
+.animation-scale-down {
+    animation-name: scale-down
+}
+
+.animation-slide-top {
+    animation-name: slide-top
+}
+
+.animation-slide-bottom {
+    animation-name: slide-bottom
+}
+
+.animation-slide-left {
+    animation-name: slide-left
+}
+
+.animation-slide-right {
+    animation-name: slide-right
+}
+
+.animation-shake {
+    animation-name: shake
+}
+
+.animation-reverse {
+    animation-direction: reverse
+}
+
+@keyframes fade {
+    0% {
+        opacity: 0
+    }
+
+    100% {
+        opacity: 1
+    }
+}
+
+@keyframes scale-up {
+    0% {
+        opacity: 0;
+        transform: scale(.2)
+    }
+
+    100% {
+        opacity: 1;
+        transform: scale(1)
+    }
+}
+
+@keyframes scale-down {
+    0% {
+        opacity: 0;
+        transform: scale(1.8)
+    }
+
+    100% {
+        opacity: 1;
+        transform: scale(1)
+    }
+}
+
+@keyframes slide-top {
+    0% {
+        opacity: 0;
+        transform: translateY(-100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateY(0)
+    }
+}
+
+@keyframes slide-bottom {
+    0% {
+        opacity: 0;
+        transform: translateY(100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateY(0)
+    }
+}
+
+@keyframes shake {
+
+    0%,
+    100% {
+        transform: translateX(0)
+    }
+
+    10% {
+        transform: translateX(-9px)
+    }
+
+    20% {
+        transform: translateX(8px)
+    }
+
+    30% {
+        transform: translateX(-7px)
+    }
+
+    40% {
+        transform: translateX(6px)
+    }
+
+    50% {
+        transform: translateX(-5px)
+    }
+
+    60% {
+        transform: translateX(4px)
+    }
+
+    70% {
+        transform: translateX(-3px)
+    }
+
+    80% {
+        transform: translateX(2px)
+    }
+
+    90% {
+        transform: translateX(-1px)
+    }
+}
+
+@keyframes slide-left {
+    0% {
+        opacity: 0;
+        transform: translateX(-100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateX(0)
+    }
+}
+
+@keyframes slide-right {
+    0% {
+        opacity: 0;
+        transform: translateX(100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateX(0)
+    }
+}

+ 65 - 0
addons/exam/uniapp/colorui/components/cu-custom.vue

@@ -0,0 +1,65 @@
+<template>
+	<view>
+		<view class="cu-custom" :style="[{height:CustomBar + 'px'}]">
+			<view class="cu-bar fixed" :style="style" :class="[bgImage!=''?'none-bg text-white bg-img':'',bgColor]">
+				<view class="action" @tap="BackPage" v-if="isBack">
+					<text class="cuIcon-back"></text>
+					<slot name="backText"></slot>
+				</view>
+				<view class="content" :style="[{top:StatusBar + 'px'}]">
+					<slot name="content"></slot>
+				</view>
+				<slot name="right"></slot>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		data() {
+			return {
+				StatusBar: this.StatusBar,
+				CustomBar: this.CustomBar
+			};
+		},
+		name: 'cu-custom',
+		computed: {
+			style() {
+				var StatusBar= this.StatusBar;
+				var CustomBar= this.CustomBar;
+				var bgImage = this.bgImage;
+				var style = `height:${CustomBar}px;padding-top:${StatusBar}px;`;
+				if (this.bgImage) {
+					style = `${style}background-image:url(${bgImage});`;
+				}
+				return style
+			}
+		},
+		props: {
+			bgColor: {
+				type: String,
+				default: ''
+			},
+			isBack: {
+				type: [Boolean, String],
+				default: false
+			},
+			bgImage: {
+				type: String,
+				default: ''
+			},
+		},
+		methods: {
+			BackPage() {
+				uni.navigateBack({
+					delta: 1
+				});
+			}
+		}
+	}
+</script>
+
+<style>
+
+</style>

File diff suppressed because it is too large
+ 36 - 0
addons/exam/uniapp/colorui/icon.css


+ 3936 - 0
addons/exam/uniapp/colorui/main.css

@@ -0,0 +1,3936 @@
+/*
+  ColorUi for uniApp  v2.1.6 | by 文晓港 2019-05-31 10:44:24
+  仅供学习交流,如作它用所承受的法律责任一概与作者无关  
+  
+  *使用ColorUi开发扩展与插件时,请注明基于ColorUi开发 
+  
+  (QQ交流群:240787041)
+*/
+
+/* ==================
+        初始化
+ ==================== */
+body {
+	background-color: #f1f1f1;
+	font-size: 28upx;
+	color: #333333;
+	font-family: Helvetica Neue, Helvetica, sans-serif;
+}
+
+view,
+scroll-view,
+swiper,
+button,
+input,
+textarea,
+label,
+navigator,
+image {
+	box-sizing: border-box;
+}
+
+.round {
+	border-radius: 5000upx;
+}
+
+.radius {
+	border-radius: 6upx;
+}
+
+/* ==================
+          图片
+ ==================== */
+
+image {
+	max-width: 100%;
+	display: inline-block;
+	position: relative;
+	z-index: 0;
+}
+
+image.loading::before {
+	content: "";
+	background-color: #f5f5f5;
+	display: block;
+	position: absolute;
+	width: 100%;
+	height: 100%;
+	z-index: -2;
+}
+
+image.loading::after {
+	content: "\e7f1";
+	font-family: "cuIcon";
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 32upx;
+	height: 32upx;
+	line-height: 32upx;
+	right: 0;
+	bottom: 0;
+	z-index: -1;
+	font-size: 32upx;
+	margin: auto;
+	color: #ccc;
+	-webkit-animation: cuIcon-spin 2s infinite linear;
+	animation: cuIcon-spin 2s infinite linear;
+	display: block;
+}
+
+.response {
+	width: 100%;
+}
+
+/* ==================
+         开关
+ ==================== */
+
+switch,
+checkbox,
+radio {
+	position: relative;
+}
+
+switch::after,
+switch::before {
+	font-family: "cuIcon";
+	content: "\e645";
+	position: absolute;
+	color: #ffffff !important;
+	top: 0%;
+	left: 0upx;
+	font-size: 26upx;
+	line-height: 26px;
+	width: 50%;
+	text-align: center;
+	pointer-events: none;
+	transform: scale(0, 0);
+	transition: all 0.3s ease-in-out 0s;
+	z-index: 9;
+	bottom: 0;
+	height: 26px;
+	margin: auto;
+}
+
+switch::before {
+	content: "\e646";
+	right: 0;
+	transform: scale(1, 1);
+	left: auto;
+}
+
+switch[checked]::after,
+switch.checked::after {
+	transform: scale(1, 1);
+}
+
+switch[checked]::before,
+switch.checked::before {
+	transform: scale(0, 0);
+}
+
+/* #ifndef MP-ALIPAY */
+radio::before,
+checkbox::before {
+	font-family: "cuIcon";
+	content: "\e645";
+	position: absolute;
+	color: #ffffff !important;
+	top: 50%;
+	margin-top: -8px;
+	right: 5px;
+	font-size: 32upx;
+	line-height: 16px;
+	pointer-events: none;
+	transform: scale(1, 1);
+	transition: all 0.3s ease-in-out 0s;
+	z-index: 9;
+}
+
+radio .wx-radio-input,
+checkbox .wx-checkbox-input,
+radio .uni-radio-input,
+checkbox .uni-checkbox-input {
+	margin: 0;
+	width: 24px;
+	height: 24px;
+}
+
+checkbox.round .wx-checkbox-input,
+checkbox.round .uni-checkbox-input {
+	border-radius: 100upx;
+}
+
+/* #endif */
+
+switch[checked]::before {
+	transform: scale(0, 0);
+}
+
+switch .wx-switch-input,
+switch .uni-switch-input {
+	border: none;
+	padding: 0 24px;
+	width: 48px;
+	height: 26px;
+	margin: 0;
+	border-radius: 100upx;
+}
+
+switch .wx-switch-input:not([class*="bg-"]),
+switch .uni-switch-input:not([class*="bg-"]) {
+	background: #8799a3 !important;
+}
+
+switch .wx-switch-input::after,
+switch .uni-switch-input::after {
+	margin: auto;
+	width: 26px;
+	height: 26px;
+	border-radius: 100upx;
+	left: 0upx;
+	top: 0upx;
+	bottom: 0upx;
+	position: absolute;
+	transform: scale(0.9, 0.9);
+	transition: all 0.1s ease-in-out 0s;
+}
+
+switch .wx-switch-input.wx-switch-input-checked::after,
+switch .uni-switch-input.uni-switch-input-checked::after {
+	margin: auto;
+	left: 22px;
+	box-shadow: none;
+	transform: scale(0.9, 0.9);
+}
+
+radio-group {
+	display: inline-block;
+}
+
+
+
+switch.radius .wx-switch-input::after,
+switch.radius .wx-switch-input,
+switch.radius .wx-switch-input::before,
+switch.radius .uni-switch-input::after,
+switch.radius .uni-switch-input,
+switch.radius .uni-switch-input::before {
+	border-radius: 10upx;
+}
+
+switch .wx-switch-input::before,
+radio.radio::before,
+checkbox .wx-checkbox-input::before,
+radio .wx-radio-input::before,
+switch .uni-switch-input::before,
+radio.radio::before,
+checkbox .uni-checkbox-input::before,
+radio .uni-radio-input::before {
+	display: none;
+}
+
+radio.radio[checked]::after,
+radio.radio .uni-radio-input-checked::after {
+	content: "";
+	background-color: transparent;
+	display: block;
+	position: absolute;
+	width: 8px;
+	height: 8px;
+	z-index: 999;
+	top: 0upx;
+	left: 0upx;
+	right: 0;
+	bottom: 0;
+	margin: auto;
+	border-radius: 200upx;
+	/* #ifndef MP */
+	border: 7px solid #ffffff !important;
+	/* #endif */
+
+	/* #ifdef MP */
+	border: 8px solid #ffffff !important;
+	/* #endif */
+}
+
+.switch-sex::after {
+	content: "\e71c";
+}
+
+.switch-sex::before {
+	content: "\e71a";
+}
+
+.switch-sex .wx-switch-input,
+.switch-sex .uni-switch-input {
+	background: #e54d42 !important;
+	border-color: #e54d42 !important;
+}
+
+.switch-sex[checked] .wx-switch-input,
+.switch-sex.checked .uni-switch-input {
+	background: #0081ff !important;
+	border-color: #0081ff !important;
+}
+
+switch.red[checked] .wx-switch-input.wx-switch-input-checked,
+checkbox.red[checked] .wx-checkbox-input,
+radio.red[checked] .wx-radio-input,
+switch.red.checked .uni-switch-input.uni-switch-input-checked,
+checkbox.red.checked .uni-checkbox-input,
+radio.red.checked .uni-radio-input {
+	background-color: #e54d42 !important;
+	border-color: #e54d42 !important;
+	color: #ffffff !important;
+}
+
+switch.orange[checked] .wx-switch-input,
+checkbox.orange[checked] .wx-checkbox-input,
+radio.orange[checked] .wx-radio-input,
+switch.orange.checked .uni-switch-input,
+checkbox.orange.checked .uni-checkbox-input,
+radio.orange.checked .uni-radio-input {
+	background-color: #f37b1d !important;
+	border-color: #f37b1d !important;
+	color: #ffffff !important;
+}
+
+switch.yellow[checked] .wx-switch-input,
+checkbox.yellow[checked] .wx-checkbox-input,
+radio.yellow[checked] .wx-radio-input,
+switch.yellow.checked .uni-switch-input,
+checkbox.yellow.checked .uni-checkbox-input,
+radio.yellow.checked .uni-radio-input {
+	background-color: #fbbd08 !important;
+	border-color: #fbbd08 !important;
+	color: #333333 !important;
+}
+
+switch.olive[checked] .wx-switch-input,
+checkbox.olive[checked] .wx-checkbox-input,
+radio.olive[checked] .wx-radio-input,
+switch.olive.checked .uni-switch-input,
+checkbox.olive.checked .uni-checkbox-input,
+radio.olive.checked .uni-radio-input {
+	background-color: #8dc63f !important;
+	border-color: #8dc63f !important;
+	color: #ffffff !important;
+}
+
+switch.green[checked] .wx-switch-input,
+switch[checked] .wx-switch-input,
+checkbox.green[checked] .wx-checkbox-input,
+checkbox[checked] .wx-checkbox-input,
+radio.green[checked] .wx-radio-input,
+radio[checked] .wx-radio-input,
+switch.green.checked .uni-switch-input,
+switch.checked .uni-switch-input,
+checkbox.green.checked .uni-checkbox-input,
+checkbox.checked .uni-checkbox-input,
+radio.green.checked .uni-radio-input,
+radio.checked .uni-radio-input {
+	background-color: #39b54a !important;
+	border-color: #39b54a !important;
+	color: #ffffff !important;
+	border-color: #39B54A !important;
+}
+
+switch.cyan[checked] .wx-switch-input,
+checkbox.cyan[checked] .wx-checkbox-input,
+radio.cyan[checked] .wx-radio-input,
+switch.cyan.checked .uni-switch-input,
+checkbox.cyan.checked .uni-checkbox-input,
+radio.cyan.checked .uni-radio-input {
+	background-color: #1cbbb4 !important;
+	border-color: #1cbbb4 !important;
+	color: #ffffff !important;
+}
+
+switch.blue[checked] .wx-switch-input,
+checkbox.blue[checked] .wx-checkbox-input,
+radio.blue[checked] .wx-radio-input,
+switch.blue.checked .uni-switch-input,
+checkbox.blue.checked .uni-checkbox-input,
+radio.blue.checked .uni-radio-input {
+	background-color: #0081ff !important;
+	border-color: #0081ff !important;
+	color: #ffffff !important;
+}
+
+switch.purple[checked] .wx-switch-input,
+checkbox.purple[checked] .wx-checkbox-input,
+radio.purple[checked] .wx-radio-input,
+switch.purple.checked .uni-switch-input,
+checkbox.purple.checked .uni-checkbox-input,
+radio.purple.checked .uni-radio-input {
+	background-color: #6739b6 !important;
+	border-color: #6739b6 !important;
+	color: #ffffff !important;
+}
+
+switch.mauve[checked] .wx-switch-input,
+checkbox.mauve[checked] .wx-checkbox-input,
+radio.mauve[checked] .wx-radio-input,
+switch.mauve.checked .uni-switch-input,
+checkbox.mauve.checked .uni-checkbox-input,
+radio.mauve.checked .uni-radio-input {
+	background-color: #9c26b0 !important;
+	border-color: #9c26b0 !important;
+	color: #ffffff !important;
+}
+
+switch.pink[checked] .wx-switch-input,
+checkbox.pink[checked] .wx-checkbox-input,
+radio.pink[checked] .wx-radio-input,
+switch.pink.checked .uni-switch-input,
+checkbox.pink.checked .uni-checkbox-input,
+radio.pink.checked .uni-radio-input {
+	background-color: #e03997 !important;
+	border-color: #e03997 !important;
+	color: #ffffff !important;
+}
+
+switch.brown[checked] .wx-switch-input,
+checkbox.brown[checked] .wx-checkbox-input,
+radio.brown[checked] .wx-radio-input,
+switch.brown.checked .uni-switch-input,
+checkbox.brown.checked .uni-checkbox-input,
+radio.brown.checked .uni-radio-input {
+	background-color: #a5673f !important;
+	border-color: #a5673f !important;
+	color: #ffffff !important;
+}
+
+switch.grey[checked] .wx-switch-input,
+checkbox.grey[checked] .wx-checkbox-input,
+radio.grey[checked] .wx-radio-input,
+switch.grey.checked .uni-switch-input,
+checkbox.grey.checked .uni-checkbox-input,
+radio.grey.checked .uni-radio-input {
+	background-color: #8799a3 !important;
+	border-color: #8799a3 !important;
+	color: #ffffff !important;
+}
+
+switch.gray[checked] .wx-switch-input,
+checkbox.gray[checked] .wx-checkbox-input,
+radio.gray[checked] .wx-radio-input,
+switch.gray.checked .uni-switch-input,
+checkbox.gray.checked .uni-checkbox-input,
+radio.gray.checked .uni-radio-input {
+	background-color: #f0f0f0 !important;
+	border-color: #f0f0f0 !important;
+	color: #333333 !important;
+}
+
+switch.black[checked] .wx-switch-input,
+checkbox.black[checked] .wx-checkbox-input,
+radio.black[checked] .wx-radio-input,
+switch.black.checked .uni-switch-input,
+checkbox.black.checked .uni-checkbox-input,
+radio.black.checked .uni-radio-input {
+	background-color: #333333 !important;
+	border-color: #333333 !important;
+	color: #ffffff !important;
+}
+
+switch.white[checked] .wx-switch-input,
+checkbox.white[checked] .wx-checkbox-input,
+radio.white[checked] .wx-radio-input,
+switch.white.checked .uni-switch-input,
+checkbox.white.checked .uni-checkbox-input,
+radio.white.checked .uni-radio-input {
+	background-color: #ffffff !important;
+	border-color: #ffffff !important;
+	color: #333333 !important;
+}
+
+/* ==================
+          边框
+ ==================== */
+
+/* -- 实线 -- */
+
+.solid,
+.solid-top,
+.solid-right,
+.solid-bottom,
+.solid-left,
+.solids,
+.solids-top,
+.solids-right,
+.solids-bottom,
+.solids-left,
+.dashed,
+.dashed-top,
+.dashed-right,
+.dashed-bottom,
+.dashed-left {
+	position: relative;
+}
+
+.solid::after,
+.solid-top::after,
+.solid-right::after,
+.solid-bottom::after,
+.solid-left::after,
+.solids::after,
+.solids-top::after,
+.solids-right::after,
+.solids-bottom::after,
+.solids-left::after,
+.dashed::after,
+.dashed-top::after,
+.dashed-right::after,
+.dashed-bottom::after,
+.dashed-left::after {
+	content: " ";
+	width: 200%;
+	height: 200%;
+	position: absolute;
+	top: 0;
+	left: 0;
+	border-radius: inherit;
+	transform: scale(0.5);
+	transform-origin: 0 0;
+	pointer-events: none;
+	box-sizing: border-box;
+}
+
+.solid::after {
+	border: 1upx solid rgba(0, 0, 0, 0.1);
+}
+
+.solid-top::after {
+	border-top: 1upx solid rgba(0, 0, 0, 0.1);
+}
+
+.solid-right::after {
+	border-right: 1upx solid rgba(0, 0, 0, 0.1);
+}
+
+.solid-bottom::after {
+	border-bottom: 1upx solid rgba(0, 0, 0, 0.1);
+}
+
+.solid-left::after {
+	border-left: 1upx solid rgba(0, 0, 0, 0.1);
+}
+
+.solids::after {
+	border: 8upx solid #eee;
+}
+
+.solids-top::after {
+	border-top: 8upx solid #eee;
+}
+
+.solids-right::after {
+	border-right: 8upx solid #eee;
+}
+
+.solids-bottom::after {
+	border-bottom: 8upx solid #eee;
+}
+
+.solids-left::after {
+	border-left: 8upx solid #eee;
+}
+
+/* -- 虚线 -- */
+
+.dashed::after {
+	border: 1upx dashed #ddd;
+}
+
+.dashed-top::after {
+	border-top: 1upx dashed #ddd;
+}
+
+.dashed-right::after {
+	border-right: 1upx dashed #ddd;
+}
+
+.dashed-bottom::after {
+	border-bottom: 1upx dashed #ddd;
+}
+
+.dashed-left::after {
+	border-left: 1upx dashed #ddd;
+}
+
+/* -- 阴影 -- */
+
+.shadow[class*='white'] {
+	--ShadowSize: 0 1upx 6upx;
+}
+
+.shadow-lg {
+	--ShadowSize: 0upx 40upx 100upx 0upx;
+}
+
+.shadow-warp {
+	position: relative;
+	box-shadow: 0 0 10upx rgba(0, 0, 0, 0.1);
+}
+
+.shadow-warp:before,
+.shadow-warp:after {
+	position: absolute;
+	content: "";
+	top: 20upx;
+	bottom: 30upx;
+	left: 20upx;
+	width: 50%;
+	box-shadow: 0 30upx 20upx rgba(0, 0, 0, 0.2);
+	transform: rotate(-3deg);
+	z-index: -1;
+}
+
+.shadow-warp:after {
+	right: 20upx;
+	left: auto;
+	transform: rotate(3deg);
+}
+
+.shadow-blur {
+	position: relative;
+}
+
+.shadow-blur::before {
+	content: "";
+	display: block;
+	background: inherit;
+	filter: blur(10upx);
+	position: absolute;
+	width: 100%;
+	height: 100%;
+	top: 10upx;
+	left: 10upx;
+	z-index: -1;
+	opacity: 0.4;
+	transform-origin: 0 0;
+	border-radius: inherit;
+	transform: scale(1, 1);
+}
+
+/* ==================
+          按钮
+ ==================== */
+
+.cu-btn {
+	position: relative;
+	border: 0upx;
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	box-sizing: border-box;
+	padding: 0 30upx;
+	font-size: 28upx;
+	height: 64upx;
+	line-height: 1;
+	text-align: center;
+	text-decoration: none;
+	overflow: visible;
+	margin-left: initial;
+	transform: translate(0upx, 0upx);
+	margin-right: initial;
+}
+
+.cu-btn::after {
+	display: none;
+}
+
+.cu-btn:not([class*="bg-"]) {
+	background-color: #f0f0f0;
+}
+
+.cu-btn[class*="line"] {
+	background-color: transparent;
+}
+
+.cu-btn[class*="line"]::after {
+	content: " ";
+	display: block;
+	width: 200%;
+	height: 200%;
+	position: absolute;
+	top: 0;
+	left: 0;
+	border: 1upx solid currentColor;
+	transform: scale(0.5);
+	transform-origin: 0 0;
+	box-sizing: border-box;
+	border-radius: 12upx;
+	z-index: 1;
+	pointer-events: none;
+}
+
+.cu-btn.round[class*="line"]::after {
+	border-radius: 1000upx;
+}
+
+.cu-btn[class*="lines"]::after {
+	border: 6upx solid currentColor;
+}
+
+.cu-btn[class*="bg-"]::after {
+	display: none;
+}
+
+.cu-btn.sm {
+	padding: 0 20upx;
+	font-size: 20upx;
+	height: 48upx;
+}
+
+.cu-btn.lg {
+	padding: 0 40upx;
+	font-size: 32upx;
+	height: 80upx;
+}
+
+.cu-btn.cuIcon.sm {
+	width: 48upx;
+	height: 48upx;
+}
+
+.cu-btn.cuIcon {
+	width: 64upx;
+	height: 64upx;
+	border-radius: 500upx;
+	padding: 0;
+}
+
+button.cuIcon.lg {
+	width: 80upx;
+	height: 80upx;
+}
+
+.cu-btn.shadow-blur::before {
+	top: 4upx;
+	left: 4upx;
+	filter: blur(6upx);
+	opacity: 0.6;
+}
+
+.cu-btn.button-hover {
+	transform: translate(1upx, 1upx);
+}
+
+.block {
+	display: block;
+}
+
+.cu-btn.block {
+	display: flex;
+}
+
+.cu-btn[disabled] {
+	opacity: 0.6;
+	color: #ffffff;
+}
+
+.radius10 {
+	border-radius: 10rpx;
+}
+
+.radius20 {
+	border-radius: 20rpx;
+}
+
+.bottom-radius20 {
+	border-bottom-left-radius: 20rpx;
+	border-bottom-right-radius: 20rpx;
+}
+
+/* ==================
+          徽章
+ ==================== */
+
+.cu-tag {
+	font-size: 24upx;
+	vertical-align: middle;
+	position: relative;
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	box-sizing: border-box;
+	padding: 0upx 16upx;
+	height: 48upx;
+	font-family: Helvetica Neue, Helvetica, sans-serif;
+	white-space: nowrap;
+}
+
+.cu-tag:not([class*="bg"]):not([class*="line"]) {
+	background-color: #f1f1f1;
+}
+
+.cu-tag[class*="line-"]::after {
+	content: " ";
+	width: 200%;
+	height: 200%;
+	position: absolute;
+	top: 0;
+	left: 0;
+	border: 1upx solid currentColor;
+	transform: scale(0.5);
+	transform-origin: 0 0;
+	box-sizing: border-box;
+	border-radius: inherit;
+	z-index: 1;
+	pointer-events: none;
+}
+
+.cu-tag.radius[class*="line"]::after {
+	border-radius: 12upx;
+}
+
+.cu-tag.round[class*="line"]::after {
+	border-radius: 1000upx;
+}
+
+.cu-tag[class*="line-"]::after {
+	border-radius: 0;
+}
+
+.cu-tag+.cu-tag {
+	margin-left: 10upx;
+}
+
+.cu-tag.sm {
+	font-size: 20upx;
+	padding: 0upx 12upx;
+	height: 32upx;
+}
+
+.cu-capsule {
+	display: inline-flex;
+	vertical-align: middle;
+}
+
+.cu-capsule+.cu-capsule {
+	margin-left: 10upx;
+}
+
+.cu-capsule .cu-tag {
+	margin: 0;
+}
+
+.cu-capsule .cu-tag[class*="line-"]:last-child::after {
+	border-left: 0upx solid transparent;
+}
+
+.cu-capsule .cu-tag[class*="line-"]:first-child::after {
+	border-right: 0upx solid transparent;
+}
+
+.cu-capsule.radius .cu-tag:first-child {
+	border-top-left-radius: 6upx;
+	border-bottom-left-radius: 6upx;
+}
+
+.cu-capsule.radius .cu-tag:last-child::after,
+.cu-capsule.radius .cu-tag[class*="line-"] {
+	border-top-right-radius: 12upx;
+	border-bottom-right-radius: 12upx;
+}
+
+.cu-capsule.round .cu-tag:first-child {
+	border-top-left-radius: 200upx;
+	border-bottom-left-radius: 200upx;
+	text-indent: 4upx;
+}
+
+.cu-capsule.round .cu-tag:last-child::after,
+.cu-capsule.round .cu-tag:last-child {
+	border-top-right-radius: 200upx;
+	border-bottom-right-radius: 200upx;
+	text-indent: -4upx;
+}
+
+.cu-tag.badge {
+	border-radius: 200upx;
+	position: absolute;
+	top: -10upx;
+	right: -10upx;
+	font-size: 20upx;
+	padding: 0upx 10upx;
+	height: 28upx;
+	color: #ffffff;
+}
+
+.cu-tag.badge:not([class*="bg-"]) {
+	background-color: #dd514c;
+}
+
+.cu-tag:empty:not([class*="cuIcon-"]) {
+	padding: 0upx;
+	width: 16upx;
+	height: 16upx;
+	top: -4upx;
+	right: -4upx;
+}
+
+.cu-tag[class*="cuIcon-"] {
+	width: 32upx;
+	height: 32upx;
+	top: -4upx;
+	right: -4upx;
+}
+
+/* ==================
+          头像
+ ==================== */
+
+.cu-avatar {
+	font-variant: small-caps;
+	margin: 0;
+	padding: 0;
+	display: inline-flex;
+	text-align: center;
+	justify-content: center;
+	align-items: center;
+	background-color: #ccc;
+	color: #ffffff;
+	white-space: nowrap;
+	position: relative;
+	width: 64upx;
+	height: 64upx;
+	background-size: cover;
+	background-position: center;
+	vertical-align: middle;
+	font-size: 1.5em;
+}
+
+.cu-avatar.sm {
+	width: 48upx;
+	height: 48upx;
+	font-size: 1em;
+}
+
+.cu-avatar.lg {
+	width: 96upx;
+	height: 96upx;
+	font-size: 2em;
+}
+
+.cu-avatar.xl {
+	width: 128upx;
+	height: 128upx;
+	font-size: 2.5em;
+}
+
+.cu-avatar .avatar-text {
+	font-size: 0.4em;
+}
+
+.cu-avatar-group {
+	direction: rtl;
+	unicode-bidi: bidi-override;
+	padding: 0 10upx 0 40upx;
+	display: inline-block;
+}
+
+.cu-avatar-group .cu-avatar {
+	margin-left: -30upx;
+	border: 4upx solid #f1f1f1;
+	vertical-align: middle;
+}
+
+.cu-avatar-group .cu-avatar.sm {
+	margin-left: -20upx;
+	border: 1upx solid #f1f1f1;
+}
+
+/* ==================
+         进度条
+ ==================== */
+
+.cu-progress {
+	overflow: hidden;
+	height: 28upx;
+	background-color: #ebeef5;
+	display: inline-flex;
+	align-items: center;
+	width: 100%;
+}
+
+.cu-progress+view,
+.cu-progress+text {
+	line-height: 1;
+}
+
+.cu-progress.xs {
+	height: 10upx;
+}
+
+.cu-progress.sm {
+	height: 20upx;
+}
+
+.cu-progress view {
+	width: 0;
+	height: 100%;
+	align-items: center;
+	display: flex;
+	justify-items: flex-end;
+	justify-content: space-around;
+	font-size: 20upx;
+	color: #ffffff;
+	transition: width 0.6s ease;
+}
+
+.cu-progress text {
+	align-items: center;
+	display: flex;
+	font-size: 20upx;
+	color: #333333;
+	text-indent: 10upx;
+}
+
+.cu-progress.text-progress {
+	padding-right: 60upx;
+}
+
+.cu-progress.striped view {
+	background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+	background-size: 72upx 72upx;
+}
+
+.cu-progress.active view {
+	animation: progress-stripes 2s linear infinite;
+}
+
+@keyframes progress-stripes {
+	from {
+		background-position: 72upx 0;
+	}
+
+	to {
+		background-position: 0 0;
+	}
+}
+
+/* ==================
+          加载
+ ==================== */
+
+.cu-load {
+	display: block;
+	line-height: 3em;
+	text-align: center;
+}
+
+.cu-load::before {
+	font-family: "cuIcon";
+	display: inline-block;
+	margin-right: 6upx;
+}
+
+.cu-load.loading::before {
+	content: "\e67a";
+	animation: cuIcon-spin 2s infinite linear;
+}
+
+.cu-load.loading::after {
+	content: "加载中...";
+}
+
+.cu-load.over::before {
+	content: "\e64a";
+}
+
+.cu-load.over::after {
+	content: "没有更多了";
+}
+
+.cu-load.erro::before {
+	content: "\e658";
+}
+
+.cu-load.erro::after {
+	content: "加载失败";
+}
+
+.cu-load.load-cuIcon::before {
+	font-size: 32upx;
+}
+
+.cu-load.load-cuIcon::after {
+	display: none;
+}
+
+.cu-load.load-cuIcon.over {
+	display: none;
+}
+
+.cu-load.load-modal {
+	position: fixed;
+	top: 0;
+	right: 0;
+	bottom: 140upx;
+	left: 0;
+	margin: auto;
+	width: 260upx;
+	height: 260upx;
+	background-color: #ffffff;
+	border-radius: 10upx;
+	box-shadow: 0 0 0upx 2000upx rgba(0, 0, 0, 0.5);
+	display: flex;
+	align-items: center;
+	flex-direction: column;
+	justify-content: center;
+	font-size: 28upx;
+	z-index: 9999;
+	line-height: 2.4em;
+}
+
+.cu-load.load-modal [class*="cuIcon-"] {
+	font-size: 60upx;
+}
+
+.cu-load.load-modal image {
+	width: 70upx;
+	height: 70upx;
+}
+
+.cu-load.load-modal::after {
+	content: "";
+	position: absolute;
+	background-color: #ffffff;
+	border-radius: 50%;
+	width: 200upx;
+	height: 200upx;
+	font-size: 10px;
+	border-top: 6upx solid rgba(0, 0, 0, 0.05);
+	border-right: 6upx solid rgba(0, 0, 0, 0.05);
+	border-bottom: 6upx solid rgba(0, 0, 0, 0.05);
+	border-left: 6upx solid #f37b1d;
+	animation: cuIcon-spin 1s infinite linear;
+	z-index: -1;
+}
+
+.load-progress {
+	pointer-events: none;
+	top: 0;
+	position: fixed;
+	width: 100%;
+	left: 0;
+	z-index: 2000;
+}
+
+.load-progress.hide {
+	display: none;
+}
+
+.load-progress .load-progress-bar {
+	position: relative;
+	width: 100%;
+	height: 4upx;
+	overflow: hidden;
+	transition: all 200ms ease 0s;
+}
+
+.load-progress .load-progress-spinner {
+	position: absolute;
+	top: 10upx;
+	right: 10upx;
+	z-index: 2000;
+	display: block;
+}
+
+.load-progress .load-progress-spinner::after {
+	content: "";
+	display: block;
+	width: 24upx;
+	height: 24upx;
+	-webkit-box-sizing: border-box;
+	box-sizing: border-box;
+	border: solid 4upx transparent;
+	border-top-color: inherit;
+	border-left-color: inherit;
+	border-radius: 50%;
+	-webkit-animation: load-progress-spinner 0.4s linear infinite;
+	animation: load-progress-spinner 0.4s linear infinite;
+}
+
+@-webkit-keyframes load-progress-spinner {
+	0% {
+		-webkit-transform: rotate(0);
+		transform: rotate(0);
+	}
+
+	100% {
+		-webkit-transform: rotate(360deg);
+		transform: rotate(360deg);
+	}
+}
+
+@keyframes load-progress-spinner {
+	0% {
+		-webkit-transform: rotate(0);
+		transform: rotate(0);
+	}
+
+	100% {
+		-webkit-transform: rotate(360deg);
+		transform: rotate(360deg);
+	}
+}
+
+/* ==================
+          列表
+ ==================== */
+.grayscale {
+	filter: grayscale(1);
+}
+
+.cu-list+.cu-list {
+	margin-top: 30upx
+}
+
+.cu-list>.cu-item {
+	transition: all .6s ease-in-out 0s;
+	transform: translateX(0upx)
+}
+
+.cu-list>.cu-item.move-cur {
+	transform: translateX(-260upx)
+}
+
+.cu-list>.cu-item .move {
+	position: absolute;
+	right: 0;
+	display: flex;
+	width: 260upx;
+	height: 100%;
+	transform: translateX(100%)
+}
+
+.cu-list>.cu-item .move view {
+	display: flex;
+	flex: 1;
+	justify-content: center;
+	align-items: center
+}
+
+.cu-list.menu-avatar {
+	overflow: hidden;
+}
+
+.cu-list.menu-avatar>.cu-item {
+	position: relative;
+	display: flex;
+	padding-right: 10upx;
+	height: 140upx;
+	background-color: #ffffff;
+	justify-content: flex-end;
+	align-items: center
+}
+
+.cu-list.menu-avatar>.cu-item>.cu-avatar {
+	position: absolute;
+	left: 30upx
+}
+
+.cu-list.menu-avatar>.cu-item .flex .text-cut {
+	max-width: 510upx
+}
+
+.cu-list.menu-avatar>.cu-item .content {
+	position: absolute;
+	left: 146upx;
+	width: calc(100% - 96upx - 60upx - 120upx - 20upx);
+	line-height: 1.6em;
+}
+
+.cu-list.menu-avatar>.cu-item .content.flex-sub {
+	width: calc(100% - 96upx - 60upx - 20upx);
+}
+
+.cu-list.menu-avatar>.cu-item .content>view:first-child {
+	font-size: 30upx;
+	display: flex;
+	align-items: center
+}
+
+.cu-list.menu-avatar>.cu-item .content .cu-tag.sm {
+	display: inline-block;
+	margin-left: 10upx;
+	height: 28upx;
+	font-size: 16upx;
+	line-height: 32upx
+}
+
+.cu-list.menu-avatar>.cu-item .action {
+	width: 100upx;
+	text-align: center
+}
+
+.cu-list.menu-avatar>.cu-item .action view+view {
+	margin-top: 10upx
+}
+
+.cu-list.menu-avatar.comment>.cu-item .content {
+	position: relative;
+	left: 0;
+	width: auto;
+	flex: 1;
+}
+
+.cu-list.menu-avatar.comment>.cu-item {
+	padding: 30upx 30upx 30upx 120upx;
+	height: auto
+}
+
+.cu-list.menu-avatar.comment .cu-avatar {
+	align-self: flex-start
+}
+
+.cu-list.menu>.cu-item {
+	position: relative;
+	display: flex;
+	padding: 0 30upx;
+	min-height: 100upx;
+	background-color: #ffffff;
+	justify-content: space-between;
+	align-items: center
+}
+
+.cu-list.menu>.cu-item:last-child:after {
+	border: none
+}
+
+.cu-list.menu-avatar>.cu-item:after,
+.cu-list.menu>.cu-item:after {
+	position: absolute;
+	top: 0;
+	left: 0;
+	box-sizing: border-box;
+	width: 200%;
+	height: 200%;
+	border-bottom: 1upx solid #ddd;
+	border-radius: inherit;
+	content: " ";
+	transform: scale(.5);
+	transform-origin: 0 0;
+	pointer-events: none
+}
+
+.cu-list.menu>.cu-item.grayscale {
+	background-color: #f5f5f5
+}
+
+.cu-list.menu>.cu-item.cur {
+	background-color: #fcf7e9
+}
+
+.cu-list.menu>.cu-item.arrow {
+	padding-right: 90upx
+}
+
+.cu-list.menu>.cu-item.arrow:before {
+	position: absolute;
+	top: 0;
+	right: 30upx;
+	bottom: 0;
+	display: block;
+	margin: auto;
+	width: 30upx;
+	height: 30upx;
+	color: #8799a3;
+	content: "\e6a3";
+	text-align: center;
+	font-size: 34upx;
+	font-family: cuIcon;
+	line-height: 30upx
+}
+
+.cu-list.menu>.cu-item button.content {
+	padding: 0;
+	background-color: transparent;
+	justify-content: flex-start
+}
+
+.cu-list.menu>.cu-item button.content:after {
+	display: none
+}
+
+.cu-list.menu>.cu-item .cu-avatar-group .cu-avatar {
+	border-color: #ffffff
+}
+
+.cu-list.menu>.cu-item .content>view:first-child {
+	display: flex;
+	align-items: center
+}
+
+.cu-list.menu>.cu-item .content>text[class*=cuIcon] {
+	display: inline-block;
+	margin-right: 10upx;
+	width: 1.6em;
+	text-align: center
+}
+
+.cu-list.menu>.cu-item .content>image {
+	display: inline-block;
+	margin-right: 10upx;
+	width: 1.6em;
+	height: 1.6em;
+	vertical-align: middle
+}
+
+.cu-list.menu>.cu-item .content {
+	font-size: 30upx;
+	line-height: 1.6em;
+	flex: 1
+}
+
+.cu-list.menu>.cu-item .content .cu-tag.sm {
+	display: inline-block;
+	margin-left: 10upx;
+	height: 28upx;
+	font-size: 16upx;
+	line-height: 32upx
+}
+
+.cu-list.menu>.cu-item .action .cu-tag:empty {
+	right: 10upx
+}
+
+.cu-list.menu {
+	display: block;
+	overflow: hidden
+}
+
+.cu-list.menu.sm-border>.cu-item:after {
+	left: 30upx;
+	width: calc(200% - 120upx)
+}
+
+.cu-list.grid>.cu-item {
+	position: relative;
+	display: flex;
+	padding: 20upx 0 30upx;
+	transition-duration: 0s;
+	flex-direction: column
+}
+
+.cu-list.grid>.cu-item:after {
+	position: absolute;
+	top: 0;
+	left: 0;
+	box-sizing: border-box;
+	width: 200%;
+	height: 200%;
+	border-right: 1px solid rgba(0, 0, 0, .1);
+	border-bottom: 1px solid rgba(0, 0, 0, .1);
+	border-radius: inherit;
+	content: " ";
+	transform: scale(.5);
+	transform-origin: 0 0;
+	pointer-events: none
+}
+
+.cu-list.grid>.cu-item text {
+	display: block;
+	margin-top: 10upx;
+	color: #888;
+	font-size: 26upx;
+	line-height: 40upx
+}
+
+.cu-list.grid>.cu-item [class*=cuIcon] {
+	position: relative;
+	display: block;
+	margin-top: 20upx;
+	width: 100%;
+	font-size: 48upx
+}
+
+.cu-list.grid>.cu-item .cu-tag {
+	right: auto;
+	left: 50%;
+	margin-left: 20upx
+}
+
+.cu-list.grid {
+	background-color: #ffffff;
+	text-align: center
+}
+
+.cu-list.grid.no-border>.cu-item {
+	padding-top: 10upx;
+	padding-bottom: 20upx
+}
+
+.cu-list.grid.no-border>.cu-item:after {
+	border: none
+}
+
+.cu-list.grid.no-border {
+	padding: 20upx 10upx
+}
+
+.cu-list.grid.col-3>.cu-item:nth-child(3n):after,
+.cu-list.grid.col-4>.cu-item:nth-child(4n):after,
+.cu-list.grid.col-5>.cu-item:nth-child(5n):after {
+	border-right-width: 0
+}
+
+.cu-list.card-menu {
+	overflow: hidden;
+	margin-right: 30upx;
+	margin-left: 30upx;
+	border-radius: 20upx
+}
+
+
+/* ==================
+          操作条
+ ==================== */
+
+.cu-bar {
+	display: flex;
+	position: relative;
+	align-items: center;
+	min-height: 100upx;
+	justify-content: space-between;
+}
+
+.cu-bar .action {
+	display: flex;
+	align-items: center;
+	height: 100%;
+	justify-content: center;
+	max-width: 100%;
+}
+
+.cu-bar .action.border-title {
+	position: relative;
+	top: -10upx;
+}
+
+.cu-bar .action.border-title text[class*="bg-"]:last-child {
+	position: absolute;
+	bottom: -0.5rem;
+	min-width: 2rem;
+	height: 6upx;
+	left: 0;
+}
+
+.cu-bar .action.sub-title {
+	position: relative;
+	top: -0.2rem;
+}
+
+.cu-bar .action.sub-title text {
+	position: relative;
+	z-index: 1;
+}
+
+.cu-bar .action.sub-title text[class*="bg-"]:last-child {
+	position: absolute;
+	display: inline-block;
+	bottom: -0.2rem;
+	border-radius: 6upx;
+	width: 100%;
+	height: 0.6rem;
+	left: 0.6rem;
+	opacity: 0.3;
+	z-index: 0;
+}
+
+.cu-bar .action.sub-title text[class*="text-"]:last-child {
+	position: absolute;
+	display: inline-block;
+	bottom: -0.7rem;
+	left: 0.5rem;
+	opacity: 0.2;
+	z-index: 0;
+	text-align: right;
+	font-weight: 900;
+	font-size: 36upx;
+}
+
+.cu-bar.justify-center .action.border-title text:last-child,
+.cu-bar.justify-center .action.sub-title text:last-child {
+	left: 0;
+	right: 0;
+	margin: auto;
+	text-align: center;
+}
+
+.cu-bar .action:first-child {
+	margin-left: 30upx;
+	font-size: 30upx;
+}
+
+.cu-bar .action text.text-cut {
+	text-align: left;
+	width: 100%;
+}
+
+.cu-bar .cu-avatar:first-child {
+	margin-left: 20upx;
+}
+
+.cu-bar .action:first-child>text[class*="cuIcon-"] {
+	margin-left: -0.3em;
+	margin-right: 0.3em;
+}
+
+.cu-bar .action:last-child {
+	margin-right: 30upx;
+}
+
+.cu-bar .action>text[class*="cuIcon-"],
+.cu-bar .action>view[class*="cuIcon-"] {
+	font-size: 36upx;
+}
+
+.cu-bar .action>text[class*="cuIcon-"]+text[class*="cuIcon-"] {
+	margin-left: 0.5em;
+}
+
+.cu-bar .content {
+	position: absolute;
+	text-align: center;
+	width: calc(100% - 340upx);
+	left: 0;
+	right: 0;
+	bottom: 0;
+	top: 0;
+	margin: auto;
+	height: 60upx;
+	font-size: 32upx;
+	line-height: 60upx;
+	cursor: none;
+	pointer-events: none;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	overflow: hidden;
+}
+
+.cu-bar.ios .content {
+	bottom: 7px;
+	height: 30px;
+	font-size: 32upx;
+	line-height: 30px;
+}
+
+.cu-bar.btn-group {
+	justify-content: space-around;
+}
+
+.cu-bar.btn-group button {
+	padding: 20upx 32upx;
+}
+
+.cu-bar.btn-group button {
+	flex: 1;
+	margin: 0 20upx;
+	max-width: 50%;
+}
+
+.cu-bar .search-form {
+	background-color: #f5f5f5;
+	line-height: 64upx;
+	height: 64upx;
+	font-size: 24upx;
+	color: #333333;
+	flex: 1;
+	display: flex;
+	align-items: center;
+	margin: 0 30upx;
+}
+
+.cu-bar .search-form+.action {
+	margin-right: 30upx;
+}
+
+.cu-bar .search-form input {
+	flex: 1;
+	padding-right: 30upx;
+	height: 64upx;
+	line-height: 64upx;
+	font-size: 26upx;
+	background-color: transparent;
+}
+
+.cu-bar .search-form [class*="cuIcon-"] {
+	margin: 0 0.5em 0 0.8em;
+}
+
+.cu-bar .search-form [class*="cuIcon-"]::before {
+	top: 0upx;
+}
+
+.cu-bar.fixed,
+.nav.fixed {
+	position: fixed;
+	width: 100%;
+	top: 0;
+	z-index: 1024;
+	box-shadow: 0 1upx 6upx rgba(0, 0, 0, 0.1);
+}
+
+.cu-bar.foot {
+	position: fixed;
+	width: 100%;
+	bottom: 0;
+	z-index: 1024;
+	box-shadow: 0 -1upx 6upx rgba(0, 0, 0, 0.1);
+}
+
+.cu-bar.tabbar {
+	padding: 0;
+	height: calc(100upx + env(safe-area-inset-bottom) / 2);
+	padding-bottom: calc(env(safe-area-inset-bottom) / 2);
+}
+
+.cu-tabbar-height {
+	min-height: 100upx;
+	height: calc(100upx + env(safe-area-inset-bottom) / 2);
+}
+
+.cu-bar.tabbar.shadow {
+	box-shadow: 0 -1upx 6upx rgba(0, 0, 0, 0.1);
+}
+
+.cu-bar.tabbar .action {
+	font-size: 22upx;
+	position: relative;
+	flex: 1;
+	text-align: center;
+	padding: 0;
+	display: block;
+	height: auto;
+	line-height: 1;
+	margin: 0;
+	background-color: inherit;
+	overflow: initial;
+}
+
+.cu-bar.tabbar.shop .action {
+	width: 140upx;
+	flex: initial;
+}
+
+.cu-bar.tabbar .action.add-action {
+	position: relative;
+	z-index: 2;
+	padding-top: 50upx;
+}
+
+.cu-bar.tabbar .action.add-action [class*="cuIcon-"] {
+	position: absolute;
+	width: 70upx;
+	z-index: 2;
+	height: 70upx;
+	border-radius: 50%;
+	line-height: 70upx;
+	font-size: 50upx;
+	top: -35upx;
+	left: 0;
+	right: 0;
+	margin: auto;
+	padding: 0;
+}
+
+.cu-bar.tabbar .action.add-action::after {
+	content: "";
+	position: absolute;
+	width: 100upx;
+	height: 100upx;
+	top: -50upx;
+	left: 0;
+	right: 0;
+	margin: auto;
+	box-shadow: 0 -3upx 8upx rgba(0, 0, 0, 0.08);
+	border-radius: 50upx;
+	background-color: inherit;
+	z-index: 0;
+}
+
+.cu-bar.tabbar .action.add-action::before {
+	content: "";
+	position: absolute;
+	width: 100upx;
+	height: 30upx;
+	bottom: 30upx;
+	left: 0;
+	right: 0;
+	margin: auto;
+	background-color: inherit;
+	z-index: 1;
+}
+
+.cu-bar.tabbar .btn-group {
+	flex: 1;
+	display: flex;
+	justify-content: space-around;
+	align-items: center;
+	padding: 0 10upx;
+}
+
+.cu-bar.tabbar button.action::after {
+	border: 0;
+}
+
+.cu-bar.tabbar .action [class*="cuIcon-"] {
+	width: 100upx;
+	position: relative;
+	display: block;
+	height: auto;
+	margin: 0 auto 10upx;
+	text-align: center;
+	font-size: 40upx;
+}
+
+.cu-bar.tabbar .action .cuIcon-cu-image {
+	margin: 0 auto;
+}
+
+.cu-bar.tabbar .action .cuIcon-cu-image image {
+	width: 50upx;
+	height: 50upx;
+	display: inline-block;
+}
+
+.cu-bar.tabbar .submit {
+	align-items: center;
+	display: flex;
+	justify-content: center;
+	text-align: center;
+	position: relative;
+	flex: 2;
+	align-self: stretch;
+}
+
+.cu-bar.tabbar .submit:last-child {
+	flex: 2.6;
+}
+
+.cu-bar.tabbar .submit+.submit {
+	flex: 2;
+}
+
+.cu-bar.tabbar.border .action::before {
+	content: " ";
+	width: 200%;
+	height: 200%;
+	position: absolute;
+	top: 0;
+	left: 0;
+	transform: scale(0.5);
+	transform-origin: 0 0;
+	border-right: 1upx solid rgba(0, 0, 0, 0.1);
+	z-index: 3;
+}
+
+.cu-bar.tabbar.border .action:last-child:before {
+	display: none;
+}
+
+.cu-bar.input {
+	padding-right: 20upx;
+	background-color: #ffffff;
+}
+
+.cu-bar.input input {
+	overflow: initial;
+	line-height: 64upx;
+	height: 64upx;
+	min-height: 64upx;
+	flex: 1;
+	font-size: 30upx;
+	margin: 0 20upx;
+}
+
+.cu-bar.input .action {
+	margin-left: 20upx;
+}
+
+.cu-bar.input .action [class*="cuIcon-"] {
+	font-size: 48upx;
+}
+
+.cu-bar.input input+.action {
+	margin-right: 20upx;
+	margin-left: 0upx;
+}
+
+.cu-bar.input .action:first-child [class*="cuIcon-"] {
+	margin-left: 0upx;
+}
+
+.cu-custom {
+	display: block;
+	position: relative;
+}
+
+.cu-custom .cu-bar .content {
+	width: calc(100% - 440upx);
+}
+
+/* #ifdef MP-ALIPAY */
+.cu-custom .cu-bar .action .cuIcon-back {
+	opacity: 0;
+}
+
+/* #endif */
+
+.cu-custom .cu-bar .content image {
+	height: 60upx;
+	width: 240upx;
+}
+
+.cu-custom .cu-bar {
+	min-height: 0px;
+	/* #ifdef MP-WEIXIN */
+	padding-right: 220upx;
+	/* #endif */
+	/* #ifdef MP-ALIPAY */
+	padding-right: 150upx;
+	/* #endif */
+	box-shadow: 0upx 0upx 0upx;
+	z-index: 9999;
+}
+
+.cu-custom .cu-bar .border-custom {
+	position: relative;
+	background: rgba(0, 0, 0, 0.15);
+	border-radius: 1000upx;
+	height: 30px;
+}
+
+.cu-custom .cu-bar .border-custom::after {
+	content: " ";
+	width: 200%;
+	height: 200%;
+	position: absolute;
+	top: 0;
+	left: 0;
+	border-radius: inherit;
+	transform: scale(0.5);
+	transform-origin: 0 0;
+	pointer-events: none;
+	box-sizing: border-box;
+	border: 1upx solid #ffffff;
+	opacity: 0.5;
+}
+
+.cu-custom .cu-bar .border-custom::before {
+	content: " ";
+	width: 1upx;
+	height: 110%;
+	position: absolute;
+	top: 22.5%;
+	left: 0;
+	right: 0;
+	margin: auto;
+	transform: scale(0.5);
+	transform-origin: 0 0;
+	pointer-events: none;
+	box-sizing: border-box;
+	opacity: 0.6;
+	background-color: #ffffff;
+}
+
+.cu-custom .cu-bar .border-custom text {
+	display: block;
+	flex: 1;
+	margin: auto !important;
+	text-align: center;
+	font-size: 34upx;
+}
+
+/* ==================
+         导航栏
+ ==================== */
+
+.nav {
+	white-space: nowrap;
+}
+
+::-webkit-scrollbar {
+	display: none;
+}
+
+.nav .cu-item {
+	height: 90upx;
+	display: inline-block;
+	line-height: 90upx;
+	margin: 0 10upx;
+	padding: 0 20upx;
+}
+
+.nav .cu-item.cur {
+	border-bottom: 4upx solid;
+}
+
+/* ==================
+         时间轴
+ ==================== */
+
+.cu-timeline {
+	display: block;
+	background-color: #ffffff;
+}
+
+.cu-timeline .cu-time {
+	width: 120upx;
+	text-align: center;
+	padding: 20upx 0;
+	font-size: 26upx;
+	color: #888;
+	display: block;
+}
+
+.cu-timeline>.cu-item {
+	padding: 30upx 30upx 30upx 120upx;
+	position: relative;
+	display: block;
+	z-index: 0;
+}
+
+.cu-timeline>.cu-item:not([class*="text-"]) {
+	color: #ccc;
+}
+
+.cu-timeline>.cu-item::after {
+	content: "";
+	display: block;
+	position: absolute;
+	width: 1upx;
+	background-color: #ddd;
+	left: 60upx;
+	height: 100%;
+	top: 0;
+	z-index: 8;
+}
+
+.cu-timeline>.cu-item::before {
+	font-family: "cuIcon";
+	display: block;
+	position: absolute;
+	top: 36upx;
+	z-index: 9;
+	background-color: #ffffff;
+	width: 50upx;
+	height: 50upx;
+	text-align: center;
+	border: none;
+	line-height: 50upx;
+	left: 36upx;
+}
+
+.cu-timeline>.cu-item:not([class*="cuIcon-"])::before {
+	content: "\e763";
+}
+
+.cu-timeline>.cu-item[class*="cuIcon-"]::before {
+	background-color: #ffffff;
+	width: 50upx;
+	height: 50upx;
+	text-align: center;
+	border: none;
+	line-height: 50upx;
+	left: 36upx;
+}
+
+.cu-timeline>.cu-item>.content {
+	padding: 30upx;
+	border-radius: 6upx;
+	display: block;
+	line-height: 1.6;
+}
+
+.cu-timeline>.cu-item>.content:not([class*="bg-"]) {
+	background-color: #f1f1f1;
+	color: #333333;
+}
+
+.cu-timeline>.cu-item>.content+.content {
+	margin-top: 20upx;
+}
+
+/* ==================
+         聊天
+ ==================== */
+
+.cu-chat {
+	display: flex;
+	flex-direction: column;
+}
+
+.cu-chat .cu-item {
+	display: flex;
+	padding: 30upx 30upx 70upx;
+	position: relative;
+}
+
+.cu-chat .cu-item>.cu-avatar {
+	width: 80upx;
+	height: 80upx;
+}
+
+.cu-chat .cu-item>.main {
+	max-width: calc(100% - 260upx);
+	margin: 0 40upx;
+	display: flex;
+	align-items: center;
+}
+
+.cu-chat .cu-item>image {
+	height: 320upx;
+}
+
+.cu-chat .cu-item>.main .content {
+	padding: 20upx;
+	border-radius: 6upx;
+	display: inline-flex;
+	max-width: 100%;
+	align-items: center;
+	font-size: 30upx;
+	position: relative;
+	min-height: 80upx;
+	line-height: 40upx;
+	text-align: left;
+}
+
+.cu-chat .cu-item>.main .content:not([class*="bg-"]) {
+	background-color: #ffffff;
+	color: #333333;
+}
+
+.cu-chat .cu-item .date {
+	position: absolute;
+	font-size: 24upx;
+	color: #8799a3;
+	width: calc(100% - 320upx);
+	bottom: 20upx;
+	left: 160upx;
+}
+
+.cu-chat .cu-item .action {
+	padding: 0 30upx;
+	display: flex;
+	align-items: center;
+}
+
+.cu-chat .cu-item>.main .content::after {
+	content: "";
+	top: 27upx;
+	transform: rotate(45deg);
+	position: absolute;
+	z-index: 100;
+	display: inline-block;
+	overflow: hidden;
+	width: 24upx;
+	height: 24upx;
+	left: -12upx;
+	right: initial;
+	background-color: inherit;
+}
+
+.cu-chat .cu-item.self>.main .content::after {
+	left: auto;
+	right: -12upx;
+}
+
+.cu-chat .cu-item>.main .content::before {
+	content: "";
+	top: 30upx;
+	transform: rotate(45deg);
+	position: absolute;
+	z-index: -1;
+	display: inline-block;
+	overflow: hidden;
+	width: 24upx;
+	height: 24upx;
+	left: -12upx;
+	right: initial;
+	background-color: inherit;
+	filter: blur(5upx);
+	opacity: 0.3;
+}
+
+.cu-chat .cu-item>.main .content:not([class*="bg-"])::before {
+	background-color: #333333;
+	opacity: 0.1;
+}
+
+.cu-chat .cu-item.self>.main .content::before {
+	left: auto;
+	right: -12upx;
+}
+
+.cu-chat .cu-item.self {
+	justify-content: flex-end;
+	text-align: right;
+}
+
+.cu-chat .cu-info {
+	display: inline-block;
+	margin: 20upx auto;
+	font-size: 24upx;
+	padding: 8upx 12upx;
+	background-color: rgba(0, 0, 0, 0.2);
+	border-radius: 6upx;
+	color: #ffffff;
+	max-width: 400upx;
+	line-height: 1.4;
+}
+
+/* ==================
+         卡片
+ ==================== */
+
+.cu-card {
+	display: block;
+	overflow: hidden;
+}
+
+.cu-card>.cu-item {
+	display: block;
+	background-color: #ffffff;
+	overflow: hidden;
+	border-radius: 20upx;
+	margin: 30upx;
+}
+
+.cu-card>.cu-item.shadow-blur {
+	overflow: initial;
+}
+
+.cu-card.no-card>.cu-item {
+	margin: 0upx;
+	border-radius: 0upx;
+}
+
+.cu-card .grid.grid-square {
+	margin-bottom: -20upx;
+}
+
+.cu-card.case .image {
+	position: relative;
+}
+
+.cu-card.case .image image {
+	width: 100%;
+}
+
+.cu-card.case .image .cu-tag {
+	position: absolute;
+	right: 0;
+	top: 0;
+}
+
+.cu-card.case .image .cu-bar {
+	position: absolute;
+	bottom: 0;
+	width: 100%;
+	background-color: transparent;
+	padding: 0upx 30upx;
+}
+
+.cu-card.case.no-card .image {
+	margin: 30upx 30upx 0;
+	overflow: hidden;
+	border-radius: 10upx;
+}
+
+.cu-card.dynamic {
+	display: block;
+}
+
+.cu-card.dynamic>.cu-item {
+	display: block;
+	background-color: #ffffff;
+	overflow: hidden;
+}
+
+.cu-card.dynamic>.cu-item>.text-content {
+	padding: 0 30upx 0;
+	max-height: 6.4em;
+	overflow: hidden;
+	font-size: 30upx;
+	margin-bottom: 20upx;
+}
+
+.cu-card.dynamic>.cu-item .square-img {
+	width: 100%;
+	height: 200upx;
+	border-radius: 6upx;
+}
+
+.cu-card.dynamic>.cu-item .only-img {
+	width: 100%;
+	height: 320upx;
+	border-radius: 6upx;
+}
+
+/* card.dynamic>.cu-item .comment {
+  padding: 20upx;
+  background-color: #f1f1f1;
+  margin: 0 30upx 30upx;
+  border-radius: 6upx;
+} */
+
+.cu-card.article {
+	display: block;
+}
+
+.cu-card.article>.cu-item {
+	padding-bottom: 30upx;
+}
+
+.cu-card.article>.cu-item .title {
+	font-size: 30upx;
+	font-weight: 900;
+	color: #333333;
+	line-height: 100upx;
+	padding: 0 30upx;
+}
+
+.cu-card.article>.cu-item .content {
+	display: flex;
+	padding: 0 30upx;
+}
+
+.cu-card.article>.cu-item .content>image {
+	width: 240upx;
+	height: 6.4em;
+	margin-right: 20upx;
+	border-radius: 6upx;
+}
+
+.cu-card.article>.cu-item .content .desc {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+	justify-content: space-between;
+}
+
+.cu-card.article>.cu-item .content .text-content {
+	font-size: 28upx;
+	color: #888;
+	height: 4.8em;
+	overflow: hidden;
+}
+
+/* ==================
+         表单
+ ==================== */
+
+.cu-form-group {
+	background-color: #ffffff;
+	padding: 1upx 30upx;
+	display: flex;
+	align-items: center;
+	min-height: 100upx;
+	justify-content: space-between;
+}
+
+.cu-form-group+.cu-form-group {
+	border-top: 1upx solid #eee;
+}
+
+.cu-form-group .title {
+	text-align: justify;
+	padding-right: 30upx;
+	font-size: 30upx;
+	position: relative;
+	height: 60upx;
+	line-height: 60upx;
+}
+
+.cu-form-group input {
+	flex: 1;
+	font-size: 30upx;
+	color: #555;
+	padding-right: 20upx;
+}
+
+.cu-form-group>text[class*="cuIcon-"] {
+	font-size: 36upx;
+	padding: 0;
+	box-sizing: border-box;
+}
+
+.cu-form-group textarea {
+	margin: 32upx 0 30upx;
+	height: 4.6em;
+	width: 100%;
+	line-height: 1.2em;
+	flex: 1;
+	font-size: 28upx;
+	padding: 0;
+}
+
+.cu-form-group.align-start .title {
+	height: 1em;
+	margin-top: 32upx;
+	line-height: 1em;
+}
+
+.cu-form-group picker {
+	flex: 1;
+	padding-right: 40upx;
+	overflow: hidden;
+	position: relative;
+}
+
+.cu-form-group picker .picker {
+	line-height: 100upx;
+	font-size: 28upx;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	overflow: hidden;
+	width: 100%;
+	text-align: right;
+}
+
+.cu-form-group picker::after {
+	font-family: cuIcon;
+	display: block;
+	content: "\e6a3";
+	position: absolute;
+	font-size: 34upx;
+	color: #8799a3;
+	line-height: 100upx;
+	width: 60upx;
+	text-align: center;
+	top: 0;
+	bottom: 0;
+	right: -20upx;
+	margin: auto;
+}
+
+.cu-form-group textarea[disabled],
+.cu-form-group textarea[disabled] .placeholder {
+	color: transparent;
+}
+
+/* ==================
+         模态窗口
+ ==================== */
+
+.cu-modal {
+	position: fixed;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	z-index: 1110;
+	opacity: 0;
+	outline: 0;
+	text-align: center;
+	-ms-transform: scale(1.185);
+	transform: scale(1.185);
+	backface-visibility: hidden;
+	perspective: 2000upx;
+	background: rgba(0, 0, 0, 0.6);
+	transition: all 0.3s ease-in-out 0s;
+	pointer-events: none;
+}
+
+.cu-modal::before {
+	content: "\200B";
+	display: inline-block;
+	height: 100%;
+	vertical-align: middle;
+}
+
+.cu-modal.show {
+	opacity: 1;
+	transition-duration: 0.3s;
+	-ms-transform: scale(1);
+	transform: scale(1);
+	overflow-x: hidden;
+	overflow-y: auto;
+	pointer-events: auto;
+}
+
+.cu-dialog {
+	position: relative;
+	display: inline-block;
+	vertical-align: middle;
+	margin-left: auto;
+	margin-right: auto;
+	width: 680upx;
+	max-width: 100%;
+	background-color: #f8f8f8;
+	border-radius: 10upx;
+	overflow: hidden;
+}
+
+.cu-modal.bottom-modal::before {
+	vertical-align: bottom;
+}
+
+.cu-modal.bottom-modal .cu-dialog {
+	width: 100%;
+	border-radius: 0;
+}
+
+.cu-modal.bottom-modal {
+	margin-bottom: -1000upx;
+}
+
+.cu-modal.bottom-modal.show {
+	margin-bottom: 0;
+}
+
+.cu-modal.drawer-modal {
+	transform: scale(1);
+	display: flex;
+}
+
+.cu-modal.drawer-modal .cu-dialog {
+	height: 100%;
+	min-width: 200upx;
+	border-radius: 0;
+	margin: initial;
+	transition-duration: 0.3s;
+}
+
+.cu-modal.drawer-modal.justify-start .cu-dialog {
+	transform: translateX(-100%);
+}
+
+.cu-modal.drawer-modal.justify-end .cu-dialog {
+	transform: translateX(100%);
+}
+
+.cu-modal.drawer-modal.show .cu-dialog {
+	transform: translateX(0%);
+}
+.cu-modal .cu-dialog>.cu-bar:first-child .action{
+  min-width: 100rpx;
+  margin-right: 0;
+  min-height: 100rpx;
+}
+/* ==================
+         轮播
+ ==================== */
+swiper .a-swiper-dot {
+	display: inline-block;
+	width: 16upx;
+	height: 16upx;
+	background: rgba(0, 0, 0, .3);
+	border-radius: 50%;
+	vertical-align: middle;
+}
+
+swiper[class*="-dot"] .wx-swiper-dots,
+swiper[class*="-dot"] .a-swiper-dots,
+swiper[class*="-dot"] .uni-swiper-dots {
+	display: flex;
+	align-items: center;
+	width: 100%;
+	justify-content: center;
+}
+
+swiper.square-dot .wx-swiper-dot,
+swiper.square-dot .a-swiper-dot,
+swiper.square-dot .uni-swiper-dot {
+	background-color: #ffffff;
+	opacity: 0.4;
+	width: 10upx;
+	height: 10upx;
+	border-radius: 20upx;
+	margin: 0 8upx !important;
+}
+
+swiper.square-dot .wx-swiper-dot.wx-swiper-dot-active,
+swiper.square-dot .a-swiper-dot.a-swiper-dot-active,
+swiper.square-dot .uni-swiper-dot.uni-swiper-dot-active {
+	opacity: 1;
+	width: 30upx;
+}
+
+swiper.round-dot .wx-swiper-dot,
+swiper.round-dot .a-swiper-dot,
+swiper.round-dot .uni-swiper-dot {
+	width: 10upx;
+	height: 10upx;
+	position: relative;
+	margin: 4upx 8upx !important;
+}
+
+swiper.round-dot .wx-swiper-dot.wx-swiper-dot-active::after,
+swiper.round-dot .a-swiper-dot.a-swiper-dot-active::after,
+swiper.round-dot .uni-swiper-dot.uni-swiper-dot-active::after {
+	content: "";
+	position: absolute;
+	width: 10upx;
+	height: 10upx;
+	top: 0upx;
+	left: 0upx;
+	right: 0;
+	bottom: 0;
+	margin: auto;
+	background-color: #ffffff;
+	border-radius: 20upx;
+}
+
+swiper.round-dot .wx-swiper-dot.wx-swiper-dot-active,
+swiper.round-dot .a-swiper-dot.a-swiper-dot-active,
+swiper.round-dot .uni-swiper-dot.uni-swiper-dot-active {
+	width: 18upx;
+	height: 18upx;
+}
+
+.screen-swiper {
+	min-height: 375upx;
+}
+
+.screen-swiper image,
+.screen-swiper video,
+.swiper-item image,
+.swiper-item video {
+	width: 100%;
+	display: block;
+	height: 100%;
+	margin: 0;
+	pointer-events: none;
+}
+
+.card-swiper {
+	height: 420upx !important;
+}
+
+.card-swiper swiper-item {
+	width: 610upx !important;
+	left: 70upx;
+	box-sizing: border-box;
+	padding: 40upx 0upx 70upx;
+	overflow: initial;
+}
+
+.card-swiper swiper-item .swiper-item {
+	width: 100%;
+	display: block;
+	height: 100%;
+	border-radius: 10upx;
+	transform: scale(0.9);
+	transition: all 0.2s ease-in 0s;
+	overflow: hidden;
+}
+
+.card-swiper swiper-item.cur .swiper-item {
+	transform: none;
+	transition: all 0.2s ease-in 0s;
+}
+
+
+.tower-swiper {
+	height: 420upx;
+	position: relative;
+	max-width: 750upx;
+	overflow: hidden;
+}
+
+.tower-swiper .tower-item {
+	position: absolute;
+	width: 300upx;
+	height: 380upx;
+	top: 0;
+	bottom: 0;
+	left: 50%;
+	margin: auto;
+	transition: all 0.2s ease-in 0s;
+	opacity: 1;
+}
+
+.tower-swiper .tower-item.none {
+	opacity: 0;
+}
+
+.tower-swiper .tower-item .swiper-item {
+	width: 100%;
+	height: 100%;
+	border-radius: 6upx;
+	overflow: hidden;
+}
+
+/* ==================
+          步骤条
+ ==================== */
+
+.cu-steps {
+	display: flex;
+}
+
+scroll-view.cu-steps {
+	display: block;
+	white-space: nowrap;
+}
+
+scroll-view.cu-steps .cu-item {
+	display: inline-block;
+}
+
+.cu-steps .cu-item {
+	flex: 1;
+	text-align: center;
+	position: relative;
+	min-width: 100upx;
+}
+
+.cu-steps .cu-item:not([class*="text-"]) {
+	color: #8799a3;
+}
+
+.cu-steps .cu-item [class*="cuIcon-"],
+.cu-steps .cu-item .num {
+	display: block;
+	font-size: 40upx;
+	line-height: 80upx;
+}
+
+.cu-steps .cu-item::before,
+.cu-steps .cu-item::after,
+.cu-steps.steps-arrow .cu-item::before,
+.cu-steps.steps-arrow .cu-item::after {
+	content: "";
+	display: block;
+	position: absolute;
+	height: 0px;
+	width: calc(100% - 80upx);
+	border-bottom: 1px solid #ccc;
+	left: calc(0px - (100% - 80upx) / 2);
+	top: 40upx;
+	z-index: 0;
+}
+
+.cu-steps.steps-arrow .cu-item::before,
+.cu-steps.steps-arrow .cu-item::after {
+	content: "\e6a3";
+	font-family: 'cuIcon';
+	height: 30upx;
+	border-bottom-width: 0px;
+	line-height: 30upx;
+	top: 0;
+	bottom: 0;
+	margin: auto;
+	color: #ccc;
+}
+
+.cu-steps.steps-bottom .cu-item::before,
+.cu-steps.steps-bottom .cu-item::after {
+	bottom: 40upx;
+	top: initial;
+}
+
+.cu-steps .cu-item::after {
+	border-bottom: 1px solid currentColor;
+	width: 0px;
+	transition: all 0.3s ease-in-out 0s;
+}
+
+.cu-steps .cu-item[class*="text-"]::after {
+	width: calc(100% - 80upx);
+	color: currentColor;
+}
+
+.cu-steps .cu-item:first-child::before,
+.cu-steps .cu-item:first-child::after {
+	display: none;
+}
+
+.cu-steps .cu-item .num {
+	width: 40upx;
+	height: 40upx;
+	border-radius: 50%;
+	line-height: 40upx;
+	margin: 20upx auto;
+	font-size: 24upx;
+	border: 1px solid currentColor;
+	position: relative;
+	overflow: hidden;
+}
+
+.cu-steps .cu-item[class*="text-"] .num {
+	background-color: currentColor;
+}
+
+.cu-steps .cu-item .num::before,
+.cu-steps .cu-item .num::after {
+	content: attr(data-index);
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	margin: auto;
+	transition: all 0.3s ease-in-out 0s;
+	transform: translateY(0upx);
+}
+
+.cu-steps .cu-item[class*="text-"] .num::before {
+	transform: translateY(-40upx);
+	color: #ffffff;
+}
+
+.cu-steps .cu-item .num::after {
+	transform: translateY(40upx);
+	color: #ffffff;
+	transition: all 0.3s ease-in-out 0s;
+}
+
+.cu-steps .cu-item[class*="text-"] .num::after {
+	content: "\e645";
+	font-family: 'cuIcon';
+	color: #ffffff;
+	transform: translateY(0upx);
+}
+
+.cu-steps .cu-item[class*="text-"] .num.err::after {
+	content: "\e646";
+}
+
+/* ==================
+          布局
+ ==================== */
+
+/*  -- flex弹性布局 -- */
+
+.flex {
+	display: flex;
+}
+
+.basis-xs {
+	flex-basis: 20%;
+}
+
+.basis-sm {
+	flex-basis: 40%;
+}
+
+.basis-df {
+	flex-basis: 50%;
+}
+
+.basis-lg {
+	flex-basis: 60%;
+}
+
+.basis-xl {
+	flex-basis: 80%;
+}
+
+.flex-sub {
+	flex: 1;
+}
+
+.flex-twice {
+	flex: 2;
+}
+
+.flex-treble {
+	flex: 3;
+}
+
+.flex-direction {
+	flex-direction: column;
+}
+
+.flex-wrap {
+	flex-wrap: wrap;
+}
+
+.align-start {
+	align-items: flex-start;
+}
+
+.align-end {
+	align-items: flex-end;
+}
+
+.align-center {
+	align-items: center;
+}
+
+.align-stretch {
+	align-items: stretch;
+}
+
+.self-start {
+	align-self: flex-start;
+}
+
+.self-center {
+	align-self: flex-center;
+}
+
+.self-end {
+	align-self: flex-end;
+}
+
+.self-stretch {
+	align-self: stretch;
+}
+
+.align-stretch {
+	align-items: stretch;
+}
+
+.justify-start {
+	justify-content: flex-start;
+}
+
+.justify-end {
+	justify-content: flex-end;
+}
+
+.justify-center {
+	justify-content: center;
+}
+
+.justify-between {
+	justify-content: space-between;
+}
+
+.justify-around {
+	justify-content: space-around;
+}
+
+/* grid布局 */
+
+.grid {
+	display: flex;
+	flex-wrap: wrap;
+}
+
+.grid.grid-square {
+	overflow: hidden;
+}
+
+.grid.grid-square .cu-tag {
+	position: absolute;
+	right: 0;
+	top: 0;
+	border-bottom-left-radius: 6upx;
+	padding: 6upx 12upx;
+	height: auto;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+
+.grid.grid-square>view>text[class*="cuIcon-"] {
+	font-size: 52upx;
+	position: absolute;
+	color: #8799a3;
+	margin: auto;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	flex-direction: column;
+}
+
+.grid.grid-square>view {
+	margin-right: 20upx;
+	margin-bottom: 20upx;
+	border-radius: 6upx;
+	position: relative;
+	overflow: hidden;
+}
+.grid.grid-square>view.bg-img image {
+	width: 100%;
+	height: 100%;
+	position: absolute;
+}
+.grid.col-1.grid-square>view {
+	padding-bottom: 100%;
+	height: 0;
+	margin-right: 0;
+}
+
+.grid.col-2.grid-square>view {
+	padding-bottom: calc((100% - 20upx)/2);
+	height: 0;
+	width: calc((100% - 20upx)/2);
+}
+
+.grid.col-3.grid-square>view {
+	padding-bottom: calc((100% - 40upx)/3);
+	height: 0;
+	width: calc((100% - 40upx)/3);
+}
+
+.grid.col-4.grid-square>view {
+	padding-bottom: calc((100% - 60upx)/4);
+	height: 0;
+	width: calc((100% - 60upx)/4);
+}
+
+.grid.col-5.grid-square>view {
+	padding-bottom: calc((100% - 80upx)/5);
+	height: 0;
+	width: calc((100% - 80upx)/5);
+}
+
+.grid.col-2.grid-square>view:nth-child(2n),
+.grid.col-3.grid-square>view:nth-child(3n),
+.grid.col-4.grid-square>view:nth-child(4n),
+.grid.col-5.grid-square>view:nth-child(5n) {
+	margin-right: 0;
+}
+
+.grid.col-1>view {
+	width: 100%;
+}
+
+.grid.col-2>view {
+	width: 50%;
+}
+
+.grid.col-3>view {
+	width: 33.33%;
+}
+
+.grid.col-4>view {
+	width: 25%;
+}
+
+.grid.col-5>view {
+	width: 20%;
+}
+
+/*  -- 内外边距 -- */
+
+.margin-0 {
+	margin: 0;
+}
+
+.margin-xs {
+	margin: 10upx;
+}
+
+.margin-sm {
+	margin: 20upx;
+}
+
+.margin {
+	margin: 30upx;
+}
+
+.margin-lg {
+	margin: 40upx;
+}
+
+.margin-xl {
+	margin: 50upx;
+}
+
+.margin-top-xs {
+	margin-top: 10upx;
+}
+
+.margin-top-sm {
+	margin-top: 20upx;
+}
+
+.margin-top {
+	margin-top: 30upx;
+}
+
+.margin-top-lg {
+	margin-top: 40upx;
+}
+
+.margin-top-xl {
+	margin-top: 50upx;
+}
+
+.margin-right-xs {
+	margin-right: 10upx;
+}
+
+.margin-right-sm {
+	margin-right: 20upx;
+}
+
+.margin-right {
+	margin-right: 30upx;
+}
+
+.margin-right-lg {
+	margin-right: 40upx;
+}
+
+.margin-right-xl {
+	margin-right: 50upx;
+}
+
+.margin-bottom-xs {
+	margin-bottom: 10upx;
+}
+
+.margin-bottom-sm {
+	margin-bottom: 20upx;
+}
+
+.margin-bottom {
+	margin-bottom: 30upx;
+}
+
+.margin-bottom-lg {
+	margin-bottom: 40upx;
+}
+
+.margin-bottom-xl {
+	margin-bottom: 50upx;
+}
+
+.margin-left-xs {
+	margin-left: 10upx;
+}
+
+.margin-left-sm {
+	margin-left: 20upx;
+}
+
+.margin-left {
+	margin-left: 30upx;
+}
+
+.margin-left-lg {
+	margin-left: 40upx;
+}
+
+.margin-left-xl {
+	margin-left: 50upx;
+}
+
+.margin-lr-xs {
+	margin-left: 10upx;
+	margin-right: 10upx;
+}
+
+.margin-lr-sm {
+	margin-left: 20upx;
+	margin-right: 20upx;
+}
+
+.margin-lr {
+	margin-left: 30upx;
+	margin-right: 30upx;
+}
+
+.margin-lr-lg {
+	margin-left: 40upx;
+	margin-right: 40upx;
+}
+
+.margin-lr-xl {
+	margin-left: 50upx;
+	margin-right: 50upx;
+}
+
+.margin-tb-xs {
+	margin-top: 10upx;
+	margin-bottom: 10upx;
+}
+
+.margin-tb-sm {
+	margin-top: 20upx;
+	margin-bottom: 20upx;
+}
+
+.margin-tb {
+	margin-top: 30upx;
+	margin-bottom: 30upx;
+}
+
+.margin-tb-lg {
+	margin-top: 40upx;
+	margin-bottom: 40upx;
+}
+
+.margin-tb-xl {
+	margin-top: 50upx;
+	margin-bottom: 50upx;
+}
+
+.padding-0 {
+	padding: 0;
+}
+
+.padding-xs {
+	padding: 10upx;
+}
+
+.padding-sm {
+	padding: 20upx;
+}
+
+.padding {
+	padding: 30upx;
+}
+
+.padding-lg {
+	padding: 40upx;
+}
+
+.padding-xl {
+	padding: 50upx;
+}
+
+.padding-top-xs {
+	padding-top: 10upx;
+}
+
+.padding-top-sm {
+	padding-top: 20upx;
+}
+
+.padding-top {
+	padding-top: 30upx;
+}
+
+.padding-top-lg {
+	padding-top: 40upx;
+}
+
+.padding-top-xl {
+	padding-top: 50upx;
+}
+
+.padding-right-xs {
+	padding-right: 10upx;
+}
+
+.padding-right-sm {
+	padding-right: 20upx;
+}
+
+.padding-right {
+	padding-right: 30upx;
+}
+
+.padding-right-lg {
+	padding-right: 40upx;
+}
+
+.padding-right-xl {
+	padding-right: 50upx;
+}
+
+.padding-bottom-xs {
+	padding-bottom: 10upx;
+}
+
+.padding-bottom-sm {
+	padding-bottom: 20upx;
+}
+
+.padding-bottom {
+	padding-bottom: 30upx;
+}
+
+.padding-bottom-lg {
+	padding-bottom: 40upx;
+}
+
+.padding-bottom-xl {
+	padding-bottom: 50upx;
+}
+
+.padding-left-xs {
+	padding-left: 10upx;
+}
+
+.padding-left-sm {
+	padding-left: 20upx;
+}
+
+.padding-left {
+	padding-left: 30upx;
+}
+
+.padding-left-lg {
+	padding-left: 40upx;
+}
+
+.padding-left-xl {
+	padding-left: 50upx;
+}
+
+.padding-lr-xs {
+	padding-left: 10upx;
+	padding-right: 10upx;
+}
+
+.padding-lr-sm {
+	padding-left: 20upx;
+	padding-right: 20upx;
+}
+
+.padding-lr {
+	padding-left: 30upx;
+	padding-right: 30upx;
+}
+
+.padding-lr-lg {
+	padding-left: 40upx;
+	padding-right: 40upx;
+}
+
+.padding-lr-xl {
+	padding-left: 50upx;
+	padding-right: 50upx;
+}
+
+.padding-tb-xs {
+	padding-top: 10upx;
+	padding-bottom: 10upx;
+}
+
+.padding-tb-sm {
+	padding-top: 20upx;
+	padding-bottom: 20upx;
+}
+
+.padding-tb {
+	padding-top: 30upx;
+	padding-bottom: 30upx;
+}
+
+.padding-tb-lg {
+	padding-top: 40upx;
+	padding-bottom: 40upx;
+}
+
+.padding-tb-xl {
+	padding-top: 50upx;
+	padding-bottom: 50upx;
+}
+
+/* -- 浮动 --  */
+
+.cf::after,
+.cf::before {
+	content: " ";
+	display: table;
+}
+
+.cf::after {
+	clear: both;
+}
+
+.fl {
+	float: left;
+}
+
+.fr {
+	float: right;
+}
+
+/* ==================
+          背景
+ ==================== */
+
+.line-red::after,
+.lines-red::after {
+	border-color: #e54d42;
+}
+
+.line-orange::after,
+.lines-orange::after {
+	border-color: #f37b1d;
+}
+
+.line-yellow::after,
+.lines-yellow::after {
+	border-color: #fbbd08;
+}
+
+.line-olive::after,
+.lines-olive::after {
+	border-color: #8dc63f;
+}
+
+.line-green::after,
+.lines-green::after {
+	border-color: #39b54a;
+}
+
+.line-cyan::after,
+.lines-cyan::after {
+	border-color: #1cbbb4;
+}
+
+.line-blue::after,
+.lines-blue::after {
+	border-color: #0081ff;
+}
+
+.line-purple::after,
+.lines-purple::after {
+	border-color: #6739b6;
+}
+
+.line-mauve::after,
+.lines-mauve::after {
+	border-color: #9c26b0;
+}
+
+.line-pink::after,
+.lines-pink::after {
+	border-color: #e03997;
+}
+
+.line-brown::after,
+.lines-brown::after {
+	border-color: #a5673f;
+}
+
+.line-grey::after,
+.lines-grey::after {
+	border-color: #8799a3;
+}
+
+.line-gray::after,
+.lines-gray::after {
+	border-color: #aaaaaa;
+}
+
+.line-black::after,
+.lines-black::after {
+	border-color: #333333;
+}
+
+.line-white::after,
+.lines-white::after {
+	border-color: #ffffff;
+}
+
+.bg-red {
+	background-color: #e54d42;
+	color: #ffffff;
+}
+
+.bg-orange {
+	background-color: #f37b1d;
+	color: #ffffff;
+}
+
+.bg-yellow {
+	background-color: #fbbd08;
+	color: #333333;
+}
+
+.bg-olive {
+	background-color: #8dc63f;
+	color: #ffffff;
+}
+
+.bg-green {
+	background-color: #39b54a;
+	color: #ffffff;
+}
+
+.bg-cyan {
+	background-color: #1cbbb4;
+	color: #ffffff;
+}
+
+.bg-blue {
+	background-color: #0081ff;
+	color: #ffffff;
+}
+
+.bg-purple {
+	background-color: #6739b6;
+	color: #ffffff;
+}
+
+.bg-mauve {
+	background-color: #9c26b0;
+	color: #ffffff;
+}
+
+.bg-pink {
+	background-color: #e03997;
+	color: #ffffff;
+}
+
+.bg-brown {
+	background-color: #a5673f;
+	color: #ffffff;
+}
+
+.bg-grey {
+	background-color: #8799a3;
+	color: #ffffff;
+}
+
+.bg-gray {
+	background-color: #f0f0f0;
+	color: #333333;
+}
+
+.bg-black {
+	background-color: #333333;
+	color: #ffffff;
+}
+
+.bg-white {
+	background-color: #ffffff;
+	color: #666666;
+}
+
+.bg-shadeTop {
+	background-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.01));
+	color: #ffffff;
+}
+
+.bg-shadeBottom {
+	background-image: linear-gradient(rgba(0, 0, 0, 0.01), rgba(0, 0, 0, 1));
+	color: #ffffff;
+}
+
+.bg-red.light {
+	color: #e54d42;
+	background-color: #fadbd9;
+}
+
+.bg-orange.light {
+	color: #f37b1d;
+	background-color: #fde6d2;
+}
+
+.bg-yellow.light {
+	color: #fbbd08;
+	background-color: #fef2ced2;
+}
+
+.bg-olive.light {
+	color: #8dc63f;
+	background-color: #e8f4d9;
+}
+
+.bg-green.light {
+	color: #39b54a;
+	background-color: #d7f0dbff;
+}
+
+.bg-cyan.light {
+	color: #1cbbb4;
+	background-color: #d2f1f0;
+}
+
+.bg-blue.light {
+	color: #0081ff;
+	background-color: #cce6ff;
+}
+
+.bg-purple.light {
+	color: #6739b6;
+	background-color: #e1d7f0;
+}
+
+.bg-mauve.light {
+	color: #9c26b0;
+	background-color: #ebd4ef;
+}
+
+.bg-pink.light {
+	color: #e03997;
+	background-color: #f9d7ea;
+}
+
+.bg-brown.light {
+	color: #a5673f;
+	background-color: #ede1d9;
+}
+
+.bg-grey.light {
+	color: #8799a3;
+	background-color: #e7ebed;
+}
+
+.bg-gradual-red {
+	background-image: linear-gradient(45deg, #f43f3b, #ec008c);
+	color: #ffffff;
+}
+
+.bg-gradual-orange {
+	background-image: linear-gradient(45deg, #ff9700, #ed1c24);
+	color: #ffffff;
+}
+
+.bg-gradual-green {
+	background-image: linear-gradient(45deg, #39b54a, #8dc63f);
+	color: #ffffff;
+}
+
+.bg-gradual-purple {
+	background-image: linear-gradient(45deg, #9000ff, #5e00ff);
+	color: #ffffff;
+}
+
+.bg-gradual-pink {
+	background-image: linear-gradient(45deg, #ec008c, #6739b6);
+	color: #ffffff;
+}
+
+.bg-gradual-blue {
+	background-image: linear-gradient(45deg, #0081ff, #1cbbb4);
+	color: #ffffff;
+}
+
+.shadow[class*="-red"] {
+	box-shadow: 6upx 6upx 8upx rgba(204, 69, 59, 0.2);
+}
+
+.shadow[class*="-orange"] {
+	box-shadow: 6upx 6upx 8upx rgba(217, 109, 26, 0.2);
+}
+
+.shadow[class*="-yellow"] {
+	box-shadow: 6upx 6upx 8upx rgba(224, 170, 7, 0.2);
+}
+
+.shadow[class*="-olive"] {
+	box-shadow: 6upx 6upx 8upx rgba(124, 173, 55, 0.2);
+}
+
+.shadow[class*="-green"] {
+	box-shadow: 6upx 6upx 8upx rgba(48, 156, 63, 0.2);
+}
+
+.shadow[class*="-cyan"] {
+	box-shadow: 6upx 6upx 8upx rgba(28, 187, 180, 0.2);
+}
+
+.shadow[class*="-blue"] {
+	box-shadow: 6upx 6upx 8upx rgba(0, 102, 204, 0.2);
+}
+
+.shadow[class*="-purple"] {
+	box-shadow: 6upx 6upx 8upx rgba(88, 48, 156, 0.2);
+}
+
+.shadow[class*="-mauve"] {
+	box-shadow: 6upx 6upx 8upx rgba(133, 33, 150, 0.2);
+}
+
+.shadow[class*="-pink"] {
+	box-shadow: 6upx 6upx 8upx rgba(199, 50, 134, 0.2);
+}
+
+.shadow[class*="-brown"] {
+	box-shadow: 6upx 6upx 8upx rgba(140, 88, 53, 0.2);
+}
+
+.shadow[class*="-grey"] {
+	box-shadow: 6upx 6upx 8upx rgba(114, 130, 138, 0.2);
+}
+
+.shadow[class*="-gray"] {
+	box-shadow: 6upx 6upx 8upx rgba(114, 130, 138, 0.2);
+}
+
+.shadow[class*="-black"] {
+	box-shadow: 6upx 6upx 8upx rgba(26, 26, 26, 0.2);
+}
+
+.shadow[class*="-white"] {
+	box-shadow: 6upx 6upx 8upx rgba(26, 26, 26, 0.2);
+}
+
+.text-shadow[class*="-red"] {
+	text-shadow: 6upx 6upx 8upx rgba(204, 69, 59, 0.2);
+}
+
+.text-shadow[class*="-orange"] {
+	text-shadow: 6upx 6upx 8upx rgba(217, 109, 26, 0.2);
+}
+
+.text-shadow[class*="-yellow"] {
+	text-shadow: 6upx 6upx 8upx rgba(224, 170, 7, 0.2);
+}
+
+.text-shadow[class*="-olive"] {
+	text-shadow: 6upx 6upx 8upx rgba(124, 173, 55, 0.2);
+}
+
+.text-shadow[class*="-green"] {
+	text-shadow: 6upx 6upx 8upx rgba(48, 156, 63, 0.2);
+}
+
+.text-shadow[class*="-cyan"] {
+	text-shadow: 6upx 6upx 8upx rgba(28, 187, 180, 0.2);
+}
+
+.text-shadow[class*="-blue"] {
+	text-shadow: 6upx 6upx 8upx rgba(0, 102, 204, 0.2);
+}
+
+.text-shadow[class*="-purple"] {
+	text-shadow: 6upx 6upx 8upx rgba(88, 48, 156, 0.2);
+}
+
+.text-shadow[class*="-mauve"] {
+	text-shadow: 6upx 6upx 8upx rgba(133, 33, 150, 0.2);
+}
+
+.text-shadow[class*="-pink"] {
+	text-shadow: 6upx 6upx 8upx rgba(199, 50, 134, 0.2);
+}
+
+.text-shadow[class*="-brown"] {
+	text-shadow: 6upx 6upx 8upx rgba(140, 88, 53, 0.2);
+}
+
+.text-shadow[class*="-grey"] {
+	text-shadow: 6upx 6upx 8upx rgba(114, 130, 138, 0.2);
+}
+
+.text-shadow[class*="-gray"] {
+	text-shadow: 6upx 6upx 8upx rgba(114, 130, 138, 0.2);
+}
+
+.text-shadow[class*="-black"] {
+	text-shadow: 6upx 6upx 8upx rgba(26, 26, 26, 0.2);
+}
+
+.bg-img {
+	background-size: cover;
+	background-position: center;
+	background-repeat: no-repeat;
+}
+
+.bg-mask {
+	background-color: #333333;
+	position: relative;
+}
+
+.bg-mask::after {
+	content: "";
+	border-radius: inherit;
+	width: 100%;
+	height: 100%;
+	display: block;
+	background-color: rgba(0, 0, 0, 0.4);
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	top: 0;
+}
+
+.bg-mask view,
+.bg-mask cover-view {
+	z-index: 5;
+	position: relative;
+}
+
+.bg-video {
+	position: relative;
+}
+
+.bg-video video {
+	display: block;
+	height: 100%;
+	width: 100%;
+	-o-object-fit: cover;
+	object-fit: cover;
+	position: absolute;
+	top: 0;
+	z-index: 0;
+	pointer-events: none;
+}
+
+/* ==================
+          文本
+ ==================== */
+
+.text-xs {
+	font-size: 20upx;
+}
+
+.text-sm {
+	font-size: 24upx;
+}
+
+.text-df {
+	font-size: 28upx;
+}
+
+.text-lg {
+	font-size: 32upx;
+}
+
+.text-xl {
+	font-size: 36upx;
+}
+
+.text-xxl {
+	font-size: 44upx;
+}
+
+.text-sl {
+	font-size: 80upx;
+}
+
+.text-xsl {
+	font-size: 120upx;
+}
+
+.text-Abc {
+	text-transform: Capitalize;
+}
+
+.text-ABC {
+	text-transform: Uppercase;
+}
+
+.text-abc {
+	text-transform: Lowercase;
+}
+
+.text-price::before {
+	content: "¥";
+	font-size: 80%;
+	margin-right: 4upx;
+}
+
+.text-cut {
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	overflow: hidden;
+}
+
+.text-bold {
+	font-weight: bold;
+}
+
+.text-center {
+	text-align: center;
+}
+
+.text-content {
+	line-height: 1.6;
+}
+
+.text-left {
+	text-align: left;
+}
+
+.text-right {
+	text-align: right;
+}
+
+.text-red,
+.line-red,
+.lines-red {
+	color: #e54d42;
+}
+
+.text-orange,
+.line-orange,
+.lines-orange {
+	color: #f37b1d;
+}
+
+.text-yellow,
+.line-yellow,
+.lines-yellow {
+	color: #fbbd08;
+}
+
+.text-olive,
+.line-olive,
+.lines-olive {
+	color: #8dc63f;
+}
+
+.text-green,
+.line-green,
+.lines-green {
+	color: #39b54a;
+}
+
+.text-cyan,
+.line-cyan,
+.lines-cyan {
+	color: #1cbbb4;
+}
+
+.text-blue,
+.line-blue,
+.lines-blue {
+	color: #0081ff;
+}
+
+.text-purple,
+.line-purple,
+.lines-purple {
+	color: #6739b6;
+}
+
+.text-mauve,
+.line-mauve,
+.lines-mauve {
+	color: #9c26b0;
+}
+
+.text-pink,
+.line-pink,
+.lines-pink {
+	color: #e03997;
+}
+
+.text-brown,
+.line-brown,
+.lines-brown {
+	color: #a5673f;
+}
+
+.text-grey,
+.line-grey,
+.lines-grey {
+	color: #8799a3;
+}
+
+.text-gray,
+.line-gray,
+.lines-gray {
+	color: #aaaaaa;
+}
+
+.text-black,
+.line-black,
+.lines-black {
+	color: #333333;
+}
+
+.text-white,
+.line-white,
+.lines-white {
+	color: #ffffff;
+}
+
+
+.text-primary,
+.line-primary,
+.lines-primary {
+	color: #5677fc;
+}
+.line-primary::after,
+.lines-primary::after {
+	border-color: #5677fc;
+}

+ 48 - 0
addons/exam/uniapp/common/api/common.js

@@ -0,0 +1,48 @@
+import utils from "../js/utils.js";
+
+/**
+ * 公共接口
+ */
+module.exports = {
+
+	/**
+	 * 上传文件
+	 * @returns {Promise<*>}
+	 */
+	uploadFile(handler, filePath, data = {}, fileType = 'image') {
+		
+		uni.uploadFile({
+			url: fun.SITE + 'api/wx/wxpayApplyImage',
+			header: {
+				'token': uni.getStorageSync('token'),
+				"Content-Type": "multipart/form-data"
+			},
+			fileType: fileType,
+			filePath: filePath,
+			formData: data,
+			name: 'file',
+			success: (res) => {
+				if (res.statusCode == 413) {
+					this.modalDo(true, '图片太大了,请压缩后再重新上传')
+					return
+				}
+
+				let data = JSON.parse(res.data)
+				if (data.status == 1) {
+					let result = data.result
+					this.ocrResult(type, result, lists)
+					return
+				} else {
+					this.modalDo(true, '识别有误,提交可能影响审核,建议重新上传更清晰的图片')
+					return
+				}
+			},
+			fail: (res) => {
+				console.log('fail res', res)
+			},
+			complete: (uploadFileRes) => {
+				this.showPopup = false
+			},
+		})
+	},
+}

+ 31 - 0
addons/exam/uniapp/common/api/correction.js

@@ -0,0 +1,31 @@
+import utils from "../js/utils.js";
+
+/**
+ * 纠错相关接口
+ */
+module.exports = {
+
+	/**
+	 * 纠错类型
+	 * @returns {Promise<*>}
+	 */
+	getCorrectionTypes(handler, data = {}) {
+		return utils.http(handler, 'correction/types', data)
+	},
+
+	/**
+	 * 提交纠错
+	 * @returns {Promise<*>}
+	 */
+	submitCorrection(handler, data = {}) {
+		return utils.http(handler, 'correction/submit', data)
+	},
+
+	/**
+	 * 获取纠错反馈列表
+	 * @returns {Promise<*>}
+	 */
+	getCorrectionList(handler, data = {}) {
+		return utils.http(handler, 'correction/list', data)
+	},
+}

+ 23 - 0
addons/exam/uniapp/common/api/notice.js

@@ -0,0 +1,23 @@
+import utils from "../js/utils.js";
+
+/**
+ * 公告相关接口
+ */
+module.exports = {
+
+	/**
+	 * 获取列表
+	 * @returns {Promise<*>}
+	 */
+	getNoticeList(handler, data = {}) {
+		return utils.http(handler, 'notice/index', data)
+	},
+
+	/**
+	 * 获取详情
+	 * @returns {Promise<*>}
+	 */
+	getNoticeDetail(handler, data = {}) {
+		return utils.http(handler, 'notice/detail', data)
+	},
+}

+ 79 - 0
addons/exam/uniapp/common/api/user.js

@@ -0,0 +1,79 @@
+import utils from "../js/utils.js";
+
+/**
+ * 用户相关接口
+ */
+module.exports = {
+
+    /**
+     * 获取用户信息
+     * @returns {Promise<*>}
+     */
+	getUserInfo(handler) {
+        return utils.http(handler, 'user/info')
+    },
+
+    /**
+     * 获取会员开通配置
+     * @returns {Promise<*>}
+     */
+    getMemberOpenConfig(handler) {
+        return utils.http(handler, 'user/memberOpenConfig')
+    },
+	
+	/**
+	 * 创建会员开通订单
+	 * @returns {Promise<*>}
+	 */
+	createMemberOrder(handler, member_config_id) {
+	    return utils.http(handler, 'user/createMemberOrder', {member_config_id: member_config_id})
+	},
+	
+	/**
+	 * 获取会员配置
+	 * @returns {Promise<*>}
+	 */
+	getMemberConfigs(handler) {
+	    return utils.http(handler, 'user/memberConfigs')
+	},
+	
+	/**
+	 * 获取微信绑定的手机号码
+	 * @returns {Promise<*>}
+	 */
+	getWechatPhone(handler, data) {
+	    return utils.http(handler, 'user/getWechatPhone', data)
+	},
+	
+	/**
+	 * 保存用户信息
+	 * @returns {Promise<*>}
+	 */
+	save(handler, data) {
+	    return utils.http(handler, 'user/save', data)
+	},
+	
+	/**
+	 * 保存我常用的题库
+	 * @returns {Promise<*>}
+	 */
+	saveMyCate(handler, data) {
+	    return utils.http(handler, 'user/saveMyCate', data)
+	},
+	
+	/**
+	 * 注册
+	 * @returns {Promise<*>}
+	 */
+	register(handler, data) {
+		return utils.http(handler, 'user/register', data)
+	},
+	
+	/**
+	 * 账号密码登录
+	 * @returns {Promise<*>}
+	 */
+	login(handler, data) {
+		return utils.http(handler, 'user/userLogin', data)
+	},
+}

+ 60 - 0
addons/exam/uniapp/common/js/ad.js

@@ -0,0 +1,60 @@
+var interstitialAd = null;
+let interstitial = {
+	load(id) {
+		if (id && uni.createInterstitialAd) {
+			interstitialAd = uni.createInterstitialAd({
+				adUnitId: id
+			})
+			interstitialAd.onLoad(() => {
+				console.log('插屏广告加载中')
+			})
+			interstitialAd.onError((err) => {
+				console.log('加载错误', err)
+			})
+			interstitialAd.onClose((res) => {
+				console.log('插屏广告关闭', res)
+			})
+		}
+	},
+	show() {
+		if (interstitialAd) {
+			interstitialAd.show().catch((err) => {
+				console.error(err)
+			})
+		}
+	}
+}
+
+let videoAd = null;
+let rewarded = {
+	load(id, e) {
+		if (id && uni.createRewardedVideoAd) {
+			videoAd = uni.createRewardedVideoAd({
+				adUnitId: id
+			})
+			videoAd.onError(err => {})
+			videoAd.onClose((status) => {
+				if (status && status.isEnded || status === undefined) {
+					e()
+				} else {
+
+				}
+			})
+		}
+	},
+	show() {
+		if (videoAd) {
+			videoAd.show().catch(() => {
+				// 失败重试
+				videoAd.load().then(() => videoAd.show()).catch(err => {
+					console.log('激励视频 广告显示失败')
+				})
+			})
+		}
+	}
+}
+
+module.exports = {
+	interstitial,
+	rewarded
+};

File diff suppressed because it is too large
+ 14 - 0
addons/exam/uniapp/common/js/lodash.min.js


+ 27 - 0
addons/exam/uniapp/common/js/page.js

@@ -0,0 +1,27 @@
+const path = require('path')
+const fs = require('fs')
+
+const fromFile = path.join(__filename, '../../pages.json')
+const toFile = path.join(__filename, '../../router/index.js')
+const buffPrefix = Buffer.from('export default ')
+
+const fileData = fs.readFileSync(fromFile)
+
+// 转成可读的js, 正则删除注释, 不然JSON.parse会报错
+const fileObj = JSON.parse(fileData.toString().replace(/\/\/.*/g, ''))
+
+// 遍历,只取路径和标题,其他的不要,已减小文件体积
+const routes = fileObj.pages.map(e => {
+  return {
+    title: e.style ? e.style.navigationBarTitleText : '未知',
+    path: e.path,
+  }
+})
+
+fs.writeFileSync(toFile, buffPrefix + Buffer.from(JSON.stringify(routes)))
+
+module.exports = {
+  configureWebpack: {
+    plugins: []
+  }
+}

+ 405 - 0
addons/exam/uniapp/common/js/picker.city.js

@@ -0,0 +1,405 @@
+//测试数据,数据并非完整,只验证功能
+module.exports = [{
+    "text": "北京市",
+    "value": "110000",
+    "children": [{
+      "text": "北京市市辖区",
+      "value": "110100",
+      "children": [{
+        "text": "东城区",
+        "value": "110101"
+      }, {
+        "text": "西城区",
+        "value": "110102"
+      }, {
+        "text": "朝阳区",
+        "value": "110105"
+      }]
+    }]
+  },
+  {
+    "text": "安徽省",
+    "value": "340000",
+    "children": [{
+      "text": "合肥市",
+      "value": "340100",
+      "children": [{
+        "text": "瑶海区",
+        "value": "340102"
+      }, {
+        "text": "庐阳区",
+        "value": "340103"
+      }, {
+        "text": "蜀山区",
+        "value": "340104"
+      }, {
+        "text": "包河区",
+        "value": "340111"
+      }, {
+        "text": "长丰县",
+        "value": "340121"
+      }, {
+        "text": "肥东县",
+        "value": "340122"
+      }, {
+        "text": "肥西县",
+        "value": "340123"
+      }, {
+        "text": "庐江县",
+        "value": "340124"
+      }, {
+        "text": "巢湖市",
+        "value": "340181"
+      }]
+    }, {
+      "text": "芜湖市",
+      "value": "340200",
+      "children": [{
+        "text": "镜湖区",
+        "value": "340202"
+      }, {
+        "text": "弋江区",
+        "value": "340203"
+      }, {
+        "text": "鸠江区",
+        "value": "340207"
+      }, {
+        "text": "三山区",
+        "value": "340208"
+      }, {
+        "text": "芜湖县",
+        "value": "340221"
+      }, {
+        "text": "繁昌县",
+        "value": "340222"
+      }, {
+        "text": "南陵县",
+        "value": "340223"
+      }, {
+        "text": "无为县",
+        "value": "340225"
+      }]
+    }, {
+      "text": "蚌埠市",
+      "value": "340300",
+      "children": [{
+        "text": "龙子湖区",
+        "value": "340302"
+      }, {
+        "text": "蚌山区",
+        "value": "340303"
+      }, {
+        "text": "禹会区",
+        "value": "340304"
+      }, {
+        "text": "淮上区",
+        "value": "340311"
+      }, {
+        "text": "怀远县",
+        "value": "340321"
+      }, {
+        "text": "五河县",
+        "value": "340322"
+      }, {
+        "text": "固镇县",
+        "value": "340323"
+      }]
+    }, {
+      "text": "淮南市",
+      "value": "340400",
+      "children": [{
+        "text": "大通区",
+        "value": "340402"
+      }, {
+        "text": "田家庵区",
+        "value": "340403"
+      }, {
+        "text": "谢家集区",
+        "value": "340404"
+      }, {
+        "text": "八公山区",
+        "value": "340405"
+      }, {
+        "text": "潘集区",
+        "value": "340406"
+      }, {
+        "text": "凤台县",
+        "value": "340421"
+      }, {
+        "text": "寿县",
+        "value": "340422"
+      }]
+    }, {
+      "text": "马鞍山市",
+      "value": "340500",
+      "children": [{
+        "text": "花山区",
+        "value": "340503"
+      }, {
+        "text": "雨山区",
+        "value": "340504"
+      }, {
+        "text": "博望区",
+        "value": "340506"
+      }, {
+        "text": "当涂县",
+        "value": "340521"
+      }, {
+        "text": "含山县",
+        "value": "340522"
+      }, {
+        "text": "和县",
+        "value": "340523"
+      }]
+    }, {
+      "text": "淮北市",
+      "value": "340600",
+      "children": [{
+        "text": "杜集区",
+        "value": "340602"
+      }, {
+        "text": "相山区",
+        "value": "340603"
+      }, {
+        "text": "烈山区",
+        "value": "340604"
+      }, {
+        "text": "濉溪县",
+        "value": "340621"
+      }]
+    }, {
+      "text": "铜陵市",
+      "value": "340700",
+      "children": [{
+        "text": "铜官区",
+        "value": "340705"
+      }, {
+        "text": "义安区",
+        "value": "340706"
+      }, {
+        "text": "郊区",
+        "value": "340711"
+      }, {
+        "text": "枞阳县",
+        "value": "340722"
+      }]
+    }, {
+      "text": "安庆市",
+      "value": "340800",
+      "children": [{
+        "text": "迎江区",
+        "value": "340802"
+      }, {
+        "text": "大观区",
+        "value": "340803"
+      }, {
+        "text": "宜秀区",
+        "value": "340811"
+      }, {
+        "text": "怀宁县",
+        "value": "340822"
+      }, {
+        "text": "潜山县",
+        "value": "340824"
+      }, {
+        "text": "太湖县",
+        "value": "340825"
+      }, {
+        "text": "宿松县",
+        "value": "340826"
+      }, {
+        "text": "望江县",
+        "value": "340827"
+      }, {
+        "text": "岳西县",
+        "value": "340828"
+      }, {
+        "text": "桐城市",
+        "value": "340881"
+      }]
+    }, {
+      "text": "黄山市",
+      "value": "341000",
+      "children": [{
+        "text": "屯溪区",
+        "value": "341002"
+      }, {
+        "text": "黄山区",
+        "value": "341003"
+      }, {
+        "text": "徽州区",
+        "value": "341004"
+      }, {
+        "text": "歙县",
+        "value": "341021"
+      }, {
+        "text": "休宁县",
+        "value": "341022"
+      }, {
+        "text": "黟县",
+        "value": "341023"
+      }, {
+        "text": "祁门县",
+        "value": "341024"
+      }]
+    }, {
+      "text": "滁州市",
+      "value": "341100",
+      "children": [{
+        "text": "琅琊区",
+        "value": "341102"
+      }, {
+        "text": "南谯区",
+        "value": "341103"
+      }, {
+        "text": "来安县",
+        "value": "341122"
+      }, {
+        "text": "全椒县",
+        "value": "341124"
+      }, {
+        "text": "定远县",
+        "value": "341125"
+      }, {
+        "text": "凤阳县",
+        "value": "341126"
+      }, {
+        "text": "天长市",
+        "value": "341181"
+      }, {
+        "text": "明光市",
+        "value": "341182"
+      }]
+    }, {
+      "text": "阜阳市",
+      "value": "341200",
+      "children": [{
+        "text": "颍州区",
+        "value": "341202"
+      }, {
+        "text": "颍东区",
+        "value": "341203"
+      }, {
+        "text": "颍泉区",
+        "value": "341204"
+      }, {
+        "text": "临泉县",
+        "value": "341221"
+      }, {
+        "text": "太和县",
+        "value": "341222"
+      }, {
+        "text": "阜南县",
+        "value": "341225"
+      }, {
+        "text": "颍上县",
+        "value": "341226"
+      }, {
+        "text": "界首市",
+        "value": "341282"
+      }]
+    }, {
+      "text": "宿州市",
+      "value": "341300",
+      "children": [{
+        "text": "埇桥区",
+        "value": "341302"
+      }, {
+        "text": "砀山县",
+        "value": "341321"
+      }, {
+        "text": "萧县",
+        "value": "341322"
+      }, {
+        "text": "灵璧县",
+        "value": "341323"
+      }, {
+        "text": "泗县",
+        "value": "341324"
+      }]
+    }, {
+      "text": "六安市",
+      "value": "341500",
+      "children": [{
+        "text": "金安区",
+        "value": "341502"
+      }, {
+        "text": "裕安区",
+        "value": "341503"
+      }, {
+        "text": "叶集区",
+        "value": "341504"
+      }, {
+        "text": "霍邱县",
+        "value": "341522"
+      }, {
+        "text": "舒城县",
+        "value": "341523"
+      }, {
+        "text": "金寨县",
+        "value": "341524"
+      }, {
+        "text": "霍山县",
+        "value": "341525"
+      }]
+    }, {
+      "text": "亳州市",
+      "value": "341600",
+      "children": [{
+        "text": "谯城区",
+        "value": "341602"
+      }, {
+        "text": "涡阳县",
+        "value": "341621"
+      }, {
+        "text": "蒙城县",
+        "value": "341622"
+      }, {
+        "text": "利辛县",
+        "value": "341623"
+      }]
+    }, {
+      "text": "池州市",
+      "value": "341700",
+      "children": [{
+        "text": "贵池区",
+        "value": "341702"
+      }, {
+        "text": "东至县",
+        "value": "341721"
+      }, {
+        "text": "石台县",
+        "value": "341722"
+      }, {
+        "text": "青阳县",
+        "value": "341723"
+      }]
+    }, {
+      "text": "宣城市",
+      "value": "341800",
+      "children": [{
+        "text": "宣州区",
+        "value": "341802"
+      }, {
+        "text": "郎溪县",
+        "value": "341821"
+      }, {
+        "text": "广德县",
+        "value": "341822"
+      }, {
+        "text": "泾县",
+        "value": "341823"
+      }, {
+        "text": "绩溪县",
+        "value": "341824"
+      }, {
+        "text": "旌德县",
+        "value": "341825"
+      }, {
+        "text": "宁国市",
+        "value": "341881"
+      }]
+    }]
+  }
+
+];

+ 308 - 0
addons/exam/uniapp/common/js/utils.js

@@ -0,0 +1,308 @@
+const app_info = require("@/static/appInfo.js");
+const api_host = app_info.api_host
+const version = app_info.version
+const api_prefix = api_host + '/addons/exam/'
+
+//工具类
+module.exports = {
+
+	timestamp() {
+		return parseInt(new Date().getTime() / 1000);
+	},
+	cons_log(...params) {
+		for (let p of params) {
+			console.log(p);
+		}
+	},
+	cons_log_json(...params) {
+		for (let p of params) {
+			console.log(JSON.stringify(p));
+		}
+	},
+
+	goto(url) {
+		uni.navigateTo({
+			url: url
+		});
+	},
+
+	goreto(url) {
+		uni.redirectTo({
+			url: url
+		});
+	},
+
+	gotore(url) {
+		uni.reLaunch({
+			url: url
+		});
+	},
+
+	goback(delta = 1) {
+		uni.navigateBack({
+			delta: delta
+		});
+	},
+
+	toast(msg, icon = 'none', duration = 2000, mask = false, image = '') {
+		uni.showToast({
+			title: msg,
+			icon: icon,
+			duration: duration,
+			mask: mask,
+			image: image
+		})
+	},
+
+	alert(content, callBack) {
+		uni.hideLoading();
+		uni.showModal({
+			content: content,
+			showCancel: false,
+			success: function() {
+				if (callBack != undefined) {
+					callBack();
+				}
+			}
+		})
+	},
+
+	getData(key, is_clear = false) {
+		var value = uni.getStorageSync(key);
+		if (is_clear) {
+			uni.setStorageSync(key, '');
+		}
+		// console.log('get cache', key, value)
+		return value;
+	},
+
+	setData(key, value) {
+		// console.log('set cache', key, value)
+		uni.setStorageSync(key, value);
+	},
+
+	array_column(obj, key) {
+		var objects = [];
+		for (var i in obj) {
+			if (!obj.hasOwnProperty(i)) continue;
+			if (typeof obj[i] == 'object') {
+				objects = objects.concat(this.array_column(obj[i], key));
+			} else if (i == key) {
+				objects.push(obj[i]);
+			}
+		}
+		return objects;
+	},
+
+	shuffle(arr) {
+		var l = arr.length
+		var index, temp
+		while (l > 0) {
+			index = Math.floor(Math.random() * l)
+			temp = arr[l - 1]
+			arr[l - 1] = arr[index]
+			arr[index] = temp
+			l--
+		}
+		return arr
+	},
+
+	// 秒数转时分秒格式
+	sec_to_time(s) {
+		var t;
+		if (s > -1) {
+			var hour = Math.floor(s / 3600);
+			var min = Math.floor(s / 60) % 60;
+			var sec = s % 60;
+			if (hour < 10) {
+				t = '0' + hour + ":";
+			} else {
+				t = hour + ":";
+			}
+
+			if (min < 10) {
+				t += "0";
+			}
+			t += min + ":";
+			if (sec < 10) {
+				t += "0";
+			}
+			t += sec.toFixed(2);
+		}
+		return t;
+	},
+
+	login() {
+		var token = this.getData('token');
+		var user = this.getData('user');
+		if (!token || !user) {
+			const pages = getCurrentPages()
+			const currentPage = pages[pages.length - 1]
+			const url = currentPage.route
+			const options = currentPage.options
+			let urlWithArgs = `/${url}?`
+			for (let key in options) {
+				const value = options[key]
+				urlWithArgs += `${key}=${value}&`
+			}
+			urlWithArgs = urlWithArgs.substring(0, urlWithArgs.length - 1)
+			uni.setStorageSync('route', urlWithArgs);
+			console.log('没有缓存数据,需先授权')
+			uni.redirectTo({
+				url: "/pages/login/login"
+			})
+			return;
+		}
+		return true;
+	},
+	
+	/**
+	 * rich-text富文本中图片宽度最大100%
+	 * @param {String} html
+	 */
+	formatRichText(html) {
+		// 去掉img标签里的style、width、height属性
+		let content_data= html.replace(/<img[^>]*>/gi, function(match,capture) {
+			match = match.replace(/style="[^"]+"/gi, '').replace(/style='[^']+'/gi, '')
+			match = match.replace(/width="[^"]+"/gi, '').replace(/width='[^']+'/gi, '')
+			match = match.replace(/height="[^"]+"/gi, '').replace(/height='[^']+'/gi, '')
+			return match
+		})
+			
+		// 修改所有style里的width属性为max-width:100%
+		content_data = content_data.replace(/style="[^"]+"/gi,function(match,capture){
+			match = match.replace(/width:[^;]+;/gi, 'max-width:100%;').replace(/width:[^;]+;/gi, 'max-width:100%;')
+			return match
+		})
+			
+		// 去掉<br/>标签
+		content_data = content_data.replace(/<br[^>]*\/>/gi, '')
+		// img标签添加style属性:max-width:100%;height:auto
+		content_data = content_data.replace(/\<img/gi, '<img style="max-width:100%;height:auto;display:block;margin:0px auto;"')
+		
+		return content_data
+	},
+
+	/**
+	 * http请求
+	 * @param handler
+	 * @param path
+	 * @param data
+	 * @param method
+	 * @param showLoading
+	 * @returns {Promise<unknown>}
+	 */
+	http(handler, path, data = {}, method = 'post', showLoading = true) {
+		let url = api_prefix + path
+		let timestamp = Date.parse(new Date())
+		if (typeof(data) == 'object') {
+			data['timestamp'] = timestamp
+		}
+
+		if (showLoading) {
+			uni.showLoading({
+				title: '加载中..',
+				mask: true
+			})
+		}
+
+		return new Promise((resolve, reject) => {
+			uni.request({
+				url: url,
+				method: method,
+				data: data,
+				header: {
+					'content-type': 'application/json',
+					'token': this.getData('token'),
+					'app-version': version
+				},
+				success: (res => {
+					console.log('http res', res)
+					if (res.statusCode == 200) {
+						resolve(res.data)
+					} else {
+						console.log('http statusCode', res.statusCode)
+						uni.showToast({
+							title: res.data.message,
+							icon: 'none'
+						})
+
+						// 未登录
+						if (res.statusCode == 401) {
+							// console.log('this.$refs', this.$refs)
+							// this.$refs.login.modal = true
+							// uni.$emit('uniLogin', handler)
+							
+							handler.$refs.login.modal = true
+							// handler.$refs.login.afterMethod = () => {
+							// 	this.http(handler, path, data)
+							// }
+						}
+						// reject(res.data);
+					}
+				}),
+				fail: (res => {
+					console.log(res)
+					return false;
+				}),
+				complete: (res => {
+					showLoading && uni.hideLoading()
+				})
+			})
+		})
+	},
+	
+	/**
+	 * 上传文件
+	 * @returns {Promise<*>}
+	 */
+	uploadFile(handler, filePath, data = {}, fileType = 'image', showLoading = true) {
+		
+		if (showLoading) {
+			uni.showLoading({
+				title: '加载中..',
+				mask: true
+			})
+		}
+		
+		return new Promise((resolve, reject) => {
+			uni.uploadFile({
+				url: api_prefix + 'common/upload',
+				header: {
+					'token': uni.getStorageSync('token'),
+					"Content-Type": "multipart/form-data"
+				},
+				fileType: fileType,
+				filePath: filePath,
+				formData: data,
+				name: 'file',
+				success: (res) => {
+					console.log('upload res', res)
+					let data = JSON.parse(res.data)
+					if (res.statusCode == 200) {
+						resolve(data)
+					} else {
+						console.log('upload statusCode', res.statusCode)
+						uni.showToast({
+							title: data.message,
+							icon: 'none'
+						})
+						
+						// 未登录
+						if (res.statusCode == 401) {
+							handler.$refs.login.modal = true
+						}
+					}
+				},
+				fail: (res) => {
+					console.log('fail res', res)
+					return false;
+				},
+				complete: (res => {
+					showLoading && uni.hideLoading()
+				})
+			})
+		})
+	},
+};
+

+ 62 - 0
addons/exam/uniapp/common/mixins/share.js

@@ -0,0 +1,62 @@
+export default {
+    data() {
+        return {
+			// 这里会导致组件报错
+            // share: {
+            //     // 转发的标题 (默认标题)
+            //     title: '默认标题--分享标题',
+            //     // 默认是当前页面,必须是以‘/’开头的完整路径
+            //     path: '',
+            //     //自定义图片路径,可以是本地文件路径、代码包文件路径或者网络图片路径,
+            //     //支持PNG及JPG,不传入 imageUrl 则使用默认截图。显示图片长宽比是 5:4
+            //     imageUrl: ''
+            // }
+        }
+    },
+    /*
+     在要分享的页面 生命周期中  设计当前页面分享标题
+     onLoad() {
+         this.share.title = "当前页面分享标题"
+     },
+     */
+    // 发送给朋友
+    onShareAppMessage(res) {
+        // 来自页面内的按钮的转发
+        if (res.from == 'button') {
+            console.log("按钮转发--配置");
+        }
+        // 获取加载的页面
+        let pages = getCurrentPages(),
+            // 获取当前页面的对象
+            view = pages[pages.length - 1];
+        this.share.path = `/${view.route}`;
+		
+		let system = uni.getStorageSync('system')
+		if (system && system.share_title) {
+			this.share.title = system.share_title
+		}
+        
+		console.log('onShareAppMessage', this.share)
+        //转发参数
+        return this.share;
+    },
+    //分享到朋友圈
+    onShareTimeline(res) {
+        // 获取加载的页面
+        let pages = getCurrentPages(),
+            // 获取当前页面的对象
+            view = pages[pages.length - 1];
+        // console.log("获取加载的页面", pages);
+        console.log("当前页面的对象", view);
+        this.share.path = `/${view.route}`;
+		
+		let system = uni.getStorageSync('system')
+		if (system && system.share_title) {
+			this.share.title = system.share_title
+		}
+		
+		console.log('onShareAppMessage', this.share)
+        //转发参数
+        return this.share;
+    }
+}

+ 794 - 0
addons/exam/uniapp/components/HM-filterDropdown/HM-filterDropdown.vue

@@ -0,0 +1,794 @@
+<template>
+	<view class="HMfilterDropdown" :class="{'setDropdownBottom':maskVisibility}" :style="{'top':menuTop+'rpx'}" @touchmove.stop.prevent="discard" @tap.stop="discard">
+		<view class="nav">
+			<block v-for="(item,index) in menu" :key="index">
+				<view class="first-menu" :class="{'on':showPage==index}" @tap="togglePage(index)">
+					<text class="name">{{item.name}}</text>
+					<text class="iconfont triangle" :style="'transform:rotate('+triangleDeg[index]+'deg);'"></text>
+				</view>
+			</block>
+		</view>
+		<view class="mask" :class="{'show':isShowMask,'hide':maskVisibility!=true}" @tap="togglePage(showPage)"></view>
+		<block v-for="(page,page_index) in subData" :key="page_index">
+			<view class="sub-menu-class" :class="{'show':showPage==page_index,'hide':pageState[page_index]!=true}">
+				<block v-if="page.type=='hierarchy'&& page.submenu.length>0">
+					<scroll-view class="sub-menu-list" :class="[activeMenuArr[page_index].length>1?'first':'alone']"
+					 :scroll-y="true" :scroll-into-view="'first_id'+firstScrollInto">
+						<block v-for="(sub,index) in page.submenu" :key="index">
+							<view class="sub-menu" :id="'first_id'+index" :class="{'on':activeMenuArr[page_index][0]==index}" @tap="selectHierarchyMenu(page_index,index,null,null)">
+								<view class="menu-name">
+									<text>{{sub.name}}</text>
+									<text class="iconfont selected"></text>
+								</view>
+							</view>
+						</block>
+					</scroll-view>
+					<block v-for="(sub,index) in page.submenu" :key="index">
+						<scroll-view class="sub-menu-list not-first" :scroll-y="true" v-if="activeMenuArr[page_index][0]==index&&sub.submenu.length>0"
+						 :scroll-into-view="'second_id'+secondScrollInto">
+							<block v-for="(sub_second,second_index) in sub.submenu" :key="second_index">
+								<view class="sub-menu" :id="'second_id'+second_index" :class="{'on':activeMenuArr[page_index][1]==second_index}">
+									<view class="menu-name" @tap="selectHierarchyMenu(page_index,activeMenuArr[page_index][0],second_index,null)">
+										<text>{{sub_second.name}}</text>
+										<text class="iconfont selected"></text>
+									</view>
+									<view class="more-sub-menu" v-if="sub_second.submenu&&sub.submenu.length>0&&sub_second.submenu.length>0">
+										<block v-for="(sub2,sub2_index) in sub_second.submenu" :key="sub2_index">
+											<text v-if="sub_second.showAllSub || (sub2_index<8)" :class="{'on':activeMenuArr[page_index][1]==second_index&&activeMenuArr[page_index][2]==sub2_index}"
+											 @tap="selectHierarchyMenu(page_index,activeMenuArr[page_index][0],second_index,sub2_index)">{{sub2.name}}</text>
+											<text v-if="sub_second.showAllSub!=true && sub2_index==8 && sub_second.submenu.length>9" @tap="showMoreSub(second_index)">更多<text
+												 class="iconfont triangle"></text></text>
+										</block>
+									</view>
+								</view>
+							</block>
+						</scroll-view>
+					</block>
+				</block>
+				<block v-if="page.type=='filter'">
+					<view class="filter">
+						<scroll-view class="menu-box" :scroll-y="true">
+							<view class="box" v-for="(box,box_index) in page.submenu" :key="box_index">
+								<view class="title">{{box.name}}</view>
+								<view class="labels">
+									<view v-for="(label,label_index) in box.submenu" :key="label_index" @tap="selectFilterLabel(page_index,box_index,label_index)"
+									 :class="{'on':label.selected}">{{label.name}}</view>
+								</view>
+							</view>
+						</scroll-view>
+						<view class="btn-box">
+							<view class="reset" @tap="resetFilterData(page_index)">重置</view>
+							<view class="submit" @tap="setFilterData(page_index)">确定</view>
+						</view>
+					</view>
+				</block>
+				<block v-if="page.type=='radio'">
+					<view class="filter">
+						<scroll-view class="menu-box" :scroll-y="true">
+							<view class="box" v-for="(box,box_index) in page.submenu" :key="box_index">
+								<view class="title">{{box.name}}</view>
+								<view class="labels">
+									<view v-for="(label,label_index) in box.submenu" :key="label_index" @tap="selectRadioLabel(page_index,box_index,label_index)"
+									 :class="{'on':label.selected}">{{label.name}}</view>
+								</view>
+							</view>
+						</scroll-view>
+						<view class="btn-box">
+							<view class="reset" @tap="resetFilterData(page_index)">重置</view>
+							<view class="submit" @tap="setFilterData(page_index)">确定</view>
+						</view>
+					</view>
+				</block>
+			</view>
+		</block>
+	</view>
+</template>
+<script>
+	export default {
+		data() {
+			return {
+				subData: [], //菜单数据
+				menu: [], //顶部横条数据
+				showPage: -1, //菜单页面显示/隐藏动画控制
+				pageState: [], //页面的状态
+				activeMenuArr: [], //UI状态
+				shadowActiveMenuArr: [], //记录选中
+				defaultActive:[],
+				triangleDeg: [], //小三角形的翻转动画控制
+				isShowMask: false, //遮罩层显示/隐藏动画控制
+				maskVisibility: false, //遮罩层显示/隐藏状态
+				//滚动区域定位
+				firstScrollInto: 0,
+				secondScrollInto: 0,
+				componentTop:0	,//组件top
+				isReadNewSelect:false
+			}
+		},
+		props: {
+			menuTop:{
+				value: Number,
+				default: false
+			},
+			filterData: {
+				value: Array,
+				default: []
+			},
+			defaultSelected:{
+				value: Array,
+				default: []
+			},
+			updateMenuName:{
+				value: Boolean,
+				default: true
+			},
+			dataFormat:{
+				value: String,
+				default: 'Array'
+			}
+		},
+		watch: {
+			filterData: {
+				handler() {
+					this.initMenu(); //filterData重新赋值初始化菜单
+				},
+				immediate: true
+			},
+			defaultSelected(newVal) {
+
+				if(newVal.length==0){
+					return;
+				}
+				this.defaultActive = JSON.parse(JSON.stringify(newVal));
+				this.activeMenuArr = JSON.parse(JSON.stringify(newVal));
+				this.shadowActiveMenuArr = JSON.parse(JSON.stringify(newVal));
+				if(this.updateMenuName){
+					this.setMenuName();
+				}
+			}
+		},
+		methods: {
+			initMenu() {
+
+				let tmpMenuActiveArr=[];
+				let tmpMenu=[];
+				for (let i = 0; i < this.filterData.length; i++) {
+					let tmpitem = this.filterData[i];
+					tmpMenu.push({
+						//如果没有设置name,则取第一个菜单作为menu.name,filter类型则将"筛选"作为menu.name
+						name: tmpitem.name || (tmpitem.type == "filter" ? "筛选" : tmpitem.submenu[0].name),
+						type: tmpitem.type
+					});
+					//初始化选中项数组-ui状态
+					tmpMenuActiveArr.push(this.processActive(tmpitem));
+					//初始化角度数组
+					this.triangleDeg.push(0);
+					//初始化控制显示状态数组
+					this.pageState.push(false);
+					//递归处理子菜单数据
+					tmpitem = this.processSubMenu(tmpitem);
+					this.filterData[i] = tmpitem;
+				}
+				this.menu = tmpMenu;
+				//初始化选中项数组
+				tmpMenuActiveArr = this.defaultActive.length>0?this.defaultActive:this.activeMenuArr.length>0?this.activeMenuArr:tmpMenuActiveArr;
+				this.defaultActive = [];
+				this.activeMenuArr = JSON.parse(JSON.stringify(tmpMenuActiveArr));
+				this.shadowActiveMenuArr = JSON.parse(JSON.stringify(tmpMenuActiveArr));
+				//加载菜单数据
+				this.subData = this.filterData;
+				//设定顶部菜单名字
+				if(this.updateMenuName){
+					this.setMenuName();
+				}
+			},
+			setMenuName(){
+				for(var i=0;i<this.activeMenuArr.length;i++){
+					let row = this.activeMenuArr[i];
+					if(this.subData[i].type=='hierarchy'){
+						if (typeof(row[0]) == 'number'){
+							let tmpsub = this.subData[i].submenu[row[0]];
+							if(row.length>1){
+								tmpsub = tmpsub.submenu[row[1]];
+								if(row.length>2){
+									tmpsub = tmpsub.submenu[row[2]];
+								}
+							}
+							this.menu[i].name = tmpsub.name;
+						}else{
+							this.menu[i].name = this.subData[i].name;
+						}
+					}
+				}
+			},
+			//展开更多
+			showMoreSub(index) {
+				this.subData[this.showPage].submenu[this.activeMenuArr[this.showPage][0]].submenu[index].showAllSub = true;
+				this.$forceUpdate();
+			},
+			//选中
+			selectHierarchyMenu(page_index, level1_index, level2_index, level3_index) {
+				//读取记录
+				if (level1_index != null && level2_index == null && level3_index == null && this.shadowActiveMenuArr[page_index][0] ==
+					level1_index) {
+					this.activeMenuArr.splice(page_index, 1, JSON.parse(JSON.stringify(this.shadowActiveMenuArr[page_index])));
+				} else {
+					this.activeMenuArr[page_index].splice(0, 1, level1_index);
+					(level2_index!=null||this.activeMenuArr[page_index].length>=2)&&this.activeMenuArr[page_index].splice(1, 1, level2_index) || this.activeMenuArr[page_index].splice(1, 1);
+					(level3_index!=null||this.activeMenuArr[page_index].length>=3)&&this.activeMenuArr[page_index].splice(2, 1, level3_index) || this.activeMenuArr[page_index].splice(2, 1);
+				}
+				//写入结果
+				if (level3_index != null || level2_index != null || (level1_index != null && this.subData[page_index].submenu[level1_index].submenu.length == 0)
+				) {
+					let sub = this.subData[page_index].submenu[level1_index].submenu[level2_index];
+					if(this.updateMenuName){
+						this.menu[page_index].name = (level3_index != null && sub.submenu[level3_index].name) || (level2_index != null && sub.name) || this.subData[page_index].submenu[level1_index].name;
+					}
+					this.shadowActiveMenuArr[page_index] = JSON.parse(JSON.stringify(this.activeMenuArr[page_index]));
+					this.togglePage(this.showPage);
+				}
+			},
+			//写入结果,筛选
+			setFilterData(page_index) {
+				this.shadowActiveMenuArr[page_index] = JSON.parse(JSON.stringify(this.activeMenuArr[page_index]));
+				this.togglePage(this.showPage);
+			},
+			//重置结果和ui,筛选
+			resetFilterData(page_index) {
+				let tmpArr = [];
+				let level = this.shadowActiveMenuArr[page_index].length;
+				while (level > 0) {
+					tmpArr.push([]);
+					let box = this.subData[page_index].submenu[level - 1].submenu;
+					for (let i = 0; i < box.length; i++) {
+						this.subData[page_index].submenu[level - 1].submenu[i].selected = false;
+					}
+					level--;
+				}
+				this.activeMenuArr[page_index] = JSON.parse(JSON.stringify(tmpArr));
+				this.$forceUpdate();
+			},
+			//选中筛选类label-UI状态
+			selectFilterLabel(page_index, box_index, label_index) {
+				let find_index = this.activeMenuArr[page_index][box_index].indexOf(label_index);
+				if (find_index > -1) {
+					this.activeMenuArr[page_index][box_index].splice(find_index, 1);
+					this.subData[page_index].submenu[box_index].submenu[label_index].selected = false;
+				} else {
+					this.activeMenuArr[page_index][box_index].push(label_index);
+					this.subData[page_index].submenu[box_index].submenu[label_index].selected = true;
+				}
+				this.$forceUpdate();
+			},
+			//选中单选类label-UI状态
+			selectRadioLabel(page_index, box_index, label_index) {
+				
+				let activeIndex = this.activeMenuArr[page_index][box_index][0];
+				if(activeIndex == label_index){
+					this.subData[page_index].submenu[box_index].submenu[activeIndex].selected = false;
+					this.activeMenuArr[page_index][box_index][0] = null;
+				}else{
+					if(activeIndex!=null && activeIndex<this.subData[page_index].submenu[box_index].submenu.length){
+						this.subData[page_index].submenu[box_index].submenu[activeIndex].selected = false;
+					}
+					
+					this.subData[page_index].submenu[box_index].submenu[label_index].selected = true;
+					this.activeMenuArr[page_index][box_index][0] = label_index;
+				}
+				this.$forceUpdate();
+			},
+			//菜单开关
+			togglePage(index) {
+				if (index == this.showPage) {
+					this.hidePageLayer(true);
+					this.hideMask();
+					this.showPage = -1;
+				} else {
+					if (this.showPage > -1) {
+						this.hidePageLayer(false);
+					}
+					this.showPageLayer(index);
+					this.showMask();
+				}
+			},
+			//hide遮罩层
+			hideMask() {
+				this.isShowMask = false;
+				setTimeout(() => {
+					this.maskVisibility = false;
+				}, 200);
+			},
+			//show遮罩层
+			showMask() {
+				this.maskVisibility = true;
+				this.$nextTick(() => {
+					setTimeout(() => {
+						this.isShowMask = true;
+					}, 0);
+				})
+			},
+			//hide菜单页
+			hidePageLayer(isAnimation) {
+				this.triangleDeg[this.showPage] = 0;
+				let tmpIndex = this.showPage;
+				if (isAnimation) {
+					setTimeout(() => {
+						this.pageState.splice(tmpIndex, 1, false);
+					}, 200);
+					this.confirm();
+				} else {
+					this.pageState.splice(tmpIndex, 1, false)
+				}
+				this.firstScrollInto = null;
+				this.secondScrollInto = null;
+			},
+			confirm() {
+				let index = JSON.parse(JSON.stringify(this.shadowActiveMenuArr));
+				let value = JSON.parse(JSON.stringify(this.shadowActiveMenuArr));
+				
+				//对结果做一下处理
+				index.forEach((item, i) => {
+					if (typeof(item[0]) == 'object') {
+						//针对筛选结果过一个排序
+						item.forEach((s, j) => {
+							if(s!=null){
+								s.sort((val1, val2) => {
+									return val1 - val2;
+								});
+								item[j] = s;
+								s.forEach((v, k) => {
+									value[i][j][k] = (v==null||v>=this.subData[i].submenu[j].submenu.length)?null:this.subData[i].submenu[j].submenu[v].value;
+									if(this.subData[i].type == 'radio' && value[i][j][k] == null){
+										value[i][j] = [];
+										index[i][j] = [];
+									}
+								});
+							}
+						});
+					}else{
+						let submenu = this.subData[i].submenu[item[0]];
+						value[i][0] = submenu.value;
+						if(value[i].length>=2  && item[1]!=null){
+							if(submenu.submenu.length>0){
+								submenu = submenu.submenu[item[1]];
+								value[i][1] = submenu.hasOwnProperty('value')?submenu.value:null;
+							}else{
+								value[i][1] = null
+							}
+							if(value[i].length>=3 && item[2]!=null){
+								if(submenu.submenu.length>0){
+									submenu = submenu.submenu[item[2]];
+									value[i][2] = submenu.hasOwnProperty('value')?submenu.value:null;
+								}else{
+									value[i][2] = null;
+								}
+							}
+						}
+					}
+					index[i] = item;
+					
+				});
+				// 输出
+				this.$emit('confirm', {
+					index: index,
+					value: value
+				});
+			},
+			//show菜单页
+			showPageLayer(index) {
+				this.processPage(index);
+				this.pageState.splice(index, 1, true);
+				this.$nextTick(() => {
+					setTimeout(() => {
+						this.showPage = index;
+					}, 0);
+				})
+				this.triangleDeg[index] = 180;
+			},
+			reloadActiveMenuArr(){
+				for (let i = 0; i < this.filterData.length; i++) {
+					let tmpitem = this.filterData[i];
+					let tmpArr = this.processActive(tmpitem);
+					tmpitem = this.processSubMenu(tmpitem);
+					if(this.activeMenuArr[i].length!=tmpArr.length){
+						this.filterData[i] = tmpitem;
+						this.activeMenuArr.splice(i, 1, JSON.parse(JSON.stringify(tmpArr)));
+						this.shadowActiveMenuArr.splice(i, 1, JSON.parse(JSON.stringify(tmpArr)));
+					}
+				} 
+				this.subData = this.filterData;
+				this.$forceUpdate();
+			},
+			processPage(index) {
+				//check UI控制数组,结果数组,防止传入数据层级和UI控制数组不同步
+				this.reloadActiveMenuArr();
+				//重置UI控制数组
+				this.activeMenuArr.splice(index, 1, JSON.parse(JSON.stringify(this.shadowActiveMenuArr[index])));
+				if (this.menu[index].type == 'filter') {
+					//重载筛选页选中状态
+					let level = this.shadowActiveMenuArr[index].length;
+					for (let i = 0; i < level; i++) {
+						let box = this.subData[index].submenu[i].submenu;
+						for (let j = 0; j < box.length; j++) {
+							if (this.shadowActiveMenuArr[index][i].indexOf(j) > -1) {
+								this.subData[index].submenu[i].submenu[j].selected = true;
+							} else {
+								this.subData[index].submenu[i].submenu[j].selected = false;
+							}
+						}
+					}
+				} else if (this.menu[index].type == 'hierarchy') {
+					this.$nextTick(() => {
+						setTimeout(() => {
+							//滚动到选中项
+							this.firstScrollInto = parseInt(this.activeMenuArr[index][0]);
+							this.secondScrollInto = parseInt(this.activeMenuArr[index][1]);
+						}, 0);
+					})
+				} else if (this.menu[index].type == 'radio') {
+					//重载筛选页选中状态
+					let level = this.shadowActiveMenuArr[index].length;
+					for (let i = 0; i < level; i++) {
+						let box = this.subData[index].submenu[i].submenu;
+						for (let j = 0; j < box.length; j++) {
+							if (this.shadowActiveMenuArr[index][i].indexOf(j) > -1) {
+								this.subData[index].submenu[i].submenu[j].selected = true;
+							} else {
+								this.subData[index].submenu[i].submenu[j].selected = false;
+							}
+						}
+					}
+				} 
+			},
+			processActive(tmpitem) {
+				let tmpArr = []
+				if (tmpitem.type == 'hierarchy'&&tmpitem.hasOwnProperty('submenu')&&tmpitem.submenu.length>0) {
+					let level = this.getMaxFloor(tmpitem.submenu);
+					while (level > 0) {
+						tmpArr.push(null);
+						level--;
+					}
+				} else if (tmpitem.type == 'filter') {
+					let level = tmpitem.submenu.length;
+					while (level > 0) {
+						tmpArr.push([]);
+						level--;
+					}
+				} else if (tmpitem.type == 'radio') {
+					let level = tmpitem.submenu.length;
+					while (level > 0) {
+						tmpArr.push([]);
+						level--;
+					}
+				}
+				return tmpArr;
+			},
+			processSubMenu(menu) {
+				if (menu.hasOwnProperty('submenu') && menu.submenu.length > 0) {
+					for (let i = 0; i < menu.submenu.length; i++) {
+						menu.submenu[i] = this.processSubMenu(menu.submenu[i]);
+					}
+				} else {
+					menu.submenu = [];
+				}
+				return menu;
+			},
+			//计算菜单层级
+			getMaxFloor(treeData) {
+				let floor = 0
+				let max = 0
+				function each(data, floor) {
+					data.forEach(e => {
+						max = floor > max ? floor : max;
+						if (e.hasOwnProperty('submenu') && e.submenu.length > 0) {
+							each(e.submenu, floor + 1)
+						}
+					})
+				}
+				each(treeData, 1)
+				return max;
+			},
+			discard() {
+
+			}
+		}
+	}
+</script>
+<style lang="scss">
+	.HMfilterDropdown {
+		flex-shrink: 0;
+		width: 100%;
+		position: fixed;
+		// position: sticky;
+		z-index: 997;
+		flex-wrap: nowrap;
+		display: flex;
+		flex-direction: row;
+		top: var(--window-top);
+		left:0;
+		// top:100px;
+		overflow-y: hidden;
+		&.setDropdownBottom{
+			// height: 345px;
+			bottom: 0;
+		}
+		view {
+			display: flex;
+			flex-wrap: nowrap;
+		}
+	}
+	.region {
+		flex: 1;
+		height: 44px;
+	}
+	.nav {
+		width: 100%;
+		height: 44px;
+		border-bottom: solid 1rpx #eee;
+		z-index: 12;
+		background-color: #ffffff;
+		flex-direction: row;
+		.first-menu {
+			width: 100%;
+			font-size: 13px;
+			color: #757575;
+			flex-direction: row;
+			align-items: center;
+			justify-content: center;
+			transition: color .2s linear;
+
+			&.on {
+				color: #5677fc;
+
+				.iconfont {
+					color: #5677fc;
+				}
+			}
+			.name {
+				height: 20px;
+				text-align: center;
+				text-overflow: clip;
+				overflow: hidden;
+			}
+			.iconfont {
+				width: 13px;
+				height: 13px;
+				align-items: center;
+				justify-content: center;
+				transition: transform .2s linear, color .2s linear;
+			}
+		}
+	}
+	.sub-menu-class {
+		width: 100%;
+		position: absolute;
+		left: 0;
+		transform: translate3d(0, - 100%, 0);
+		max-height: 345px;
+		background-color: #ffffff;
+		z-index: 11;
+		box-shadow: 0 5px 5px rgba(0, 0, 0, .1);
+		overflow: hidden;
+		flex-direction: row;
+		transition: transform .15s linear;
+		&.hide {
+			display: none;
+		}
+
+		&.show {
+			transform: translate3d(0, calc(44px + 1rpx), 0);
+		}
+	}
+	.sub-menu-list {
+		width: 100%;
+		height: 345px;
+		flex-direction: column;
+		.sub-menu {
+			min-height: 44px;
+			font-size: 13px;
+			flex-direction: column;
+			padding-right: 15px;
+			>.menu-name {
+				height: 44px;
+				flex-direction: row;
+				align-items: center;
+				justify-content: space-between;
+				>.iconfont {
+					display: none;
+					font-size: 18px;
+					color: #5677fc;
+				}
+			}
+		}
+		&.first {
+			flex-shrink: 0;
+			width: 236rpx;
+			background-color: #f0f0f0;
+			.sub-menu {
+				padding-left: 15px;
+
+				&.on {
+					background-color: #fff;
+				}
+			}
+		}
+		&.alone {
+			max-height: 345px;
+			// min-height: 170px;
+			height: auto;
+			.sub-menu {
+				min-height: calc(44px - 1rpx);
+				margin-left: 15px;
+				border-bottom: solid 1rpx #e5e5e5;
+
+				&.on {
+					color: #5677fc;
+
+					>.menu-name {
+						>.iconfont {
+							display: block;
+						}
+					}
+				}
+			}
+		}
+		&.not-first {
+			.sub-menu {
+				min-height: calc(44px - 1rpx);
+				margin-left: 15px;
+				border-bottom: solid 1rpx #e5e5e5;
+				>.menu-name {
+					height: calc(44px - 1rpx);
+					>.iconfont {
+						display: none;
+						font-size: 18px;
+						color: #5677fc;
+					}
+				}
+				&.on {
+					color: #5677fc;
+					>.menu-name {
+						>.iconfont {
+							display: block;
+						}
+					}
+				}
+				.more-sub-menu {
+					flex-direction: row;
+					flex-wrap: wrap;
+					padding-bottom: 9px;
+					>text {
+						height: 30px;
+						border-radius: 3px;
+						background-color: #f5f5f5;
+						color: #9b9b9b;
+						margin-bottom: 6px;
+						margin-right: 6px;
+						text-align: center;
+						line-height: 30px;
+						border: solid #f5f5f5 1rpx;
+						flex: 0 0 calc(33.33% - 6px);
+						overflow: hidden;
+						font-size: 12px;
+						&:nth-child(3n) {
+							margin-right: 0;
+						}
+						&.on {
+							border-color: #f6c8ac;
+							color: #5677fc;
+						}
+						.iconfont {
+							color: #9b9b9b;
+						}
+					}
+				}
+			}
+		}
+	}
+	.filter {
+		width: 100%;
+		height: 345px;
+		display: flex;
+		flex-direction: column;
+		justify-content: space-between;
+		align-items: center;
+		.menu-box {
+			width: 698rpx;
+			height: calc(345px - 75px);
+			flex-shrink: 1;
+			.box {
+				width: 100%;
+				margin-top: 16px;
+				flex-direction: column;
+				.title {
+					width: 100%;
+					font-size: 13px;
+					color: #888;
+				}
+				.labels {
+					flex-direction: row;
+					flex-wrap: wrap;
+					.on {
+						border-color: #5677fc;
+						background-color: #5677fc;
+						color: #fff;
+					}
+					>view {
+						width: 148rpx;
+						height: 30px;
+						border: solid 1rpx #adadad;
+						border-radius: 2px;
+						margin-right: 15px;
+						margin-top: 8px;
+						font-size: 12px;
+						flex-direction: row;
+						justify-content: center;
+						align-items: center;
+						&:nth-child(4n) {
+							margin-right: 0;
+						}
+					}
+				}
+			}
+		}
+		.btn-box {
+			flex-shrink: 0;
+			width: 698rpx;
+			height: 75px;
+			flex-direction: row !important;
+			align-items: center;
+			justify-content: space-between;
+			>view {
+				width: 320rpx;
+				height: 40px;
+				border-radius: 40px;
+				border: solid 1rpx #5677fc;
+				align-items: center;
+				justify-content: center;
+			}
+			.reset {
+				color: #5677fc;
+			}
+			.submit {
+				color: #fff;
+				background-color: #5677fc;
+			}
+		}
+	}
+	.mask {
+		z-index: 10;
+		position: fixed;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background-color: rgba(0, 0, 0, 0);
+		transition: background-color .15s linear;
+		&.show {
+			background-color: rgba(0, 0, 0, 0.5);
+		}
+		&.hide {
+			display: none;
+		}
+	}
+	/* 字体图标 */
+	@font-face {
+		font-family: "HM-FD-font";
+		src: url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAALAAAsAAAAABpQAAAJzAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDBgp4gQIBNgIkAwwLCAAEIAWEbQc5G8sFERWMIbIfCbbzqA4hp7InSBibVsYGb4J42o82b3e/nJlHMw/NHbGOlwKJRCRpwzPtpAECCOZubdqxjYpQLMlVg+70/08edrgQOtx2ukpVyApZn+dyehPoQObHo3O85rYx9vOjXoBxQIHugW2yIkqIW2QXcScu4jwE8CSWbKSmrqUHFwOaJoCsLM5P4haSGIxRcRHshrUGucLCVcfqI3AZfV/+USguKCwNmtsxVztDxU/n55C+3W0Z4QQpEOTNFqCBbMCAjDUWB9CIwWk87aa70cYgqLkyd3dEmm+18R8eKATEBrV7A5CulBT8dKiWOYZk412XNcDdKSEKSGODnyKIDl+dmVt9/Dx4pu/xyeutkMlHISGPTsPCnoTNP9nOT6wTtDdlO6dPr47efvj942lkYuQzrhMKEjq9N6y98P3340gmlJ/RStUD6F31CAEEPtUW94/7rf+7XgaAz57X0ZHXAGsFFwVgw38yALuMb0IBbVyNamFYEw4oKMDTj3AHRQP5Pt4dci9VwSVkRNQh5r7CLskZadhsWHhRDBsXczk8ZYk3ewnCxmQeQKa3BOHvA8XXO2j+vqRhf7CE+sPmn4anvoL29JLa4qqaUQkmoK+QG2osCckq7txi2leK86aIPyJ3eQZ8xytXYmyQ51jQndJAxIJlqiGSLsOqImiZCjTiZCJt6Lq26U2OoXqwUo0hRaAE0K5AziANy/uLVeXzWyjVqyjcoeupjxDr5MMDn8MDkLG9Aenu5ZrOSSoghAUsRmogkkahSoWAtnlUARnCkY3It0Iu7mWhdmd9Z/19BwBP6GidEi0G56opckXTGZVSPxgAAAA=');
+	}
+	.iconfont {
+		font-family: "HM-FD-font" !important;
+		font-size: 13px;
+		font-style: normal;
+		color: #757575;
+		&.triangle {
+			&:before {
+				content: "\e65a";
+			}
+		}
+		&.selected {
+			&:before {
+				content: "\e607";
+			}
+		}
+	}
+</style>

+ 70 - 0
addons/exam/uniapp/components/headline/headline.vue

@@ -0,0 +1,70 @@
+<template>
+	<view class="tui-rolling-news list-item">
+		<text class="cuIcon-notification"></text>
+		<swiper :vertical="true" :autoplay="true" :circular="true" :interval="4000" class="tui-swiper">
+			<swiper-item v-for="(item, index) in list" :key="index" class="tui-swiper-item">
+				<view class="tui-news-item">{{ item }}</view>
+			</swiper-item>
+		</swiper>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: "headline",
+		props: {
+			list: {
+				type: Array,
+				default: function() {
+					return [];
+				}
+			},
+		},
+		data() {
+			return {
+
+			};
+		}
+	}
+</script>
+
+<style>
+	/*headlines*/
+
+	.tui-rolling-news {
+		width: 100%;
+		padding: 0 30rpx;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		flex-wrap: nowrap;
+		background: #fff;
+	}
+
+	.tui-rolling-news::after {
+		left: 0;
+	}
+
+	.tui-swiper {
+		margin-left: 8rpx;
+		font-size: 28rpx;
+		height: 80rpx;
+		flex: 1;
+	}
+
+	.tui-swiper-item {
+		display: flex;
+		align-items: center;
+	}
+
+	.tui-news-item {
+		line-height: 28rpx;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		color: #555;
+	}
+
+	/*headlines*/
+</style>

+ 81 - 0
addons/exam/uniapp/components/kz-ad/kz-ad.vue

@@ -0,0 +1,81 @@
+<template>
+	<view>
+		<view v-if="kind && config_data && config_data[field] && config_data[field + '_open'] == 1">
+			<!-- banner广告 -->
+			<view v-if="kind == 'BANNER'">
+				<ad :unit-id="config_data[field]"></ad>
+			</view>
+
+			<!-- 视频广告 -->
+			<view v-else-if="kind == 'VIDEO'">
+				<ad :unit-id="config_data[field]" ad-type="video" ad-theme="white"></ad>
+			</view>
+
+			<!-- 视频贴片广告 -->
+			<view v-else-if="kind == 'VIDEO_PATCH'">
+				<video :unit-id="config_data[field]" style="width: 100%"></video>
+			</view>
+
+			<!-- 其他忽略 -->
+			<view v-else></view>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: "kz-ad",
+		props: {
+			/**
+			 * 广告种类
+			 * BANNER:banner广告
+			 * VIDEO:视频广告
+			 * VIDEO_PATCH:视频贴片广告
+			 */
+			kind: {
+				type: String,
+				default: 'BANNER'
+			},
+			// 广告配置集合
+			config: {
+				type: Object,
+				default: () => {}
+			},
+			// 配置字段
+			field: {
+				type: String,
+				default: ''
+			},
+		},
+		data() {
+			return {
+				config_data: [],
+			};
+		},
+		watch: {
+			/**
+			 * 监听广告配置
+			 * @param val
+			 */
+			config(val) {
+				console.log('kz-ad watch val', val)
+				this.config_data = val
+			}
+		},
+		methods: {
+			/**
+			 * 手动刷新广告配置
+			 * @param val
+			 */
+			refresh(val) {
+				this.config_data = val
+				console.log('kz-ad refresh', val, this.config_data)
+				// this.$forceUpdate()
+			}
+		}
+	}
+</script>
+
+<style>
+
+</style>

+ 100 - 0
addons/exam/uniapp/components/kz-follow-mp/kz-follow-mp.vue

@@ -0,0 +1,100 @@
+<template>
+	<view>
+		<tn-landscape
+		  :show="showFollow"
+		  :closeBtn="true"
+		  closePosition="bottom"
+		  :mask="true"
+		  :maskCloseable="true"
+		  @close="() => showFollow = false"
+		>
+			<view class="card_test">
+				<view class="card_test__content">
+					123123123
+					<image :src="followImage" mode="widthFix"></image>
+				</view>
+				<view class="blob">123</view>
+				<view class="blob">33</view>
+				<view class="blob">22</view>
+			</view>
+			
+		</tn-landscape>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"kz-follow-mp",
+		props: {
+			// 显示关注
+			showFollow: {
+				type: Boolean,
+				default: false
+			},
+		},
+		data() {
+			return {
+				
+			};
+		}
+	}
+</script>
+
+<style>
+	.card_mp {
+	  position: relative;
+	  width: 190px;
+	  height: 254px;
+	  background: lightgrey;
+	  box-shadow: #d11bff42 0 15px 40px -5px;
+	  z-index: 1;
+	  border-radius: 21px;
+	  overflow: hidden;
+	}
+	
+	.card_mp__content {
+	  background: linear-gradient(rgba(255, 255, 255, 0.473), rgba(150, 150, 150, 0.25));
+	  z-index: 1;
+	  backdrop-filter: blur(20px);
+	  -webkit-backdrop-filter: blur(20px);
+	  position: absolute;
+	  left: 0;
+	  top: 0;
+	  width: 100%;
+	  height: 100%;
+	  border-radius: 21px;
+	}
+	
+	.card_mp .blob {
+	  position: absolute;
+	  z-index: -1;
+	  border-radius: 5em;
+	  width: 200px;
+	  height: 200px;
+	}
+	
+	.card_mp .blob:nth-child(2) {
+	  left: -50px;
+	  top: -90px;
+	  background: #ff930f;
+	}
+	
+	.card_mp .blob:nth-child(3) {
+	  left: 110px;
+	  top: -20px;
+	  z-index: -1;
+	  background: #bf0fff;
+	}
+	
+	.card_mp .blob:nth-child(4) {
+	  left: -40px;
+	  top: 100px;
+	  background: #ff1b6b;
+	}
+	
+	.card_mp .blob:nth-child(5) {
+	  left: 100px;
+	  top: 180px;
+	  background: #0061ff;
+	}
+</style>

+ 193 - 0
addons/exam/uniapp/components/kz-grade-result/kz-grade-result.vue

@@ -0,0 +1,193 @@
+<template>
+	<view>
+		<view class="cu-card article">
+			<view class="cu-item shadow">
+				<view class="title">
+					<text class="text-green">{{tips}}</text>
+				</view>
+
+				<view class="tui-progress-box">
+					<tui-circular-progress
+						:fontShow="false"
+						:percentage="score / 2"
+						:sAngle="sAngle"
+						:diam="240"
+						:height="130"
+						:lineWidth="12"
+						:progressColor="color"
+						:fontColor="color"
+						defaultColor="rgba(25,190,107,0.1)"
+						@change="change"
+					>
+						<view class="tui-progress-text">
+							<view>分数</view>
+							<view class="tui-progress-num">{{ score }} 分</view>
+						</view>
+					</tui-circular-progress>
+				</view>
+			</view>
+
+			<view class="cu-list menu card-menu margin-top sm-border" v-if="gradeResult">
+				<view class="cu-item">
+					<view class="content">
+						<text class="cuIcon-my text-green"></text>
+						<text class="text-grey">考生昵称</text>
+					</view>
+					<view class="action">
+						<text class="text-grey">{{user.nickname}}</text>
+					</view>
+				</view>
+
+				<view class="cu-item">
+					<view class="content">
+						<text class="cuIcon-time text-green"></text>
+						<text class="text-grey">考试时长</text>
+					</view>
+					<view class="action">
+						<text class="text-grey">{{gradeResult.grade_time|format_second}}</text>
+					</view>
+				</view>
+
+				<view class="cu-item">
+					<view class="content">
+						<text class="cuIcon-info text-green"></text>
+						<text class="text-grey">试卷总分</text>
+					</view>
+					<view class="action">
+						<text class="text-grey">{{gradeResult.total_score}}</text>
+					</view>
+				</view>
+
+				<view class="cu-item">
+					<view class="content">
+						<text class="cuIcon-filter text-green"></text>
+						<text class="text-grey">及格分数</text>
+					</view>
+					<view class="action">
+						<text class="text-grey">{{gradeResult.pass_score}}</text>
+					</view>
+				</view>
+
+				<view class="cu-item">
+					<view class="content">
+						<text class="cuIcon-text text-green"></text>
+						<text class="text-grey">题目数量</text>
+					</view>
+					<view class="action">
+						<text class="text-grey">{{gradeResult.total_count}}</text>
+					</view>
+				</view>
+
+				<view class="cu-item" v-if="gradeResult && gradeResult.error_count > 0">
+					<view class="content">
+						<text class="cuIcon-warn text-yellow"></text>
+						<text class="text-grey">错误题数</text>
+					</view>
+					<view class="action">
+						<button class="cu-btn round bg-yellow shadow" @click="goWrong()">去查看</button>
+					</view>
+				</view>
+			</view>
+
+			<view class="padding flex flex-direction">
+				<button class="cu-btn block bg-green margin-tb-sm lg radius10" @click="goIndex()">返回首页</button>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"kz-grade-result",
+		props: {
+			score: {
+				type: Number,
+				default: null
+			},
+			gradeResult: {
+				type: Object,
+				default: null
+			},
+			examMode: {
+				type: String,
+				default: 'PAPER'
+			},
+		},
+		data() {
+			return {
+				sAngle: -Math.PI,
+				color: '#19be6b',
+				tips: '',
+				// score: this.gradeScore,
+				user: this.utils.getData('user')
+			};
+		},
+		watch: {
+			// gradeScore: function() {
+			// 	console.log('watch gradeScore', this.gradeScore)
+			// 	this.score = this.gradeScore
+			// },
+			gradeResult: function() {
+				console.log('watch gradeResult', this.gradeResult)
+				if (this.gradeResult) {
+					// this.score = this.gradeResult.score
+					// setTimeout(() => {this.score = this.gradeResult.score}, 500)
+
+					if (this.gradeResult.is_pass) {
+						this.tips = '恭喜您,考试通过啦!'
+						this.color = '#19be6b'
+					} else {
+						this.tips = '很遗憾,考试没通过,请再接再厉!'
+						this.color = '#ff6667'
+					}
+				}
+			},
+		},
+		methods: {
+			change(e) {
+				//半圆 进度 * 2
+				// this.score = e.percentage * 2;
+			},
+			goIndex() {
+				this.utils.gotore('/pages/index/index')
+			},
+			goWrong() {
+				this.utils.goto('/pages/wrong/index?question_ids=' + this.gradeResult.error_ids)
+			}
+		}
+	}
+</script>
+
+<style>
+	.title {
+		font-size: 36upx;
+		font-weight: 900;
+		line-height: 100upx;
+		padding: 0 30upx;
+		text-align: center;
+	}
+	.tui-progress-box {
+		width: 100%;
+		padding: 0 30rpx;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
+	.tui-progress-text {
+		width: 100%;
+		height: 130px;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		flex-direction: column;
+		padding-top: 40rpx;
+		position: absolute;
+		left: 0;
+		top: 0;
+	}
+	.tui-progress-num {
+		font-size: 60rpx;
+		padding-top: 20rpx;
+	}
+</style>

+ 729 - 0
addons/exam/uniapp/components/kz-page-index-color/kz-page-index-color.vue

@@ -0,0 +1,729 @@
+<template>
+	<view class="page-index-list">
+		<view class="template-course tn-safe-area-inset-bottom">
+			<view class="tn-padding-top-xs">
+				<!-- 功能列表 start -->
+				<view class="about-shadow tn-margin-top tn-padding-top-sm tn-padding-bottom-sm">
+					<tn-scroll-list indicatorColor="#cfd2ff" indicatorActiveColor="#5677fc">
+						<block v-for="(item, index) in modules" :key="index">
+							<view class="tn-padding-sm tn-margin-xs" @click="goPage(item.url)">
+								<view class="tn-flex tn-flex-direction-column tn-flex-row-center tn-flex-col-center">
+									<view
+										class="icon15__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-shadow-blur tn-color-white"
+										:class="[item.color]">
+										<view :class="[item.icon]"></view>
+									</view>
+									<view class="tn-text-center">
+										<text class="tn-text-ellipsis">{{item.title}}</text>
+									</view>
+								</view>
+							</view>
+						</block>
+
+					</tn-scroll-list>
+				</view>
+				<!-- 功能列表 end -->
+
+				<!-- 试卷列表 -->
+				<view class="about-shadow tn-margin-top tn-padding-top-xs tn-padding-bottom-sm"
+					v-if="papers.length > 0">
+					<view class="tn-flex tn-flex-row-between tn-margin-sm">
+						<view class="justify-content-item tn-text-bold tn-text-xl">
+							<text class="tn-icon-title "></text>
+							<text class="">热门试卷</text>
+						</view>
+						<view class="justify-content-item tn-text-lg" @click="goPage('/pages/paper/index')">
+							<text class="tn-padding-xs">更多</text>
+							<text class="tn-icon-right"></text>
+						</view>
+					</view>
+
+					<view class="tn-margin-top-sm">
+						<view class="skill-sequence-panel-content-wrapper">
+							<!--左边虚化-->
+							<!-- <view class="hide-content-box hide-content-box-left"></view> -->
+							<!--右边虚化-->
+							<!-- <view class="hide-content-box hide-content-box-right"></view> -->
+							<scroll-view scroll-x="true" class="kite-classify-scroll">
+								<view class="kite-classify-cell shadow" v-for="(item, index) in papers" :key="index">
+									<!-- <view :class="'nav-li bg-index' + (index + 1)"> -->
+									<view :class="'nav-li bg-index' + bgColorList[index]">
+										<view class="nav-name">{{ item.title }}</view>
+									</view>
+									<view class="nav-content"> </view>
+									<view
+										class="tn-flex tn-flex-row-between tn-flex-col-center tn-padding-left-sm tn-padding-right-sm tn-padding-bottom-sm"
+										:class="[item.users.length ? '' : 'tn-margin']">
+										<view class="justify-content-item tn-flex tn-flex-col-center"
+											v-show="item.users.length">
+											<view style="margin-right: 10rpx;margin-left: 20rpx;">
+												<tn-avatar-group :lists="item.users" size="sm"></tn-avatar-group>
+											</view>
+											<text class="tn-color-gray">{{ item.join_count }}人</text>
+										</view>
+									</view>
+									<view @click="goPage('/pages/paper/paper?id=' + item.id)" class="nav-btn shadow"
+										:class="'bg-index' + bgColorList[index]">立即练习</view>
+									<!-- <view @click="goPage('/pages/paper/paper?id=' + item.id)" class="nav-btn shadow" :class="'bg-index' + (index + 1)">立即练习</view> -->
+								</view>
+							</scroll-view>
+						</view>
+					</view>
+				</view>
+
+				<!-- 考场列表 -->
+				<view class="about-shadow tn-margin-top-xl tn-padding-top-xs tn-padding-bottom-sm" v-if="rooms.length > 0" style="margin-bottom: 200rpx;">
+					<view class="tn-flex tn-flex-row-between tn-margin-sm">
+						<view class="justify-content-item tn-text-bold tn-text-xl">
+							<text class="tn-icon-title "></text>
+							<text class="">热门考场</text>
+						</view>
+						<view class="justify-content-item tn-text-lg" @click="goPage('/pages/room/index')">
+							<text class="tn-padding-xs">更多</text>
+							<text class="tn-icon-right"></text>
+						</view>
+					</view>
+
+					<view class="tn-margin-top-sm">
+						<view class="skill-sequence-panel-content-wrapper">
+							<!-- 考场列表 start-->
+							<view class="tn-flex tn-flex-wrap tn-margin-sm">
+								<block v-for="(item, index) in rooms" :key="index">
+									<view class="" style="width: 50%;">
+										<view class="tn-blogger-content__wrap">
+											<view :class="'nav-li bg-index' + bgColorList[index]">
+												<view class="nav-name">{{ item.name }}</view>
+											</view>
+											
+											<!-- <view
+												class="tn-flex tn-flex-row-between tn-flex-col-center tn-padding-left-sm tn-padding-right-sm tn-padding-bottom-sm"
+												:class="[item.users.length ? '' : 'tn-margin']">
+												<view class="justify-content-item tn-flex tn-flex-col-center"
+													v-show="item.users.length">
+													<view style="margin-right: 10rpx;margin-left: 20rpx;">
+														<tn-avatar-group :lists="item.users" size="sm"></tn-avatar-group>
+													</view>
+													<text class="tn-color-gray">{{ item.grade_count }}人</text>
+												</view>
+											</view> -->
+											
+											
+											<!-- :class="'bg-index' + bgColorList[index]" -->
+											<view @click="goPage('/pages/room/detail?id=' + item.id)" class="nav-btn shadow">立即参加</view>
+										</view>
+									</view>
+								</block>
+							</view>
+							<!-- 考场列表 end-->
+						</view>
+					</view>
+				</view>
+			
+			</view>
+			
+		</view>
+	</view>
+</template>
+
+<script>
+	import template_page_mixin from '@/libs/mixin/template_page_mixin.js'
+	// import NavIndexButton from '@/libs/components/nav-index-button.vue'
+
+	export default {
+		name: "kz-page-index-list1",
+		mixins: [template_page_mixin],
+		components: {
+			// NavIndexButton
+		},
+		props: {
+			// 关注提示
+			focusOnTip: {
+				type: String,
+				default: '点击「添加小程序」,下次访问更便捷'
+			},
+			// banner图集合
+			banners: {
+				type: Array,
+				default: () => []
+			},
+			// 公告集合
+			headlines: {
+				type: Array,
+				default: () => []
+			},
+			// 试卷集合
+			papers: {
+				type: Array,
+				default: () => []
+			},
+			// 考场集合
+			rooms: {
+				type: Array,
+				default: () => []
+			},
+			// 功能集合
+			modules: {
+				type: Array,
+				default: () => [{
+						title: '看题模式',
+						color: 'tn-bg-green',
+						icon: 'tn-icon-eye',
+						url: '/pages/train/index?page=look',
+					},
+					{
+						title: '答题练习',
+						color: 'tn-bg-blue',
+						icon: 'tn-icon-edit-write',
+						url: '/pages/train/index?page=train',
+					},
+					{
+						title: '模拟考试',
+						color: 'tn-bg-orange',
+						icon: 'tn-icon-edit-form',
+						url: '/pages/paper/index'
+					},
+					{
+						title: '我的错题',
+						color: 'tn-bg-red',
+						icon: 'tn-icon-close-circle',
+						url: '/pages/wrong/index'
+					},
+					{
+						title: '考场报名',
+						color: 'tn-bg-cyan',
+						icon: 'tn-icon-empty-data',
+						url: '/pages/room/index'
+					},
+					{
+						title: '题目搜索',
+						color: 'tn-bg-indigo',
+						icon: 'tn-icon-search-list',
+						url: '/pages/search/index'
+					},
+					{
+						title: '题目收藏',
+						color: 'tn-bg-purple',
+						icon: 'tn-icon-like-lack',
+						url: '/pages/collect/index'
+					},
+					// {
+					// 	title: '报名记录',
+					// 	color: 'orange',
+					// 	icon: 'tn-icon-empty-coupon',
+					// 	url: '/pages/room/signup-index'
+					// },
+				]
+			},
+
+		},
+		watch: {
+			/**
+			 * 监听banners
+			 * @param list
+			 */
+			banners(list) {
+				console.log('watch banners', list)
+				let banners = []
+				for (var image of list) {
+					banners.push({
+						image: this.imgUrl + image
+					})
+				}
+				this.banner = banners
+				console.log('watch banner', this.banner)
+			},
+			/**
+			 * 监听papers
+			 * @param list
+			 */
+			papers(list) {
+				this.bgColorList = this.utils.shuffle(this.bgColorList)
+				// 随机颜色
+				console.log('watch bgColorList', this.bgColorList)
+			},
+			/**
+			 * 监听rooms
+			 * @param list
+			 */
+			rooms(list) {
+			},
+		},
+		data() {
+			return {
+				// 图片域名
+				imgUrl: this.imgUrl,
+				// banner图集
+				banner: [],
+				// 图鸟颜色列表
+				// tuniaoColorList: this.$t.color.getTuniaoColorList(),
+				bgColorList: this.utils.shuffle([1,2,3,4,5,6]),
+			}
+		},
+		methods: {
+			// 跳转页面
+			goPage(page) {
+				this.utils.goto(page)
+			},
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.page-index-list {
+		background-color: #FFFFFF;
+	}
+
+	/* 自定义导航栏内容 start */
+	.custom-nav {
+		height: 100%;
+
+		&__back {
+			margin: auto 5rpx;
+			font-size: 40rpx;
+			margin-right: 10rpx;
+			margin-left: 30rpx;
+			flex-basis: 5%;
+		}
+
+		&__search {
+			flex-basis: 60%;
+			width: 100%;
+			height: 100%;
+
+			&__box {
+				width: 100%;
+				height: 70%;
+				padding: 10rpx 0;
+				margin: 0 30rpx;
+				border-radius: 60rpx 60rpx 0 60rpx;
+				font-size: 24rpx;
+			}
+
+			&__icon {
+				padding-right: 10rpx;
+				margin-left: 20rpx;
+				font-size: 30rpx;
+			}
+
+			&__text {
+				color: #AAAAAA;
+			}
+		}
+	}
+
+	/*logo start */
+	.logo-image {
+		width: 65rpx;
+		height: 65rpx;
+		position: relative;
+	}
+
+	.logo-pic {
+		background-size: cover;
+		background-repeat: no-repeat;
+		// background-attachment:fixed;
+		background-position: top;
+		border-radius: 50%;
+	}
+
+	/* 自定义导航栏内容 end */
+
+	/* 内容布局 start*/
+	.course-shadow {
+		box-shadow: 0rpx 0rpx 80rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+
+	.course-radius {
+		border-radius: 15rpx;
+	}
+
+	/* 图标容器15 start */
+	.icon15 {
+		&__item {
+			width: 30%;
+			background-color: #FFFFFF;
+			border-radius: 10rpx;
+			padding: 30rpx;
+			margin: 20rpx 10rpx;
+			transform: scale(1);
+			transition: transform 0.3s linear;
+			transform-origin: center center;
+
+			&--icon {
+				width: 100rpx;
+				height: 100rpx;
+				font-size: 60rpx;
+				border-radius: 50%;
+				margin-bottom: 18rpx;
+				position: relative;
+				z-index: 1;
+
+				&::after {
+					content: " ";
+					position: absolute;
+					z-index: -1;
+					width: 100%;
+					height: 100%;
+					left: 0;
+					bottom: 0;
+					border-radius: inherit;
+					opacity: 1;
+					transform: scale(1, 1);
+					background-size: 100% 100%;
+
+
+				}
+			}
+		}
+	}
+
+	/* 业务展示 start */
+	.tn-info {
+
+		&__container {
+			margin-top: 10rpx;
+			margin-bottom: 50rpx;
+		}
+
+		&__item {
+			width: 48%;
+			margin: 15rpx 0rpx;
+			padding: 40rpx 30rpx;
+			border-radius: 15rpx;
+
+
+			position: relative;
+			z-index: 1;
+
+			&::after {
+				content: " ";
+				position: absolute;
+				z-index: -1;
+				width: 100%;
+				height: 100%;
+				left: 0;
+				bottom: 0;
+				border-radius: inherit;
+				opacity: 1;
+				transform: scale(1, 1);
+				background-size: 100% 100%;
+				background-image: url(https://tnuiimage.tnkjapp.com/cool_bg_image/3.png);
+			}
+
+			&__left {
+
+				&--icon {
+					width: 80rpx;
+					height: 80rpx;
+					border-radius: 30%;
+					font-size: 50rpx;
+					margin-right: 20rpx;
+					position: relative;
+					z-index: 1;
+
+					&::after {
+						content: " ";
+						position: absolute;
+						z-index: -1;
+						width: 100%;
+						height: 100%;
+						left: 0;
+						bottom: 0;
+						border-radius: inherit;
+						opacity: 1;
+						transform: scale(1, 1);
+						background-size: 100% 100%;
+						background-image: url(https://tnuiimage.tnkjapp.com/cool_bg_image/icon_bg5.png);
+					}
+				}
+
+				&__content {
+					font-size: 30rpx;
+
+					&--data {
+						margin-top: 5rpx;
+						font-weight: bold;
+					}
+				}
+			}
+
+			&__right {
+				&--icon {
+					position: absolute;
+					right: 0upx;
+					top: 50upx;
+					font-size: 100upx;
+					width: 108upx;
+					height: 108upx;
+					text-align: center;
+					line-height: 60upx;
+					opacity: 0.15;
+				}
+			}
+		}
+	}
+
+	/* 业务展示 end */
+
+	/* 文章内容 start*/
+	.tn-blogger-content {
+		&__wrap {
+			box-shadow: 0rpx 0rpx 80rpx 0rpx rgba(0, 0, 0, 0.07);
+			border-radius: 20rpx;
+			margin: 15rpx;
+		}
+
+		&__info {
+			&__btn {
+				margin-right: -12rpx;
+				opacity: 0.5;
+			}
+		}
+
+		&__label {
+			&__item {
+				line-height: 45rpx;
+				padding: 0 20rpx;
+				margin: 5rpx 18rpx 0 0;
+
+				&--prefix {
+					color: #82B2FF;
+					padding-right: 10rpx;
+				}
+			}
+
+			&__desc {
+				line-height: 35rpx;
+			}
+		}
+
+		&__main-image {
+			border-radius: 16rpx 16rpx 0 0;
+
+			&--1 {
+				max-width: 690rpx;
+				min-width: 690rpx;
+				max-height: 400rpx;
+				min-height: 400rpx;
+			}
+
+			&--2 {
+				max-width: 260rpx;
+				max-height: 260rpx;
+			}
+
+			&--3 {
+				height: 212rpx;
+				width: 100%;
+			}
+		}
+
+		&__count-icon {
+			font-size: 30rpx;
+			padding-right: 5rpx;
+		}
+	}
+
+	.image-music {
+		padding: 100rpx 0rpx;
+		font-size: 16rpx;
+		font-weight: 300;
+		position: relative;
+	}
+
+	.image-pic {
+		background-size: cover;
+		background-repeat: no-repeat;
+		// background-attachment:fixed;
+		background-position: top;
+		border-radius: 20rpx 20rpx 0 0;
+	}
+
+	/* 文章内容 end*/
+
+	/* 底部tabbar start*/
+	.footerfixed {
+		position: fixed;
+		width: 100%;
+		bottom: 0;
+		z-index: 999;
+		background-color: #FFFFFF;
+		box-shadow: 0rpx 0rpx 30rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+
+	.tabbar {
+		display: flex;
+		align-items: center;
+		min-height: 110rpx;
+		justify-content: space-between;
+		padding: 0;
+		height: calc(110rpx + env(safe-area-inset-bottom) / 2);
+		padding-bottom: calc(env(safe-area-inset-bottom) / 2);
+	}
+
+	.tabbar .action {
+		font-size: 22rpx;
+		position: relative;
+		flex: 1;
+		text-align: center;
+		padding: 0;
+		display: block;
+		height: auto;
+		line-height: 1;
+		margin: 0;
+		overflow: initial;
+	}
+
+	.tabbar .action .bar-icon {
+		width: 100rpx;
+		position: relative;
+		display: block;
+		height: auto;
+		margin: 0 auto 10rpx;
+		text-align: center;
+		font-size: 42rpx;
+	}
+
+	.tabbar .action .bar-icon image {
+		width: 50rpx;
+		height: 50rpx;
+		display: inline-block;
+	}
+
+	/*scroll-view外层*/
+	.skill-sequence-panel-content-wrapper {
+		position: relative;
+		white-space: nowrap;
+		padding: 10rpx 0 10rpx 10rpx;
+		// background-color: #f2f5f9;
+	}
+
+	/*左右渐变遮罩*/
+	.hide-content-box {
+		position: absolute;
+		top: 0;
+		height: 100%;
+		width: 10px;
+		z-index: 2;
+	}
+
+	.hide-content-box-left {
+		left: 0;
+		background-image: linear-gradient(to left, rgba(255, 255, 255, 0), #f3f3f3 60%);
+	}
+
+	.hide-content-box-right {
+		right: 0;
+		background-image: linear-gradient(to right, rgba(255, 255, 255, 0), #f3f3f3 60%);
+	}
+
+	.kite-classify-scroll {
+		width: 100%;
+		height: 380rpx;
+		overflow: hidden;
+		white-space: nowrap;
+		padding-top: 15rpx;
+	}
+
+	.kite-classify-cell {
+		display: inline-block;
+		width: 266rpx;
+		height: 350rpx;
+		margin-right: 20rpx;
+		background-color: #ffffff;
+		border-radius: 20rpx;
+		overflow: hidden;
+		box-shadow: 2px 2px 3px rgba(26, 26, 26, 0.2);
+	}
+
+	.nav-li {
+		padding: 40rpx 30rpx;
+		width: 100%;
+		background-image: url(https://s1.ax1x.com/2020/06/27/NyU04x.png);
+		background-size: cover;
+		background-position: center;
+		position: relative;
+		z-index: 1;
+		margin: 0 !important;
+	}
+
+	.nav-name {
+		font-size: 28upx;
+		text-transform: Capitalize;
+		margin-top: 20upx;
+		position: relative;
+		text-overflow: ellipsis;
+		overflow: hidden;
+	}
+
+	.nav-name::before {
+		content: '';
+		position: absolute;
+		display: block;
+		width: 40rpx;
+		height: 6rpx;
+		background: #fff;
+		bottom: 0;
+		right: 0;
+		opacity: 0.5;
+	}
+
+	.nav-name::after {
+		content: '';
+		position: absolute;
+		display: block;
+		width: 100rpx;
+		height: 1px;
+		background: #fff;
+		bottom: 0;
+		right: 40rpx;
+		opacity: 0.3;
+	}
+
+	.nav-content {
+		width: 100%;
+		padding: 15rpx;
+		display: inline-block;
+		overflow-wrap: break-word;
+		white-space: normal;
+	}
+
+	.nav-btn {
+		width: 200rpx;
+		height: 60rpx;
+		margin: 8rpx auto;
+		text-align: center;
+		line-height: 60rpx;
+		border-radius: 10rpx;
+	}
+
+	.bg-index1 {
+		background-color: #19cf8a;
+		color: #fff;
+	}
+
+	.bg-index2 {
+		background-color: #954ff6;
+		color: #fff;
+	}
+
+	.bg-index3 {
+		background-color: #5177ee;
+		color: #fff;
+	}
+
+	.bg-index4 {
+		background-color: #f49a02;
+		color: #fff;
+	}
+
+	.bg-index5 {
+		background-color: #ff4f94;
+		color: #fff;
+	}
+
+	.bg-index6 {
+		background-color: #7fd02b;
+		color: #fff;
+	}
+</style>

+ 400 - 0
addons/exam/uniapp/components/kz-page-index-simple/kz-page-index-simple.vue

@@ -0,0 +1,400 @@
+<template>
+	<view>
+		
+		<!-- banner -->
+		<!-- <view class="tui-banner-box">
+			<swiper
+				:indicator-dots="true"
+				:autoplay="true"
+				:interval="5000"
+				:duration="150"
+				class="tui-banner-swiper"
+				:circular="true"
+				previous-margin="60rpx"
+				next-margin="60rpx"
+			>
+				<swiper-item v-for="(item, index) in banners" :key="index" class="tui-banner-item">
+				  <image :src="imgUrl + item" class="tui-slide-image" :class="[current != index ? 'tui-banner-scale' : '']" mode="scaleToFill" lazy-load></image>
+				</swiper-item>
+			</swiper>
+		</view> -->
+		<!-- color-ui样式 -->
+		<!-- 
+		<swiper class="swiper" indicator-dots="true" circular="true" autoplay="true" interval="5000" duration="500"
+			style="height: 220px;">
+			<swiper-item v-for="(item, index) in banner" :key="index">
+				<image :src="imgUrl + item" style="width: 100%;height: 100%;"></image>
+			</swiper-item>
+		</swiper>
+		-->
+		<!-- banner end -->
+		
+		<!-- 公告 -->
+		<!-- <view class="tui-rolling-news">
+			<tui-icon name="news-fill" :size='24' color='#5677fc'></tui-icon>
+			<swiper vertical autoplay circular interval="3000" class="tui-swiper">
+				<swiper-item v-for="(item,index) in headlines" :key="index" class="tui-swiper-item">
+					<view class="tui-news-item">{{item}}</view>
+				</swiper-item>
+			</swiper>
+		</view> -->
+		
+		<!-- 菜单 -->
+		<view class="nav-list margin-top-xl">
+			<navigator hover-class="none" :url="item.url" class="nav-li" navigateTo :class="'bg-'+item.color"
+				:style="[{animation: 'show ' + ((index+1)*0.2+1) + 's 1'}]" v-for="(item,index) in modules"
+				:key="index">
+				<view class="nav-title">{{ item.title }}</view>
+				<view class="nav-name">{{ item.name }}</view>
+				<text :class="'cuIcon-' + item.cuIcon"></text>
+			</navigator>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"kz-page-index-simple",
+		props: {
+			// 关注提示
+			focusOnTip: {
+				type: String,
+				default: '点击「添加小程序」,下次访问更便捷'
+			},
+			// banner图集合
+			banners: {
+				type: Array,
+				default: () => []
+			},
+			// 公告集合
+			headlines: {
+				type: Array,
+				default: () => []
+			},
+			// 功能集合
+			modules: {
+				type: Array,
+				default: () => [
+					{
+						title: '看题模式',
+						name: 'train',
+						color: 'green',
+						cuIcon: 'creative',
+						url: '/pages/train/index?page=look',
+					},
+					{
+						title: '答题练习',
+						name: 'train',
+						color: 'olive',
+						cuIcon: 'copy',
+						url: '/pages/train/index?page=train',
+					},
+					{
+						title: '模拟考试',
+						name: 'exam',
+						color: 'cyan',
+						cuIcon: 'newsfill',
+						url: '/pages/paper/index'
+					},
+					{
+						title: '我的收藏',
+						name: 'collect',
+						color: 'blue',
+						cuIcon: 'colorlens',
+						url: '/pages/collect/index'
+					},
+					{
+						title: '我的错题',
+						name: 'wrong',
+						color: 'purple',
+						cuIcon: 'font',
+						url: '/pages/wrong/index'
+					},
+					{
+						title: '题目搜索',
+						name: 'search',
+						color: 'mauve',
+						cuIcon: 'cuIcon',
+						url: '/pages/search/index'
+					},
+					{
+						title: '考场报名',
+						name: 'signup',
+						color: 'green',
+						cuIcon: 'btn',
+						url: '/pages/room/index'
+					},
+					{
+						title: '报名记录',
+						name: 'signupLog',
+						color: 'orange',
+						cuIcon: 'tagfill',
+						url: '/pages/room/signup-index'
+					},
+				]
+			},
+		},
+		watch: {
+			/**
+			 * 监听headlines
+			 * @param val
+			 */
+			headlines(val) {
+				console.log('watch headlines', val)
+			}
+		},
+		data() {
+			return {
+				imgUrl: this.imgUrl,
+			};
+		}
+	}
+</script>
+
+<style>
+	.nav-list {
+		display: flex;
+		flex-wrap: wrap;
+		padding: 0px 40 upx 0px;
+		justify-content: space-between;
+		padding-bottom: 100px;
+	}
+
+	.nav-li {
+		padding: 30 upx;
+		border-radius: 12 upx;
+		width: 45%;
+		margin: 0 2.5% 40 upx;
+		background-image: url(https://cdn.nlark.com/yuque/0/2019/png/280374/1552996358352-assets/web-upload/cc3b1807-c684-4b83-8f80-80e5b8a6b975.png);
+		background-size: cover;
+		background-position: center;
+		position: relative;
+		z-index: 1;
+	}
+
+	.nav-li::after {
+		content: "";
+		position: absolute;
+		z-index: -1;
+		background-color: inherit;
+		width: 100%;
+		height: 100%;
+		left: 0;
+		bottom: -10%;
+		border-radius: 10 upx;
+		opacity: 0.2;
+		transform: scale(0.9, 0.9);
+	}
+
+	.nav-li.cur {
+		color: #fff;
+		background: rgb(94, 185, 94);
+		box-shadow: 4 upx 4 upx 6 upx rgba(94, 185, 94, 0.4);
+	}
+
+	.nav-title {
+		font-size: 32 upx;
+		font-weight: 300;
+	}
+
+	.nav-title::first-letter {
+		font-size: 40 upx;
+		margin-right: 4 upx;
+	}
+
+	.nav-name {
+		font-size: 28 upx;
+		text-transform: Capitalize;
+		margin-top: 20 upx;
+		position: relative;
+	}
+
+	.nav-name::before {
+		content: "";
+		position: absolute;
+		display: block;
+		width: 40 upx;
+		height: 6 upx;
+		background: #fff;
+		bottom: 0;
+		right: 0;
+		opacity: 0.5;
+	}
+
+	.nav-name::after {
+		content: "";
+		position: absolute;
+		display: block;
+		width: 100 upx;
+		height: 1px;
+		background: #fff;
+		bottom: 0;
+		right: 40 upx;
+		opacity: 0.3;
+	}
+
+	.nav-name::first-letter {
+		font-weight: bold;
+		font-size: 36 upx;
+		margin-right: 1px;
+	}
+
+	.nav-li text {
+		position: absolute;
+		right: 30 upx;
+		top: 30 upx;
+		font-size: 52 upx;
+		width: 60 upx;
+		height: 60 upx;
+		text-align: center;
+		line-height: 60 upx;
+	}
+
+	.text-light {
+		font-weight: 300;
+	}
+
+	@keyframes show {
+		0% {
+			transform: translateY(-50px);
+		}
+
+		60% {
+			transform: translateY(40 upx);
+		}
+
+		100% {
+			transform: translateY(0px);
+		}
+	}
+
+	@-webkit-keyframes show {
+		0% {
+			transform: translateY(-50px);
+		}
+
+		60% {
+			transform: translateY(40 upx);
+		}
+
+		100% {
+			transform: translateY(0px);
+		}
+	}
+
+	/*banner*/
+
+	.tui-banner-box {
+		width: 100%;
+		padding-top: 20rpx;
+		box-sizing: border-box;
+		background: #fff;
+	}
+
+	.tui-banner-swiper {
+		width: 100%;
+		height: 320rpx;
+	}
+
+	.tui-banner-item {
+		padding: 0 16rpx;
+		box-sizing: border-box;
+	}
+
+	.tui-slide-image {
+		width: 100%;
+		height: 320rpx;
+		display: block;
+		border-radius: 12rpx;
+		/* transition: all 0.1s linear; */
+	}
+
+	.tui-banner-scale {
+		transform: scaleY(0.9);
+		transform-origin: center center;
+	}
+
+	/* #ifdef MP-WEIXIN */
+	.tui-banner-swiper .wx-swiper-dot {
+		width: 8rpx;
+		height: 8rpx;
+		display: inline-flex;
+		background: none;
+		justify-content: space-between;
+	}
+
+	.tui-banner-swiper .wx-swiper-dot::before {
+		content: '';
+		flex-grow: 1;
+		background: rgba(255, 255, 255, 0.8);
+		border-radius: 16rpx;
+		overflow: hidden;
+	}
+
+	.tui-banner-swiper .wx-swiper-dot-active::before {
+		background: #fff;
+	}
+
+	.tui-banner-swiper .wx-swiper-dot.wx-swiper-dot-active {
+		width: 16rpx;
+	}
+
+	/* #endif */
+
+	/* #ifndef MP-WEIXIN */
+	>>>.tui-banner-swiper .uni-swiper-dot {
+		width: 8rpx;
+		height: 8rpx;
+		display: inline-flex;
+		background: none;
+		justify-content: space-between;
+	}
+
+	>>>.tui-banner-swiper .uni-swiper-dot::before {
+		content: '';
+		flex-grow: 1;
+		background: rgba(255, 255, 255, 0.8);
+		border-radius: 16rpx;
+		overflow: hidden;
+	}
+
+	>>>.tui-banner-swiper .uni-swiper-dot-active::before {
+		background: #fff;
+	}
+
+	>>>.tui-banner-swiper .uni-swiper-dot.uni-swiper-dot-active {
+		width: 16rpx;
+	}
+
+	/* #endif */
+	/*banner*/
+	
+	.tui-rolling-news {
+		width: 100%;
+		padding: 12rpx 30rpx;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		flex-wrap: nowrap;
+		background: #fff;
+	}
+	
+	.tui-swiper {
+		font-size: 24rpx;
+		height: 50rpx;
+		flex: 1;
+	}
+	
+	.tui-swiper-item {
+		display: flex;
+		align-items: center
+	}
+	
+	.tui-news-item {
+		line-height: 24rpx;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+</style>

+ 867 - 0
addons/exam/uniapp/components/kz-page-my-color/kz-page-my-color.vue

@@ -0,0 +1,867 @@
+<template>
+	<view class="page">
+		<view class="top-backgroup">
+			<!-- <image src='https://tnuiimage.tnkjapp.com/my/my-bg3.png' mode='widthFix' class='backgroud-image'></image> -->
+		</view>
+
+		<view class="about__wrap">
+			<!-- 用户信息 -->
+			<view class="tn-flex tn-flex-row-between tn-flex-col-center tn-margin-bottom">
+				<view class="justify-content-item">
+					<view class="tn-flex tn-flex-col-center tn-flex-row-left" @tap="login">
+						<!-- 图标logo -->
+						<view class="logo-pic tn-shadow">
+							<view class="logo-image">
+								<view v-if="userInfo && userInfo.avatar" class="tn-shadow-blur user-avatar"
+									:style="'background-image:url(' + userInfo.avatar + ')'">
+								</view>
+								<view v-else class="cuIcon-people user-avatar-empty" style=""></view>
+							</view>
+						</view>
+						<view class="tn-padding-right tn-color-white">
+							<view class="tn-padding-right tn-padding-left-sm tn-text-xxl">
+								<text v-if="userInfo && userInfo.nickname">{{ userInfo.nickname }}</text>
+								<text v-else style="font-size: 36rpx;">点击授权登录</text>
+							</view>
+							<!-- TODO 会员功能 -->
+							<!-- <view class="tn-padding-right tn-padding-top-xs tn-padding-left-sm tn-text-ellipsis">
+								会员级别
+							</view> -->
+						</view>
+
+					</view>
+				</view>
+			</view>
+
+			<!-- 常用功能 start-->
+			<view class="tn-flex tn-flex-row-center tn-bg-white course-shadow course-radius">
+				<view class="tn-padding-sm tn-margin-xs" v-for="(item, index) in useModule" :key="index">
+					<view class="tn-flex tn-flex-direction-column tn-flex-row-center tn-flex-col-center" @click="goTo(item.url)">
+						<view
+							class="icon15__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-shadow-blur tn-color-white" :class="[item.color]">
+							<view :class="[item.icon]"></view>
+						</view>
+						<view class="tn-text-center">
+							<text class="tn-text-ellipsis">{{item.title}}</text>
+						</view>
+					</view>
+				</view>
+			</view>
+			<!-- 常用功能 end-->
+
+			<!-- 更多功能 -->
+			<view class="about-shadow tn-margin-top-xl tn-padding-top-sm tn-padding-bottom-sm module-list">
+				<tn-scroll-list :indicatorWidth="100" :indicatorBarWidth="30" indicatorColor="#cfd2ff" indicatorActiveColor="#5677fc">
+					<view class="tn-flex tn-margin-left-sm tn-margin-right-sm tn-margin-top">
+						<block v-for="(item, index) in moreModule" :key="index">
+							<view class="tn-flex-1 tn-padding-sm tn-radius" @click="goTo(item.url)">
+								<view class="tn-flex tn-flex-direction-column tn-flex-row-center tn-flex-col-center">
+									<view
+										class="icon11__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-shadow-blur"
+										:class="[`tn-bg-${item.color}--light tn-color-${item.color}`]">
+										<view :class="[`${item.icon}`]"></view>
+									</view>
+									<view class="tn-color-black tn-text-df tn-text-center tn-margin-top-sm">
+										<text class="tn-text-ellipsis">{{ item.title }}</text>
+									</view>
+								</view>
+							</view>
+						</block>
+					</view>
+				</tn-scroll-list>
+			</view>
+
+			<!-- 更多信息-->
+			<view class="about-shadow tn-margin-top-xl tn-padding-top-sm tn-padding-bottom-sm">
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30" arrow @click="goTo('/pages/user/set')" v-if="userInfo && userInfo.nickname">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-5 tn-color-white">
+							<view class="tn-icon-my-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">个人设置</view>
+						<!-- <view class="tn-margin-left-sm tn-color-red tn-icon-fire-fill"></view> -->
+					</view>
+				</tn-list-cell>
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30" arrow @click="goTo('/pages/user/my-cate')">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-4 tn-color-white">
+							<view class="tn-icon-star-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">常用分类</view>
+						<!-- <view class="tn-margin-left-sm tn-color-red tn-icon-fire-fill"></view> -->
+					</view>
+				</tn-list-cell>
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30" arrow @click="goTo('/pages/user/my-correction')">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-6 tn-color-white">
+							<view class="tn-icon-reply-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">反馈纠错</view>
+					</view>
+				</tn-list-cell>
+				
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30" @click="clickFollow()" v-show="showFollowBtn">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-8 tn-color-white">
+							<view class="tn-icon-message-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">关注我们</view>
+						<view class="tn-margin-left-sm tn-color-blue tn-icon-copy-fill"></view>
+					</view>
+				</tn-list-cell>
+				
+				<!-- #ifdef MP-WEIXIN -->
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30">
+					<button class="tn-flex tn-flex-col-center tn-button--clear-style text-left" open-type="contact">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-12 tn-color-white">
+							<view class="tn-icon-wechat-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">联系客服</view>
+						<view class="tn-margin-left-sm tn-color-green tn-icon-service-fill"></view>
+						<button open-type="contact"></button>
+					</button>
+				</tn-list-cell>
+				<!-- #endif -->
+				
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30">
+					<view class="tn-flex tn-flex-col-center" @tap.stop="clearStorage()">
+						<view class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-16 tn-color-white">
+							<view class="tn-icon-delete-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">清除缓存</view>
+						<view class="tn-margin-left-sm tn-color-red tn-icon-clear"></view>
+					</view>
+				</tn-list-cell>
+
+				<!-- TODO 待定功能 -->
+				<!-- <tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-8 tn-color-white">
+							<view class="tn-icon-message-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">问题反馈</view>
+						<view class="tn-margin-left-sm tn-color-blue tn-icon-copy-fill"></view>
+					</view>
+				</tn-list-cell>
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-6 tn-color-white">
+							<view class="tn-icon-phone-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">技术支持</view>
+						<view
+							class="tn-margin-left-sm tn-color-orangered tn-text-sm tn-padding-left-xs tn-padding-right-xs tn-bg-orange--disabled tn-round">
+							136****0470</view>
+					</view>
+				</tn-list-cell>
+				<tn-list-cell :hover="true" :unlined="true" :radius="true" :fontSize="30">
+					<view class="tn-flex tn-flex-col-center">
+						<view
+							class="icon1__item--icon tn-flex tn-flex-row-center tn-flex-col-center tn-cool-bg-color-16 tn-color-white">
+							<view class="tn-icon-safe-fill"></view>
+						</view>
+						<view class="tn-margin-left-sm tn-flex-1">会员协议</view>
+						<view class="tn-margin-left-sm tn-color-red tn-icon-fire-fill"></view>
+					</view>
+				</tn-list-cell> -->
+			</view>
+
+			
+		</view>
+		
+		<!-- 关注我们 -->
+		<tui-modal :show="showFollow" @cancel="showFollow = false" :custom="true">
+			<view class="tui-modal-custom">
+				<image :src="followImage" mode="widthFix"></image>
+				<view class="tui-modal-custom-text">
+					
+				</view>
+				<tui-button height="72rpx" :size="28" type="primary" shape="circle" @click="showFollow = false">确定</tui-button>
+			</view>
+		</tui-modal>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: "kz-page-my-color",
+		emits: ['login', 'goTo', 'clearStorage'],
+		props: {
+			// 用户信息
+			user: {
+				type: Object,
+				default: () => null
+			},
+		},
+		watch: {
+			/**
+			 * 监听user
+			 * @param value
+			 */
+			user(value) {
+				console.log('watch user', value)
+				this.userInfo = value
+				
+				var page = uni.getStorageSync('page')
+				this.showFollowBtn = parseInt(page.page_my_follow_mp_btn) == 1
+			},
+		},
+		data() {
+			return {
+				userInfo: null,
+				useModule: [
+					{
+						title: '看题模式',
+						color: 'tn-bg-orange', //'tn-bg-green',
+						icon: 'tn-icon-eye',
+						url: '/pages/train/index?page=look',
+					},
+					{
+						title: '答题练习',
+						color: 'tn-bg-bluepurple', //'tn-bg-blue',
+						icon: 'tn-icon-edit-write',
+						url: '/pages/train/index?page=train',
+					},
+					{
+						title: '模拟考试',
+						color: 'tn-bg-indigo', //'tn-bg-orange',
+						icon: 'tn-icon-edit-form',
+						url: '/pages/paper/index'
+					},
+					{
+						title: '考场报名',
+						color: 'tn-bg-purplered', //'tn-bg-cyan',
+						icon: 'tn-icon-empty-data',
+						url: '/pages/room/index'
+					},
+				],
+				moreModule: [
+					{
+						title: '我的收藏',
+						icon: 'tn-icon-like-lack',
+						color: 'orange',
+						url: '/pages/collect/index'
+					},
+					{
+						title: '我的错题',
+						icon: 'tn-icon-close-circle',
+						color: 'purple',
+						url: '/pages/wrong/index'
+					},
+					{
+						title: '题目搜索',
+						icon: 'tn-icon-search-list',
+						color: 'blue',
+						url: '/pages/search/index'
+					},
+					{
+						title: '报名记录',
+						icon: 'tn-icon-ticket',
+						color: 'purplered',
+						url: '/pages/room/signup-index'
+					},
+					{
+						title: '考场成绩',
+						icon: 'tn-icon-identity',
+						color: 'teal',
+						url: '/pages/room/grade'
+					},
+					{
+						title: '考卷成绩',
+						icon: 'tn-icon-order',
+						color: 'orangered',
+						url: '/pages/paper/grade'
+					},
+				],
+				// 关注公众号
+				showFollowBtn: false,
+				showFollow: false,
+				followImage: '',
+			};
+		},
+		methods: {
+			login() {
+				this.$emit('login')
+			},
+			goTo(page) {
+				this.$emit('goTo', page)
+			},
+			clearStorage() {
+				console.log('emit clearStorage')
+				this.$emit('clearStorage')
+			},
+			clickFollow() {
+				var page = uni.getStorageSync('page')
+				if (page.page_my_follow_mp_click == 'image') {
+					this.followImage = this.appInfo.api_host + page.follow_mp_image
+					this.showFollow = true
+				} else if (page.page_my_follow_mp_click == 'article') {
+					this.goTo('/pages/webview/webview?url=' + page.follow_mp_article_url)
+				} 
+				
+			},
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.page {
+		background-color: #fff;
+	}
+
+	/* 顶部背景图 start */
+	.top-backgroup {
+		height: 300rpx;
+		z-index: -1;
+		background: #5677fc;
+		border-radius: 0 0 10% 10%;
+
+		.backgroud-image {
+			width: 100%;
+			height: 450rpx;
+			// z-index: -1;
+		}
+	}
+
+	/* 顶部背景图 end */
+
+	/* 用户头像 start */
+	.logo-image {
+		width: 120rpx;
+		height: 120rpx;
+		position: relative;
+	}
+
+	.logo-pic {
+		background-size: cover;
+		background-repeat: no-repeat;
+		// background-attachment:fixed;
+		background-position: top;
+		border-radius: 50%;
+		overflow: hidden;
+		background-color: #FFFFFF;
+	}
+
+	/* 页面 start*/
+	.about-shadow {
+		border-radius: 15rpx;
+		box-shadow: 0rpx 0rpx 50rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+
+	.about {
+
+		&__wrap {
+			position: relative;
+			z-index: 1;
+			margin: 20rpx 30rpx;
+			margin-top: -230rpx;
+		}
+	}
+
+	/* 页面 end*/
+
+	/* 图标容器1 start */
+	.icon1 {
+		&__item {
+			// width: 30%;
+			background-color: #FFFFFF;
+			border-radius: 10rpx;
+			padding: 30rpx;
+			margin: 20rpx 10rpx;
+			transform: scale(1);
+			transition: transform 0.3s linear;
+			transform-origin: center center;
+
+			&--icon {
+				width: 40rpx;
+				height: 40rpx;
+				font-size: 28rpx;
+				border-radius: 50%;
+				position: relative;
+				z-index: 1;
+
+				&::after {
+					content: " ";
+					position: absolute;
+					z-index: -1;
+					width: 100%;
+					height: 100%;
+					left: 0;
+					bottom: 0;
+					border-radius: inherit;
+					opacity: 1;
+					transform: scale(1, 1);
+					background-size: 100% 100%;
+					background-image: url(https://tnuiimage.tnkjapp.com/cool_bg_image/icon_bg.png);
+				}
+			}
+		}
+	}
+
+	/* 图标容器1 end */
+
+	.my-shadow {
+		box-shadow: 0rpx 0rpx 80rpx 0rpx rgba(0, 0, 0, 0.07);
+		border-radius: 20rpx;
+	}
+
+	/* 图标容器7 start */
+	.icon7 {
+		&__item {
+			width: 30%;
+			background-color: #FFFFFF;
+			border-radius: 10rpx;
+			padding: 10rpx;
+			margin: 20rpx 10rpx;
+			transform: scale(1);
+			transition: transform 0.3s linear;
+			transform-origin: center center;
+
+			&--icon {
+				width: 100rpx;
+				height: 100rpx;
+				font-size: 60rpx;
+				border-radius: 0;
+				margin-bottom: 18rpx;
+				position: relative;
+				z-index: 1;
+			}
+		}
+	}
+
+	/* 图标容器12 start */
+	.icon12 {
+		&__item {
+			width: 30%;
+			background-color: #FFFFFF;
+			border-radius: 10rpx;
+			padding: 30rpx;
+			margin: 20rpx 10rpx;
+			transform: scale(1);
+			transition: transform 0.3s linear;
+			transform-origin: center center;
+
+			&--icon {
+				width: 100rpx;
+				height: 100rpx;
+				font-size: 60rpx;
+				// border-radius: 50%;
+				margin-bottom: 0rpx;
+				position: relative;
+				z-index: 1;
+
+				&::after {
+					content: " ";
+					position: absolute;
+					z-index: -1;
+					width: 100%;
+					height: 100%;
+					left: 0;
+					bottom: 0;
+					border-radius: inherit;
+					opacity: 1;
+					transform: scale(1, 1);
+					background-size: 100% 100%;
+					// background-image: url(https://tnuiimage.tnkjapp.com/cool_bg_image/icon_bg.png);
+
+
+				}
+			}
+		}
+	}
+
+	/* 图标容器11 start */
+	.icon11 {
+		&__item {
+			width: 30%;
+			background-color: #FFFFFF;
+			border-radius: 10rpx;
+			padding: 30rpx;
+			margin: 20rpx 10rpx;
+			transform: scale(1);
+			transition: transform 0.3s linear;
+			transform-origin: center center;
+
+			&--icon {
+				width: 100rpx;
+				height: 100rpx;
+				font-size: 60rpx;
+				border-radius: 50%;
+				margin-bottom: 18rpx;
+				position: relative;
+				z-index: 1;
+
+				&::after {
+					content: " ";
+					position: absolute;
+					z-index: -1;
+					width: 100%;
+					height: 100%;
+					left: 0;
+					bottom: 0;
+					border-radius: inherit;
+					opacity: 1;
+					transform: scale(1, 1);
+					background-size: 100% 100%;
+					background-image: url(https://tnuiimage.tnkjapp.com/cool_bg_image/icon_bg.png);
+				}
+			}
+		}
+	}
+
+	.course-shadow {
+		box-shadow: 0rpx 0rpx 80rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+
+	.course-radius {
+		border-radius: 15rpx;
+	}
+
+	/* 图标容器15 start */
+	.icon15 {
+		&__item {
+			width: 30%;
+			background-color: #FFFFFF;
+			border-radius: 10rpx;
+			padding: 30rpx;
+			margin: 20rpx 10rpx;
+			transform: scale(1);
+			transition: transform 0.3s linear;
+			transform-origin: center center;
+
+			&--icon {
+				width: 100rpx;
+				height: 100rpx;
+				font-size: 60rpx;
+				border-radius: 50%;
+				margin-bottom: 18rpx;
+				position: relative;
+				z-index: 1;
+
+				&::after {
+					content: " ";
+					position: absolute;
+					z-index: -1;
+					width: 100%;
+					height: 100%;
+					left: 0;
+					bottom: 0;
+					border-radius: inherit;
+					opacity: 1;
+					transform: scale(1, 1);
+					background-size: 100% 100%;
+
+
+				}
+			}
+		}
+	}
+
+	.avatar-eye {
+		position: absolute;
+		top: -10%;
+		width: 65px;
+		height: 65px;
+		background: linear-gradient(105deg, white, #cb87f4);
+		border-radius: 100%;
+		box-shadow: 4px 8px 5px rgba(0, 0, 0, 0.2);
+		margin: 3px;
+		-webkit-transform: translateX(-50%);
+		transform: translateX(-50%);
+	}
+
+
+	.avatar-eye--green {
+		background: linear-gradient(to bottom, #fdfdfd, #c3efea);
+	}
+
+	.avatar-eye--violet {
+		background: linear-gradient(to bottom, #fdfdfd, #e6d6f6);
+	}
+
+
+	.eye--left {
+		left: 10%;
+	}
+
+	.eye--right {
+		left: 85%;
+	}
+
+	.eye--center {
+		left: 45%;
+		top: 10%;
+	}
+
+	.avatar-eye-pupil {
+		position: absolute;
+		width: 55%;
+		height: 55%;
+		left: 50%;
+		top: 25%;
+		-webkit-transform: translate(-50%);
+		transform: translate(-50%);
+		z-index: 100;
+		border-radius: 100%;
+	}
+
+
+	.pupil--green {
+		background: linear-gradient(135deg, rgba(188, 248, 177, 0.7), #2fa38c 75%);
+	}
+
+	.pupil--pink {
+		background: linear-gradient(135deg, #f1a183, #8535cd);
+	}
+
+
+	.avatar-eye-pupil-blackThing {
+		position: absolute;
+		width: 55%;
+		height: 55%;
+		left: 50%;
+		top: 25%;
+		background: #2c2f32;
+		-webkit-transform: translate(-50%);
+		transform: translate(-50%);
+		border-radius: 100%;
+		box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2);
+	}
+
+	.avatar-eye-pupil-lightReflection {
+		position: absolute;
+		width: 7px;
+		height: 7px;
+		left: 25%;
+		top: 10%;
+		background: #ebebeb;
+		-webkit-transform: translate(-50%);
+		transform: translate(-50%);
+		border-radius: 100%;
+		box-shadow: 10px 10px 10px rgba(255, 255, 255, 0.2);
+	}
+
+
+
+
+
+	/**/
+	@keyframes t {
+
+		0%,
+		10%,
+		80%,
+		100% {
+			top: -30px;
+		}
+
+		20% {
+			top: 0px;
+		}
+
+		30% {
+			top: -20px;
+		}
+
+		40% {
+			top: -0px;
+		}
+
+		50% {
+			top: -25px;
+		}
+
+		70% {
+			top: 0px;
+		}
+	}
+
+	@keyframes b {
+
+		0%,
+		10%,
+		80%,
+		100% {
+			bottom: -30px;
+		}
+
+		20% {
+			bottom: 0px;
+		}
+
+		30% {
+			bottom: -20px;
+		}
+
+		40% {
+			bottom: -0px;
+		}
+
+		50% {
+			bottom: -25px;
+		}
+
+		70% {
+			bottom: 0px;
+		}
+	}
+
+	@keyframes mouth {
+
+		0%,
+		10%,
+		100% {
+			width: 100%;
+			height: 0%;
+		}
+
+		15% {
+			width: 90%;
+			height: 30%;
+		}
+
+		20% {
+			width: 50%;
+			height: 70%;
+		}
+
+		25% {
+			width: 70%;
+			height: 70%;
+		}
+
+		30% {
+			width: 80%;
+			height: 60%;
+		}
+
+		35% {
+			width: 60%;
+			height: 70%;
+		}
+
+		40% {
+			width: 55%;
+			height: 75%;
+		}
+
+		45% {
+			width: 50%;
+			height: 90%;
+		}
+
+		50% {
+			width: 40%;
+			height: 70%;
+		}
+
+		55% {
+			width: 70%;
+			height: 95%;
+		}
+
+		60% {
+			width: 40%;
+			height: 50%;
+		}
+
+		65% {
+			width: 100%;
+			height: 60%;
+		}
+
+		70% {
+			width: 100%;
+			height: 70%;
+		}
+
+		75% {
+			width: 90%;
+			height: 70%;
+		}
+
+		80% {
+			width: 50%;
+			height: 70%;
+		}
+
+		85% {
+			width: 90%;
+			height: 50%;
+		}
+
+		85% {
+			width: 40%;
+			height: 70%;
+		}
+
+		90% {
+			width: 90%;
+			height: 30%;
+		}
+
+		95% {
+			width: 100%;
+			height: 10%;
+		}
+	}
+
+	@keyframes tongue {
+
+		0%,
+		20%,
+		100% {
+			bottom: -80px;
+		}
+
+		30%,
+		90% {
+			bottom: -40px;
+		}
+
+		40% {
+			bottom: -45px;
+		}
+
+		50% {
+			bottom: -50px;
+		}
+
+		70% {
+			bottom: -80px;
+		}
+
+		90% {
+			bottom: -40px;
+		}
+	}
+
+	.module-list {
+		background-color: #fff;
+	}
+
+	.user-avatar {
+		width: 120rpx;
+		height: 120rpx;
+		background-size: cover;
+	}
+
+	.user-avatar-empty {
+		width: 120rpx;
+		height: 120rpx;
+		line-height: 120rpx;
+		text-align: center;
+		font-size: 80rpx;
+		color: #fff;
+		background-color: #ccc;
+		box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.2);
+	}
+
+</style>

+ 218 - 0
addons/exam/uniapp/components/kz-page-my-simple/kz-page-my-simple.vue

@@ -0,0 +1,218 @@
+<template>
+	<view class="page">
+		<view class="bg-user user-info ">
+			<view class="user" @tap="login">
+				<view class="cu-avatar xl round user-avatar" v-if="userInfo" :style="'background-image:url(' + userInfo.avatar + ')'">
+					<text class="cuIcon-people" v-if="!userInfo.avatar"></text>
+				</view>
+				<view class="user-name" v-if="userInfo && userInfo.nickname">{{ userInfo.nickname }}</view>
+				<view class="user-name" v-else>点击授权登录</view>
+				<navigator class="merchant_num" url="score" hover-class="none"></navigator>
+			</view>
+		</view>
+	
+		<view class="cu-card case" style="margin-top: -50px;z-index: 11;position: relative;">
+			<view class="cu-item shadow">
+				<view class="cu-list grid col-3">
+					<view class="cu-item" @tap="goTo" data-url="../room/index">
+						<view class="cuIcon-list text-blue"></view>
+						<text>考场</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../room/signup-index">
+						<view class="cuIcon-edit text-olive"></view>
+						<text>报名记录</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../room/grade">
+						<view class="cuIcon-formfill text-yellow"></view>
+						<text>考场成绩</text>
+					</view>
+				</view>
+			</view>
+		</view>
+	
+		<view class="cu-card case">
+			<view class="cu-item shadow">
+				<view class="cu-list grid col-3">
+					<view class="cu-item" @tap="goTo" data-url="../search/index">
+						<view class="cuIcon-searchlist text-purple"></view>
+						<text>试题查询</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../collect/index">
+						<view class="cuIcon-likefill text-orange"></view>
+						<text>试题收藏</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../wrong/index">
+						<view class="cuIcon-roundclose text-blue"></view>
+						<text>我的错题</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../paper/grade">
+						<view class="cuIcon-newshotfill text-cyan"></view>
+						<text>考卷成绩</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../user/set">
+						<view class="cuIcon-my text-mauve"></view>
+						<text>个人设置</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../user/my-cate">
+						<view class="cuIcon-favor text-green"></view>
+						<text>常用题库</text>
+					</view>
+					<view class="cu-item" @tap="goTo" data-url="../user/my-correction">
+						<view class="cuIcon-wenzi text-red"></view>
+						<text>纠错反馈</text>
+					</view>
+					<view class="cu-item" @tap="clickFollow()" v-show="showFollowBtn">
+						<view class="cuIcon-weixin tn-color-green"></view>
+						<text>关注我们</text>
+					</view>
+					<!-- #ifdef MP-WEIXIN -->
+					<view class="cu-item">
+						<button class="content" style="color: #333333;" open-type="contact">
+							<view class="cuIcon-weixin text-green"></view>
+							<text>联系客服</text>
+						</button>
+					</view>
+					<!-- #endif -->
+					<view class="cu-item" @tap="clearStorage">
+						<view class="cuIcon-deletefill text-grey"></view>
+						<text>清除缓存</text>
+					</view>
+				</view>
+			</view>
+		</view>
+		
+		<!-- 关注我们 -->
+		<tui-modal :show="showFollow" @cancel="showFollow = false" :custom="true">
+			<view class="tui-modal-custom">
+				<image :src="followImage" mode="widthFix"></image>
+				<view class="tui-modal-custom-text">
+					
+				</view>
+				<tui-button height="72rpx" :size="28" type="primary" shape="circle" @click="showFollow = false">确定</tui-button>
+			</view>
+		</tui-modal>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"kz-page-my-simple",
+		// emits: ['login', 'goTo', 'clearStorage'],
+		props: {
+			// 用户信息
+			user: {
+				type: Object,
+				default: () => null
+			},
+		},
+		watch: {
+			/**
+			 * 监听user
+			 * @param value
+			 */
+			user(value) {
+				console.log('watch user', value)
+				this.userInfo = value
+				
+				var page = uni.getStorageSync('page')
+				this.showFollowBtn = parseInt(page.page_my_follow_mp_btn) == 1
+			},
+		},
+		data() {
+			return {
+				userInfo: null,
+				// 关注公众号
+				showFollowBtn: false,
+				showFollow: false,
+				followImage: '',
+			};
+		},
+		methods: {
+			login() {
+				this.$emit('login')
+			},
+			goTo(e) {
+				let page = e.currentTarget.dataset.url
+				this.$emit('goTo', page)
+			},
+			clearStorage() {
+				this.$emit('clearStorage')
+			},
+			clickFollow() {
+				var page = uni.getStorageSync('page')
+				if (page.page_my_follow_mp_click == 'image') {
+					this.followImage = this.appInfo.api_host + page.follow_mp_image
+					this.showFollow = true
+				} else if (page.page_my_follow_mp_click == 'article') {
+					this.goTo('/pages/webview/webview?url=' + page.follow_mp_article_url)
+				} 
+				
+			},
+		}
+	}
+</script>
+
+<style>
+	.page {
+		height: 100vh;
+	}
+	
+	.bg-user {
+		background: #5677fc;
+		color: #fff;
+	}
+	
+	.user-info {
+		width: 100%;
+		height: 150px;
+		position: relative;
+		border-radius: 0 0 10% 10%;
+	}
+	
+	.user {
+		width: 100%;
+		height: 80px;
+	}
+	
+	.avatar {
+		width: 80px;
+		height: 80px;
+		border: 1px solid #fff;
+		margin-left: 5%;
+		float: left;
+	}
+	
+	.avatar image {
+		width: 100%;
+		height: 100%;
+		display: block;
+	}
+	
+	.user-avatar {
+		margin-left: 50rpx;
+		margin-top: 50rpx;
+	}
+	
+	.user-name {
+		font-size: 18px;
+		width: calc(95% - 100px);
+		float: right;
+		margin-top: 80rpx;
+		color: #fff;
+	}
+	
+	button.contact::after {
+		border: none;
+	}
+	
+	button.content {
+		background-color: #fff;
+	
+		padding-left: 0;
+	
+		padding-right: 0;
+	
+		line-height: inherit;
+		border-radius: 0;
+	}
+</style>

+ 2177 - 0
addons/exam/uniapp/components/kz-question/kz-question.vue

@@ -0,0 +1,2177 @@
+<template>
+  <view class="questions">
+
+    <!-- 头部信息 -->
+    <view class="test-header" v-if="showCountDown">
+      <tui-countdown :time="limit_time" borderColor="#FFF" color="#080808" :size="36" :colonSize="36"
+                     @end="endOfTime"></tui-countdown>
+    </view>
+
+    <!-- 题目列表 -->
+    <view class="card-shadow">
+      <view class="topic-title">
+        <view class="topic-title_left">
+          <!-- 题型 -->
+          <view class="text-kind">
+            {{ kindText() }}
+          </view>
+          <!-- 收藏 -->
+          <view class="test-favor" v-if="canCollect">
+            <tui-icon name="like-fill" color="#f74d54" :size="18" @click="collectDel()"
+                      v-if="list[swiperIndex-1] && list[swiperIndex-1].collected"></tui-icon>
+            <tui-icon name="like" color="#aaa" :size="18" @click="collectAdd()" v-else></tui-icon>
+            <view class="tui-fabulous"
+                  :class="{ 'tui-fabulous__active': list[swiperIndex-1] && list[swiperIndex-1].collected }">
+              <tui-icon name="like-fill" color="#f74d54"></tui-icon>
+            </view>
+          </view>
+        </view>
+        <!-- 题标 -->
+        <view class="topic-title_right">
+          <text class="title-index">{{ swiperIndex }}</text>
+          /{{ total }}
+        </view>
+      </view>
+      <!-- 防切屏提示 -->
+      <view class="topic-title" style="font-size: 24rpx;" v-if="isPreventSwitchScreen">
+        注意:当前考试开启了防切屏,切屏次数超过{{ switchScreenCount }}次考试将自动结束
+      </view>
+
+      <swiper :current="swiperIndex - 1" @change="swiperChange" class="questions-cont">
+        <swiper-item v-for="(item, index) in list" :key="index" class="swiper-item">
+          <block v-if="index == swiperIndex - 1">
+            <!-- 材料题标题 -->
+            <view class="material-title" v-if="item.material_title != undefined">
+              <view class="material-title-tip">材料:</view>
+              <view v-if="!item.show_full">
+                <rich-text :nodes="questionTitle(swiperIndex, item, 'material_title')"></rich-text>
+                <button @click="showFullMaterialTitle(swiperIndex, item, true)">展开</button>
+              </view>
+              <view v-else>
+                <rich-text :nodes="questionTitle(swiperIndex, item, 'material_title')"></rich-text>
+                <button @click="showFullMaterialTitle(swiperIndex, item, false)">收起</button>
+              </view>
+            </view>
+
+            <!-- 题目视频 -->
+            <view v-if="item.title_video_url != undefined && item.title_video" class="title-video">
+              <video id="title-video" class="title-video" :src="item.title_video_url" :controls="true"></video>
+            </view>
+
+            <!-- 判断、单选、多选题 -->
+            <view v-if="item.kind === 'JUDGE' || item.kind === 'SINGLE' || item.kind === 'MULTI'">
+              <view class="test-main">
+                <view class="test-title">
+                  <rich-text :nodes="questionTitle(swiperIndex, item)"
+                             style="font-size: 36rpx; font-weight: bold;"></rich-text>
+                </view>
+                <view class="test-cont">
+                  <!-- optionItem.click_index ? 'active_true' : '' optionChooseClass(index, optionIndex, item, optionItem) -->
+                  <view class="test-cont-item"
+                        v-for="(optionItem, optionIndex) in item.options_json" :key="optionIndex"
+                        @tap.stop="chooseItem(index, optionIndex, item.kind)"
+                        :class="optionItem.classes ? optionItem.classes : ''">
+                    <view class="key text-cut">
+                      {{ optionItem.key }}
+                    </view>
+                    <view class="cont">
+                      <view class="cont-text">
+                        <block v-if="item.options_img" v-for="(optionImg, optionImgIndex) in item.options_img"
+                               :key="optionImgIndex">
+                          <image class="image" v-if="optionImg.key == optionItem.key"
+                                 @tap.stop="previewImage(optionImg.value)" :src="optionImg.value"
+                                 mode="widthFix"></image>
+                        </block>
+
+                        <view>{{ optionItem.value ? optionItem.value : '' }}</view>
+                      </view>
+                      <view class="cont-icon">
+                        <view v-if="optionItem.classes">
+                          <text v-if="optionItem.classes.indexOf('true') != -1" class="cuIcon-check"></text>
+                          <text
+                              v-else-if="optionItem.classes.indexOf('true') == -1 && optionItem.classes.indexOf('active') != -1"
+                              class="cuIcon-close"></text>
+                        </view>
+                      </view>
+                    </view>
+                  </view>
+                </view>
+              </view>
+
+              <view class="test-describe" v-if="showAnswer || item.show_answer">
+                <view class="describe-cont">
+                  <view>
+                    正确答案:
+                    <text style="color: #5677fc;">{{ item.answer }}</text>
+                  </view>
+                  <view v-if="mode == 'VIEW' && item.user_answer">
+                    用户答案:
+                    <text>{{ item.user_answer }}</text>
+                  </view>
+                  <view>
+                    <view>答案解析:</view>
+                    <view>
+                      <rich-text :nodes="questionExplain(swiperIndex, item)"
+                                 style="border-top: solid 1px #ccc;width: 100%;display: block;"
+                                 v-if="item.explain"></rich-text>
+                      <text v-else>无</text>
+                    </view>
+
+                  </view>
+                </view>
+              </view>
+            </view>
+
+            <!-- 填空题 -->
+            <view v-else-if="item.kind === 'FILL'">
+              <view class="test-main">
+                <view class="test-title test-title-fill">
+                  <block v-for="(titleText, titleIndex) in item.title_data" :key="titleIndex">
+                    <view class="test-title-fill-item">{{ titleText }}</view>
+                    <view class="test-title-fill-item">
+                      <!-- #ifdef MP-WEIXIN -->
+                      <input type="text" placeholder="请输入答案" class="fill-input"
+                             v-if="item.title_data && item.title_data.length - 1 != titleIndex"
+                             :class="[item.answer && item.answer[titleIndex] && item.answer[titleIndex].class ? item.answer[titleIndex].class : '']"
+                             :value="setFillInputValue(titleIndex)"
+                             :disabled="mode === 'VIEW'"
+                             @change="(e) => changeFillInput(e, titleIndex, swiperIndex)"
+                      />
+                      <!-- #endif -->
+
+                      <!-- #ifdef H5 -->
+                      <input type="text" placeholder="请输入答案" class="fill-input"
+                             v-if="item.title_data && item.title_data.length - 1 != titleIndex"
+                             :class="[item.answer && item.answer[titleIndex] && item.answer[titleIndex].class ? item.answer[titleIndex].class : '']"
+                             :value="setFillInputValue(titleIndex)"
+                             :disabled="mode === 'VIEW'"
+                             @blur="(e) => changeFillInput(e, titleIndex, swiperIndex)"
+                      />
+                      <!-- #endif -->
+                    </view>
+                  </block>
+                  <view class="test-title-fill-item">{{ getQuestionTitleScore(index, item) }}</view>
+                </view>
+                <view class="test-cont">
+                  <!-- 确认答案按钮 -->
+                  <view class="btn-confirm" v-if="mode == 'TRAINING' && item.is_answered === false">
+                    <tui-button type="primary" shape="circle" @click="confirmFillAnswer" width="360rpx" height="60rpx"
+                                :size="26" margin="0 auto">确认答案
+                    </tui-button>
+                  </view>
+                </view>
+              </view>
+              <view class="test-describe" v-if="showAnswer || item.show_answer">
+                <view class="describe-cont">
+                  <view>
+                    正确答案:
+                    <view v-for="(answer, answerIndex) in item.answer" :key="answerIndex">
+                      填空位{{ answerIndex + 1 }}答案:{{ answer.answers.join(',') }}
+                    </view>
+                  </view>
+                  <view v-if="mode == 'VIEW' && item.user_answer">
+                    用户答案:
+                    <view v-for="(userAnswer, userAnswerIndex) in item.user_answer" :key="userAnswerIndex">
+                      填空位{{ userAnswerIndex + 1 }}答案:{{ userAnswer }}
+                    </view>
+                  </view>
+                  <view>
+                    <view>答案解析:</view>
+                    <view>
+                      <rich-text :nodes="questionExplain(swiperIndex, item)"
+                                 style="border-top: solid 1px #ccc;width: 100%;display: block;"
+                                 v-if="item.explain"></rich-text>
+                      <text v-else>无</text>
+                    </view>
+
+                  </view>
+                </view>
+              </view>
+            </view>
+
+            <!-- 简答题 -->
+            <view v-else-if="item.kind === 'SHORT'">
+              <view class="test-main">
+                <view class="test-title">
+                  <rich-text :nodes="questionTitle(swiperIndex, item)"
+                             style="font-size: 36rpx; font-weight: bold;"></rich-text>
+                </view>
+                <view class="test-cont">
+                  <!-- 答案输入框 -->
+                  <textarea placeholder="在此输入答案" class="short-input"
+                            :maxlength="-1"
+                            :value="setShortInputValue(swiperIndex)"
+                            :auto-blur="true"
+                            @blur="(e) => changeShortInput(e, swiperIndex)"
+                            @input="(e) => changeShortInput(e, swiperIndex)"
+                  />
+
+                  <!-- 确认答案按钮 -->
+                  <view class="btn-confirm" v-if="mode == 'TRAINING'">
+                    <tui-button type="primary" shape="circle" @click="confirmShortAnswer" width="360rpx" height="60rpx"
+                                :size="26" margin="0 auto">确认答案
+                    </tui-button>
+                  </view>
+                </view>
+              </view>
+
+              <view class="test-describe" v-if="showAnswer || item.show_answer">
+                <view class="describe-cont">
+                  <view>
+                    <view>
+                      正确答案:
+                      <text style="color: #5677fc;" v-if="item.answer && item.answer.answer">{{
+                          item.answer.answer
+                        }}
+                      </text>
+                    </view>
+                    <view v-for="(keyword, keywordIndex) in item.answer.config" :key="keywordIndex">
+                      <view :class="[keyword.class]">
+                        关键词{{ keywordIndex + 1 }}:{{ keyword.answer }}({{ keyword.score }}分)
+                      </view>
+                    </view>
+                  </view>
+                  <view>
+                    <view>答案解析:</view>
+                    <view>
+                      <rich-text :nodes="questionExplain(swiperIndex, item)"
+                                 style="border-top: solid 1px #ccc;width: 100%;display: block;"
+                                 v-if="item.explain"></rich-text>
+                      <text v-else>无</text>
+                    </view>
+                  </view>
+                </view>
+              </view>
+            </view>
+
+            <!-- 解析视频 -->
+            <view class="explain-video-view"
+                  v-if="(showAnswer || item.show_answer) && item.explain_video_url != undefined && item.explain_video">
+              <video id="explain-video" class="explain-video" :src="item.explain_video_url" controls></video>
+            </view>
+
+          </block>
+          <view style="height: 100rpx;"></view>
+        </swiper-item>
+      </swiper>
+    </view>
+
+    <!-- 底部栏 -->
+    <!-- :class="mode == 'EXAM' || mode == 'VIEW' ? (canDeleteWrong ? ['col-5'] : ['col-4']) : ['col-3']" -->
+    <view class="fix-bottom grid text-center bg-white cu-list" :class="[getBottomBarClass()]">
+      <view url="/pages/index/index" class="cu-item" @click="handleNumberPanel"><!-- v-if="mode != 'TRAINING'" -->
+        <image src="/static/img/caidan.png"></image>
+        <text>{{ swiperIndex }}/{{ total }}</text>
+      </view>
+
+      <view url="/pages/index/index" class="cu-item" @tap="prev">
+        <image src="/static/img/left.png"></image>
+        <text>上一题</text>
+      </view>
+
+      <view url="/pages/index/index" class="cu-item" @tap="next">
+        <image src="/static/img/right.png"></image>
+        <text>下一题</text>
+      </view>
+
+      <view url="/pages/index/index" class="cu-item" @tap="buttonClicked==true?submitShowModal():''"
+            v-if="mode == 'EXAM'">
+        <image src="/static/img/jiaojuan.png"></image>
+        <text>交卷</text>
+      </view>
+
+      <view url="/pages/index/index" class="cu-item" @tap="buttonClicked==true?endTrainShowModal():''"
+            v-if="mode == 'TRAINING'">
+        <image src="/static/img/jiaojuan.png"></image>
+        <text>结束练习</text>
+      </view>
+
+      <view url="/pages/index/index" class="cu-item" @tap="wrongDel()" v-if="mode == 'VIEW' && canDeleteWrong">
+        <image src="/static/img/delete.png"></image>
+        <text>删除</text>
+      </view>
+
+      <!-- <view url="/pages/index/index" class="cu-item" @tap="wrongClear()" v-if="mode == 'VIEW' && canDeleteWrong">
+        <image src="/static/img/round_close.png"></image>
+        <text>清空</text>
+      </view> -->
+    </view>
+
+    <!-- 题标 -->
+    <!-- <view class="fixed-bottom" :class="{ active: showNumberPanel }" @click.stop="handleNumberPanel">
+      <view class="tibiao" @click.stop>
+        <scroll-view scroll-y="true" class="tibiao-scroll">
+          <view class="tibiao-item" v-for="(item, index) in total" :key="index" :class="swiperIndex - 1 == index ? 'selected' : ''"
+            @click="changeQuestion(index)">
+            {{ index + 1 }}
+          </view>
+        </scroll-view>
+      </view>
+    </view> -->
+
+	<!-- 题标 -->
+    <view class="cu-modal bottom-modal fixed-bottom" :class="showNumberPanel?'show':''" @click.stop="handleNumberPanel">
+      <view class="cu-dialog tibiao" @click.stop>
+        <scroll-view scroll-y="true" class="tibiao-scroll">
+          <view class="tibiao-scroll-list">
+			<!-- class="tibiao-item" -->
+            <!-- :class="[getNumberPanelClass(index)]" -->
+            <!-- :class="swiperIndex - 1 == index ? 'selected' : (list[index] && (list[index].check || list[index].user_answers) ? 'right' : '')" -->
+            <view 
+                  :class="['tibiao-item', getNumberPanelClass(index)]"
+                  v-for="(item, index) in total" :key="index" @click="changeQuestion(index)">
+              {{ index + 1 }}
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+
+    <!-- 删除错题对话框 -->
+    <view class="cu-modal" :class="showDeleteDialog?'show':''">
+      <view class="cu-dialog">
+        <view class="cu-bar bg-white justify-end">
+          <view class="content">提示</view>
+          <view class="action" @tap="hideModal">
+            <text class="cuIcon-close text-red"></text>
+          </view>
+        </view>
+        <view class="padding-xl">
+          {{ wrongDeleteType == 'single' ? '确定删除该错题吗?' : '确定清空所有错题吗?' }}
+        </view>
+        <view class="cu-bar bg-white justify-end">
+          <view class="action">
+            <button class="cu-btn line-primary text-primary" @tap="hideModal">取消</button>
+            <button class="cu-btn bg-primary margin-left" @tap="confirmDelWrong">确定</button>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 结束练习对话框 -->
+    <tui-modal :show="showEndTrainDialog" @cancel="hideModal" :custom="true">
+      <view class="tui-modal-custom">
+        <view class="tui-prompt-title">练习结果</view>
+        <view class="tui-modal-custom-text">
+          <view class="">
+            正确题数:
+            <text class="text-green">{{ trainResult.right }}</text>
+            题
+          </view>
+          <view class="">
+            错误题数:
+            <text class="text-red">{{ trainResult.error }}</text>
+            题
+          </view>
+          <view class="">
+            未答题数:
+            <text class="text-yellow">{{ trainResult.unchecked }}</text>
+            题
+          </view>
+        </view>
+
+        <view class="tui-flex-box">
+          <view class="tui-flex-botton-view">
+            <tui-button margin="0 20rpx 0 0" height="72rpx" :size="28" shape="circle" @click="endTrain">确定退出
+            </tui-button>
+          </view>
+          <view class="tui-flex-botton-view">
+            <tui-button margin="0 20rpx 0 0" height="72rpx" :size="28" shape="circle" @click="hideModal" plain>取消
+            </tui-button>
+          </view>
+        </view>
+      </view>
+    </tui-modal>
+
+    <!-- 悬浮按钮 -->
+    <tn-fab
+        :btnList="fabBtnList"
+        left="auto"
+        :right="40"
+        :bottom="180"
+        :width="88"
+        :height="88"
+        :iconSize="45"
+        :zIndex="998"
+        backgroundColor="fab-bg-color"
+        fontColor="#aaa"
+        icon="up"
+        animationType="up"
+        :showMask="false"
+        :customBtn="true"
+        @click="clickFabItem"
+    >
+    </tn-fab>
+
+    <!-- 纠错反馈弹窗 -->
+    <tn-popup
+        v-model="showCorrection"
+        length="50%"
+        mode="bottom"
+        backgroundColor="#fff"
+        :zIndex="999"
+        :borderRadius="23"
+        :closeBtn="true"
+        :maskCloseable="false"
+    >
+      <view class="popup-content" :class="{'popup-content--center': mode === 'center'}">
+        <view class="tn-border-solid-bottom margin-top padding-bottom text-center text-bold" style="font-size: 30rpx;">
+          纠错反馈
+        </view>
+
+        <view class="padding">
+          <tn-checkbox-group @change="correctionTypeChange" activeColor="#5677fc" :size="28">
+            <tn-checkbox :labelSize="28" v-model="item.checked" v-for="(item, index) in correctionTypeslist"
+                         :key="index" :name="item.name">{{ item.name }}
+            </tn-checkbox>
+          </tn-checkbox-group>
+        </view>
+
+        <view style="padding: 0rpx 20rpx;">
+					<textarea v-model="correctionRemark"
+                    :maxlength="500"
+                    placeholder="其他错误,请描述您遇到的问题"
+                    style="background-color: #f3f3f3; padding: 20rpx; width: 100%; height: 150rpx; font-size: 28rpx;">
+					</textarea>
+        </view>
+
+        <view class="btn-confirm">
+          <tui-button type="primary" shape="circle" @click="submitCorrection" width="600rpx" height="70rpx" :size="26"
+                      margin="0 auto">提交
+          </tui-button>
+        </view>
+      </view>
+    </tn-popup>
+  </view>
+</template>
+
+<script>
+import correctionApi from "@/common/api/correction.js"
+import { cons_log } from "../../common/js/utils";
+
+export default {
+  name: "kz-question",
+  props: {
+    /**
+     * 模式
+     * EXAM:考试模式
+     * VIEW:看题模式
+     * TRAINING:练习模式
+     */
+    mode: {
+      type: String,
+      default: 'EXAM'
+    },
+    // 试卷标题
+    title: {
+      type: String,
+      default: '标题'
+    },
+    // 试题集
+    questions: {
+      type: Array,
+      default: () => []
+    },
+    // 试题集总数(不传默认获取试题集长度)
+    questionCount: {
+      type: Number,
+      default: 0
+    },
+    // 试题集每页条数
+    pageCount: {
+      type: Number,
+      default: 20
+    },
+    // 试题集当前页
+    currentPage: {
+      type: Number,
+      default: 1
+    },
+    // 考试时间(倒计时)
+    limit_time: {
+      type: Number,
+      default: 0
+    },
+    // 考试配置
+    configs: {
+      type: Object,
+      default: () => {
+      }
+    },
+    // 允许收藏试题
+    canCollect: {
+      type: Boolean,
+      default: true
+    },
+    // 允许删除错题
+    canDeleteWrong: {
+      type: Boolean,
+      default: false
+    },
+    // 看题/练题的模式:normal=普通模式,memory=记忆模式,random=随机查询
+    viewMode: {
+      type: String,
+      default: 'normal'
+    },
+    /**
+     * 试卷选题模式
+     * RANDOM:随机模式
+     * FIX:固定模式
+     */
+    paperMode: {
+      type: String,
+      default: 'RANDOM'
+    },
+    // 防切屏
+    isPreventSwitchScreen: {
+      type: Boolean,
+      default: false
+    },
+    // 切屏限制次数
+    switchScreenCount: {
+      type: Number,
+      default: 0
+    },
+  },
+  data() {
+    return {
+      // swiper当前下标
+      swiperIndex: 1,
+      // 总题数
+      total: 0,
+      // 试题集合
+      list: [],
+      // 题目面板
+      showNumberPanel: false,
+      // 防止交卷按钮点击多次
+      buttonClicked: true,
+      // 显示倒计时
+      showCountDown: false,
+      // 显示试题答案
+      showAnswer: false,
+      // 显示试题分数
+      showQuestionScore: false,
+      // 显示正确选择
+      showRightChoose: false,
+      // 显示错误选择
+      showErrorChoose: false,
+      // 显示结束练习对话框
+      showEndTrainDialog: false,
+      // 显示删除错题对话框
+      showDeleteDialog: false,
+      // 错题删除类型
+      wrongDeleteType: 'single',
+      // 答题开始时间
+      startTime: 0,
+      // 即将删除的错题ID
+      wrongDelId: 0,
+      // 练习结果
+      trainResult: {
+        right: 0,
+        error: 0,
+        unchecked: 0,
+        rightIds: [],
+        errorIds: [],
+      },
+      // 记忆模式相关数据
+      memoryData: {},
+      // 加载试题次数(以此来判断是否是第一次进入)
+      loadQuestionCount: 0,
+      // 延迟加载已加载页码
+      loadQuestionPage: [1,],
+      // 悬浮按钮列表
+      fabBtnList: [
+        {
+          text: '纠错反馈',
+          bgColor: '#fff',
+          textColor: '#aaa',
+          iconColor: '#aaa',
+          icon: 'edit-form',
+          iconSize: 45,
+        },
+		{
+		  text: '清空错题',
+		  bgColor: '#fff',
+		  textColor: '#aaa',
+		  iconColor: '#aaa',
+		  icon: 'clear',
+		  iconSize: 45,
+		},
+      ],
+      // 纠错反馈弹窗
+      showCorrection: false,
+      correctionTypeslist: [],
+      checkCorrectionTypeslist: [],
+      correctionRemark: '',
+    };
+  },
+  watch: {
+    // 加载试题时
+    questions: function () {
+      let questionCount = this.questionCount ? this.questionCount : this.questions.length
+      this.total = questionCount < this.questions.length ? this.questions.length : questionCount
+      this.list = [...this.questions]
+
+      // 材料题处理
+      this.showFullMaterialTitle(0)
+      // 填空题处理
+      this.splitFillTitle(0)
+      // 初始化模式配置
+      this.initMode()
+      // 记忆模式 - 跳转上次题标
+      this.jumpMemoryQuestion()
+
+    },
+  },
+  computed: {
+    // 试题类型
+    kindText() {
+      return function () {
+        return this.list[this.swiperIndex - 1] ? this.list[this.swiperIndex - 1].kind_text : '未知'
+      }
+    },
+    // 试题标题
+    questionTitle() {
+      return function (index, item, field = 'title') {
+        let html = item[field]
+        html = this.utils.formatRichText(html)
+        html += this.getQuestionTitleScore(index, item)
+
+        if (field == 'material_title') {
+          // console.log('material_title show_full', item.show_full)
+          if (this.list[index] && !this.list[index].show_full) {
+            // if (!item.show_full) {
+            // if (!show_full) {
+            html = this.truncatedText(html)
+            // console.log('material_title', html)
+          }
+        }
+
+        return html
+      }
+    },
+    // 试题解析
+    questionExplain() {
+      return function (index, item) {
+        return this.utils.formatRichText(item.explain)
+      }
+    },
+    // 获取试题分数
+    getQuestionTitleScore() {
+      return function (index, item) {
+        if (this.showQuestionScore) {
+          return '(' + item.score + '分)'
+          // return '(' + this.getSingleScore(item.kind, item.difficulty, item) + '分)'
+        }
+
+        return ''
+      }
+    },
+    // 截取文本
+    truncatedText() {
+      return function (text, maxLength = 100) {
+        if (text.length > maxLength) {
+          return text.slice(0, maxLength) + '...';
+        }
+        return text;
+      }
+    },
+    // 设置填空题答案
+    setFillInputValue() {
+      return (titleIndex) => {
+        let index = this.swiperIndex - 1
+        if (this.mode == 'VIEW') {
+          if (this.list[index].answer) {
+            if (this.list[index].answer[titleIndex].answers != undefined) {
+              return this.list[index].answer[titleIndex].answers[0]
+            }
+          }
+        } else {
+          if (this.list[index].user_answers) {
+            return this.list[index].user_answers[titleIndex]
+          }
+        }
+
+        return ''
+      }
+    },
+    // 设置简答题答案
+    setShortInputValue() {
+      return () => {
+        let index = this.swiperIndex - 1
+        if (this.mode == 'VIEW') {
+          if (this.list[index].answer) {
+            if (this.list[index].answer.answer != undefined) {
+              return this.list[index].answer.answer
+            }
+          }
+        } else {
+          if (this.list[index].user_answers) {
+            return this.list[index].user_answers
+          }
+        }
+
+        return ''
+      }
+    },
+    // 设置底部栏样式
+    getBottomBarClass() {
+      return () => {
+        // console.log('getBottomBarClass', this.mode)
+        let classes = []
+        if (this.mode == 'EXAM') {
+          if (this.canDeleteWrong) {
+            classes = ['col-5']
+          } else {
+            classes = ['col-4']
+          }
+        } else if (this.mode == 'TRAINING') {
+          if (this.canDeleteWrong) {
+            classes = ['col-5']
+          } else {
+            classes = ['col-4']
+          }
+        } else if (this.mode == 'VIEW') {
+          if (this.canDeleteWrong) {
+            classes = ['col-4']
+          } else {
+            classes = ['col-3']
+          }
+        } else {
+          classes = ['col-3']
+        }
+
+        return classes
+      }
+    },
+    // 设置答题卡样式
+    getNumberPanelClass() {
+      return (index) => {
+        if (this.swiperIndex - 1 == index) {
+          return ['selected']
+        }
+
+        let classes = []
+        let question = this.list[index]
+        // console.log('getNumberPanelClass question', question)
+        if (question && question.kind) {
+          switch (question.kind) {
+            case 'JUDGE':
+            case 'SINGLE':
+            case 'MULTI':
+              if (question.is_right) {
+				  if (question.is_right == 'right') {
+					  return ['tibiao-right']
+				  } else {
+					  return ['tibiao-error']
+				  }
+              }
+			  if (question.check) {
+                return ['tibiao-right']
+              }
+              break
+
+            case 'FILL':
+            case 'SHORT':
+              if (question.user_answers) {
+				  if (question.is_right) {
+					  if (question.is_right == 'right') {
+						  return ['tibiao-right']
+					  } else {
+						  return ['tibiao-error']
+					  }
+				  }
+                return ['tibiao-right']
+              }
+              break
+
+            default:
+              return classes
+          }
+        }
+
+        return classes
+      }
+    },
+  },
+  methods: {
+    // 初始化模式配置
+    initMode(reset = false) {
+      switch (this.mode) {
+          // 考试模式
+        case 'EXAM':
+          this.showCountDown = true
+          this.showAnswer = false
+          this.showQuestionScore = true
+          this.showRightChoose = false
+          this.showErrorChoose = false
+          this.startTime = this.utils.timestamp()
+          break
+
+          // 练习模式
+        case 'TRAINING':
+          this.showCountDown = false
+          this.showAnswer = false
+          this.showQuestionScore = false
+          this.showRightChoose = false
+          this.showErrorChoose = true
+          break
+
+          // 看题模式
+        case 'VIEW':
+          this.showCountDown = false
+          this.showAnswer = true
+          this.showQuestionScore = false
+          this.showRightChoose = true
+          this.showErrorChoose = false
+
+          // 显示试题正确的选项
+          this.list.forEach((item, index) => {
+            if (item.options_json) {
+              // console.log('item.options_json', item.options_json)
+              Array.from(item.options_json).forEach((optionItem, optionIndex) => {
+                item.options_json[optionIndex].click_index = true
+                item.options_json[optionIndex].classes = item.answer.indexOf(optionItem.key) > -1 ? 'active_true' : ''
+              })
+
+              this.list[index] = item
+            }
+          })
+          break
+      }
+    },
+    // 计算试题积分
+    getSingleScore(kind, difficulty, question) {
+      if (this.paperde == 'FIX') {
+        return question.score
+      }
+
+      const configs = this.configs[kind.toLowerCase()];
+      if (configs && configs['use_difficulty']) {
+        return configs['difficulty'][difficulty.toLowerCase()]['score'];
+      }
+      return configs['score'];
+    },
+    // 延迟加载试题
+    loadQuestion() {
+      if (this.mode != 'TRAINING' && this.mode != 'VIEW') {
+        return
+      }
+      if (this.viewMode != 'normal') {
+        return
+      }
+
+      // 取下一页数据
+      let page = Math.round(this.swiperIndex / this.pageCount) + 1
+      if (!this.loadQuestionPage.includes(page)) {
+        this.loadQuestionPage.push(page)
+        this.$emit('loadQuestion', page)
+      }
+    },
+    // 滑动切题
+    swiperChange(e, type = '') {
+      // console.log('swiperChange', this.list.length, this.swiperIndex, e.detail.current)
+      // 当前题标
+      this.swiperIndex = e.detail.current + 1
+      // 材料题处理
+      this.showFullMaterialTitle(e.detail.current)
+      // 填空题处理
+      this.splitFillTitle(e.detail.current)
+      // 加载题目
+      this.loadQuestion()
+      // 记忆模式
+      this.memoryQuestion()
+    },
+    // 上一题
+    prev() {
+      if (this.swiperIndex > 1) {
+        this.swiperIndex--
+      }
+    },
+    // 下一题
+    next() {
+      // console.log('next', this.list.length, this.swiperIndex)
+      if (this.list.length - this.swiperIndex >= 1) {
+        // 填空题处理
+        this.splitFillTitle(this.swiperIndex)
+        this.swiperIndex++
+      } else {
+        let title = '没有更多题了~'
+        if (this.mode == 'EXAM') {
+          title = '可以交卷了~'
+        }
+
+        uni.showToast({
+          title: title,
+          icon: 'none',
+          duration: 1500
+        })
+      }
+    },
+    // 选择
+    chooseItem(questionIndex, optionIndex, kind) {
+      // 看题模式不让选
+      if (this.mode == 'VIEW') {
+        return
+      }
+
+      let questionItem = this.list[questionIndex]
+
+      switch (kind) {
+          // 多选题
+        case 'MULTI':
+          questionItem.options_json[optionIndex].click_index = !questionItem.options_json[optionIndex].click_index;
+          //多选 确认按钮
+          let arr = []
+          questionItem.options_json.forEach((item, index) => {
+            if (item.click_index == true) {
+              arr.push(item.key)
+            }
+          })
+          questionItem.check = arr.join(',')
+          questionItem.selected = true
+          break
+
+          // 单选/判断
+        default:
+          questionItem.options_json.forEach((item, index) => {
+            questionItem.options_json[index].click_index = false
+            questionItem.options_json[index].classes = ''
+          })
+          questionItem.options_json[optionIndex].click_index = true
+          questionItem.check = questionItem.options_json[optionIndex].key
+          questionItem.selected = true
+          break
+      }
+
+      questionItem.options_json[optionIndex].classes = this.optionChooseClass(questionIndex, optionIndex, questionItem, questionItem.options_json[optionIndex])
+      this.list[questionIndex] = questionItem
+
+      // #ifdef H5
+      this.$forceUpdate()
+      // #endif
+    },
+    // 选项选择样式
+    optionChooseClass(questionIndex, optionIndex, questionItem, optionItem) {
+      if (this.showRightChoose) {
+        let classNames = ''
+
+        if ((questionItem.selected && optionItem.code) || (questionItem.kind == 'MULTI' && optionItem.click_index)) {
+          classNames = 'active_true'
+        }
+        if ((questionItem.kind != 'MULTI' && optionItem.click_index && !optionItem.code) || (questionItem.kind == 'MULTI' && optionItem.click_index && !optionItem.code && questionItem.selected)) {
+          classNames += ' active'
+        }
+
+        return classNames
+      } else {
+        // 练习模式,选择后显示错误选项
+        if (this.showErrorChoose) {
+          let result = ''
+          switch (questionItem.kind) {
+              // 多选题
+            case 'MULTI':
+				let isRight = true
+				let answer_arr = questionItem.answer.split(',')
+				console.log('questionItem.answer', questionItem.answer, questionItem.check, answer_arr)
+				
+				let check_arr = questionItem.check.indexOf(',') > 0 ? questionItem.check.split(',') : [questionItem.check]
+				for (const check_answer of check_arr) {
+					if (!answer_arr.includes(check_answer)) {
+						isRight = false
+						break
+					}
+				}
+			
+              // 选择数量跟答案数量一致
+              if (questionItem.check && (!isRight || questionItem.check.length == questionItem.answer.length)) {
+                // 选择后显示答案和解析
+                questionItem.show_answer = true
+                this.list[questionIndex] = questionItem
+              }
+
+              result = questionItem.answer.indexOf(optionItem.key) > -1 ? 'active_true' : 'active'
+              break
+
+              // 单选/判断
+            default:
+              // 选择后显示答案和解析
+              questionItem.show_answer = true
+              this.list[questionIndex] = questionItem
+              // 选择正确与否
+              result = optionItem.key == questionItem.answer ? 'active_true' : 'active'
+              break
+          }
+
+          // 记录练习结果
+          if (result === 'active_true') {
+            // this.trainResult.right++
+            // this.trainResult.rightIds.push(questionItem.id)
+			this.list[questionIndex]['is_right'] = 'right'
+          } else {
+            // this.trainResult.error++
+            // this.trainResult.errorIds.push(questionItem.id)
+			this.list[questionIndex]['is_right'] = 'error'
+          }
+
+          return result
+        } else {
+          if (optionItem.click_index) {
+            return 'active_true'
+          }
+        }
+      }
+
+      return ''
+    },
+    // 获取未答题数量
+    getUncheckedCount() {
+      if (this.mode === 'EXAM') {
+        let unchecked = []
+        for (let i in this.list) {
+          let question = this.list[i]
+          let item = {
+            id: question.id,
+            answer: ''
+          }
+
+          switch (question.kind) {
+            case 'JUDGE':
+            case 'SINGLE':
+            case 'MULTI':
+              if (question.check) {
+                item.answer = question.check
+              } else {
+                unchecked.push({
+                  id: question.id
+                })
+              }
+              break
+
+            case 'FILL':
+              if (question.user_answers) {
+                item.answer = question.user_answers
+              } else {
+                unchecked.push({
+                  id: question.id
+                })
+              }
+              break
+
+            case 'SHORT':
+              if (question.user_answers) {
+                item.answer = question.user_answers
+              } else {
+                unchecked.push({
+                  id: question.id
+                })
+              }
+              break
+          }
+        }
+
+        return unchecked.length
+      } else {
+		  let right = 0
+		  let error = 0
+		  let unchecked = 0
+		  for (let i in this.list) {
+			  if (this.list[i].is_right) {
+				  if (this.list[i].is_right == 'right') {
+					  right++
+				  } else {
+					  error++
+				  }
+			  } else {
+				  unchecked++
+			  }
+		  }
+  //       this.trainResult.right = (Array.from(new Set(this.trainResult.rightIds))).length
+		// this.trainResult.error = (Array.from(new Set(this.trainResult.errorIds))).length
+		this.trainResult.right = right
+		this.trainResult.error = error
+        let count = this.total - this.trainResult.right - this.trainResult.error
+        return count > 0 ? count : 0;
+      }
+    },
+    // 左下角交卷按钮点击弹窗
+    submitShowModal() {
+      this.buttonClicked = false
+
+      let unchecked_count = this.getUncheckedCount()
+      let modal_title = '确认要交卷吗?'
+      if (unchecked_count > 0) {
+        modal_title = `还有${unchecked_count}道题未答,` + modal_title
+      }
+
+      uni.showModal({
+        title: '提示',
+        content: modal_title,
+        success: res => {
+          if (res.confirm) {
+            this.submit()
+          } else {
+            this.buttonClicked = true
+          }
+        }
+      })
+    },
+    // 倒计时结束
+    endOfTime() {
+      // 非考试模式不处理
+      if (this.mode != 'EXAM') {
+        return
+      }
+      // 为0时不处理
+      if (this.limit_time == 0) {
+        return
+      }
+
+      uni.showToast({
+        title: '考试时间到,即将自动交卷',
+        duration: 2500,
+      })
+      this.buttonClicked = true
+
+      setTimeout(() => {
+        this.submit()
+      }, 2500)
+    },
+    // 交卷
+    submit() {
+      let data = {}
+      let questions = {}
+
+      let list = this.list
+      for (let i in list) {
+        let item = {}
+
+        switch (list[i].kind) {
+          case 'JUDGE':
+          case 'SINGLE':
+          case 'MULTI':
+            item.id = list[i].id
+            item.answer = list[i].check ? list[i].check : ''
+            break
+
+          case 'FILL':
+            item.id = list[i].id
+            item.answer = list[i].user_answers ? list[i].user_answers : []
+            break
+
+          case 'SHORT':
+            item.id = list[i].id
+            item.answer = list[i].user_answers ? list[i].user_answers : ''
+            break
+
+          default:
+            break
+        }
+
+        // 材料题主题ID
+        item.material_id = list[i].material_id ? list[i].material_id : 0
+        questions[i] = item
+      }
+
+      data.start_time = this.startTime
+      data.questions = questions
+      this.$emit('submitQuestion', data)
+    },
+    // 控制题目面板显示隐藏
+    handleNumberPanel() {
+      // if (this.mode == 'TRAINING') {
+      // 	return
+      // }
+      this.showNumberPanel = !this.showNumberPanel;
+    },
+    // 题目面板跳题
+    async changeQuestion(e, type = '') {
+      // 题已加载,直接跳
+      if (e + 1 <= this.list.length) {
+        // this.swiperIndex = e + 1
+        this.showNumberPanel = !this.showNumberPanel
+        this.swiperChange({detail: {current: e}},)
+      } else {
+        // 题未加载,一页页加载
+        let currLastPage = this.loadQuestionPage.at(-1)
+        let willGetPage = Math.round(e / this.pageCount) + 1
+
+
+        for (var page = currLastPage; page <= willGetPage; page++) {
+
+          if (this.loadQuestionPage.includes(page)) {
+            continue;
+          }
+
+          await new Promise((resolve, reject) => {
+            this.$emit('loadQuestion', page, () => {
+              this.loadQuestionPage.push(page)
+
+              if (page >= willGetPage) {
+                setTimeout(() => {
+                  this.changeQuestion(e, 'digui')
+                }, 1000)
+              }
+
+              resolve()
+            })
+          })
+
+        }
+      }
+    },
+    // 记录错题
+    wrong(id) {
+      this.http('question/wrongAdd', {
+        question_id: id,
+      }, 'get').then(res => {
+      })
+    },
+    // 删除错题
+    wrongDel(showDialog = true) {
+      // 弹窗提示
+      if (showDialog) {
+        this.showDeleteDialog = true
+        this.wrongDeleteType = 'single'
+        return
+      }
+
+      this.hideModal()
+
+      // 执行删除
+      this.http('question/wrongDelete', {
+        question_id: this.list[this.swiperIndex - 1].id
+      }, 'get').then(res => {
+        uni.showToast({
+          icon: 'none',
+          title: res.msg
+        })
+
+        if (res.code == 1) {
+          setTimeout(() => {
+            this.$emit('refresh')
+          }, 1200)
+        }
+      });
+    },
+    // 清空错题
+    wrongClear(showDialog = true) {
+      // 弹窗提示
+      if (showDialog) {
+        this.showDeleteDialog = true
+        this.wrongDeleteType = 'all'
+        return
+      }
+
+      this.hideModal()
+
+      // 执行清空
+      this.http('question/wrongClear', {}).then(res => {
+        uni.showToast({
+          icon: 'none',
+          title: res.msg
+        })
+
+        if (res.code == 1) {
+          setTimeout(() => {
+            this.$emit('refresh')
+          }, 1200)
+        }
+      });
+    },
+    // 确认删除错题
+    confirmDelWrong() {
+      if (this.wrongDeleteType == 'single') {
+        this.wrongDel(false)
+      } else if (this.wrongDeleteType == 'all') {
+        this.wrongClear(false)
+      }
+
+    },
+    // 隐藏弹窗
+    hideModal(e) {
+      this.showDeleteDialog = false
+      this.showEndTrainDialog = false
+      this.buttonClicked = true
+    },
+    // 添加收藏
+    collectAdd() {
+      let id = this.list[this.swiperIndex - 1].id
+      let index = this.swiperIndex - 1
+
+      this.http('question/collectAdd', {
+        question_id: id,
+      }, 'get').then(res => {
+        uni.showToast({
+          icon: 'none',
+          title: res.msg
+        })
+
+        if (res.code == 1) {
+          this.list[index]['collected'] = true
+          this.$forceUpdate()
+        }
+      });
+    },
+    // 取消收藏
+    collectDel() {
+      let id = this.list[this.swiperIndex - 1].id
+      let index = this.swiperIndex - 1
+
+      this.http('question/collectCancel', {
+        question_id: id
+      }, 'get').then(res => {
+        uni.showToast({
+          icon: 'none',
+          title: res.msg
+        })
+
+        if (res.code == 1) {
+          this.list[index]['collected'] = false
+          this.$forceUpdate()
+        }
+      });
+    },
+    // 结束练习弹窗
+    endTrainShowModal() {
+      this.buttonClicked = false
+      this.trainResult.unchecked = this.getUncheckedCount()
+      this.showEndTrainDialog = true
+    },
+    // 结束练习
+    endTrain() {
+      this.utils.goback()
+    },
+    // 记忆模式 - 缓存key
+    getMemoryCacheKey(cate_id) {
+      return this.mode.toLowerCase() + '-' + cate_id
+    },
+    // 记忆当前题目信息
+    memoryQuestion() {
+      if (this.viewMode != 'memory') {
+        return
+      }
+
+      let question = this.list[0]
+      let data = {
+        memory_cate_id: question.cate_id,
+        memory_question_id: question.id,
+        memory_index: this.swiperIndex,
+      }
+      this.memoryData = data
+
+      // 缓存当前题目信息
+      let cache_key = this.getMemoryCacheKey(question.cate_id) //this.mode.toLowerCase() + '-' + question.cate_id
+      this.utils.setData(cache_key, data)
+    },
+    // 记忆模式 - 跳转上次题标
+    jumpMemoryQuestion() {
+      // 记忆模式且是第一次加载试题
+      if (this.viewMode == 'memory' && this.loadQuestionCount == 0) {
+        let cache_key = this.getMemoryCacheKey(this.questions[0].cate_id)
+        this.memoryData = this.utils.getData(cache_key)
+        if (this.memoryData) {
+          // 跳转到上次题标位置
+          this.swiperIndex = this.memoryData.memory_index
+        }
+      }
+    },
+    // 图片预览
+    previewImage(image) {
+      uni.previewImage({
+        current: 0,
+        urls: [image]
+      })
+    },
+    // 填空题 - 处理题目数据
+    splitFillTitle(index) {
+      if (this.list[index] && this.list[index].kind == 'FILL') {
+        if (!this.list[index].title_data) {
+          this.list[index]['title_data'] = this.list[index].title.split('______')
+        }
+
+        // 未回答标识
+        if (!this.list[index].is_answered) {
+          this.list[index]['is_answered'] = false
+        }
+      }
+    },
+    // 填空题 - 文本框修改
+    changeFillInput(e, titleIndex, swiperIndex) {
+      if (this.mode == 'VIEW') {
+        return
+      }
+
+      if (!this.list[swiperIndex - 1]['user_answers']) {
+        this.list[swiperIndex - 1]['user_answers'] = []
+      }
+
+      this.list[swiperIndex - 1].user_answers[titleIndex] = e.target.value
+    },
+    // 填空题 - 练习模式 - 确认答案
+    confirmFillAnswer() {
+      let index = this.swiperIndex - 1
+      if (!this.list[index].user_answers || this.list[index].user_answers.length != this.list[index].answer.length) {
+        this.utils.toast('请在文本框填写完整的答案')
+        return
+      }
+
+      let right_count = 0
+      for (var i = 0; i < this.list[index].user_answers.length; i++) {
+        let user_answer = this.list[index].user_answers[i]
+        if (user_answer === '') {
+          this.utils.toast('第' + (i + 1) + '个文本框未填写答案')
+          return
+        }
+
+        let is_right = false
+        for (var j = 0; j < this.list[index].answer[i].answers.length; j++) {
+          let right_answer = this.list[index].answer[i].answers[j]
+          if (user_answer === right_answer) {
+            is_right = true
+            break
+          }
+        }
+
+        // 填空题对错 - 文本框样式
+        this.list[index].answer[i]['class'] = is_right ? 'fill-input-right' : 'fill-input-error'
+        if (is_right) {
+          right_count++
+        }
+      }
+
+      // 练习模式,选择后显示错误选项
+      if (this.showErrorChoose) {
+        // 记录练题情况
+        if (right_count == this.list[index].user_answers.length) {
+          // this.trainResult.right++
+		  this.list[index]['is_right'] = 'right'
+        } else {
+          // this.trainResult.error++
+		  this.list[index]['is_right'] = 'error'
+        }
+      }
+
+      // 显示答案
+      this.list[index]['show_answer'] = true
+      // 标记为已回答
+      this.list[index]['is_answered'] = true
+
+      this.$forceUpdate()
+    },
+    // 简答题 - 文本框修改
+    changeShortInput(e, swiperIndex) {
+      if (this.mode == 'VIEW') {
+        return
+      }
+
+      if (!this.list[swiperIndex - 1]['user_answers']) {
+        this.list[swiperIndex - 1]['user_answers'] = ''
+      }
+
+      this.list[swiperIndex - 1].user_answers = e.target.value
+    },
+    // 简答题 - 练习模式 - 确认答案
+    confirmShortAnswer() {
+      let index = this.swiperIndex - 1
+      if (!this.list[index].user_answers) {
+        this.utils.toast('请在文本框填写完整的答案')
+        return
+      }
+
+      let right_count = 0
+      // let right_indexes = []
+      for (var i = 0; i < this.list[index].answer.config.length; i++) {
+        this.list[index].answer.config[i]['class'] = ''
+        if (this.list[index].user_answers.indexOf(this.list[index].answer.config[i].answer) > -1) {
+          right_count++
+          this.list[index].answer.config[i]['class'] = 'short-input-right'
+          // right_indexes.push(i)
+        }
+      }
+
+      // 练习模式,选择后显示错误选项
+      if (this.showErrorChoose) {
+        // 记录练题情况
+        if (right_count > 0) {
+          // this.trainResult.right++
+		  this.list[index]['is_right'] = 'right'
+        } else {
+          // this.trainResult.error++
+		  this.list[index]['is_right'] = 'error'
+        }
+      }
+
+
+      // 显示答案
+      this.list[index]['show_answer'] = true
+      // 标记为已回答
+      this.list[index]['is_answered'] = true
+
+      this.$forceUpdate()
+    },
+    // 材料题 - 处理材料题目数据
+    showFullMaterialTitle(index, item, status = false) {
+      if (item && item.material_title != undefined) {// item.kind == 'MATERIAL'
+        // 未回答标识
+        if (!item.show_full) {
+          item['show_full'] = false
+          if (this.list[index]) {
+            this.list[index]['show_full'] = false
+          }
+        }
+
+        item.show_full = status
+        if (this.list[index]) {
+          this.list[index].show_full = status
+        }
+        // console.log('showFullMaterialTitle', item.show_full)
+        this.$forceUpdate()
+      }
+    },
+    // 点击悬浮按钮的内容
+    clickFabItem(e) {
+      console.log('clickFabItem', e)
+      // 错题反馈
+      if (e.index === 0) {
+        correctionApi.getCorrectionTypes(this, {}).then(res => {
+          this.correctionTypeslist = res.data.types
+          this.showCorrection = true
+        })
+      } else if (e.index === 1 && this.canDeleteWrong) {
+		  // 清空错题
+		  this.wrongClear()
+	  }
+    },
+    // 纠错反馈类型选择
+    correctionTypeChange(e) {
+      console.log('correctionTypeChange', e)
+      this.checkCorrectionTypeslist = e
+    },
+    // 提交纠错反馈
+    submitCorrection() {
+      if (this.checkCorrectionTypeslist.length == 0) {
+        this.utils.toast('请选择纠错类型')
+        return
+      }
+
+      let data = {
+        question_id: this.list[this.swiperIndex - 1].id,
+        type_names: this.checkCorrectionTypeslist,
+        remark: this.correctionRemark
+      }
+      correctionApi.submitCorrection(this, data).then(res => {
+        if (res.code) {
+          this.checkCorrectionTypeslist = []
+          this.correctionRemark = ''
+          this.utils.toast(res.msg)
+        }
+        this.showCorrection = false
+      })
+    },
+  }
+}
+</script>
+
+<style lang="less">
+page {
+  height: 100%;
+}
+
+.questions {
+  height: 100%;
+  position: relative;
+
+  .test-header {
+    width: 100%;
+    padding: 0 30rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 80rpx;
+    background: #fff;
+    position: relative;
+    font-size: 34rpx;
+  }
+
+  .card-shadow {
+    margin-top: 20rpx;
+
+    .topic-title {
+      font-size: 34rpx;
+      padding: 30rpx 20rpx;
+      background: #fff;
+      border-bottom: 1px solid #f0f0f0;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+
+      .topic-title_left {
+        display: flex;
+        align-items: center;
+
+        .text-kind {
+          font-size: 24rpx;
+          color: #fff;
+          background: linear-gradient(135deg, #7892fd, #5677fc);
+          padding: 8rpx 10rpx;
+          border-radius: 15rpx 15rpx 15rpx 0;
+          margin-right: 20rpx;
+        }
+      }
+
+      .title-index {
+        color: #5677fc;
+      }
+    }
+
+    .questions-cont {
+      height: 100vh;
+      display: flex;
+      flex-wrap: nowrap;
+      transition: all 0.5s;
+
+      .swiper-item {
+        width: 100vw;
+        min-width: 100vw;
+        max-width: 100vw;
+        height: 100%;
+        overflow: auto;
+
+        .test-main {
+          padding: 0 20rpx;
+          // margin: 40rpx 30rpx;
+          // margin-bottom: 40rpx;
+          // border-radius: 8px;
+          background: #fff;
+
+          .test-title {
+            color: #333;
+            padding: 20rpx 0;
+
+            .text-kind {
+              font-size: 24rpx;
+              color: #fff;
+              background: linear-gradient(135deg, #7892fd, #5677fc);
+              padding: 8rpx 10rpx;
+              border-radius: 15rpx 15rpx 15rpx 0;
+            }
+
+            .test-favor {
+              position: relative;
+              color: #aaa;
+              float: right;
+            }
+
+            .test-favor-fill {
+              background: #fff;
+              color: #fbbd08;
+              float: right;
+            }
+          }
+
+          .test-title-fill {
+            width: 100%;
+            word-wrap: break-word;
+            word-break: break-all;
+            display: flex;
+            flex-wrap: wrap;
+          }
+
+          .test-title-fill-item {
+            margin: 5px;
+          }
+
+          .test-cont {
+            display: flex;
+            flex-direction: column;
+            padding-bottom: 20rpx;
+            color: #333333;
+
+            .test-cont-item {
+              padding: 20rpx;
+              display: flex;
+              background-color: #f6f6f6;
+              margin-bottom: 20rpx;
+              border-radius: 10rpx;
+              align-items: center;
+              justify-content: center;
+              position: relative;
+
+              &::after {
+                background: #333;
+                content: "";
+                width: 100%;
+                height: 100%;
+                position: absolute;
+                opacity: 0;
+                transition: all 0.35s;
+              }
+
+              &:active::after {
+                opacity: .3;
+                width: 0%;
+                transition: 0s;
+              }
+
+              .cont {
+                flex: 1;
+                height: 100%;
+                padding-left: 20rpx;
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+
+                .cont-text {
+                  display: flex;
+                  align-items: center;
+                  flex-wrap: wrap;
+                  flex: 1;
+                  height: 100%;
+                  font-p: 32rpx;
+
+                  .image {
+                    width: 50%;
+                    height: auto;
+                    margin-right: 20rpx;
+                  }
+                }
+
+                .cont-icon {
+                  width: 40rpx;
+                  margin-left: 20rpx;
+                  color: #5677fc;
+                  font-size: 36rpx;
+                }
+              }
+
+              .key {
+                width: 50rpx;
+                height: 50rpx;
+                background-color: #d0d0d0;
+                border-radius: 50%;
+                color: #FFFFFF;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+              }
+
+              &.active_true {
+                background-color: rgba(86, 119, 252, 0.2);
+
+                .key {
+                  background-color: #5677fc;
+                }
+
+                .cont {
+                  .cont-icon {
+                    font-weight: bold;
+                    color: #5677fc;
+                  }
+                }
+              }
+
+              &.active {
+                background-color: rgba(255, 68, 0, 0.2);
+
+                .key {
+                  background-color: #ff4400;
+                }
+
+                .cont {
+                  .cont-icon {
+                    font-weight: bold;
+                    color: #ff4400;
+                  }
+                }
+              }
+
+            }
+          }
+        }
+
+        .test-describe {
+          // padding: 0 20rpx;
+          margin-bottom: 20px;
+
+          .describe-title {
+            height: 48px;
+            line-height: 48px;
+            display: flex;
+
+            text {
+              color: #666;
+              font-size: 12px;
+            }
+
+            image {
+              width: 14px;
+              height: 14px;
+              margin-top: 17px;
+              margin-left: 3px;
+            }
+          }
+
+          .describe-cont {
+            // background: #f5f5f5;
+            // padding: 12rpx;
+            display: flex;
+            flex-direction: column;
+            font-size: 34rpx;
+
+            & > view {
+              color: #666;
+              font-size: 15px;
+              line-height: 40px;
+              background-color: #fff;
+              margin-bottom: 10px;
+              text-indent: 15px;
+              // border-radius: 10rpx;
+
+              &:nth-child(3) {
+                font-size: 12px;
+                line-height: 20px;
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+
+  .fixed-bottom {
+    .tibiao {
+      background: #fff;
+      width: 100%;
+      height: 50vh;
+      padding: 35rpx;
+      padding-bottom: calc(constant(safe-area-inset-bottom) + 35rpx);
+      padding-bottom: calc(constant(safe-area-inset-bottom) + 35rpx);
+      border-radius: 20rpx 20rpx 0 0;
+
+      .tibiao-scroll {
+        height: 100%;
+
+        .tibiao-scroll-list {
+          display: flex;
+          align-items: center;
+          flex-wrap: wrap;
+
+          .tibiao-item {
+            height: 100rpx;
+            width: 100rpx;
+            border-radius: 50%;
+            margin-bottom: 30rpx;
+            border: 1rpx solid #d0d0d0;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            margin-right: 45rpx;
+
+            &:nth-child(5n) {
+              margin-right: 0;
+            }
+
+            &.tibiao-right {
+              background: #4caf50;
+              color: #fff;
+            }
+
+            &.tibiao-error {
+              background: #ff4400;
+              color: #fff;
+            }
+
+            &.selected {
+              background: #5677fc;
+              color: #fff;
+            }
+          }
+        }
+
+
+      }
+    }
+  }
+}
+
+
+.cu-list {
+  width: 100%;
+  height: 100rpx;
+  position: fixed;
+  left: 0;
+  bottom: 0;
+  text-align: center;
+  border-radius: 8px;
+}
+
+.cu-list.grid > .cu-item {
+  padding-top: 5px;
+}
+
+.cu-list image {
+  width: 25px;
+  height: 25px;
+  display: inline-block;
+  margin: 0 auto;
+}
+
+// 弹窗
+.result {
+  width: 100%;
+  height: 100vh;
+  background: #fff;
+  padding-top: 10px;
+}
+
+.progress_box {
+  position: relative;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
+
+.progress_bg {
+  position: absolute;
+  width: 220px;
+  height: 220px;
+}
+
+.progress_bar {
+  width: 220px;
+  height: 220px;
+}
+
+.progress_txt {
+  position: absolute;
+  font-size: 28upx;
+  color: #999999;
+}
+
+.progress_info {
+  font-size: 36upx;
+  padding-left: 16upx;
+  letter-spacing: 2upx;
+  font-size: 52upx;
+  color: #333333;
+}
+
+.progress_dot {
+  width: 16upx;
+  height: 16upx;
+  border-radius: 50%;
+  background-color: #fb9126;
+}
+
+.table {
+  width: 90%;
+  margin: 0 auto;
+  overflow: hidden;
+}
+
+.flex {
+  height: 50px;
+  line-height: 50px;
+  border-bottom: 1px solid #ddd;
+}
+
+.flex_1 {
+  text-align: left;
+}
+
+.flex_2 {
+  text-align: right;
+}
+
+.red {
+  color: #f00;
+}
+
+.error {
+  display: inline-block;
+  height: 30px;
+  line-height: 30px;
+  border-radius: 5px;
+  padding: 0 10px;
+  margin-left: 15px;
+}
+
+/*收藏 */
+
+.tui-fabulous__box {
+  position: relative;
+}
+
+.tui-fabulous {
+  position: absolute;
+  left: 60px;
+  top: 0;
+  visibility: hidden;
+}
+
+.tui-fabulous__active {
+  animation: fabulousAni 2s linear;
+}
+
+@keyframes fabulousAni {
+  0% {
+    transform: translateY(0) scale(0.8);
+    visibility: visible;
+    opacity: 1;
+  }
+
+  15% {
+    transform: translateY(-40px) scale(1.25);
+    opacity: 1;
+  }
+
+  100% {
+    transform: translateY(-240px) scale(0.5);
+    visibility: hidden;
+    opacity: 0;
+  }
+}
+
+/* 红心收藏效果  */
+
+.cu-list.grid > .cu-item text {
+  margin-top: 0;
+}
+
+.cu-list.grid > .cu-item:after {
+  border: 0px;
+}
+
+.fix-bottom {
+  bottom: calc(constant(safe-area-inset-bottom) + 30rpx);
+  bottom: calc(env(safe-area-inset-bottom) + 30rpx);
+  width: 95%;
+  position: fixed;
+  margin: 0 auto;
+  left: 0;
+  right: 0;
+}
+
+.tui-prompt-title {
+  padding-bottom: 20rpx;
+  font-size: 34rpx;
+  font-weight: bold;
+  text-align: center;
+}
+
+.tui-flex-box {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  margin-top: 40rpx;
+}
+
+.tui-flex-botton-view {
+  width: 45%;
+  margin: 0 auto;
+}
+
+@keyframes anime {
+  0% {
+    background-size: 0% 0%;
+  }
+
+  100% {
+    background-position: 100% 100%;
+  }
+}
+
+/** 填空题输入框 */
+.fill-input {
+  border: 0px;
+  border-bottom: 2px solid #5677fc;
+  width: 200rpx;
+  margin: 0 10rpx;
+}
+
+.fill-input-right {
+  border-bottom: 2px solid #4caf50;
+}
+
+.fill-input-error {
+  border-bottom: 2px solid #ff4400;
+}
+
+.btn-confirm {
+  margin-top: 60rpx;
+  margin-bottom: 20rpx;
+}
+
+/** 简答题输入框 */
+.short-input {
+  border: 0px;
+  border-bottom: 2px solid #5677fc;
+  width: 100%;
+  min-height: 500rpx;
+  margin: 10rpx;
+}
+
+.short-input-right {
+  color: #4caf50;
+}
+
+.short-input-error {
+  color: #ff4400;
+}
+
+.material-title {
+  background-color: #fff;
+  padding: 10px;
+}
+
+.material-title-tip {
+  font-size: 36rpx;
+  font-weight: bold;
+}
+
+.title-video {
+  width: 100%;
+  min-height: 200px;
+  pointer-events: auto !important;
+}
+
+.explain-video-view {
+  width: 100%;
+  min-height: 240px;
+  background-color: #fff;
+  padding: 20px 0px;
+}
+
+.explain-video {
+  width: 100%;
+  min-height: 200px;
+  pointer-events: auto !important;
+}
+
+.fab-bg-color {
+  background-color: #e6e6e6;
+}
+
+textarea::-webkit-input-placeholder {
+  font-size: 24rpx;
+}
+
+textarea:-moz-placeholder {
+  font-size: 24rpx;
+}
+
+textarea::-moz-placeholder {
+  font-size: 24rpx;
+}
+
+textarea::-ms-input-placeholder {
+  font-size: 24rpx;
+}
+</style>
+

+ 104 - 0
addons/exam/uniapp/components/kz-skeleton/kz-skeleton.vue

@@ -0,0 +1,104 @@
+<template>
+	<view>
+		<tui-skeleton v-if="showSkeleton" backgroundColor="#fafafa" borderRadius="10rpx"></tui-skeleton>
+		<view class="container tui-skeleton">
+			<image src="/static/img/train-banner1.png" mode="widthFix" class="tui-banner tui-skeleton-rect"></image>
+			<view class="tui-text">
+				<text class=" tui-skeleton-rect"> </text>
+			</view>
+		
+			<view class="tui-view">
+				<view class="tui-cell">
+					<view class="tui-title tui-skeleton-rect"> </view>
+					<view class="tui-link tui-skeleton-fillet" > </view>
+				</view>
+				<view class="tui-cell">
+					<view class="tui-title tui-skeleton-rect"> </view>
+					<view class="tui-link tui-skeleton-fillet" > </view>
+				</view>
+				<view class="tui-cell">
+					<view class="tui-title tui-skeleton-rect"> </view>
+					<view class="tui-link tui-skeleton-fillet" > </view>
+				</view>
+				<view class="tui-cell">
+					<view class="tui-title tui-skeleton-rect"> </view>
+					<view class="tui-link tui-skeleton-fillet" > </view>
+				</view>
+				<view class="tui-cell">
+					<view class="tui-title tui-skeleton-rect"> </view>
+					<view class="tui-link tui-skeleton-fillet" > </view>
+				</view>
+				<view class="tui-cell">
+					<view class="tui-title tui-skeleton-rect"> </view>
+					<view class="tui-link tui-skeleton-fillet" > </view>
+				</view>
+			</view>
+		
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"kz-skeleton",
+		props: {
+			// 显示开关
+			showSkeleton: {
+				type: Boolean,
+				default: true
+			},
+		},
+		data() {
+			return {
+				
+			};
+		}
+	}
+</script>
+
+<style>
+	.tui-banner {
+		width: 100%;
+		height: 375rpx;
+	}
+	
+	.tui-text {
+		width: 100%;
+		padding: 12rpx 30rpx 20rpx;
+		box-sizing: border-box;
+		color: #B3B3B3;
+		font-size: 26rpx;
+		text-align: right;
+		margin-top: 8rpx
+	}
+	
+	.tui-view {
+		width: 100%;
+		padding: 20rpx 30rpx;
+		box-sizing: border-box;
+	}
+	
+	
+	.tui-cell {
+		padding: 24rpx 0;
+		color: #555;
+	}
+	
+	.tui-title {
+		padding: 0 8rpx;
+		box-sizing: border-box;
+		display: inline-block;
+	}
+	
+	.tui-link {
+		width: 100%;
+		padding: 30rpx;
+		box-sizing: border-box;
+		background: #fff;
+		box-shadow: 0px 3rpx 20rpx rgba(183, 183, 183, 0.1);
+		border-radius: 10rpx;
+		color: #06c;
+		margin-top: 20rpx;
+		word-break: break-all;
+	}
+</style>

+ 152 - 0
addons/exam/uniapp/components/kz-wx-privacy-check/kz-wx-privacy-check.vue

@@ -0,0 +1,152 @@
+<template>
+	<tn-modal v-model="showPrivacy" :custom="true" title="隐私保护指引" :maskCloseable="false">
+		<view class="content">
+			<view class="title">隐私保护指引</view>
+			<view class="des">
+				在使用当前小程序服务之前,请仔细阅读
+				<text class="link text-primary" @tap="handleOpenPrivacyContract">{{privacyContractName}}</text>
+				。如你同意{{privacyContractName}},请点击“同意”开始使用。
+			</view>
+			<view class="btns">
+				<button class="item reject" @tap="handleDisagree">拒绝</button>
+				<button id="agree-btn" class="item agree bg-primary" open-type="agreePrivacyAuthorization"
+					@agreeprivacyauthorization="handleAgreePrivacyAuthorization">同意</button>
+			</view>
+		</view>
+	</tn-modal>
+</template>
+
+<script>
+	export default {
+		name: 'KzWxPrivacyCheck',
+		data() {
+			return {
+				privacyContractName: '',
+				showPrivacy: false,
+				resolvePrivacyAuthorization: null,
+			};
+		},
+		mounted() {
+			// #ifdef MP-WEIXIN
+			this.getPrivacySetting()
+			if (wx.onNeedPrivacyAuthorization) {
+				wx.onNeedPrivacyAuthorization(resolve => {
+					console.log('触发 onNeedPrivacyAuthorization', resolve)
+					this.showPrivacy = true
+					this.resolvePrivacyAuthorization = resolve
+				})
+			}
+			// #endif
+		},
+
+		methods: {
+			getPrivacySetting(type) {
+				return new Promise((resolve, reject) => {
+					// #ifdef APP-PLUS
+					resolve(true)
+					// #endif
+
+					// #ifdef MP-WEIXIN
+					if (wx.getPrivacySetting) {
+						wx.getPrivacySetting({
+							success: res => {
+								// 返回结果为: res = { needAuthorization: true/false, privacyContractName: '《xxx隐私保护指引》' }
+								console.log(res)
+								if (res.needAuthorization) {
+									// 需要弹出隐私协议
+									if (type == "showPrivacy") {
+										this.showPrivacy = true
+									}
+									this.privacyContractName = res.privacyContractName
+									resolve(false)
+								} else {
+									// 用户已经同意过隐私协议,所以不需要再弹出隐私协议,也能调用已声明过的隐私接口
+									resolve(true)
+								}
+							}
+						})
+					}else{
+						resolve(true)
+					}
+					
+					// #endif
+				})
+			},
+
+			async handleAgreePrivacyAuthorization() {
+				// 用户点击了同意,之后所有已声明过的隐私接口和组件都可以调用了
+				this.showPrivacy = false
+				if (typeof this.resolvePrivacyAuthorization === 'function') {
+					this.resolvePrivacyAuthorization({
+						buttonId: 'agree-btn',
+						event: 'agree'
+					})
+				}
+			},
+
+			handleDisagree() {
+				this.showPrivacy = false
+				if (typeof this.resolvePrivacyAuthorization === 'function') {
+					this.resolvePrivacyAuthorization({
+						event: 'disagree'
+					})
+				}
+			},
+
+			handleOpenPrivacyContract() {
+				// 打开隐私协议页面
+				wx.openPrivacyContract({
+					success: () => {}, // 打开成功
+					fail: () => {}, // 打开失败
+					complete: () => {}
+				})
+			}
+		},
+	};
+</script>
+
+<style>
+
+	.content .title {
+		text-align: center;
+		color: #333;
+		font-weight: bold;
+		font-size: 32rpx;
+	}
+
+	.content .des {
+		font-size: 26rpx;
+		color: #666;
+		margin-top: 40rpx;
+		text-align: justify;
+		line-height: 1.6;
+	}
+
+	.content .des .link {
+		color: #07c160;
+		text-decoration: underline;
+	}
+
+	.btns {
+		margin-top: 48rpx;
+		display: flex;
+	}
+
+	.btns .item {
+		width: 180rpx;
+		height: 70rpx;
+		overflow: visible;
+		display: flex;
+		align-items: center;
+		margin: 0 12px;
+		justify-content: center;
+		box-sizing: border-box;
+		border: none !important;
+		font-size: 26rpx;
+	}
+
+	.btns .reject {
+		background: #f4f4f5;
+		color: #909399;
+	}
+</style>

+ 344 - 0
addons/exam/uniapp/components/login/login.vue

@@ -0,0 +1,344 @@
+<template>
+  <view>
+    <block v-if="modal == true ? 'show' : ''">
+      <view class="login-bg"></view>
+      <view class="login-box">
+        <view class="login-title">你还没登录</view>
+        <text>请选择以下任一登录方式</text>
+        <text>登录后再进行操作</text>
+
+        <image src="/static/login.png" mode="widthFix"></image>
+        <!-- #ifdef MP-WEIXIN -->
+        <view class="login-btnbox">
+          <button
+            class="login-btn"
+            hover-class="none"
+            @tap="getUserProfile"
+            style="background-color: #09ba07; color: #fff"
+          >
+            微信登录
+          </button>
+          <button
+            class="login-btn"
+            hover-class="none"
+            @click="goLogin"
+            style="background-color: #5677fc; color: #fff"
+          >
+            账号登录
+          </button>
+        </view>
+        <!-- #endif -->
+
+        <!-- #ifndef MP-WEIXIN -->
+        <view class="login-btnbox" v-if="showRegBtn">
+          <view
+            class="login-btn"
+            v-on:click="goLogin"
+            style="
+              color: #333;
+              background-color: #5677fc;
+              color: #fff;
+              width: 88%;
+            "
+            >账号登录</view
+          >
+        </view>
+        <!-- #endif -->
+        <view class="login-btnbox">
+          <view
+            class="login-btn"
+            v-on:click="hideModal"
+            style="color: #333; background-color: #f1f1f1; width: 88%"
+            >暂不登录
+          </view>
+        </view>
+      </view>
+    </block>
+
+    <wx-user-info-modal
+      v-model="showAuthorizationModal"
+      @updated="updatedUserInfoEvent"
+    ></wx-user-info-modal>
+    <kz-wx-privacy-check ref="kzWxPrivacyCheck"></kz-wx-privacy-check>
+  </view>
+</template>
+
+<script>
+import userApi from "@/common/api/user.js";
+import WxUserInfoModal from "@/uni_modules/tuniaoui-wx-user-info/components/tuniaoui-wx-user-info/tuniaoui-wx-user-info.vue";
+
+export default {
+  name: "login",
+  components: { WxUserInfoModal },
+  props: {
+    show: {
+      default: true,
+    },
+    pid: {
+      default: 0,
+    },
+  },
+  data() {
+    return {
+      modal: false,
+      showAuthorizationModal: false,
+      afterMethod: null,
+      // system: null,
+      showRegBtn: true,
+    };
+  },
+  mounted() {
+    if (this.show && !uni.getStorageSync("user")) {
+      //this.modal = true;
+    }
+
+    // let system = uni.getStorageSync("system");
+    // if (
+    //   system &&
+    //   system.open_user_register != undefined &&
+    //   parseInt(system.open_user_register) === 0
+    // ) {
+    //   this.showRegBtn = false;
+    // }
+  },
+  methods: {
+    goLogin() {
+      this.utils.goto("/pages/user/login-reg");
+    },
+    async getUserProfile(e) {
+      let isPrivacySetting =
+        await this.$refs.kzWxPrivacyCheck.getPrivacySetting("showPrivacy");
+      console.log("isPrivacySetting", isPrivacySetting);
+      if (isPrivacySetting) {
+        var that = this;
+        uni.getUserProfile({
+          lang: "zh_CN",
+          desc: "用于完善会员资料",
+          success: (res) => {
+            uni.login({
+              provider: "weixin",
+              success(e) {
+                that.wxlogin(e.code, res.userInfo);
+              },
+            });
+          },
+          fail: (err) => {
+            console.log(err);
+          },
+        });
+      }
+    },
+    wxlogin(code, userInfo) {
+      let that = this;
+      this.http(
+        "User/login",
+        {
+          code: code,
+          userInfo: userInfo,
+          pid: this.pid,
+        },
+        "post"
+      ).then((res) => {
+        if (res.code == 0) {
+          uni.showModal({
+            content: "请求失败:" + res.msg,
+            showCancel: false,
+            success: function () {
+              if (callBack != undefined) {
+                callBack();
+              }
+            },
+          });
+          uni.$emit("login_fail", "请求失败:" + res.msg);
+        } else {
+          if (res.data.user.status == "hidden") {
+            uni.showModal({
+              content: "账号已被禁用,请联系管理员重新开启!",
+              showCancel: false,
+              success: function () {
+                if (callBack != undefined) {
+                  callBack();
+                }
+              },
+            });
+            uni.$emit("login_fail", "账号已被禁用,请联系管理员重新开启!");
+          } else {
+            uni.setStorageSync("token", res.data.token);
+            uni.setStorageSync("user", res.data.user);
+            uni.$emit("login_success", res.data);
+
+            this.modal = false;
+
+            console.log("this.afterMethod", this.afterMethod);
+            // 登录后执行之前的操作
+            if (this.afterMethod) {
+              this.afterMethod();
+
+              // 置空
+              this.afterMethod = null;
+            }
+
+            if (res.data.user.nickname == "微信用户") {
+              this.showAuthorizationModal = true;
+              console.log(
+                "showAuthorizationModal",
+                this.showAuthorizationModal
+              );
+            }
+
+            this.$emit("succ", res.data);
+          }
+        }
+      });
+    },
+    hideModal(e) {
+      this.modal = false;
+    },
+    // 获取到的用户信息
+    async updatedUserInfoEvent(info) {
+      console.log("获取到的用户信息", info);
+
+      let newAvatar = "";
+      let uploadResult = "";
+
+      // 上传头像
+      if (info.avatar && info.avatar.indexOf("tmp") > 0) {
+        await this.utils.uploadFile(this, info.avatar).then((res) => {
+          if (res.code == 1) {
+            newAvatar = res.data.fullurl;
+            uploadResult = "";
+          } else {
+            uploadResult = res.msg;
+          }
+        });
+      }
+
+      console.log("uploadResult", uploadResult);
+      console.log("newAvatar", newAvatar);
+      if (uploadResult) {
+        this.utils.alert(uploadResult);
+        return;
+      }
+
+      let data = {
+        nickname: info.nickname,
+      };
+      if (newAvatar) {
+        data["avatar"] = newAvatar;
+      }
+      if (info.mobile) {
+        data["mobile"] = info.mobile;
+      }
+
+      console.log("save user data", data);
+      await userApi.save(this, data).then((res) => {
+        console.log("user save res", res);
+        if (res.code == 1) {
+          uni.setStorageSync("user", this.user);
+          this.showAuthorizationModal = false;
+          uni.$emit("login_success", res.data);
+          this.utils.alert("保存成功");
+        } else {
+          this.utils.alert(res.msg);
+        }
+      });
+    },
+  },
+};
+</script>
+
+<style>
+.login-bg {
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.6);
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 990;
+}
+
+.login-box {
+  width: 80%;
+  height: auto;
+  background-color: #ffffff;
+  border-radius: 16rpx;
+  box-sizing: border-box;
+  padding: 50rpx 30rpx;
+  text-align: center;
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 991;
+}
+
+.login-title {
+  font-weight: 500;
+  color: #000000;
+  padding-bottom: 10rpx;
+  font-size: 16px;
+}
+
+.login-box > text {
+  color: #999999;
+  font-size: 28rpx;
+  display: block;
+}
+
+.login-box > image {
+  width: 80%;
+  height: auto;
+  margin: 20rpx 0 50rpx;
+}
+
+.login-btnbox {
+  height: 100rpx;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+}
+
+button,
+button::after {
+  margin: 0;
+  padding: 0;
+  border: none;
+  outline: none;
+  border-radius: 0;
+}
+
+.login-btn,
+button.login-btn {
+  width: 200rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  text-align: center;
+  border-radius: 8rpx;
+  color: #ffffff;
+  font-size: 28rpx;
+}
+
+.login-page {
+  width: 100vw;
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* 授权按钮 */
+.submit-btn {
+  width: 100%;
+  background-color: #05c160;
+  color: #ffffff;
+  margin-top: 60rpx;
+  border-radius: 10rpx;
+  padding: 25rpx;
+  font-size: 32rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin: 30rpx;
+}
+</style>

+ 453 - 0
addons/exam/uniapp/components/monster/monster.vue

@@ -0,0 +1,453 @@
+<template>
+	<view>
+		<button open-type="contact">
+			<view class="dong">
+				<view class="monster">
+					<view class="monster__face">
+						<view class="monster__eye avatar-eye avatar-eye--green eye--left">
+							<view class="avatar-eye-pupil pupil--green"><span class="avatar-eye-pupil-blackThing"><span
+										class="avatar-eye-pupil-lightReflection"></span></span></view>
+						</view>
+						<view class="monster__eye avatar-eye avatar-eye--violet eye--right">
+							<view class="avatar-eye-pupil pupil--pink"><span class="avatar-eye-pupil-blackThing"><span
+										class="avatar-eye-pupil-lightReflection"></span></span></view>
+						</view>
+						<view class="monster__noses">
+							<view class="monster__nose"></view>
+							<view class="monster__nose"></view>
+						</view>
+						<view class="monster__mouth">
+							<view class="monster__top"></view>
+							<view class="monster__bottom"></view>
+						</view>
+					</view>
+				</view>
+			</view>
+		</button>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: "monster",
+		props: {
+			// 跳转地址
+			pageUrl: {
+				type: String,
+				default: ''
+			},
+		},
+		data() {
+			return {
+
+			};
+		},
+		methods: {
+			goTo() {
+				if (this.pageUrl) {
+					this.utils.goto(this.pageUrl)
+				}
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	/* 大嘴鸟*/
+	.dong {
+		z-index: 9999;
+		position: fixed;
+		top: 60px;
+		right: -80px;
+		transform: scale(0.24);
+		-webkit-transform: scale(0.24);
+		-moz-transform: scale(0.24);
+
+	}
+
+	.monster {
+		transform: rotate(-50deg);
+		-ms-transform: rotate(-50deg);
+		/* IE 9 */
+		-moz-transform: rotate(-50deg);
+		/* Firefox */
+		-webkit-transform: rotate(-50deg);
+		/* Safari 和 Chrome */
+		-o-transform: rotate(-50deg);
+		/* Opera */
+		display: flex;
+		justify-content: center;
+		position: relative;
+		width: 170px;
+		height: 400px;
+		border-top-left-radius: 200px;
+		border-top-right-radius: 200px;
+		background-color: #3C47D7;
+		box-shadow: 20px 20px 60px #4650E5;
+	}
+
+	.monster__face {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		flex-direction: column;
+		position: absolute;
+		top: 14%;
+		width: 75%;
+		height: 160px;
+	}
+
+	.monster__noses {
+		top: 50%;
+		display: flex;
+		justify-content: space-between;
+		width: 28%;
+		height: auto;
+		margin-bottom: 10px;
+	}
+
+	.monster__nose {
+		width: 8px;
+		height: 12px;
+		border-radius: 20px;
+		background: rgba(0, 0, 0, 0.5);
+		box-shadow: 4px 8px 5px rgba(0, 0, 0, 0.1);
+	}
+
+	.monster__mouth {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		position: relative;
+		width: 100%;
+		height: 0%;
+		overflow: hidden;
+		border: 25px solid #FFC333;
+		border-radius: 100px;
+		background-color: #810332;
+		animation: mouth 1.75s infinite;
+		box-shadow: 4px 8px 5px rgba(0, 0, 0, 0.2);
+		box-sizing: border-box;
+	}
+
+	.monster__mouth::before {
+		content: '';
+		position: absolute;
+		width: 150px;
+		height: 80px;
+		border-radius: 100px;
+		background-color: #400018;
+	}
+
+	.monster__mouth::after {
+		content: '';
+		position: absolute;
+		bottom: -80px;
+		width: 160px;
+		height: 80px;
+		border-top-left-radius: 50%;
+		border-top-right-radius: 50%;
+		background-color: #DC1B50;
+		animation: tongue 1.75s infinite;
+	}
+
+	.monster__top {
+		position: absolute;
+		top: -30px;
+		width: 170px;
+		height: 30px;
+		border-bottom-left-radius: 10px;
+		border-bottom-right-radius: 10px;
+		background-color: #ffffff;
+		z-index: 100;
+		animation: t 1.75s infinite;
+	}
+
+	.monster__bottom {
+		position: absolute;
+		bottom: 0;
+		width: 100px;
+		height: 30px;
+		border-top-left-radius: 10px;
+		border-top-right-radius: 10px;
+		background-color: #ffffff;
+		z-index: 100;
+		animation: b 1.75s infinite;
+	}
+
+
+	.avatar-eye {
+		position: absolute;
+		top: -10%;
+		width: 65px;
+		height: 65px;
+		background: linear-gradient(105deg, white, #cb87f4);
+		border-radius: 100%;
+		box-shadow: 4px 8px 5px rgba(0, 0, 0, 0.2);
+		margin: 3px;
+		-webkit-transform: translateX(-50%);
+		transform: translateX(-50%);
+	}
+
+
+	.avatar-eye--green {
+		background: linear-gradient(to bottom, #fdfdfd, #c3efea);
+	}
+
+	.avatar-eye--violet {
+		background: linear-gradient(to bottom, #fdfdfd, #e6d6f6);
+	}
+
+
+	.eye--left {
+		left: 10%;
+	}
+
+	.eye--right {
+		left: 85%;
+	}
+
+	.eye--center {
+		left: 45%;
+		top: 10%;
+	}
+
+	.avatar-eye-pupil {
+		position: absolute;
+		width: 55%;
+		height: 55%;
+		left: 50%;
+		top: 25%;
+		-webkit-transform: translate(-50%);
+		transform: translate(-50%);
+		z-index: 100;
+		border-radius: 100%;
+	}
+
+
+	.pupil--green {
+		background: linear-gradient(135deg, rgba(188, 248, 177, 0.7), #2fa38c 75%);
+	}
+
+	.pupil--pink {
+		background: linear-gradient(135deg, #f1a183, #8535cd);
+	}
+
+
+	.avatar-eye-pupil-blackThing {
+		position: absolute;
+		width: 55%;
+		height: 55%;
+		left: 50%;
+		top: 25%;
+		background: #2c2f32;
+		-webkit-transform: translate(-50%);
+		transform: translate(-50%);
+		border-radius: 100%;
+		box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2);
+	}
+
+	.avatar-eye-pupil-lightReflection {
+		position: absolute;
+		width: 7px;
+		height: 7px;
+		left: 25%;
+		top: 10%;
+		background: #ebebeb;
+		-webkit-transform: translate(-50%);
+		transform: translate(-50%);
+		border-radius: 100%;
+		box-shadow: 10px 10px 10px rgba(255, 255, 255, 0.2);
+	}
+
+	/*大嘴动起来*/
+	@keyframes t {
+
+		0%,
+		10%,
+		80%,
+		100% {
+			top: -30px;
+		}
+
+		20% {
+			top: 0px;
+		}
+
+		30% {
+			top: -20px;
+		}
+
+		40% {
+			top: -0px;
+		}
+
+		50% {
+			top: -25px;
+		}
+
+		70% {
+			top: 0px;
+		}
+	}
+
+	@keyframes b {
+
+		0%,
+		10%,
+		80%,
+		100% {
+			bottom: -30px;
+		}
+
+		20% {
+			bottom: 0px;
+		}
+
+		30% {
+			bottom: -20px;
+		}
+
+		40% {
+			bottom: -0px;
+		}
+
+		50% {
+			bottom: -25px;
+		}
+
+		70% {
+			bottom: 0px;
+		}
+	}
+
+	@keyframes mouth {
+
+		0%,
+		10%,
+		100% {
+			width: 100%;
+			height: 0%;
+		}
+
+		15% {
+			width: 90%;
+			height: 30%;
+		}
+
+		20% {
+			width: 50%;
+			height: 70%;
+		}
+
+		25% {
+			width: 70%;
+			height: 70%;
+		}
+
+		30% {
+			width: 80%;
+			height: 60%;
+		}
+
+		35% {
+			width: 60%;
+			height: 70%;
+		}
+
+		40% {
+			width: 55%;
+			height: 75%;
+		}
+
+		45% {
+			width: 50%;
+			height: 90%;
+		}
+
+		50% {
+			width: 40%;
+			height: 70%;
+		}
+
+		55% {
+			width: 70%;
+			height: 95%;
+		}
+
+		60% {
+			width: 40%;
+			height: 50%;
+		}
+
+		65% {
+			width: 100%;
+			height: 60%;
+		}
+
+		70% {
+			width: 100%;
+			height: 70%;
+		}
+
+		75% {
+			width: 90%;
+			height: 70%;
+		}
+
+		80% {
+			width: 50%;
+			height: 70%;
+		}
+
+		85% {
+			width: 90%;
+			height: 50%;
+		}
+
+		85% {
+			width: 40%;
+			height: 70%;
+		}
+
+		90% {
+			width: 90%;
+			height: 30%;
+		}
+
+		95% {
+			width: 100%;
+			height: 10%;
+		}
+	}
+
+	@keyframes tongue {
+
+		0%,
+		20%,
+		100% {
+			bottom: -80px;
+		}
+
+		30%,
+		90% {
+			bottom: -40px;
+		}
+
+		40% {
+			bottom: -45px;
+		}
+
+		50% {
+			bottom: -50px;
+		}
+
+		70% {
+			bottom: -80px;
+		}
+
+		90% {
+			bottom: -40px;
+		}
+	}
+</style>

+ 105 - 0
addons/exam/uniapp/components/struggler-uniapp-add-tip/struggler-uniapp-add-tip.vue

@@ -0,0 +1,105 @@
+<template>
+	<view>
+		<view class="uni-add-tips-box" v-if="showTip">
+			<view class='uni-add-tips-content' @tap='hideTip'>
+				<text>{{tip}}</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	const SHOW_TIP = "SHOW_TIP"
+	export default {
+		data() {
+			return {
+				showTip: false,
+			}
+		},
+		mounted() {
+			this.showTip = !uni.getStorageInfoSync().keys.includes(SHOW_TIP)
+			// setTimeout(()=>{
+			// 	this.showTip = false
+			// },this.duration*3000)
+		},
+		props: {
+			tip: {
+				type: String,
+				default: "点击「添加小程序」,下次访问更便捷",
+				required: true
+			},
+			duration: {
+				type: Number,
+				default: 5,
+				required: false
+			}
+		},
+		methods: {
+			hideTip() {
+				uni.setStorageSync(SHOW_TIP, true)
+				this.showTip = false
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	$themeColor:#5677fc; //主题色
+
+	.uni-add-tips-box {
+		position: fixed;
+		top: CustomBar + 20rpx;
+		right: -20rpx;
+		z-index: 99999;
+		opacity: 0.8;
+		display: flex;
+		justify-content: flex-end;
+		align-items: flex-end;
+		flex-direction: column;
+		width: 600upx;
+		animation: opacityC 1s linear infinite;
+	}
+
+	.uni-add-tips-content::before {
+		content: "";
+		position: absolute;
+		width: 0;
+		height: 0;
+		top: -38upx;
+		right: 105upx;
+		border-width: 20upx;
+		border-style: solid;
+		display: block;
+		border-color: transparent transparent $themeColor transparent;
+	}
+
+	.uni-add-tips-content {
+		border-width: 0upx;
+		margin-top: 20upx;
+		position: relative;
+		background-color: $themeColor;
+		box-shadow: 0 10upx 20upx -10upx $themeColor;
+		border-radius: 12upx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: 18upx 20upx;
+		margin-right: 40upx;
+	}
+
+	.uni-add-tips-content>text {
+		color: #fff;
+		font-size: 28upx;
+		font-weight: 400;
+	}
+
+	@keyframes opacityC {
+		0% {
+			opacity: 0.8;
+		}
+
+		50% {
+			opacity: 1;
+		}
+	}
+</style>

+ 325 - 0
addons/exam/uniapp/components/tabbar/tabbar.vue

@@ -0,0 +1,325 @@
+<template>
+	<view>
+		<block v-if="style === 'glass'">
+			<view class="tabbar footerfixed dd-glass" style="border-radius: 100rpx;">
+				<view class="action">
+					<navigator url="/pages/index/index" hover-class="none" open-type="reLaunch" :class="[clickClass('/pages/index/index')]">
+						<view class="bar-icon">
+							<view class="tn-icon-home ">
+							</view>
+						</view>
+						<view class="">首页</view>
+					</navigator>
+				</view>
+
+				<view class="action">
+					<navigator url="/pages/paper/index" hover-class="none" open-type="reLaunch" :class="[clickClass('/pages/paper/index')]">
+						<view class="bar-icon">
+							<view class="tn-icon-edit-form ">
+							</view>
+						</view>
+						<view class="">模拟考试</view>
+					</navigator>
+				</view>
+
+				<view class="action">
+					<navigator url="/pages/search/index" hover-class="none" open-type="reLaunch" :class="[clickClass('/pages/search/index')]">
+						<view class="bar-icon">
+							<view class="tn-icon-search ">
+							</view>
+						</view>
+						<view class="">试题查询</view>
+					</navigator>
+				</view>
+
+				<view class="action">
+					<navigator url="/pages/room/index" hover-class="none" open-type="reLaunch" :class="[clickClass('/pages/room/index')]">
+						<view class="bar-icon">
+							<view class="tn-icon-menu-list ">
+							</view>
+						</view>
+						<view class="">考场列表</view>
+					</navigator>
+				</view>
+
+
+				<view class="action">
+					<navigator url="/pages/user/user" hover-class="none" open-type="reLaunch" :class="[clickClass('/pages/user/user')]">
+						<view class="bar-icon">
+							<view class="tn-icon-my">
+							</view>
+						</view>
+						<view class="">我的</view>
+					</navigator>
+				</view>
+			</view>
+		</block>
+
+
+		<block v-if="style === 'simple'">
+			<view style="height: 120rpx;"></view>
+			<view class="disFix index-bottom radius20">
+				<navigator url="/pages/index/index" hover-class="none" open-type="reLaunch">
+					<image :src="route == '/pages/index/index' ? '/static/tabbar/index1.png' : '/static/tabbar/index.png'">
+					</image>
+					<text :style="'color:' + (route == '/pages/index/index' ? '#5677fc' : '#9b9b9b')">首页</text>
+				</navigator>
+
+				<navigator url="/pages/paper/index" hover-class="none" open-type="reLaunch">
+					<image
+						:src="route == '/pages/paper/index' ? '/static/tabbar/kaoshi1.png' : '/static/tabbar/kaoshi.png'">
+					</image>
+					<text :style="'color:' + (route == '/pages/paper/index' ? '#5677fc' : '#9b9b9b')">模拟考试</text>
+				</navigator>
+
+				<navigator class="index-b-center" url="/pages/search/index" hover-class="none" open-type="reLaunch">
+					<image
+						:src="route == '/pages/search/index' ? '/static/tabbar/chaxun1.png' : '/static/tabbar/chaxun.png'">
+					</image>
+					<text :style="'color:' + (route == '/pages/search/index' ? '#5677fc' : '#9b9b9b')">试题查询</text>
+				</navigator>
+				
+				<!-- <navigator url="/pagesSignUp/submit/submit" hover-class="none" open-type="reLaunch">
+					<image :src="route == '/pagesSignUp/submit/submit' ? '/static/tabbar/tiku1.png' : '/static/tabbar/tiku.png'">
+					</image>
+					<text :style="'color:' + (route == '/pagesSignUp/submit/submit' ? '#5677fc' : '#9b9b9b')">报名</text>
+				</navigator>
+				
+				<navigator url="/pagesSignUp/list/list" hover-class="none" open-type="reLaunch">
+					<image :src="route == '/pagesSignUp/list/list' ? '/static/tabbar/tiku1.png' : '/static/tabbar/tiku.png'">
+					</image>
+					<text :style="'color:' + (route == '/pagesSignUp/list/list' ? '#5677fc' : '#9b9b9b')">报名列表</text>
+				</navigator> -->
+
+				<navigator url="/pages/room/index" hover-class="none" open-type="reLaunch">
+					<image :src="route == '/pages/room/index' ? '/static/tabbar/tiku1.png' : '/static/tabbar/tiku.png'">
+					</image>
+					<text :style="'color:' + (route == '/pages/room/index' ? '#5677fc' : '#9b9b9b')">考场列表</text>
+				</navigator>
+				
+				<navigator url="/pages/user/user" hover-class="none" open-type="reLaunch">
+					<image :src="route == '/pages/user/user' ? '/static/tabbar/user1.png' : '/static/tabbar/user.png'">
+					</image>
+					<text :style="'color:' + (route == '/pages/user/user' ? '#5677fc' : '#9b9b9b')">我的</text>
+				</navigator>
+			</view>
+		</block>
+
+
+
+	</view>
+</template>
+
+<script>
+	export default {
+		name: 'tabbar',
+		props: {
+			// 样式风格
+			theme: {
+				type: String,
+				default: 'simple'
+			},
+		},
+		data() {
+			return {
+				navbar: false,
+				route: '',
+				style: 'simple',
+				page: null,
+			};
+		},
+		watch: {
+			/**
+			 * 监听user
+			 * @param value
+			 */
+			theme(value) {
+				if (value && value != undefined) {
+					this.style = value
+				}
+			},
+		},
+		mounted() {
+			var that = this;
+			var pages = getCurrentPages();
+			var currentPage = pages[pages.length - 1];
+			const url = currentPage.route;
+			const options = currentPage.options;
+			let urlWithArgs = `/${url}?`;
+			for (let key in options) {
+				const value = options[key];
+				urlWithArgs += `${key}=${value}&`;
+			}
+			that.route = urlWithArgs.substring(0, urlWithArgs.length - 1);
+
+			// tabbar风格
+			this.page = uni.getStorageSync('page')
+			if (this.page?.page_tabbar_style) {
+				this.style = this.page.page_tabbar_style
+			}
+			// console.log('tabbar style', this.style)
+		},
+		methods: {
+			// 选中样式
+			clickClass(url) {
+				return this.route == url ? 'text-primary' : 'tn-color-grey'
+				// return this.route == url ? 'tn-color-grey--dark' : 'tn-color-grey'
+			},
+		}
+	};
+</script>
+
+<style>
+	.page {
+		background: transparent;
+	}
+
+	button {
+		overflow: inherit;
+	}
+
+	.disFix {
+		display: flex;
+		justify-content: space-between;
+		z-index: 999;
+	}
+
+	.index-bottom {
+		position: fixed;
+		/* left: 0;
+		bottom: 1;
+		width: 100%; */
+		height: 90rpx;
+		background: #ffffff;
+		box-shadow: 0 0 2px 0 rgba(134, 133, 133, 0.5);
+		font-size: 0;
+		box-sizing: border-box;
+		/* padding-bottom: 0;
+		padding-bottom: constant(safe-area-inset-bottom);
+		padding-bottom: env(safe-area-inset-bottom); */
+
+		bottom: 30rpx;
+		width: 95%;
+		position: fixed;
+		margin: 0 auto;
+		left: 0;
+		right: 0;
+	}
+
+	.index-bottom>navigator,
+	.index-bottom>view,
+	.index-bottom>button {
+		/* width: 20%; */
+		flex: 1;
+		display: inline-block;
+		text-align: center;
+	}
+
+	.index-bottom>button image {
+		display: block;
+	}
+
+	.index-bottom>button {
+		border-radius: 0px;
+		padding: 0px;
+		line-height: 15px;
+		background: transparent;
+		font-size: 20rpx;
+		margin: 0 auto;
+	}
+
+	.index-bottom>button::after {
+		border: none;
+	}
+
+	.index-bottom text {
+		font-size: 20rpx;
+	}
+
+	.index-bottom image {
+		display: block;
+		width: 38rpx;
+		height: 38rpx;
+		margin: 14rpx auto 6rpx;
+	}
+
+	.index-b-center {
+		position: relative;
+		width: 90rpx;
+		height: 90rpx;
+		margin: -40rpx 0 0;
+	}
+
+	.index-bottom .index-b-center image {
+		width: 76rpx;
+		height: 76rpx;
+	}
+
+
+
+
+
+
+
+
+
+
+
+	/* 底部tabbar 毛玻璃风格 start*/
+	.footerfixed {
+		position: fixed;
+		// margin: 20rpx;
+		margin: 40rpx 30rpx;
+		width: 690rpx;
+		bottom: calc(env(safe-area-inset-bottom) / 2);
+		z-index: 999;
+		background-color: rgba(255, 255, 255, 0.5);
+		box-shadow: 0rpx 0rpx 30rpx 0rpx rgba(0, 0, 0, 0.07);
+	}
+
+	.tabbar {
+		display: flex;
+		align-items: center;
+		min-height: 110rpx;
+		justify-content: space-between;
+		padding: 0;
+		height: calc(110rpx + env(safe-area-inset-bottom) / 2);
+		// padding-bottom: calc(env(safe-area-inset-bottom) / 2);
+	}
+
+	.tabbar .action {
+		font-size: 22rpx;
+		position: relative;
+		flex: 1;
+		text-align: center;
+		padding: 0;
+		display: block;
+		height: auto;
+		line-height: 1;
+		margin: 0;
+		overflow: initial;
+	}
+
+	.tabbar .action .bar-icon {
+		width: 100rpx;
+		position: relative;
+		display: block;
+		height: auto;
+		margin: 0 auto 10rpx;
+		text-align: center;
+		font-size: 42rpx;
+	}
+
+	.tabbar .action .bar-icon image {
+		width: 50rpx;
+		height: 50rpx;
+		display: inline-block;
+	}
+
+	/* 毛玻璃*/
+	.dd-glass {
+		width: 690rpx;
+		backdrop-filter: blur(20rpx);
+		-webkit-backdrop-filter: blur(20rpx);
+	}
+</style>

+ 69 - 0
addons/exam/uniapp/components/topbar/topbar.vue

@@ -0,0 +1,69 @@
+<template>
+	<view>
+		<tui-navigation-bar splitLine @init="initNavigation" @change="opacityChange" :scrollTop="scrollTop" :title="title" :backgroundColor="backgroundColor" :color="color">
+			<view class="tui-header-icon" :style="{ marginTop: top + 'px' }"><tui-icon name="arrowleft" :color="opacity > 0.85 ? color : backgroundColor" @click="back"></tui-icon></view>
+		</tui-navigation-bar>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"topbar",
+		props: {
+			title: {
+				type: String,
+				default: ''
+			},
+			color: {
+				type: String,
+				default: '#333'
+			},
+			backgroundColor: {
+				type: String,
+				default: '#fff'
+			},
+			//滚动条滚动距离
+			scrollTop: {
+				type: [Number, String],
+				default: 0
+			},
+		},
+		data() {
+			return {
+				top: 0, //标题图标距离顶部距离
+				opacity: 0,
+				scrollTop: 0.5,
+			};
+		},
+		onPageScroll(e) {
+			this.scrollTop = e.scrollTop;
+		},
+		methods: {
+			initNavigation(e) {
+				this.opacity = e.opacity;
+				this.top = e.top;
+			},
+			opacityChange(e) {
+				this.opacity = e.opacity;
+			},
+			back() {
+				uni.navigateBack()
+			},
+		}
+	}
+</script>
+
+<style>
+.tui-header-icon {
+	width: 100%;
+	position: fixed;
+	top: 0;
+	padding: 0 12rpx;
+	display: flex;
+	align-items: center;
+	height: 32px;
+	transform: translateZ(0);
+	z-index: 99999;
+	box-sizing: border-box;
+}
+</style>

+ 519 - 0
addons/exam/uniapp/components/tui-button/tui-button.vue

@@ -0,0 +1,519 @@
+<template>
+	<button
+		class="tui-btn"
+		:class="[
+			plain ? 'tui-' + type + '-outline' : 'tui-btn-' + (type || 'primary'),
+			getDisabledClass(disabled, type, plain),
+			getShapeClass(shape, plain),
+			getShadowClass(type, shadow, plain),
+			bold ? 'tui-text-bold' : '',
+			link ? 'tui-btn__link' : ''
+		]"
+		:hover-class="getHoverClass(disabled, type, plain)"
+		:style="{ width: width, height: height, lineHeight: height, fontSize: size + 'rpx', margin: margin }"
+		:loading="loading"
+		:form-type="formType"
+		:open-type="openType"
+		@getuserinfo="bindgetuserinfo"
+		@getphonenumber="bindgetphonenumber"
+		@contact="bindcontact"
+		@error="binderror"
+		:disabled="disabled"
+		@tap="handleClick"
+	>
+		<slot></slot>
+	</button>
+</template>
+
+<script>
+export default {
+	name: 'tuiButton',
+	// #ifndef MP-QQ
+	behaviors: ['wx://form-field-button'],
+	// #endif
+	props: {
+		//样式类型 primary, white, danger, warning, green,blue, gray,black,brown,gray-primary,gray-danger,gray-warning,gray-green
+		type: {
+			type: String,
+			default: 'primary'
+		},
+		//是否加阴影
+		shadow: {
+			type: Boolean,
+			default: false
+		},
+		// 宽度 rpx或 %
+		width: {
+			type: String,
+			default: '100%'
+		},
+		//高度 rpx
+		height: {
+			type: String,
+			default: '96rpx'
+		},
+		//字体大小 rpx
+		size: {
+			type: Number,
+			default: 32
+		},
+		bold: {
+			type: Boolean,
+			default: false
+		},
+		margin: {
+			type: String,
+			default: '0'
+		},
+		//形状 circle(圆角), square(默认方形),rightAngle(平角)
+		shape: {
+			type: String,
+			default: 'square'
+		},
+		plain: {
+			type: Boolean,
+			default: false
+		},
+		//link样式,去掉边框,结合plain一起使用
+		link: {
+			type: Boolean,
+			default: false
+		},
+		disabled: {
+			type: Boolean,
+			default: false
+		},
+		//禁用后背景是否为灰色 (非空心button生效)
+		disabledGray: {
+			type: Boolean,
+			default: false
+		},
+		loading: {
+			type: Boolean,
+			default: false
+		},
+		formType: {
+			type: String,
+			default: ''
+		},
+		openType: {
+			type: String,
+			default: ''
+		},
+		index: {
+			type: [Number, String],
+			default: 0
+		},
+		//是否需要阻止重复点击【默认200ms】
+		preventClick: {
+			type: Boolean,
+			default: false
+		}
+	},
+	data() {
+		return {
+			time: 0
+		};
+	},
+	methods: {
+		handleClick() {
+			if (this.disabled) return;
+			if (this.preventClick) {
+				if(new Date().getTime() - this.time <= 200) return;
+				this.time = new Date().getTime();
+				setTimeout(() => {
+					this.time = 0;
+				}, 200);
+			}
+			this.$emit('click', {
+				index: Number(this.index)
+			});
+		},
+		bindgetuserinfo({ detail = {} } = {}) {
+			this.$emit('getuserinfo', detail);
+		},
+		bindcontact({ detail = {} } = {}) {
+			this.$emit('contact', detail);
+		},
+		bindgetphonenumber({ detail = {} } = {}) {
+			this.$emit('getphonenumber', detail);
+		},
+		binderror({ detail = {} } = {}) {
+			this.$emit('error', detail);
+		},
+		getShadowClass: function(type, shadow, plain) {
+			let className = '';
+			if (shadow && type != 'white' && !plain) {
+				className = 'tui-shadow-' + type;
+			}
+			return className;
+		},
+		getDisabledClass: function(disabled, type, plain) {
+			let className = '';
+			if (disabled && type != 'white' && type.indexOf('-') == -1) {
+				let classVal = this.disabledGray ? 'tui-gray-disabled' : 'tui-dark-disabled';
+				className = plain ? 'tui-dark-disabled-outline' : classVal;
+			}
+			return className;
+		},
+		getShapeClass: function(shape, plain) {
+			let className = '';
+			if (shape == 'circle') {
+				className = plain ? 'tui-outline-fillet' : 'tui-fillet';
+			} else if (shape == 'rightAngle') {
+				className = plain ? 'tui-outline-rightAngle' : 'tui-rightAngle';
+			}
+			return className;
+		},
+		getHoverClass: function(disabled, type, plain) {
+			let className = '';
+			if (!disabled) {
+				className = plain ? 'tui-outline-hover' : 'tui-' + (type || 'primary') + '-hover';
+			}
+			return className;
+		}
+	}
+};
+</script>
+
+<style scoped>
+.tui-btn-primary {
+	background: #5677fc !important;
+	color: #fff;
+}
+
+.tui-shadow-primary {
+	box-shadow: 0 10rpx 14rpx 0 rgba(86, 119, 252, 0.2);
+}
+
+.tui-btn-danger {
+	background: #eb0909 !important;
+	color: #fff;
+}
+
+.tui-shadow-danger {
+	box-shadow: 0 10rpx 14rpx 0 rgba(235, 9, 9, 0.2);
+}
+
+.tui-btn-warning {
+	background: #fc872d !important;
+	color: #fff;
+}
+
+.tui-shadow-warning {
+	box-shadow: 0 10rpx 14rpx 0 rgba(252, 135, 45, 0.2);
+}
+
+.tui-btn-green {
+	background: #07c160 !important;
+	color: #fff;
+}
+
+.tui-shadow-green {
+	box-shadow: 0 10rpx 14rpx 0 rgba(7, 193, 96, 0.2);
+}
+
+.tui-btn-blue {
+	background: #007aff !important;
+	color: #fff;
+}
+
+.tui-shadow-blue {
+	box-shadow: 0 10rpx 14rpx 0 rgba(0, 122, 255, 0.2);
+}
+
+.tui-btn-white {
+	background: #fff !important;
+	color: #333 !important;
+}
+
+.tui-btn-gray {
+	background: #bfbfbf !important;
+	color: #fff !important;
+}
+
+.tui-btn-black {
+	background: #333 !important;
+	color: #fff !important;
+}
+.tui-btn-brown{
+	background: #ac9157 !important;
+	color: #fff !important;
+}
+
+.tui-btn-gray-black {
+	background: #f2f2f2 !important;
+	color: #333;
+}
+
+.tui-btn-gray-primary {
+	background: #f2f2f2 !important;
+	color: #5677fc !important;
+}
+
+.tui-gray-primary-hover {
+	background: #d9d9d9 !important;
+}
+
+.tui-btn-gray-green {
+	background: #f2f2f2 !important;
+	color: #07c160 !important;
+}
+
+.tui-gray-green-hover {
+	background: #d9d9d9 !important;
+}
+
+.tui-btn-gray-danger {
+	background: #f2f2f2 !important;
+	color: #eb0909 !important;
+}
+
+.tui-gray-danger-hover {
+	background: #d9d9d9 !important;
+}
+
+.tui-btn-gray-warning {
+	background: #f2f2f2 !important;
+	color: #fc872d !important;
+}
+
+.tui-gray-warning-hover {
+	background: #d9d9d9 !important;
+}
+
+.tui-shadow-gray {
+	box-shadow: 0 10rpx 14rpx 0 rgba(191, 191, 191, 0.2);
+}
+
+.tui-hover-gray {
+	background: #f7f7f9 !important;
+}
+
+.tui-black-hover {
+	background: #555 !important;
+	color: #e5e5e5 !important;
+}
+.tui-brown-hover{
+	background: #A37F49 !important;
+	color: #e5e5e5 !important;
+}
+
+/* button start*/
+
+.tui-btn {
+	width: 100%;
+	position: relative;
+	border: 0 !important;
+	border-radius: 6rpx;
+	padding-left: 0;
+	padding-right: 0;
+	overflow: visible;
+}
+
+.tui-btn::after {
+	content: '';
+	position: absolute;
+	width: 200%;
+	height: 200%;
+	transform-origin: 0 0;
+	transform: scale(0.5, 0.5) translateZ(0);
+	box-sizing: border-box;
+	left: 0;
+	top: 0;
+	border-radius: 12rpx;
+	border: 0;
+}
+
+.tui-text-bold {
+	font-weight: bold;
+}
+
+.tui-btn-white::after {
+	border: 1px solid #bfbfbf;
+}
+
+.tui-white-hover {
+	background: #e5e5e5 !important;
+	color: #2e2e2e !important;
+}
+
+.tui-dark-disabled {
+	opacity: 0.6 !important;
+	color: #fafbfc !important;
+}
+
+.tui-dark-disabled-outline {
+	opacity: 0.5 !important;
+}
+
+.tui-gray-disabled {
+	background: #f3f3f3 !important;
+	color: #919191 !important;
+	box-shadow: none;
+}
+
+.tui-outline-hover {
+	opacity: 0.5;
+}
+
+.tui-primary-hover {
+	background: #4a67d6 !important;
+	color: #e5e5e5 !important;
+}
+
+.tui-primary-outline::after {
+	border: 1px solid #5677fc !important;
+}
+
+.tui-primary-outline {
+	color: #5677fc !important;
+	background: transparent;
+}
+
+.tui-danger-hover {
+	background: #c80808 !important;
+	color: #e5e5e5 !important;
+}
+
+.tui-danger-outline {
+	color: #eb0909 !important;
+	background: transparent;
+}
+
+.tui-danger-outline::after {
+	border: 1px solid #eb0909 !important;
+}
+
+.tui-warning-hover {
+	background: #d67326 !important;
+	color: #e5e5e5 !important;
+}
+
+.tui-warning-outline {
+	color: #fc872d !important;
+	background: transparent;
+}
+
+.tui-warning-outline::after {
+	border: 1px solid #fc872d !important;
+}
+
+.tui-green-hover {
+	background: #06ad56 !important;
+	color: #e5e5e5 !important;
+}
+
+.tui-green-outline {
+	color: #07c160 !important;
+	background: transparent;
+}
+
+.tui-green-outline::after {
+	border: 1px solid #07c160 !important;
+}
+
+.tui-blue-hover {
+	background: #0062cc !important;
+	color: #e5e5e5 !important;
+}
+
+.tui-blue-outline {
+	color: #007aff !important;
+	background: transparent;
+}
+
+.tui-blue-outline::after {
+	border: 1px solid #007aff !important;
+}
+
+/* #ifndef APP-NVUE */
+.tui-btn-gradual {
+	background: linear-gradient(90deg, rgb(255, 89, 38), rgb(240, 14, 44)) !important;
+	color: #fff !important;
+}
+
+.tui-shadow-gradual {
+	box-shadow: 0 10rpx 14rpx 0 rgba(235, 9, 9, 0.15);
+}
+
+/* #endif */
+
+.tui-gray-hover {
+	background: #a3a3a3 !important;
+	color: #898989;
+}
+
+/* #ifndef APP-NVUE */
+.tui-gradual-hover {
+	background: linear-gradient(90deg, #d74620, #cd1225) !important;
+	color: #fff !important;
+}
+
+/* #endif */
+
+.tui-gray-outline {
+	color: #999 !important;
+	background: transparent !important;
+}
+
+.tui-white-outline {
+	color: #fff !important;
+	background: transparent !important;
+}
+
+.tui-black-outline {
+	background: transparent !important;
+	color: #333 !important;
+}
+
+.tui-gray-outline::after {
+	border: 1px solid #ccc !important;
+}
+
+.tui-white-outline::after {
+	border: 1px solid #fff !important;
+}
+
+.tui-black-outline::after {
+	border: 1px solid #333 !important;
+}
+
+.tui-brown-outline {
+	color: #ac9157 !important;
+	background: transparent;
+}
+.tui-brown-outline::after {
+	border: 1px solid #ac9157 !important;
+}
+
+/*圆角 */
+
+.tui-fillet {
+	border-radius: 50rpx;
+}
+
+.tui-btn-white.tui-fillet::after {
+	border-radius: 98rpx;
+}
+
+.tui-outline-fillet::after {
+	border-radius: 98rpx;
+}
+
+/*平角*/
+.tui-rightAngle {
+	border-radius: 0;
+}
+
+.tui-btn-white.tui-rightAngle::after {
+	border-radius: 0;
+}
+
+.tui-outline-rightAngle::after {
+	border-radius: 0;
+}
+.tui-btn__link::after {
+	border: 0 !important;
+}
+</style>

+ 211 - 0
addons/exam/uniapp/components/tui-card/tui-card.vue

@@ -0,0 +1,211 @@
+<template>
+	<view class="tui-card-class tui-card" :class="[full?'tui-card-full':'',border?'tui-card-border':'']" @tap="handleClick"
+	 @longtap="longTap">
+		<view class="tui-card-header" :class="{'tui-header-line':header.line}" :style="{background:header.bgcolor || '#fff'}">
+			<view class="tui-header-left">
+				<image :src="image.url" class="tui-header-thumb" :class="{'tui-thumb-circle':image.circle}" mode="widthFix" v-if="image.url"
+				 :style="{height:(image.height || 60)+'rpx',width:(image.width || 60)+'rpx'}"></image>
+				<text class="tui-header-title" :style="{fontSize:(title.size || 30)+'rpx',color:(title.color || '#7A7A7A')}" v-if="title.text">{{title.text}}</text>
+			</view>
+			<view class="tui-header-right" :style="{fontSize:(tag.size || 24)+'rpx',color:(tag.color || '#b2b2b2')}" v-if="tag.text">
+				{{tag.text}}
+			</view>
+		</view>
+		<view class="tui-card-body">
+			<slot name="body"></slot>
+		</view>
+		<view class="tui-card-footer">
+			<slot name="footer"></slot>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: "tuiCard",
+		props: {
+			//是否铺满
+			full: {
+				type: Boolean,
+				default: false
+			},
+			image: {
+				type: Object,
+				default: function() {
+					return {
+						url: "", //图片地址
+						height: 60, //图片高度
+						width: 60, //图片宽度
+						circle: false
+					}
+				}
+			},
+			//标题
+			title: {
+				type: Object,
+				default: function() {
+					return {
+						text: "", //标题文字
+						size: 30, //字体大小
+						color: "#7A7A7A" //字体颜色
+					}
+				}
+			},
+			//标签,时间等
+			tag: {
+				type: Object,
+				default: function() {
+					return {
+						text: "", //标签文字
+						size: 24, //字体大小
+						color: "#b2b2b2" //字体颜色
+					}
+				}
+			},
+			header: {
+				type: Object,
+				default: function() {
+					return {
+						bgcolor: "#fff", //背景颜色
+						line: false //是否去掉底部线条
+					}
+				}
+			},
+			//是否设置外边框
+			border: {
+				type: Boolean,
+				default: false
+			},
+			index: {
+				type: Number,
+				default: 0
+			}
+		},
+		methods: {
+			handleClick() {
+				this.$emit('click', {
+					index: this.index
+				});
+			},
+			longTap() {
+				this.$emit('longclick', {
+					index: this.index
+				});
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.tui-card {
+		margin: 0 30rpx;
+		font-size: 28rpx;
+		background-color: #fff;
+		border-radius: 20rpx;
+		box-shadow: 0 0 10rpx #eee;
+		box-sizing: border-box;
+		overflow: hidden;
+	}
+
+	.tui-card-full {
+		margin: 0 !important;
+		border-radius: 0 !important;
+	}
+
+	.tui-card-full::after {
+		border-radius: 0 !important;
+	}
+
+	.tui-card-border {
+		position: relative;
+		box-shadow: none !important
+	}
+
+	.tui-card-border::after {
+		content: ' ';
+		position: absolute;
+		height: 200%;
+		width: 200%;
+		border: 1px solid #ddd;
+		transform-origin: 0 0;
+		-webkit-transform-origin: 0 0;
+		-webkit-transform: scale(0.5);
+		transform: scale(0.5);
+		left: 0;
+		top: 0;
+		border-radius: 20rpx;
+		box-sizing: border-box;
+		pointer-events: none;
+	}
+
+	.tui-card-header {
+		width: 100%;
+		padding: 20rpx;
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		position: relative;
+		box-sizing: border-box;
+		overflow: hidden;
+		border-top-left-radius: 10rpx;
+		border-top-right-radius: 10rpx;
+	}
+
+	.tui-card-header::after {
+		content: '';
+		position: absolute;
+		border-bottom: 1rpx solid #eaeef1;
+		-webkit-transform: scaleY(0.5);
+		transform: scaleY(0.5);
+		bottom: 0;
+		right: 0;
+		left: 0;
+		pointer-events: none;
+	}
+
+	.tui-header-line::after {
+		border-bottom: 0 !important;
+	}
+
+	.tui-header-thumb {
+		height: 60rpx;
+		width: 60rpx;
+		vertical-align: middle;
+		margin-right: 20rpx;
+		border-radius: 6rpx;
+	}
+
+	.tui-thumb-circle {
+		border-radius: 50% !important;
+	}
+
+	.tui-header-title {
+		display: inline-block;
+		font-size: 30rpx;
+		color: #7a7a7a;
+		vertical-align: middle;
+		max-width: 460rpx;
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+
+	.tui-header-right {
+		font-size: 24rpx;
+		color: #b2b2b2;
+	}
+
+	.tui-card-body {
+		font-size: 32rpx;
+		color: #262b3a;
+		box-sizing: border-box;
+	}
+
+	.tui-card-footer {
+		font-size: 28rpx;
+		color: #596d96;
+		border-bottom-left-radius: 10rpx;
+		border-bottom-right-radius: 10rpx;
+		box-sizing: border-box;
+	}
+</style>

+ 567 - 0
addons/exam/uniapp/components/tui-cascade-selection/tui-cascade-selection.vue

@@ -0,0 +1,567 @@
+<template>
+	<view class="tui-cascade-selection">
+		<scroll-view scroll-x scroll-with-animation :scroll-into-view="scrollViewId"
+			:style="{ backgroundColor: headerBgColor }" class="tui-bottom-line"
+			:class="{ 'tui-btm-none': !headerLine }">
+			<view class="tui-selection-header" :style="{ height: tabsHeight, backgroundColor: backgroundColor }">
+				<view class="tui-header-item" :class="{ 'tui-font-bold': idx === currentTab && bold }"
+					:style="{ color: idx === currentTab ? activeColor : color, fontSize: size + 'rpx' }"
+					:id="`id_${idx}`" @tap.stop="swichNav" :data-current="idx" v-for="(item, idx) in selectedArr"
+					:key="idx">
+					{{ item.text }}
+					<view class="tui-active-line" :style="{ backgroundColor: lineColor }"
+						v-if="idx === currentTab && showLine"></view>
+				</view>
+			</view>
+		</scroll-view>
+		<swiper class="tui-selection-list" :current="currentTab" duration="300" @change="switchTab"
+			:style="{ height: height, backgroundColor: backgroundColor }">
+			<swiper-item v-for="(item, index) in selectedArr" :key="index">
+				<scroll-view scroll-y :scroll-into-view="item.scrollViewId" class="tui-selection-item"
+					:style="{ height: height }">
+					<view class="tui-first-item" :style="{ height: firstItemTop }"></view>
+					<view class="tui-selection-cell" :style="{ padding: padding, backgroundColor: backgroundColor }"
+						:id="`id_${subIndex}`" v-for="(subItem, subIndex) in item.list" :key="subIndex"
+						@tap="change(index, subIndex, subItem)">
+						<icon type="success_no_circle" v-if="item.index === subIndex" :color="checkMarkColor"
+							:size="checkMarkSize" class="tui-icon-success"></icon>
+						<image :src="subItem.src" v-if="subItem.src" class="tui-cell-img"
+							:style="{ width: imgWidth, height: imgHeight, borderRadius: radius }"></image>
+						<view class="tui-cell-title"
+							:class="{ 'tui-font-bold': item.index === subIndex && textBold, 'tui-flex-shrink': nowrap }"
+							:style="{ color: item.index === subIndex ? textActiveColor : textColor, fontSize: textSize + 'rpx' }">
+							{{ subItem.text }}
+						</view>
+						<view class="tui-cell-sub_title" :style="{ color: subTextColor, fontSize: subTextSize + 'rpx' }"
+							v-if="subItem.subText">{{ subItem.subText }}</view>
+					</view>
+				</scroll-view>
+			</swiper-item>
+		</swiper>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: 'tuiCascadeSelection',
+		emits: ['change', 'complete'],
+		props: {
+			/**
+				 * 如果下一级是请求返回,则为第一级数据,否则所有数据
+				 * 数据格式
+				  [{
+					  src: "",
+					  text: "",
+					  subText: "",
+					  value: 0,
+					  children:[{
+						  text: "",
+						  subText: "",
+						  value: 0,
+						  children:[]
+				   }]
+				  }]
+				 * */
+			itemList: {
+				type: Array,
+				default: () => {
+					return [];
+				}
+			},
+			/*
+			   初始化默认选中数据
+			   [{
+				text: "",//选中text
+				subText: '',//选中subText
+				value: '',//选中value
+				src: '', //选中src,没有则传空或不传
+				index: 0, //选中数据在当前layer索引
+				list: [{src: "", text: "", subText: "", value: 101}] //当前layer下所有数据集合
+			  }];
+			    
+			   */
+			defaultItemList: {
+				type: Array,
+				value: []
+			},
+			defaultKey: {
+				type: String,
+				default: 'text'
+			},
+			//是否显示header底部细线
+			headerLine: {
+				type: Boolean,
+				default: true
+			},
+			//header背景颜色
+			headerBgColor: {
+				type: String,
+				default: '#FFFFFF'
+			},
+			//顶部标签栏高度
+			tabsHeight: {
+				type: String,
+				default: '88rpx'
+			},
+			//默认显示文字
+			text: {
+				type: String,
+				default: '请选择'
+			},
+			//tabs 文字大小
+			size: {
+				type: Number,
+				default: 28
+			},
+			//tabs 文字颜色
+			color: {
+				type: String,
+				default: '#555'
+			},
+			//选中颜色
+			activeColor: {
+				type: String,
+				default: '#5677fc'
+			},
+			//选中后文字加粗
+			bold: {
+				type: Boolean,
+				default: true
+			},
+			//选中后是否显示底部线条
+			showLine: {
+				type: Boolean,
+				default: true
+			},
+			//线条颜色
+			lineColor: {
+				type: String,
+				default: '#5677fc'
+			},
+			//icon 大小
+			checkMarkSize: {
+				type: Number,
+				default: 15
+			},
+			//icon 颜色
+			checkMarkColor: {
+				type: String,
+				default: '#5677fc'
+			},
+			//item 图片宽度
+			imgWidth: {
+				type: String,
+				default: '40rpx'
+			},
+			//item 图片高度
+			imgHeight: {
+				type: String,
+				default: '40rpx'
+			},
+			//图片圆角
+			radius: {
+				type: String,
+				default: '50%'
+			},
+			//item text颜色
+			textColor: {
+				type: String,
+				default: '#333'
+			},
+			textActiveColor: {
+				type: String,
+				default: '#333'
+			},
+			//选中后字体是否加粗
+			textBold: {
+				type: Boolean,
+				default: true
+			},
+			//item text字体大小
+			textSize: {
+				type: Number,
+				default: 28
+			},
+			//text 是否不换行
+			nowrap: {
+				type: Boolean,
+				default: false
+			},
+			//item subText颜色
+			subTextColor: {
+				type: String,
+				default: '#999'
+			},
+			//item subText字体大小
+			subTextSize: {
+				type: Number,
+				default: 24
+			},
+			// item padding
+			padding: {
+				type: String,
+				default: '20rpx 30rpx'
+			},
+			//占位高度,第一条数据距离顶部距离
+			firstItemTop: {
+				type: String,
+				default: '20rpx'
+			},
+			//swiper 高度
+			height: {
+				type: String,
+				default: '300px'
+			},
+			//item  swiper 内容部分背景颜色
+			backgroundColor: {
+				type: String,
+				default: '#FFFFFF'
+			},
+			//子集数据是否请求返回(默认false,一次性返回所有数据)
+			request: {
+				type: Boolean,
+				default: false
+			},
+			//子级数据(当有改变时,默认当前选中项新增子级数据,request=true时生效)
+			receiveData: {
+				type: Array,
+				default: () => {
+					return [];
+				}
+			},
+			//改变值则重置数据
+			reset: {
+				type: [Number, String],
+				default: 0
+			}
+		},
+		watch: {
+			itemList(val) {
+				this.initData(val, -1);
+			},
+			receiveData(val) {
+				this.subLevelData(val, this.currentTab);
+			},
+			reset() {
+				this.initData(this.itemList, -1);
+			},
+			defaultItemList(val) {
+				this.setDefaultData(val)
+			}
+		},
+		created() {
+			this.setDefaultData(this.defaultItemList)
+		},
+		data() {
+			return {
+				currentTab: 0,
+				//tab栏scrollview滚动的位置
+				scrollViewId: 'id__1',
+				selectedArr: []
+			};
+		},
+		methods: {
+			setDefaultData(val) {
+				let defaultItemList = val || [];
+				if (defaultItemList.length > 0) {
+					if ((typeof defaultItemList[0] === 'string' || typeof defaultItemList[0] === 'number') && !this
+						.request) {
+						let subi = -1
+						let selectedArr = []
+						for (let j = 0, len = defaultItemList.length; j < len; j++) {
+							let item = defaultItemList[j]
+							let list = []
+							let obj = {}
+							if (j === 0) {
+								list = this.getItemList(-1)
+							} else {
+								list = this.getItemList(j - 1, subi,selectedArr)
+							}
+							subi = this.getDefaultIndex(list, item)
+							if (subi !== -1) {
+								obj = list[subi]
+								selectedArr.push({
+									text: obj.text || this.text,
+									value: obj.value || '',
+									src: obj.src || '',
+									subText: obj.subText || '',
+									index: subi,
+									scrollViewId: `id_${subi}`,
+									list: list
+								})
+							}
+
+							if (subi === -1) break;
+						}
+						this.selectedArr = selectedArr;
+						this.currentTab = selectedArr.length - 1;
+						this.$nextTick(() => {
+							this.checkCor();
+						});
+					} else {
+						defaultItemList.map(item => {
+							item.scrollViewId = `id_${item.index}`;
+						});
+						this.selectedArr = defaultItemList;
+						this.currentTab = defaultItemList.length - 1;
+						this.$nextTick(() => {
+							this.checkCor();
+						});
+					}
+
+				} else {
+					this.initData(this.itemList, -1);
+				}
+			},
+			getDefaultIndex(arr, val) {
+				if (!arr || arr.length === 0 || val === undefined) return -1;
+				let index = -1;
+				let key = this.defaultKey || 'text'
+				for (let i = 0, len = arr.length; i < len; i++) {
+					if (arr[i][key] == val) {
+						index = i;
+						break;
+					}
+				}
+				return index;
+			},
+			initData(data, layer) {
+				if (!data || data.length === 0) return;
+				if (this.request) {
+					//第一级数据
+					this.subLevelData(data, layer);
+				} else {
+					let selectedValue = this.selectedValue || {};
+					if (selectedValue.type) {
+						this.setDefaultData(selectedValue);
+					} else {
+						this.subLevelData(this.getItemList(layer, -1), layer);
+					}
+				}
+			},
+			removeChildren(data) {
+				let list = data.map(item => {
+					delete item['children'];
+					return item;
+				});
+				return list;
+			},
+			getItemList(layer, index, selectedArr) {
+				let list = [];
+				let arr = JSON.parse(JSON.stringify(this.itemList));
+				selectedArr = selectedArr || this.selectedArr
+				if (layer == -1) {
+					list = this.removeChildren(arr);
+				} else {
+					let value = selectedArr[0].index;
+					value = value == -1 ? index : value;
+					list = arr[value].children || [];
+					if (layer > 0) {
+						for (let i = 1; i < layer + 1; i++) {
+							let val = layer === i ? index : selectedArr[i].index;
+							list = list[val].children || [];
+							if (list.length === 0) break;
+						}
+					}
+					list = this.removeChildren(list);
+				}
+				return list;
+			},
+			//滚动切换
+			switchTab: function(e) {
+				this.currentTab = e.detail.current;
+				this.checkCor();
+			},
+			//点击标题切换当
+			swichNav: function(e) {
+				let cur = e.currentTarget.dataset.current;
+				if (this.currentTab != cur) {
+					this.currentTab = cur;
+				}
+			},
+			checkCor: function() {
+				let item = this.selectedArr[this.currentTab];
+				item.scrollViewId = 'id__1';
+				this.$nextTick(() => {
+					setTimeout(() => {
+						let val = item.index < 2 ? 0 : Number(item.index - 2);
+						item.scrollViewId = `id_${val}`;
+					}, 2);
+				});
+
+				if (this.currentTab > 1) {
+					this.scrollViewId = `id_${this.currentTab - 1}`;
+				} else {
+					this.scrollViewId = `id_0`;
+				}
+			},
+			change(index, subIndex, subItem) {
+				let item = this.selectedArr[index];
+				if (item.index == subIndex) return;
+				item.index = subIndex;
+				item.text = subItem.text;
+				item.value = subItem.value;
+				item.subText = subItem.subText || '';
+				item.src = subItem.src || '';
+				this.$emit('change', {
+					layer: index,
+					subIndex: subIndex, //layer=> Array index
+					...subItem
+				});
+
+				if (!this.request) {
+					let data = this.getItemList(index, subIndex);
+					this.subLevelData(data, index);
+				}
+			},
+			//新增子级数据时处理
+			subLevelData(data, layer) {
+				if (!data || data.length === 0) {
+					if (layer == -1) return;
+					//完成选择
+					let arr = this.selectedArr;
+					if (layer < arr.length - 1) {
+						let newArr = arr.slice(0, layer + 1);
+						this.selectedArr = newArr;
+					}
+					let result = JSON.parse(JSON.stringify(this.selectedArr));
+					let lastItem = result[result.length - 1] || {};
+					let text = '';
+					result.map(item => {
+						text += item.text;
+						delete item['list'];
+						//delete item['index'];
+						delete item['scrollViewId'];
+						return item;
+					});
+					this.$emit('complete', {
+						result: result,
+						value: lastItem.value,
+						text: text,
+						subText: lastItem.subText,
+						src: lastItem.src
+					});
+				} else {
+					//重置数据( >layer层级)
+					let item = [{
+						text: this.text,
+						subText: '',
+						value: '',
+						src: '',
+						index: -1,
+						scrollViewId: 'id__1',
+						list: data
+					}];
+					if (layer == -1) {
+						this.selectedArr = item;
+					} else {
+						let retainArr = this.selectedArr.slice(0, layer + 1);
+						this.selectedArr = retainArr.concat(item);
+					}
+					this.$nextTick(() => {
+						this.currentTab = this.selectedArr.length - 1;
+					});
+				}
+			}
+		}
+	};
+</script>
+
+<style scoped>
+	.tui-cascade-selection {
+		width: 100%;
+		box-sizing: border-box;
+	}
+
+	.tui-selection-header {
+		width: 100%;
+		display: flex;
+		align-items: center;
+		position: relative;
+		box-sizing: border-box;
+	}
+
+	.tui-bottom-line {
+		position: relative;
+	}
+
+	.tui-bottom-line::after {
+		width: 100%;
+		content: '';
+		position: absolute;
+		border-bottom: 1rpx solid #eaeef1;
+		-webkit-transform: scaleY(0.5) translateZ(0);
+		transform: scaleY(0.5) translateZ(0);
+		transform-origin: 0 100%;
+		bottom: 0;
+		right: 0;
+		left: 0;
+	}
+
+	.tui-btm-none::after {
+		border-bottom: 0 !important;
+	}
+
+	.tui-header-item {
+		max-width: 240rpx;
+		padding: 15rpx 30rpx;
+		box-sizing: border-box;
+		flex-shrink: 0;
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+		position: relative;
+	}
+
+	.tui-font-bold {
+		font-weight: bold;
+	}
+
+	.tui-active-line {
+		width: 60rpx;
+		height: 6rpx;
+		border-radius: 4rpx;
+		position: absolute;
+		bottom: 0;
+		right: 0;
+		left: 50%;
+		transform: translateX(-50%);
+	}
+
+	.tui-selection-cell {
+		width: 100%;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+	}
+
+	.tui-icon-success {
+		margin-right: 12rpx;
+	}
+
+	.tui-cell-img {
+		margin-right: 12rpx;
+		flex-shrink: 0;
+	}
+
+	.tui-cell-title {
+		word-break: break-all;
+	}
+
+	.tui-flex-shrink {
+		flex-shrink: 0;
+	}
+
+	.tui-font-bold {
+		font-weight: bold;
+	}
+
+	.tui-cell-sub_title {
+		margin-left: 20rpx;
+		word-break: break-all;
+	}
+
+	.tui-first-item {
+		width: 100%;
+	}
+</style>

+ 264 - 0
addons/exam/uniapp/components/tui-circular-progress/tui-circular-progress.vue

@@ -0,0 +1,264 @@
+<template>
+	<view class="tui-circular-container" :style="{ width: diam + 'px', height: (height || diam) + 'px' }">
+		<canvas
+			class="tui-circular-default"
+			:canvas-id="defaultCanvasId"
+			:id="defaultCanvasId"
+			:style="{ width: diam + 'px', height: (height || diam) + 'px' }"
+			v-if="defaultShow"
+		></canvas>
+		<canvas class="tui-circular-progress" :canvas-id="progressCanvasId" :id="progressCanvasId" :style="{ width: diam + 'px', height: (height || diam) + 'px' }"></canvas>
+		<slot />
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'tuiCircularProgress',
+	props: {
+		/*
+			  传值需使用rpx进行转换保证各终端兼容
+			  px = rpx / 750 * wx.getSystemInfoSync().windowWidth
+			  圆形进度条(画布)宽度,直径 [px]
+			*/
+		diam: {
+			type: Number,
+			default: 60
+		},
+		//圆形进度条(画布)高度,默认取diam值[当画半弧时传值,height有值时则取height]
+		height: {
+			type: Number,
+			default: 0
+		},
+		//进度条线条宽度[px]
+		lineWidth: {
+			type: Number,
+			default: 4
+		},
+		/*
+			 线条的端点样式
+			 butt:向线条的每个末端添加平直的边缘
+			 round	向线条的每个末端添加圆形线帽
+			 square	向线条的每个末端添加正方形线帽
+			*/
+		lineCap: {
+			type: String,
+			default: 'round'
+		},
+		//圆环进度字体大小 [px]
+		fontSize: {
+			type: Number,
+			default: 12
+		},
+		//圆环进度字体颜色
+		fontColor: {
+			type: String,
+			default: '#5677fc'
+		},
+		//是否显示进度文字
+		fontShow: {
+			type: Boolean,
+			default: true
+		},
+		/*
+			 自定义显示文字[默认为空,显示百分比,fontShow=true时生效]
+			 可以使用 slot自定义显示内容
+			*/
+		percentText: {
+			type: String,
+			default: ''
+		},
+		//是否显示默认(背景)进度条
+		defaultShow: {
+			type: Boolean,
+			default: true
+		},
+		//默认进度条颜色
+		defaultColor: {
+			type: String,
+			default: '#CCC'
+		},
+		//进度条颜色
+		progressColor: {
+			type: String,
+			default: '#5677fc'
+		},
+		//进度条渐变颜色[结合progressColor使用,默认为空]
+		gradualColor: {
+			type: String,
+			default: ''
+		},
+		//起始弧度,单位弧度
+		sAngle: {
+			type: Number,
+			default: -Math.PI / 2
+		},
+		//指定弧度的方向是逆时针还是顺时针。默认是false,即顺时针
+		counterclockwise: {
+			type: Boolean,
+			default: false
+		},
+		//进度百分比 [10% 传值 10]
+		percentage: {
+			type: Number,
+			default: 0
+		},
+		//进度百分比缩放倍数[使用半弧为100%时,则可传2]
+		multiple: {
+			type: Number,
+			default: 1
+		},
+		//动画执行时间[单位毫秒,低于50无动画]
+		duration: {
+			type: Number,
+			default: 800
+		},
+		//backwards: 动画从头播;forwards:动画从上次结束点接着播
+		activeMode: {
+			type: String,
+			default: 'backwards'
+		}
+	},
+	watch: {
+		percentage(val) {
+			this.initDraw();
+		}
+	},
+	data() {
+		return {
+			// #ifdef MP-WEIXIN
+			progressCanvasId: 'progressCanvasId',
+			defaultCanvasId: 'defaultCanvasId',
+			// #endif
+			// #ifndef MP-WEIXIN
+			progressCanvasId: this.getCanvasId(),
+			defaultCanvasId: this.getCanvasId(),
+			// #endif
+			progressContext: null,
+			linearGradient: null,
+			//起始百分比
+			startPercentage: 0
+			// dpi
+			//pixelRatio: uni.getSystemInfoSync().pixelRatio
+		};
+	},
+	mounted() {
+		this.initDraw(true);
+	},
+	methods: {
+		//初始化绘制
+		initDraw(init) {
+			let start = this.activeMode === 'backwards' ? 0 : this.startPercentage;
+			start = start > this.percentage ? 0 : start;
+			if (this.defaultShow && init) {
+				this.drawDefaultCircular();
+			}
+			this.drawProgressCircular(start);
+		},
+		//默认(背景)圆环
+		drawDefaultCircular() {
+			let ctx = uni.createCanvasContext(this.defaultCanvasId, this);
+			ctx.setLineWidth(this.lineWidth);
+			ctx.setStrokeStyle(this.defaultColor);
+			//终止弧度
+			let eAngle = Math.PI * (this.height ? 1 : 2) + this.sAngle;
+			this.drawArc(ctx, eAngle);
+		},
+		//进度圆环
+		drawProgressCircular(startPercentage) {
+			let ctx = this.progressContext;
+			let gradient = this.linearGradient;
+			if (!ctx) {
+				ctx = uni.createCanvasContext(this.progressCanvasId, this);
+				//创建一个线性的渐变颜色 CanvasGradient对象
+				gradient = ctx.createLinearGradient(0, 0, this.diam, 0);
+				gradient.addColorStop('0', this.progressColor);
+				if (this.gradualColor) {
+					gradient.addColorStop('1', this.gradualColor);
+				}
+				// #ifdef APP-PLUS
+				const res = uni.getSystemInfoSync();
+				if (!this.gradualColor && res.platform.toLocaleLowerCase() == 'android') {
+					gradient.addColorStop('1', this.progressColor);
+				}
+				// #endif
+				this.progressContext = ctx;
+				this.linearGradient = gradient;
+			}
+			ctx.setLineWidth(this.lineWidth);
+			ctx.setStrokeStyle(gradient);
+			let time = this.percentage == 0 || this.duration < 50 ? 0 : this.duration / this.percentage;
+			if (this.percentage > 0) {
+				startPercentage = this.duration < 50 ? this.percentage - 1 : startPercentage;
+				startPercentage++;
+			}
+			if (this.fontShow) {
+				ctx.setFontSize(this.fontSize);
+				ctx.setFillStyle(this.fontColor);
+				ctx.setTextAlign('center');
+				ctx.setTextBaseline('middle');
+				let percentage = this.percentText;
+				if (!percentage) {
+					percentage = this.counterclockwise ? 100 - startPercentage * this.multiple : startPercentage * this.multiple;
+					percentage = `${percentage}%`;
+				}
+				let radius = this.diam / 2;
+				ctx.fillText(percentage, radius, radius);
+			}
+			if (this.percentage == 0 || (this.counterclockwise && startPercentage == 100)) {
+				ctx.draw();		
+			}else{
+				let eAngle = ((2 * Math.PI) / 100) * startPercentage + this.sAngle;
+				this.drawArc(ctx, eAngle);
+			}
+			setTimeout(() => {
+				this.startPercentage = startPercentage;
+				if (startPercentage == this.percentage) {
+					this.$emit('end', {
+						canvasId: this.progressCanvasId,
+						percentage: startPercentage
+					});
+				} else {
+					this.drawProgressCircular(startPercentage);
+				}
+				this.$emit('change', {
+					percentage: startPercentage
+				});
+			}, time);
+			// #ifdef H5
+			// requestAnimationFrame(()=>{})
+			// #endif
+		},
+		//创建弧线
+		drawArc(ctx, eAngle) {
+			ctx.setLineCap(this.lineCap);
+			ctx.beginPath();
+			let radius = this.diam / 2; //x=y
+			ctx.arc(radius, radius, radius - this.lineWidth, this.sAngle, eAngle, this.counterclockwise);
+			ctx.stroke();
+			ctx.draw();
+		},
+		//生成canvasId
+		getCanvasId() {
+			let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+				return (c === 'x' ? (Math.random() * 16) | 0 : 'r&0x3' | '0x8').toString(16);
+			});
+			return uuid;
+		}
+	}
+};
+</script>
+
+<style scoped>
+.tui-circular-container,
+.tui-circular-default {
+	position: relative;
+}
+
+.tui-circular-progress {
+	position: absolute;
+	left: 0;
+	top: 0;
+	z-index: 10;
+}
+</style>

+ 335 - 0
addons/exam/uniapp/components/tui-countdown/tui-countdown.vue

@@ -0,0 +1,335 @@
+<template>
+	<view class="tui-countdown-box">
+		<view class="tui-countdown-item" :style="{ background: backgroundColor, borderColor: borderColor, width: getWidth(d, width) + 'rpx', height: height + 'rpx' }" v-if="days">
+			<view class="tui-countdown-time" :class="[scale ? 'tui-countdown-scale' : '']" :style="{ fontSize: size + 'rpx', color: color, lineHeight: height + 'rpx' }">
+				{{ d }}
+			</view>
+		</view>
+		<view
+			class="tui-countdown-colon"
+			:class="{ 'tui-colon-pad': borderColor == 'transparent' }"
+			:style="{ fontSize: colonSize + 'rpx', color: colonColor }"
+			v-if="days"
+		>
+			{{ isColon ? ':' : '天' }}
+		</view>
+		<view class="tui-countdown-item" :style="{ background: backgroundColor, borderColor: borderColor, width: getWidth(h, width) + 'rpx', height: height + 'rpx' }" v-if="hours">
+			<view class="tui-countdown-time" :class="[scale ? 'tui-countdown-scale' : '']" :style="{ fontSize: size + 'rpx', color: color, lineHeight: height + 'rpx' }">
+				{{ h }}
+			</view>
+		</view>
+		<view
+			class="tui-countdown-colon"
+			:class="{ 'tui-colon-pad': borderColor == 'transparent' }"
+			:style="{ fontSize: colonSize + 'rpx', color: colonColor }"
+			v-if="hours"
+		>
+			{{ isColon ? ':' : '时' }}
+		</view>
+		<view
+			class="tui-countdown-item"
+			:style="{ background: backgroundColor, borderColor: borderColor, width: getWidth(i, width) + 'rpx', height: height + 'rpx' }"
+			v-if="minutes"
+		>
+			<view class="tui-countdown-time" :class="[scale ? 'tui-countdown-scale' : '']" :style="{ fontSize: size + 'rpx', color: color, lineHeight: height + 'rpx' }">
+				{{ i }}
+			</view>
+		</view>
+		<view
+			class="tui-countdown-colon"
+			:class="{ 'tui-colon-pad': borderColor == 'transparent' }"
+			:style="{  fontSize: colonSize + 'rpx', color: colonColor }"
+			v-if="minutes"
+		>
+			{{ isColon ? ':' : '分' }}
+		</view>
+		<view
+			class="tui-countdown-item"
+			:style="{ background: backgroundColor, borderColor: borderColor, width: getWidth(s, width) + 'rpx', height: height + 'rpx' }"
+			v-if="seconds"
+		>
+			<view class="tui-countdown-time" :class="[scale ? 'tui-countdown-scale' : '']" :style="{ fontSize: size + 'rpx', color: color, lineHeight: height + 'rpx' }">
+				{{ s }}
+			</view>
+		</view>
+		<view
+			class="tui-countdown-colon"
+			:class="{ 'tui-colon-pad': borderColor == 'transparent' }"
+			:style="{  fontSize: colonSize + 'rpx', color: colonColor }"
+			v-if="seconds && !isColon"
+		>
+			{{ unitEn ? 's' : '秒' }}
+		</view>
+
+		<view class="tui-countdown-colon" :style="{ lineHeight: colonSize + 'rpx', fontSize: colonSize + 'rpx', color: colonColor }" v-if="seconds && isMs && isColon">.</view>
+		<view
+			class="tui-countdown__ms"
+			:style="{
+				background: backgroundColor,
+				borderColor: borderColor,
+				fontSize: msSize + 'rpx',
+				color: msColor,
+				height: height + 'rpx',
+				width: msWidth > 0 ? msWidth + 'rpx' : 'auto'
+			}"
+			v-if="seconds && isMs"
+		>
+			<view :class="{ 'tui-ms__list': ani }">
+				<view class="tui-ms__item" :style="{ height: height + 'rpx' }" v-for="(item, index) in ms" :key="index">
+					<view :class="[scale ? 'tui-countdown-scale' : '']">{{item}}</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'tuiCountdown',
+	props: {
+		//数字框宽度
+		width: {
+			type: Number,
+			default: 32
+		},
+		//数字框高度
+		height: {
+			type: Number,
+			default: 32
+		},
+		//数字框border颜色
+		borderColor: {
+			type: String,
+			default: '#333'
+		},
+		//数字框背景颜色
+		backgroundColor: {
+			type: String,
+			default: '#fff'
+		},
+		//数字框字体大小
+		size: {
+			type: Number,
+			default: 24
+		},
+		//数字框字体颜色
+		color: {
+			type: String,
+			default: '#333'
+		},
+		//是否缩放 0.9
+		scale: {
+			type: Boolean,
+			default: false
+		},
+		//冒号大小
+		colonSize: {
+			type: Number,
+			default: 28
+		},
+		//冒号颜色
+		colonColor: {
+			type: String,
+			default: '#333'
+		},
+		//剩余时间 (单位:秒)
+		time: {
+			type: Number,
+			default: 0
+		},
+		//是否包含天
+		days: {
+			type: Boolean,
+			default: false
+		},
+		//是否包含小时
+		hours: {
+			type: Boolean,
+			default: true
+		},
+		//是否包含分钟
+		minutes: {
+			type: Boolean,
+			default: true
+		},
+		//是否包含秒
+		seconds: {
+			type: Boolean,
+			default: true
+		},
+		//单位用英文缩写表示 仅seconds秒数有效
+		unitEn: {
+			type: Boolean,
+			default: false
+		},
+		//是否展示为冒号,false为文字
+		isColon: {
+			type: Boolean,
+			default: true
+		},
+		//是否返回剩余时间
+		returnTime: {
+			type: Boolean,
+			default: false
+		},
+		//是否显示毫秒
+		isMs: {
+			type: Boolean,
+			default: false
+		},
+		msWidth: {
+			type: Number,
+			default: 32
+		},
+		msSize: {
+			type: Number,
+			default: 24
+		},
+		msColor: {
+			type: String,
+			default: '#333'
+		}
+	},
+	watch: {
+		time(val) {
+			this.clearTimer();
+			this.doLoop();
+		}
+	},
+	data() {
+		return {
+			countdown: null,
+			d: '0',
+			h: '00',
+			i: '00',
+			s: '00',
+			//此处若从9到1,结束需要特殊处理
+			ms: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+			ani: false
+		};
+	},
+	created() {
+		this.clearTimer();
+		this.doLoop();
+	},
+	beforeDestroy() {
+		this.clearTimer();
+	},
+	methods: {
+		getWidth: function(num, width) {
+			return num > 99 ? (width / 2) * num.toString().length : width;
+		},
+		clearTimer() {
+			clearInterval(this.countdown);
+			this.countdown = null;
+		},
+		endOfTime() {
+			this.ani = false;
+			this.clearTimer();
+			this.$emit('end', {});
+		},
+		doLoop: function() {
+			let seconds = this.time || 0;
+			this.ani = true;
+			this.countDown(seconds);
+			this.countdown = setInterval(() => {
+				seconds--;
+				if (seconds < 0) {
+					this.endOfTime();
+					return;
+				}
+				this.countDown(seconds);
+				if (this.returnTime) {
+					this.$emit('time', { seconds: seconds });
+				}
+			}, 1000);
+		},
+		countDown(seconds) {
+			let [day, hour, minute, second] = [0, 0, 0, 0];
+			if (seconds > 0) {
+				day = this.days ? Math.floor(seconds / (60 * 60 * 24)) : 0;
+				hour = this.hours ? Math.floor(seconds / (60 * 60)) - day * 24 : 0;
+				minute = this.minutes ? Math.floor(seconds / 60) - hour * 60 - day * 24 * 60 : 0;
+				second = Math.floor(seconds) - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60;
+			} else {
+				this.endOfTime();
+			}
+			hour = hour < 10 ? '0' + hour : hour;
+			minute = minute < 10 ? '0' + minute : minute;
+			second = second < 10 ? '0' + second : second;
+			this.d = day;
+			this.h = hour;
+			this.i = minute;
+			this.s = second;
+		}
+	}
+};
+</script>
+
+<style scoped>
+.tui-countdown-box {
+	display: flex;
+	align-items: center;
+}
+
+.tui-countdown-box {
+	display: flex;
+	align-items: center;
+}
+
+.tui-countdown-item {
+	border: 1rpx solid;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	border-radius: 6rpx;
+	white-space: nowrap;
+	transform: translateZ(0);
+}
+
+.tui-countdown-time {
+	margin: 0;
+	padding: 0;
+}
+
+.tui-countdown-colon {
+	display: flex;
+	justify-content: center;
+	padding: 0 5rpx;
+}
+
+.tui-colon-pad {
+	padding: 0 !important;
+}
+
+.tui-countdown-scale {
+	transform: scale(0.9);
+	transform-origin: center center;
+}
+.tui-countdown__ms {
+	border: 1rpx solid;
+	overflow: hidden;
+	border-radius: 6rpx;
+}
+
+/*ms使用css3代替js频繁更新操作,性能优化*/
+.tui-ms__list {
+	animation: loop 1s steps(10) infinite;
+}
+
+@keyframes loop {
+	from {
+		transform: translateY(0);
+	}
+
+	to {
+		transform: translateY(-100%);
+	}
+}
+
+.tui-ms__item {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+</style>

+ 103 - 0
addons/exam/uniapp/components/tui-divider/tui-divider.vue

@@ -0,0 +1,103 @@
+<template>
+	<view class="tui-divider" :style="{ height: height + 'rpx' }">
+		<view class="tui-divider-line" :style="{ width: width, background: getBgColor(gradual, gradualColor, dividerColor) }"></view>
+		<view
+			class="tui-divider-text"
+			:style="{ color: color, fontSize: size + 'rpx', lineHeight: size + 'rpx', backgroundColor: backgroundColor, fontWeight: bold ? 'bold' : 'normal' }"
+		>
+			<slot></slot>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'tuiDivider',
+	props: {
+		//divider占据高度
+		height: {
+			type: Number,
+			default: 100
+		},
+		//divider宽度,可填写具体长度,如400rpx
+		width: {
+			type: String,
+			default: '100%'
+		},
+		//divider颜色,如果为渐变线条,此属性失效
+		dividerColor: {
+			type: String,
+			default: '#e5e5e5'
+		},
+		//文字颜色
+		color: {
+			type: String,
+			default: '#999'
+		},
+		//文字大小 rpx
+		size: {
+			type: Number,
+			default: 24
+		},
+		bold: {
+			type: Boolean,
+			default: false
+		},
+		//背景颜色,和当前页面背景色保持一致
+		backgroundColor: {
+			type: String,
+			default: '#fafafa'
+		},
+		//是否为渐变线条,为true,divideColor失效
+		gradual: {
+			type: Boolean,
+			default: false
+		},
+		//渐变色值,to right ,提供两个色值即可,由浅至深
+		gradualColor: {
+			type: Array,
+			default: function() {
+				return ['#eee', '#ccc'];
+			}
+		}
+	},
+	methods: {
+		getBgColor: function(gradual, gradualColor, dividerColor) {
+			let bgColor = dividerColor;
+			if (gradual) {
+				bgColor = 'linear-gradient(to right,' + gradualColor[0] + ',' + gradualColor[1] + ',' + gradualColor[1] + ',' + gradualColor[0] + ')';
+			}
+			return bgColor;
+		}
+	}
+};
+</script>
+
+<style scoped>
+.tui-divider {
+	width: 100%;
+	position: relative;
+	text-align: center;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	box-sizing: border-box;
+	overflow: hidden;
+}
+
+.tui-divider-line {
+	position: absolute;
+	height: 1rpx;
+	top: 50%;
+	left: 50%;
+	-webkit-transform: scaleY(0.5) translateX(-50%) translateZ(0);
+	transform: scaleY(0.5) translateX(-50%) translateZ(0);
+}
+
+.tui-divider-text {
+	position: relative;
+	text-align: center;
+	padding: 0 18rpx;
+	z-index: 1;
+}
+</style>

File diff suppressed because it is too large
+ 55 - 0
addons/exam/uniapp/components/tui-icon/tui-icon.vue


+ 119 - 0
addons/exam/uniapp/components/tui-loading/tui-loading.vue

@@ -0,0 +1,119 @@
+<template>
+	<view class="tui-loading tui-loading__box" :class="{'tui-loading__fixed':fixed}" :style="{ backgroundColor: backgroundColor, color: color,position:fixed?'fixed':'static' }" v-if="type === 'column'">
+		<image class="tui-loading__ani" :src="src"></image>
+		<view class="tui-loading__text">{{ text }}</view>
+	</view>
+	<view v-else class="tui-loading tui-loading__row" :class="{'tui-loading__fixed':fixed}" :style="{position:fixed?'fixed':'static'}">
+		<image class="tui-loading-row__ani" :src="rowsrc"></image>
+		<view class="tui-loading__text">{{ text }}</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'tuiLoading',
+	props: {
+		//column or row
+		type: {
+			type: String,
+			default: 'column'
+		},
+		text: {
+			type: String,
+			default: '加载中'
+		},
+		//type=column时生效
+		color: {
+			type: String,
+			default: '#fff'
+		},
+		//type=column时生效
+		backgroundColor: {
+			type: String,
+			default: 'rgba(0, 0, 0, 0.6)'
+		},
+		//type=column时生效
+		src: {
+			type: String,
+			default:
+				'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAMAAAAOusbgAAAARVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////9SnXPCAAAAFnRSTlMA8ECgECCAYDDQ4MCwUL9wkN9vP49fRXb34AAAA1dJREFUaN7tmtmSozAMReXdxuyk+f9PnaW6o06CAdtSumqG8xqSi6QrGwfBxcXFD2OW5cP+pVsWDe9AL7YP6xO9mj1w0t3CmkK0H0yhd0qsBzBoazutZxDKACFSrefpJZmFUfad0vpVtonWOvkHZ62Kzas0QcLnJ0dFuxGOHNqny8QMVfjw6By5c+n4GHnwVOGq7tiDD3G74uq234Idzbmue7hVXZtmYfEncqRDicekKPSo+Z6o/EI7bB6Zfc+hXBl1R13V+0Jm6uL3inCiKGZX6g7ENwXKrrwfEB3uyubszYovXahBx3ve9LkvoG4lcc36pf7zagu16JCzeo4E8aLy+TJL1CXAfJWtP7zHKcsPp+NYh6NEY2posOeSbT4v64CMiLU7dvQIVGAsqzyuSKOBEHvCXwHvjZAGQ94PuAVaJIa8X2EDxMSDkGXh0lG9KCkMmCnkbdNq6oARt7t8Oa6A0dhhx1oREPpe9ukVxgEHBnOdyLQAHkI61wqtxcCQ9rXATDPgk9uef7kjFl+PiRJH4EKlijwmniyp15DEqtYBFz7VyQKXLR5ST9iJLibvZLuZiQh8qO1+kizPHq+hNXpT2AIjrlnX+GIii8J8eJPatyQwcwn/j8ISu5uZwj7+d4QBl8w3I3CT4EOnjjcCeJHJXUsDJ35LeHhDPw0yaesROIkmaesAjJgVtmjZizygd99b5CnunCQVsNGlFsbAnOs+tfkNTMdFzOjuqb0HJlR6J4h4uKHH7CSz47SX2tsIGr6Dm9mNyPGFrHZrqBuuKvuD1dhxGbs/6tOGuJcxoObMM5/QQIoWx8G0HA99IwZ89EZzAEI6PKMc+kt4ykRjix4ne6IrczhjGmzmoAn/dOlyXpgrumPoeLrr6JRd1hOkJVN2mW9mFZGywwbJU+51pa/QWJnKk6/o3xbX/Xxl0ZUPvxXp4nREqyuG31zZnFD5nJHp8ZsVc0I9NkTOFFsw9UN7t5x8W1E9oQSdyJ2p1fP04srKSUUlz0zLiqQlKwbwppvfVZ0DjvkR/bWO2urDbIp2t2n9knWk/+ojorfzsty9v8y2n/DTZgBKZLueInZAjR7CkWoYDLBgXCvSs/uoyoJ3Y3zWjOPg4T14KQf7m0FKqeHi4uJH+QUYYoTOYC/s2wAAAABJRU5ErkJggg=='
+		},
+		//type=row时生效
+		rowsrc: {
+			type: String,
+			default:
+				'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAMAAAAPdrEwAAAAM1BMVEUAAACPj4+IiIiIiIiIiIiIiIiKioqHh4eHh4eHh4eHh4eIiIiIiIiJiYmIiIiJiYmIiIi8awvKAAAAEHRSTlMAEEDw4NAwwKAgYICQULBwL0wVnQAAAmpJREFUWMPtmOmO4yAQhLlvMO//tDuyjfAaCEcj7UqTT5o/iafSVLcLY/TldyHID8fPH94oir2TLD6QyugdxVoeazAnYPUaFtsws+yNVjR+htolcWz/luHyhL7F55XNQ0Narx+L8TY8bSGTXsiYUB5V8A+zHJ4rud+p3GI+PiwqCXv0Ec+T42awf3L8+iMtzwwp36UEPHR1SIYPKueS+xxJe1CZiYkooNmTvjLHUzHDR7TVgnI2UXR9k/OhQHslieKCcb87reRnB5fCzMeTVp5cUbeY8O6arEYknYs60CJXK21zOvot7DSK4lrR51eA7dQ1y1bFF9MT2Chb358DsLHeLJeKhpZdDgkDFp1jQtRGXiEYOtZU1PLdUt7OFT84gnJUHNE5XOCO2PLnCALDzl21HD2Yam4ZRU9kER8Qs/V7IQ7BIWVqp10TTCFN0gdgwvumJmkcwWDFgigsQtv4Sv8baZ2lt5Pn+n+Wfm+CMsUsHFIJQ7VH+l2hSXsaHFUbEb1l2GRt5zFb/FC1nSfskGam+lSP4cqmks10jyOc1fditiGMXD1G4DekTH5sf14gkTeD1QOdbrRLXodGAKb1hC7gh5nmvzvgI0NoHyswA1niP7WKQE4dmtYHDH4MwzxS3T2MmCWje5OL2aK26j9EC7qkrVIk97XtrM+RYzSoHSZmUCTlLoLNvTs+aFLug+XEu2Mt59boRl9+4vk7gbD79fXnii2dsS6Xc4lb3fx5lX1bfKfPD1HJInetS64lJQnxhkrryV2+INbxeCHXN1PtaGxClUYgvGJ1XY82IA7Fn7IyFO7DIDcYfflN/AFNdjdeDj7M+wAAAABJRU5ErkJggg=='
+		},
+		fixed: {
+			type: Boolean,
+			default: true
+		}
+	}
+};
+</script>
+
+<style scoped>
+.tui-loading {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.tui-loading__fixed{
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	z-index: 9999;
+}
+.tui-loading__box {
+	min-width: 200rpx;
+	min-height: 200rpx;
+	max-width: 500rpx;
+	flex-direction: column;
+	border-radius: 10rpx;
+}
+.tui-loading__ani {
+	width: 64rpx;
+	height: 64rpx;
+	margin: 0 6px;
+	animation: rotate 0.9s linear infinite;
+	margin-bottom: 30rpx;
+}
+
+.tui-loading__row {
+	width: 100%;
+	height: 36rpx;
+	color: #888;
+}
+
+.tui-loading-row__ani {
+	width: 36rpx;
+	height: 36rpx;
+	display: block;
+	border-radius: 50%;
+	animation: rotate 0.9s linear infinite;
+	margin-right: 20rpx;
+}
+
+.tui-loading__text {
+	font-size: 26rpx;
+	line-height: 26rpx;
+	text-align: center;
+}
+
+@-webkit-keyframes rotate {
+	from {
+		transform: rotatez(0deg);
+	}
+	to {
+		transform: rotatez(360deg);
+	}
+}
+@keyframes rotate {
+	from {
+		transform: rotatez(0deg);
+	}
+	to {
+		transform: rotatez(360deg);
+	}
+}
+</style>

Some files were not shown because too many files changed in this diff