Browse Source

init commit

abpcoder 1 week ago
commit
92ce1ccfc0
100 changed files with 8281 additions and 0 deletions
  1. 7 0
      .gitignore
  2. 20 0
      App.vue
  3. 21 0
      LICENSE
  4. 61 0
      api/dict/data.js
  5. 69 0
      api/dict/type.js
  6. 176 0
      api/login.js
  7. 186 0
      api/system/user.js
  8. 266 0
      api/webApi/career-course.js
  9. 83 0
      api/webApi/career-news.js
  10. 171 0
      api/webApi/career-other.js
  11. 291 0
      api/webApi/collegemajor.js
  12. 284 0
      api/webApi/front.js
  13. 81 0
      api/webApi/ie-voluntary.js
  14. 10 0
      api/webApi/knowledge.js
  15. 34 0
      api/webApi/mental-health.js
  16. 61 0
      api/webApi/paper.js
  17. 81 0
      api/webApi/resources.js
  18. 99 0
      api/webApi/studentEvaluating.js
  19. 58 0
      api/webApi/subject.js
  20. 146 0
      api/webApi/volunteer.js
  21. 749 0
      api/webApi/webQue.js
  22. 156 0
      api/webApi/webVideo.js
  23. 30 0
      common/modules/mx-app-links-config.js
  24. 43 0
      common/modules/mx-banner-config.js
  25. 31 0
      common/modules/mx-block-index-all-test-config.js
  26. 17 0
      common/modules/mx-block-index-config.js
  27. 25 0
      common/modules/mx-business-config.js
  28. 54 0
      common/modules/mx-menus-index-config.js
  29. 36 0
      common/modules/mx-menus-index-topic-center-config.js
  30. 119 0
      common/modules/mx-menus-personal-center-config.js
  31. 44 0
      common/modules/mx-other-config.js
  32. 78 0
      common/modules/mx-tiny-buttons-config.js
  33. 628 0
      common/mx-block-widgets.js
  34. 15 0
      common/mxConfig.js
  35. 210 0
      common/mxConst.js
  36. 95 0
      common/webview.bridge.js
  37. 218 0
      components/m-drag/m-drag-vue2.vue
  38. 239 0
      components/m-drag/m-drag.vue
  39. 39 0
      components/mx-bottom-buttons/mx-bottom-buttons.vue
  40. 53 0
      components/mx-buy-vip/mx-buy-vip.vue
  41. 59 0
      components/mx-condition-dropdown/mx-condition-dropdown-item.vue
  42. 120 0
      components/mx-condition-dropdown/mx-condition-dropdown-popup.vue
  43. 47 0
      components/mx-condition-dropdown/mx-condition-dropdown.vue
  44. 13 0
      components/mx-condition-dropdown/useConditionDropdownPopupInjection.js
  45. 31 0
      components/mx-condition/modules/conditionSharedConfig.js
  46. 12 0
      components/mx-condition/modules/useConditionCollegeFeatures.js
  47. 12 0
      components/mx-condition/modules/useConditionCollegeLevel.js
  48. 12 0
      components/mx-condition/modules/useConditionCollegeLocation.js
  49. 12 0
      components/mx-condition/modules/useConditionCollegeNatureTypeCN.js
  50. 12 0
      components/mx-condition/modules/useConditionCollegeType.js
  51. 29 0
      components/mx-condition/modules/useConditionPaperType.js
  52. 15 0
      components/mx-condition/modules/useConditionPickType.js
  53. 18 0
      components/mx-condition/modules/useConditionSegmentLocation.js
  54. 19 0
      components/mx-condition/modules/useConditionSegmentMode.js
  55. 19 0
      components/mx-condition/modules/useConditionSegmentYear.js
  56. 22 0
      components/mx-condition/mx-condition-dropdown.vue
  57. 23 0
      components/mx-condition/mx-condition.vue
  58. 61 0
      components/mx-condition/useConditionDataManager.js
  59. 72 0
      components/mx-condition/useConditionEventManager.js
  60. 108 0
      components/mx-condition/useConditionFactory.js
  61. 43 0
      components/mx-condition/useSearchModelInjection.js
  62. 60 0
      components/mx-count-down/mx-count-down.vue
  63. 57 0
      components/mx-echarts/mx-echarts.vue
  64. 56 0
      components/mx-form-item/mx-form-item.vue
  65. 38 0
      components/mx-index-menus/mx-index-menus-item.vue
  66. 60 0
      components/mx-index-menus/mx-index-menus.vue
  67. 21 0
      components/mx-index-menus/mx-index-paged-menus.vue
  68. 10 0
      components/mx-index-menus/shareProps.js
  69. 52 0
      components/mx-login-form-item/mx-login-form-item.vue
  70. 102 0
      components/mx-nav-bar/mx-nav-bar.vue
  71. 19 0
      components/mx-nav-bar/useH5BackHome.js
  72. 44 0
      components/mx-paper/components/mx-paper-completion.vue
  73. 43 0
      components/mx-paper/components/mx-paper-navigator-popup.vue
  74. 17 0
      components/mx-paper/components/mx-paper-progress.vue
  75. 41 0
      components/mx-paper/components/mx-paper-tab-item.vue
  76. 13 0
      components/mx-paper/components/usePaperNavigatorRefInjection.js
  77. 97 0
      components/mx-paper/mx-paper.vue
  78. 178 0
      components/mx-paper/usePaperInjection.js
  79. 134 0
      components/mx-paper/usePaperNavigationServiceInjection.js
  80. 127 0
      components/mx-picker/mx-picker.vue
  81. 45 0
      components/mx-popup-template/mx-popup-template.vue
  82. 21 0
      components/mx-progress/mx-progress.vue
  83. 20 0
      components/mx-question-content/components/mx-question-plain-option-group.vue
  84. 15 0
      components/mx-question-content/components/mx-question-plain-option.vue
  85. 97 0
      components/mx-question-content/components/mx-question-subjective.vue
  86. 173 0
      components/mx-question-content/mx-question-content.vue
  87. 14 0
      components/mx-question-content/useMathJaxSwitchInjection.js
  88. 25 0
      components/mx-question-content/useQuestionOptionInjection.js
  89. 36 0
      components/mx-question/components/mx-question-collect.vue
  90. 51 0
      components/mx-question/components/mx-question-correct-popup.vue
  91. 26 0
      components/mx-question/components/mx-question-correct.vue
  92. 19 0
      components/mx-question/components/mx-question-navigator.vue
  93. 55 0
      components/mx-question/components/mx-question-parse.vue
  94. 52 0
      components/mx-question/components/mx-question-score-subjective.vue
  95. 123 0
      components/mx-question/components/mx-question-statistic.vue
  96. 65 0
      components/mx-question/mx-question.vue
  97. 286 0
      components/mx-question/useQuestionInjection.js
  98. 37 0
      components/mx-question/useQuestionTranslate.js
  99. 46 0
      components/mx-search/mx-search.vue
  100. 63 0
      components/mx-steps/mx-steps.vue

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+# General
+.DS_Store
+node_modules/
+.idea/
+.vscode/
+/unpackage/
+yarn.lock

+ 20 - 0
App.vue

@@ -0,0 +1,20 @@
+<script>
+export default {
+    onLaunch: function () {
+        console.log('App Launch')
+    },
+    onShow: function () {
+        console.log('App Show')
+    },
+    onHide: function () {
+        console.log('App Hide')
+    }
+}
+</script>
+
+<style>
+/*每个页面公共css */
+@import "@/uni_modules/uv-ui-tools/index.scss";
+@import "@/static/tailwind.css";
+@import "@/static/common.scss";
+</style>

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 abpcoder
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 61 - 0
api/dict/data.js

@@ -0,0 +1,61 @@
+import request from '@/utils/request'
+
+// 查询字典数据列表
+export function listData(query) {
+  return request({
+    url: '/system/dict/data/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询字典数据详细
+export function getData(dictCode) {
+  return request({
+    url: '/system/dict/data/' + dictCode,
+    method: 'get'
+  })
+}
+
+// 根据字典类型查询字典数据信息
+export function getDicts(dictType) {
+  return request({
+    url: '/system/dict/data/type/' + dictType,
+    method: 'get'
+  })
+}
+
+// 新增字典数据
+export function addData(data) {
+  return request({
+    url: '/system/dict/data',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改字典数据
+export function updateData(data) {
+  return request({
+    url: '/system/dict/data',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除字典数据
+export function delData(dictCode) {
+  return request({
+    url: '/system/dict/data/' + dictCode,
+    method: 'delete'
+  })
+}
+
+// 导出字典数据
+export function exportData(query) {
+  return request({
+    url: '/system/dict/data/export',
+    method: 'get',
+    params: query
+  })
+}

+ 69 - 0
api/dict/type.js

@@ -0,0 +1,69 @@
+import request from '@/utils/request'
+
+// 查询字典类型列表
+export function listType(query) {
+  return request({
+    url: '/system/dict/type/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询字典类型详细
+export function getType(dictId) {
+  return request({
+    url: '/system/dict/type/' + dictId,
+    method: 'get'
+  })
+}
+
+// 新增字典类型
+export function addType(data) {
+  return request({
+    url: '/system/dict/type',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改字典类型
+export function updateType(data) {
+  return request({
+    url: '/system/dict/type',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除字典类型
+export function delType(dictId) {
+  return request({
+    url: '/system/dict/type/' + dictId,
+    method: 'delete'
+  })
+}
+
+// 清理参数缓存
+export function clearCache() {
+  return request({
+    url: '/system/dict/type/clearCache',
+    method: 'delete'
+  })
+}
+
+// 导出字典类型
+export function exportType(query) {
+  return request({
+    url: '/system/dict/type/export',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取字典选择框列表
+export function optionselect() {
+  return request({
+    url: '/system/dict/type/optionselect',
+    method: 'get'
+  })
+}

+ 176 - 0
api/login.js

@@ -0,0 +1,176 @@
+import request from '@/utils/request'
+
+// 登录方法
+export function login(data) {
+    return request({
+        url: '/login?type=frontApp',
+        method: 'post',
+        data: data
+    })
+}
+
+// 获取用户详细信息
+export function getInfo() {
+    return request({
+        url: '/getInfo',
+        method: 'get'
+    })
+}
+
+// 退出方法
+export function logout() {
+    return request({
+        url: '/logout',
+        method: 'post'
+    })
+}
+
+export function logoutPhysical() {
+    return request({
+        url: '/logoutPhysical',
+        method: 'post'
+    })
+}
+
+// 获取验证码
+export function getCodeImg(params) {
+    return request({
+        url: '/captchaImage',
+        method: 'get',
+        params: params
+    })
+}
+
+// 短信验证码
+export function sendSms(params) {
+    return request({
+        url: '/common/shortMessage/sendSms',
+        method: 'post',
+        params: params,
+        custom: {autoFitNoToken: true}
+    })
+}
+
+// 验证码校验
+export function validateSms(params) {
+    return request({
+        url: '/common/shortMessage/validateSms',
+        method: 'post',
+        params: params,
+        custom: {withoutToken: true}
+    })
+}
+
+// 验证码校验
+export function improveUserInfo(params) {
+    return request({
+        url: '/improveUserInfo',
+        method: 'post',
+        data: params
+    })
+}
+
+export function getAreaAndSchool(params) {
+    return request({
+        url: '/getAreaAndSchool',
+        method: 'get',
+        params
+    })
+}
+
+export function getAreaSchoolTree(params) {
+    return request({
+        url: '/getAreaSchoolTree',
+        method: 'get',
+        params
+    })
+}
+
+export function getRegisterGaokaoYears(params) {
+    return request({
+        url: '/getRegisterGaokaoYears',
+        method: 'get',
+        params
+    })
+}
+
+export function sendSmsNoValidation(params) {
+    return request({
+        url: '/common/shortMessage/sendSmsNoValidation',
+        method: 'get',
+        data: params,
+        custom: {autoFitNoToken: true}
+    })
+}
+
+export function sendSmsNoValidationNoToken(params) {
+    return request({
+        url: '/common/shortMessage/sendSmsNoValidationNoToken',
+        method: 'get',
+        params,
+        custom: {withoutToken: true}
+    })
+}
+
+export function resetCardPassword(params) {
+    return request({
+        url: '/resetCardPassword',
+        method: 'post',
+        params: params
+    })
+}
+
+// 切换角色 changeRole?newRoleId=12
+export function changeRole(roleId) {
+    return request({
+        url: '/changeRole?newRoleId=' + roleId,
+        method: 'post'
+    })
+}
+
+// 注册
+export function register(data) {
+    return request({
+        url: '/register',
+        method: 'post',
+        data
+    })
+}
+
+// 绑卡
+export function bindCard(data) {
+    return request({
+        url: '/bindCard',
+        method: 'post',
+        data
+    })
+}
+
+// 绑手机
+export function bindPhone(data) {
+    return request({
+        url: '/bindPhone',
+        method: 'post',
+        data
+    })
+}
+
+/// 考生类别
+export function getExamTypes(params) {
+    return request({
+        url: '/getExamTypes',
+        method: 'get',
+        params,
+        custom: {autoFitNoToken: true}
+    })
+}
+
+/// 考生专业 - 职高对口
+export function getExamMajors(params) {
+    return request({
+        url: '/getExamMajors',
+        method: 'get',
+        params,
+        custom: {autoFitNoToken: true}
+    })
+}

+ 186 - 0
api/system/user.js

@@ -0,0 +1,186 @@
+import request from '@/utils/request'
+
+// 查询用户列表
+export function listUser(query) {
+    return request({
+        url: '/system/user/list',
+        method: 'get',
+        params: query
+    })
+}
+
+// 查询用户详细
+export function getUser(userId) {
+    let safeId = userId || ''
+    return request({
+        url: '/system/user/' + safeId,
+        method: 'get'
+    })
+}
+
+// 新增用户
+export function addUser(data) {
+    return request({
+        url: '/system/user',
+        method: 'post',
+        data: data
+    })
+}
+
+// 修改用户
+export function updateUser(data) {
+    return request({
+        url: '/system/user',
+        method: 'put',
+        data: data
+    })
+}
+
+// 删除用户
+export function delUser(userId) {
+    return request({
+        url: '/system/user/' + userId,
+        method: 'delete'
+    })
+}
+
+// 导出用户
+export function exportUser(query) {
+    return request({
+        url: '/system/user/export',
+        method: 'get',
+        params: query
+    })
+}
+
+// 用户密码重置
+export function resetUserPwd(userId, password) {
+    const data = {
+        userId,
+        password
+    }
+    return request({
+        url: '/system/user/resetPwd',
+        method: 'put',
+        data: data
+    })
+}
+
+// 用户状态修改
+export function changeUserStatus(userId, status) {
+    const data = {
+        userId,
+        status
+    }
+    return request({
+        url: '/system/user/changeStatus',
+        method: 'put',
+        data: data
+    })
+}
+
+// 查询用户个人信息
+// http://localhost/dev-api/back/busiSchools/list?agentcode=456dcef18ce3f2903e22412f0d1f9725&pageNum=1&pageSize=100
+export function getUserInfoByAgentcode(params) {
+    return request({
+        url: '/front/busiSchools/list',
+        method: 'get',
+        params: params
+    })
+}
+
+// 查询用户个人信息
+export function getUserProfile() {
+    return request({
+        url: '/system/user/profile',
+        method: 'get'
+    })
+}
+
+// 修改用户个人信息
+export function updateUserProfile(data) {
+    return request({
+        url: '/system/user/profile',
+        method: 'put',
+        data: data
+    })
+}
+
+// 手机号变更
+export function updatePhonenumber(params) {
+    return request({
+        url: '/system/user/profile/updatePhonenumber',
+        method: 'get',
+        params
+    })
+}
+
+// 用户密码重置
+export function updateUserPwd(data) {
+    return request({
+        url: '/system/user/profile/updatePwd',
+        method: 'put',
+        params: data
+    })
+}
+
+// 用户头像上传
+export function uploadAvatar(data) {
+    return request({
+        url: '/system/user/profile/avatar',
+        method: 'post',
+        data: data
+    })
+}
+
+// 下载用户导入模板
+export function importTemplate() {
+    return request({
+        url: '/system/user/importTemplate',
+        method: 'get'
+    })
+}
+
+//
+export function getFrontInitialRouters() {
+    return request({
+        url: '/getFrontInitialRouters',
+        method: 'get'
+    })
+}
+
+export function getRouters() {
+    return request({
+        url: '/getRouters',
+        method: 'get'
+    })
+}
+
+export function createOrder(data) {
+    return request({
+        url: '/front/ecard/createOrder',
+        method: 'post',
+        data,
+        header: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+    });
+}
+
+export function getCardPrice(data) {
+    return request({
+        url: '/front/ecard/getEcardPrices',
+        method: 'get',
+        params: data
+    });
+}
+
+// 查询支付结果
+export function queryOrder(data) {
+    return request({
+        url: '/front/ecard/queryOrder',
+        method: 'get',
+        params: data,
+        custom: {toast: false}
+    });
+}

+ 266 - 0
api/webApi/career-course.js

@@ -0,0 +1,266 @@
+import request from '@/utils/request'
+
+// 00 生涯课程类型
+export function getCareerVideoTypes(params) {
+    return request({
+        url: '/front/sykc/types',
+        autoFitNoToken: true,
+        method: 'get',
+        params: params
+    })
+}
+
+// 01 生涯课程列表
+export function getCareerVideoList(params) {
+    return request({
+        url: '/front/sykc/list',
+        autoFitNoToken: true,
+        method: 'get',
+        params: params
+    })
+}
+
+// 02 生涯课程详情
+export function info(params) {
+    return request({
+        url: '/front/sykc/info',
+        method: 'get',
+        params: params
+    })
+}
+
+/**
+ * 查询院校列表数据筛选项
+ */
+export function selectUniversity() {
+    return request({
+        url: `/front/university/filters`,
+        method: 'get'
+    })
+}
+
+/**
+ * 查询院校列表数据筛选项
+ */
+export function universityFilters() {
+    return request({
+        url: `/front/university/filters`,
+        method: 'get'
+    })
+}
+
+/**
+ * 点击详情增加点击量
+ */
+export function saveUniversitiesHits(params) {
+    return request({
+        url: `/front/university/saveUniversitiesHits`,
+        method: 'post',
+        params: params
+    })
+}
+
+/**
+ * 院校列表
+ */
+export function selectUniversityList(params) {
+    return request({
+        url: `/front/university/list`,
+        method: 'get',
+        autoFitNoToken: true,
+        params: params
+    })
+}
+
+/**
+ * 查询院校详情
+ */
+export function selectUniversityDetail(params) {
+    return request({
+        url: `/front/university/detail`,
+        method: 'get',
+        params: params
+    })
+}
+
+//  是否为新高考
+export function isNewCollegeExam(params) {
+    return request({
+        url: '/front/syzy/zytb/getUserIsUseNewCollegeEntranceExam',
+        method: 'get',
+        params: params
+    })
+}
+
+// 招生简章
+export function enrollBrochure(params) {
+    return request({
+        url: '/front/university/getUniversitiesEnrollBrochure',
+        method: 'get',
+        params: params
+    })
+}
+
+// 院校风采
+export function universitiesStyle(params) {
+    return request({
+        url: '/front/university/getUniversitiesStyle',
+        method: 'get',
+        params: params
+    })
+}
+
+// 保存招生简章热度/点击
+export function saveEnrollBrochureHits(params) {
+    return request({
+        url: '/front/university/saveUniversitiesEnrollBrochureHits',
+        method: 'post',
+        params: params
+    })
+}
+
+// 获取学科评估类型
+export function evaluateType(params) {
+    return request({
+        url: '/system/dict/data/type/subject_evaluate_type',
+        method: 'get',
+        params: params
+    })
+}
+
+// 排名筛选
+export function rankingFilter(params) {
+    return request({
+        url: '/front/university/getUniversitiesRankingFilter',
+        method: 'get',
+        params: params
+    })
+}
+
+// 排名列表
+export function universitiesRanking(params) {
+    return request({
+        url: '/front/university/getUniversitiesRanking',
+        autoFitNoToken: true,
+        method: 'get',
+        params: params
+    })
+}
+
+// 专业开设院校
+export function getUniversityByCode(params) {
+    return request({
+        url: '/front/major/getUniversityByCode',
+        method: 'get',
+        params: params
+    })
+}
+
+// 根据分数和科类获取位次
+export function getRankByScore(params) {
+    return request({
+        url: '/front/syzy/yfyd/getRankByScore',
+        method: 'get',
+        params: params
+    })
+}
+
+
+//  职业兴趣测评 01 测试步骤
+export function hollSteps(params) {
+    return request({
+        url: '/front/syzy/holland/steps',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业兴趣测评 02 步骤题目
+export function hollStepsQuestions(params) {
+    return request({
+        url: '/front/syzy/holland/questions',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业兴趣测评 03 保存测试
+export function hollSaveHolland(params) {
+    return request({
+        url: '/front/syzy/holland/save',
+        method: 'post',
+        data: params
+    })
+}
+
+//  职业兴趣测评 04 测评记录
+export function hollRecord(params) {
+    return request({
+        url: '/front/syzy/holland/record',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业兴趣测评 05 测评详情
+export function hollDetail(params) {
+    return request({
+        url: '/front/syzy/holland/record/detail',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业性格测评 01 测试步骤
+export function mbtiSteps(params) {
+    return request({
+        url: '/front/syzy/mbti/steps',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业性格测评 02 步骤题目
+export function mbtiStepsQuestions(params) {
+    return request({
+        url: '/front/syzy/mbti/questions',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业性格测评 03 保存测试
+export function mbtiSave(query) {
+    return request({
+        url: '/front/syzy/mbti/save',
+        method: 'post',
+        data: query
+    })
+}
+
+//  职业性格测评 04 测评记录
+export function mbtiRecord(params) {
+    return request({
+        url: '/front/syzy/mbti/record',
+        method: 'get',
+        params: params
+    })
+}
+
+//  职业性格测评 05 测评详情
+export function mbtiDetail(params) {
+    return request({
+        url: '/front/syzy/mbti/record/detail',
+        method: 'get',
+        params: params
+    })
+}
+
+// 推荐专业
+export function mbtiRecommendMajors(params) {
+    return request({
+        url: '/front/syzy/mbti/majors',
+        method: 'get',
+        params
+    })
+}

+ 83 - 0
api/webApi/career-news.js

@@ -0,0 +1,83 @@
+import request from '@/utils/request'
+
+// 00 生涯课程类型
+export function getNewsTypes(params) {
+  return request({
+    url: '/front/news/types',
+    method: 'get',
+    params: params,
+    custom: {autoFitNoToken: true}
+  })
+}
+
+// 单招-资讯分组-通关指南
+export function getMainList() {
+  return request({
+    url: '/front/news/getMainList',
+    method: 'get',
+    custom: {autoFitNoToken: true}
+  })
+}
+
+export function getNewsList(params) {
+  return request({
+    url: '/front/news/list',
+    method: 'get',
+    params: params,
+    custom: {autoFitNoToken: true}
+  })
+}
+
+// 02 生涯课程详情
+export function info(params) {
+  return request({
+    url: '/front/news/info',
+    method: 'get',
+    params: params
+  })
+}
+
+// 01 热门院校榜单
+export function universitiesTop(params) {
+  return request({
+    url: '/front/syzy/home/universities/top',
+    method: 'get',
+    params: params
+  })
+}
+
+// 02 热门专业榜单
+export function marjorsTop(params) {
+  return request({
+    url: '/front/syzy/home/marjors/top',
+    method: 'get',
+    params: params
+  })
+}
+
+// 高考视频
+export function gkVideo(params) {
+  return request({
+    url: '/front/newsVideo/list',
+    method: 'get',
+    params: params,
+    custom: {autoFitNoToken: true}
+  })
+}
+
+//  保存点击数
+export function saveClicked(params) {
+  return request({
+    url: '/front/newsVideo/saveClicked',
+    method: 'get',
+    params: params
+  })
+}
+
+export function getMainCourseDate(params) {
+  return request({
+    url: '/front/news/getMainCourseDate',
+    method: 'get',
+    params: params
+  })
+}

+ 171 - 0
api/webApi/career-other.js

@@ -0,0 +1,171 @@
+import request from '@/utils/request'
+
+// GET
+// /prod-api/front/customer/university/add
+// 03 关注院校
+export function concernUniversity(params) {
+  return request({
+    url: '/front/customer/university/add',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET
+// /prod-api/front/customer/university/list
+// 01 关注院校列表
+export function concernedUniversities(params) {
+  return request({
+    url: '/front/customer/university/list',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET
+// /prod-api/front/customer/university/remove
+// 02 移除关注院校
+export function removeConcernedUniversity(params) {
+  return request({
+    url: '/front/customer/university/remove',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET
+// /prod-api/front/customer/majors/add
+// 03 关注专业
+export function addConcernMajor(params) {
+  return request({
+    url: '/front/customer/marjors/add',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET
+// /prod-api/front/customer/majors/list
+// 01 关注专业列表
+export function concernedMajors(params) {
+  return request({
+    url: '/front/customer/marjors/list',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET
+// /prod-api/front/customer/majors/remove
+// 02 移除关注专业
+export function removeConcernedMajor(params) {
+  return request({
+    url: '/front/customer/marjors/remove',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  04 holland测评记录
+export function hollandRecord(params) {
+  return request({
+    url: '/front/syzy/holland/record',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  04 Mbti测评记录
+export function mbtiRecord(params) {
+  return request({
+    url: '/front/syzy/mbti/record',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  04 志愿表
+export function zytbRecord(params) {
+  return request({
+    url: '/front/syzy/zytb/record',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  04 高考名词
+export function list(params) {
+  return request({
+    url: '/front/syzy/gkmc/list',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  04 高考名词
+export function detail(params) {
+  return request({
+    url: '/front/syzy/gkmc/detail',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  批次控制线 地域
+export function locations(params) {
+  return request({
+    url: '/front/syzy/pckzx/locations',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  批次控制线 录取年份
+export function years(params) {
+  return request({
+    url: '/front/syzy/pckzx/years',
+    method: 'get',
+    params: params
+  })
+}
+
+// GET  批次控制线 列表
+export function pckzxList(params) {
+  return request({
+    url: '/front/syzy/pckzx/list',
+    method: 'get',
+    params: params
+  })
+}
+
+/**
+ * 查询我的志愿表
+ */
+export function selectZytbRecord(params) {
+  return request({
+    url: `/front/syzy/zytb/record`,
+    method: 'get',
+    params
+  })
+}
+
+/**
+ * 删除我的志愿表
+ * @param params
+ */
+export function delZytbRecord(params) {
+  return request({
+    url: `/front/syzy/zytb/record/del`,
+    method: 'get',
+    params
+  })
+}
+
+export function downloadRecommendReport(params) {
+  return request({
+    url: `/front/syzy/zytb/export`,
+    method: 'get',
+    responseType: 'blob',
+    params
+  })
+}

+ 291 - 0
api/webApi/collegemajor.js

@@ -0,0 +1,291 @@
+import request from '@/utils/request'
+
+// 院校库 00 院校列表数据筛选项
+export function filters(params) {
+    return request({
+        url: '/front/university/filters',
+        method: 'get',
+        params: params
+    })
+}
+
+// 院校库 00 院校列表数据筛选项
+export function features(params) {
+    return request({
+        url: '/front/university/features',
+        method: 'get',
+        params: params
+    })
+}
+
+// 院校库 01 院校列表
+export function universityList(params) {
+    return request({
+        url: '/front/university/list',
+        method: 'get',
+        params: params
+    })
+}
+
+// 院校库 02 院校详情
+export function universityDetail(params) {
+    return request({
+        url: '/front/university/detail',
+        method: 'get',
+        params: params
+    })
+}
+
+export function getUniversitiesEnrollBrochureDetail(params) {
+    return request({
+        url: '/front/university/getUniversitiesEnrollBrochureDetail',
+        method: 'get',
+        params: params
+    })
+}
+
+
+// 专业库 01 专业分类列表
+export function majorList(params) {
+    return request({
+        url: '/front/marjors/list',
+        method: 'get',
+        params: params
+    })
+}
+
+// 专业库 02 专业详情
+export function majorDetail(params) {
+    return request({
+        url: '/front/marjors/detail',
+        method: 'get',
+        params: params
+    })
+}
+
+// 职业库 01 职业列表
+export function getAllVocation(params) {
+    return request({
+        url: '/front/vocational/getAllVocation',
+        method: 'get',
+        params: params
+    })
+}
+
+
+/**
+ * 获取职业就业详情
+ * @param params
+ */
+export function vocationalPostsDetail(params) {
+    return request({
+        url: '/front/vocational/getVocationalPostDetailByPostName',
+        method: 'get',
+        params: params
+    })
+}
+
+/**
+ * 获取就业岗位
+ * @param params
+ */
+export function vocationalPosts(params) {
+    return request({
+        url: `/front/vocational/getVocationalPosts`,
+        method: 'get',
+        params
+    })
+}
+
+// 职业概况
+export function vocationalOverview(params) {
+    return request({
+        url: `/front/vocational/getVocationalOverview`,
+        method: 'get',
+        params
+    })
+}
+
+// 00 院校列表数据筛选项-院校类型
+export function universityTypes(params) {
+    return request({
+        url: '/front/university/types',
+        method: 'get',
+        params: params
+    })
+}
+
+// 00 院校列表数据筛选项-地域
+export function universityLocations(params) {
+    return request({
+        url: '/front/university/locations',
+        method: 'get',
+        params: params
+    })
+}
+
+// 00 院校列表数据筛选项-学历层次
+export function universityLevels(params) {
+    return request({
+        url: '/front/university/levels',
+        method: 'get',
+        params: params
+    })
+}
+
+
+// 01 查询列表
+export function xkcxList(params) {
+    return request({
+        url: '/front/syzy/xkcx/list',
+        method: 'get',
+        params: params
+    })
+}
+
+
+// 专业库 00 专业类型
+export function majorTypes(params) {
+    return request({
+        url: '/front/marjors/types',
+        method: 'get',
+        params: params
+    })
+}
+
+// 专业库 01 所有专业
+export function getAllMajor(params) {
+    return request({
+        url: '/front/major/getAllMajor',
+        method: 'get',
+        params: params
+    })
+}
+
+
+// 专业库 02 搜索
+export function getMajorByName(params) {
+    return request({
+        url: '/front/major/getMajorByName',
+        method: 'get',
+        params: params
+    })
+}
+
+// 专业库 03 详情 概况
+export function majorOverview(params) {
+    return request({
+        url: '/front/major/getMajorOverviewByCode',
+        method: 'get',
+        params: params
+    })
+}
+
+// 专业库 04 详情 就业前景
+export function careerProspects(params) {
+    return request({
+        url: '/front/major/getMajorCareerProspectsByCode',
+        method: 'get',
+        params: params
+    })
+}
+
+// 专业库 05 详情  开设院校
+export function getUniversityByCode(params) {
+    return request({
+        url: '/front/major/getUniversityByCode',
+        method: 'get',
+        params: params
+    })
+}
+
+// 院校风采
+export function universitiesStyle(params) {
+    return request({
+        url: '/front/university/getUniversitiesStyle',
+        method: 'get',
+        params: params
+    })
+}
+
+// 获取学科评估类型
+export function evaluateType(params) {
+    return request({
+        url: '/system/dict/data/type/subject_evaluate_type',
+        method: 'get',
+        params: params
+    })
+}
+
+// 招生简章
+export function enrollBrochure(params) {
+    return request({
+        url: '/front/university/getUniversitiesEnrollBrochure',
+        method: 'get',
+        params: params
+    })
+}
+
+// 保存招生简章热度/点击
+export function saveEnrollBrochureHits(params) {
+    return request({
+        url: '/front/university/saveUniversitiesEnrollBrochureHits',
+        method: 'post',
+        params: params
+    })
+}
+
+// 排名筛选
+export function rankingFilter(params) {
+    return request({
+        url: '/front/university/getUniversitiesRankingFilter',
+        method: 'get',
+        params: params
+    })
+}
+
+
+// 根据type获取排名
+export function getUniversitiesRankingByTypes(params) {
+    return request({
+        url: '/front/university/getUniversitiesRankingByTypes',
+        method: 'get',
+        params: params
+    })
+}
+
+// 收藏 移除
+export function collectRemove(params) {
+    return request({
+        url: '/front/syzy/xkcx/collect/remove',
+        method: 'get',
+        params: params
+    })
+}
+
+// 收藏 添加
+export function collectAdd(params) {
+    return request({
+        url: '/front/syzy/xkcx/collect/add',
+        method: 'get',
+        params: params
+    })
+}
+
+// 选科收藏 添加移除
+export function saveSelectCourse(params) {
+    return request({
+        url: '/front/syzy/xkcx/saveSelectCourse',
+        method: 'get',
+        params: params
+    })
+}
+
+// 选科年份
+export function selectYears(params) {
+    return request({
+        url: '/front/syzy/xkcx/years',
+        method: 'get',
+        params: params
+    })
+}

+ 284 - 0
api/webApi/front.js

@@ -0,0 +1,284 @@
+import request from '@/utils/request'
+
+// 08 学生单次测评排名-按班级排名学生
+export function getClassExaminees(params) {
+  return request({
+    url: '/front/evaluation/getClassExaminees',
+    method: 'get',
+    params: params
+  })
+}
+// 04 学生测试列表
+export function getEvaluationForStudent(params) {
+  return request({
+    url: '/front/evaluation/getEvaluationForStudent',
+    method: 'get',
+    params: params
+  })
+}
+// 09 班级测评历史
+export function getClassHistory(params) {
+  return request({
+    url: '/front/evaluation/getClassHistory',
+    method: 'get',
+    params: params
+  })
+}
+// 05 测评班级统计
+export function getClassStat(params) {
+  return request({
+    url: '/front/evaluation/getClassStat',
+    method: 'get',
+    params: params
+  })
+}
+// 05.0 测评题目分类
+export function getEvaluationQuestionTypes(evaluationClassId) {
+  return request({
+    url: '/front/evaluation/getEvaluationQuestionTypes',
+    method: 'get',
+    params: {evaluationClassId: evaluationClassId}
+  })
+}
+//02 测评班级列表
+export function getEvaluationClassForStat(params) {
+  return request({
+    url: '/front/evaluation/getEvaluationClassForStat',
+    method: 'get',
+    params: params
+  })
+}
+// 03 统计测评列表
+export function getEvaluationForStat(params) {
+  return request({
+    url: '/front/evaluation/getEvaluationForStat',
+    method: 'get',
+    params: params
+  })
+}
+export function getEvaluationListForStat(params) {
+  return request({
+    url: '/front/evaluation/getEvaluationListForStat',
+    method: 'get',
+    params: params
+  })
+}
+// 03 测试测评列表
+export function getEvaluationForTest(params) {
+  return request({
+    url: '/front/evaluation/getEvaluationForTest',
+    method: 'get',
+    params: params
+  })
+}
+// 06 学生考卷详情
+export function getExamineeQuestions(params) {
+  return request({
+    url: '/front/evaluation/getExamineeQuestions',
+    method: 'get',
+    params: params
+  })
+}
+// 06 考卷学生详情
+export function getExaminee(params) {
+  return request({
+    url: '/front/evaluation/getExaminee',
+    method: 'get',
+    params: params
+  })
+}
+// 01 统计热门测评列表
+export function getHotEvaluationForStat(params) {
+  return request({
+    url: '/front/evaluation/getHotEvaluationForStat',
+    method: 'get',
+    params: params
+  })
+}
+//01 测试热门测评列表
+export function getHotEvaluationForTest(params) {
+  return request({
+    url: '/front/evaluation/getHotEvaluationForTest',
+    method: 'get',
+    params: params
+  })
+}
+// 07 学生测评历史
+export function getStudentHistory(params) {
+  return request({
+    url: '/front/evaluation/getStudentHistory',
+    method: 'get',
+    params: params
+  })
+}
+// 20 错题管理-班级知识点统计
+export function getClassKnownledgeStats(params) {
+  return request({
+    url: '/front/wrong/getClassKnownledgeStats',
+    method: 'get',
+    params: params
+  })
+}
+// 21 错题管理-班级学生统计
+export function getClassStudentStats(params) {
+  return request({
+    url: '/front/wrong/getClassStudentStats',
+    method: 'get',
+    params: params
+  })
+}
+// 22 错题管理-班级错题列表
+export function getClassStudentQuestions(params) {
+  return request({
+    url: '/front/wrong/getClassStudentQuestions',
+    method: 'get',
+    params: params
+  })
+}
+// 10 错题管理-学生知识点统计 front/wrong/getStudentKnownledgeStats
+export function getStudentKnownledgeStats(params) {
+  return request({
+    url: '/front/wrong/getStudentKnownledgeStats',
+    method: 'get',
+    params: params
+  })
+}
+// 11 错题管理-学生错题列表 front/wrong/getStudentDetails
+export function getStudentDetails(params) {
+  return request({
+    url: '/front/wrong/getStudentDetails',
+    method: 'get',
+    params: params
+  })
+}
+//  预览试卷
+export function previewEvaluationForTeacher(params) {
+  return request({
+    url: '/front/teacher/previewEvaluationForTeacher',
+    method: 'get',
+    params: params
+  })
+}
+//  01 查找测评
+export function getEvaluationsForOpen(params) {
+  return request({
+    url: '/front/teacher/getEvaluationsForOpen',
+    method: 'get',
+    params: params
+  })
+}
+//  02 开启测评
+export function open(data) {
+  return request({
+    url: '/front/teacher/open',
+    method: 'post',
+    data: data
+  })
+}
+//30 打开错题
+export function openWrongQuestion(params) {
+  return request({
+    url: '/front/wrong/openWrongQuestion',
+    method: 'get',
+    params: params
+  })
+}
+export function openQuestion(params) {
+  return request({
+    url: '/front/examination/openQuestion',
+    method: 'get',
+    params: params
+  })
+}
+// 30 举一反三取新题
+export function openWrongSimilarQuestion(params) {
+  return request({
+    url: '/front/wrong/openWrongSimilarQuestion',
+    method: 'get',
+    params: params
+  })
+}
+//31 提交错题答案
+export function commitWrongQuestion(params) {
+  return request({
+    url: '/front/wrong/commitWrongQuestion',
+    method: 'post',
+    data: params
+  })
+}
+//32 scoreWrongQuestion 阅卷打分
+export function scoreWrongQuestion(params) {
+  return request({
+    url: '/front/wrong/scoreWrongQuestion',
+    method: 'post',
+    data: params
+  })
+}
+// 收藏
+export function uploadGeneratePicAndBindQuestion(params) {
+  return request({
+    url: '/front/questionCollection/uploadGeneratePicAndBindQuestion',
+    method: 'post',
+    data: params
+  })
+}
+// 取消收藏
+export function cancelQuestionCollection(params) {
+  return request({
+    url: '/front/questionCollection/cancelQuestionCollection',
+    method: 'DELETE',
+    params: params
+  })
+}
+// 纠错
+export function correctQuestion(params) {
+  return request({
+    url: '/front/adjustWrong/correctQuestion',
+    method: 'post',
+    data: params
+  })
+}
+// 开启测评
+export function openExamination(params) {
+  return request({
+    url: '/front/teacher/openExamination',
+    method: 'post',
+    data: params
+  })
+}
+
+// 测评班级人员
+export  function getEvaluationStudents(params) {
+  return request({
+    url: '/front/teacher/getEvaluationStudents',
+    method: 'get',
+    params: params
+  })
+}
+
+// 发布成绩
+export function publishScores(params) {
+  return request({
+    url: '/front/teacher/publishScores',
+    method: 'post',
+    params: params
+  })
+}
+
+//01 测评单次排名(统计)
+export function getEvaluationKnownledge(params) {
+  return request({
+    url: '/front/wrong/getEvaluationKnownledge',
+    method: 'get',
+    params: params
+  })
+}
+
+//02 测评单次排名(明细)
+export function getEvaluationStudent(params) {
+  return request({
+    url: '/front/wrong/getEvaluationStudent',
+    method: 'get',
+    params: params
+  })
+}

+ 81 - 0
api/webApi/ie-voluntary.js

@@ -0,0 +1,81 @@
+import request from '@/utils/request'
+
+
+export function getAIRenderRules(data) {
+    return request({
+        url: '/getAIRenderRules',
+        method: 'post',
+        data
+    })
+}
+
+export function getVoluntaryConfig(params) {
+    return request({
+        url: '/getVoluntaryConfig',
+        method: 'get',
+        params
+    })
+}
+
+export function getVoluntary(params) {
+    return request({
+        url: '/getVoluntary',
+        method: 'get',
+        params
+    })
+}
+
+export function getVoluntaryList(params) {
+    return request({
+        url: '/getVoluntaryList',
+        method: 'get',
+        params
+    })
+}
+
+export function postAIResult(data) {
+    return request({
+        url: '/postAIResult',
+        method: 'post',
+        data
+    })
+}
+
+export function postMultipleResult(data) {
+    return request({
+        url: '/postMultipleResult',
+        method: 'post',
+        data
+    })
+}
+
+export function postSingleResult(data) {
+    return request({
+        url: '/postSingleResult',
+        method: 'post',
+        data
+    })
+}
+
+export function postCalculateResult(data) {
+    return request({
+        url: '/postCalculateResult',
+        method: 'post',
+        data
+    })
+}
+
+export function submitVoluntary(data) {
+    return request({
+        url: '/submitVoluntary',
+        method: 'post',
+        data
+    })
+}
+
+export function removeVoluntary(id) {
+    return request({
+        url: '/removeVoluntary/' + id,
+        method: 'delete'
+    })
+}

+ 10 - 0
api/webApi/knowledge.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 列表
+export function list(params) {
+    return request({
+        url: '/front/knowledgeTree/list',
+        method: 'get',
+        params: params
+    })
+}

+ 34 - 0
api/webApi/mental-health.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+
+export function getMentalHealthHistories(params) {
+  return request(({
+    url: '/front/mentalHealth/list',
+    method: 'get',
+    params
+  }))
+}
+
+export function getMentalHealthReport(params) {
+  return request(({
+    url: '/front/mentalHealth/report',
+    method: 'get',
+    params
+  }))
+}
+
+export function submitMentalHealthForm(data) {
+  return request(({
+    url: '/front/mentalHealth/submit',
+    method: 'post',
+    data
+  }))
+}
+
+export function exportMentalHealthReport(params) {
+  return request(({
+    url: '/front/mentalHealth/export',
+    method: 'get',
+    responseType: 'blob',
+    params
+  }))
+}

+ 61 - 0
api/webApi/paper.js

@@ -0,0 +1,61 @@
+import request from '@/utils/request'
+
+/**
+ * 科目下试卷列表
+*/
+export function getPaperList(params) {
+  return request({
+    url: '/front/papers/list',
+    method: 'get',
+    params: params
+  })
+}
+
+/**
+ * 考试记录
+*/
+export function getExamRecords(params) {
+  return request({
+    url: '/front/customer/records',
+    method: 'get',
+    params: params
+  })
+}
+
+/**
+ * 考试记录详情
+*/
+export function getExamRecordsDetail(params) {
+  return request({
+    url: '/front/customer/recordDetail',
+    method: 'get',
+    params: params
+  })
+}
+
+/**
+ * 错题本题目列表
+*/
+export function getWrongQuestions(params) {
+  return request({
+    url: '/front/v2/wrongBook/wrongQuestions',
+    method: 'get',
+    params: params
+  })
+}
+
+export function getWrongQuestionPaper(params) {
+  return request({
+    url: '/front/v2/wrongBook/wrongExaminees',
+    method: 'get',
+    params: params
+  })
+}
+export function getObjectivePaperStatistic(params) {
+  return request({
+    url: '/front/papers/getObjectivePaperStatistic',
+    method: 'get',
+    params: params
+  })
+}
+

+ 81 - 0
api/webApi/resources.js

@@ -0,0 +1,81 @@
+import request from '@/utils/request'
+
+
+// 查询位置编码
+/*
+ * @description 获取可注册省份
+ *  /prod-api/mingxue/SysLocations/getLocationsList?level=1 0 中国, 1 省、直辖市  2 市  3 区县
+ *  pro
+ *  city
+ *  area
+ *  address
+ * */
+export function getLocationsList(params) {
+    return request({
+        url: '/mingxue/SysLocations/getLocationsList',
+        method: 'get',
+        params: params
+    })
+}
+
+/*
+* @description 获取当前用户可选省份,受卡分配影响
+* */
+export function getFrontLocationsList(params) {
+    return request({
+        url: '/mingxue/SysLocations/getFrontLocationsList',
+        method: 'get',
+        params: params
+    })
+}
+
+// 根据地区信息查询学校列表
+/*
+ * /prod-api/mingxue/HighSchools/list 查询学校
+ * pro
+ * city
+ * area
+ * */
+//export function schoolList(params) {
+//return request({
+//  url: '/mingxue/HighSchools/list',
+//  method: 'get',
+//  params:params
+//})
+//}
+// export function schoolList(params) {
+//   return request({
+//     url: '/mingxue/HighSchools/GetSchoolsList',
+//     method: 'get',
+//     params:params
+//   })
+// }
+export function schoolList(params) {
+    return request({
+        url: '/back/busiSchools/list',
+        method: 'get',
+        params: params
+    })
+}
+
+// 列表
+export function list(params) {
+    return request({
+        url: '/web/cloud/resources/list',
+        method: 'get',
+        params: params
+    })
+}
+
+// /prod-api/web/person/resources/getPersonResourcesForAppHomepage
+// app首页默认个人资源库列表
+export function getPersonResourcesForAppHomepage() {
+    return request({
+        url: '/web/person/resources/getPersonResourcesForAppHomepage',
+        method: 'get',
+        params: {
+            pageNum: 1,
+            pageSize: 4
+        }
+    })
+}

+ 99 - 0
api/webApi/studentEvaluating.js

@@ -0,0 +1,99 @@
+import request from '@/utils/request'
+
+// 测评列表(学生)
+export function getEvaluationForTest(params) {
+  return request({
+    url: '/front/evaluation/getEvaluationForTest',
+    method: 'get',
+    params:params
+  })
+}
+// 测评列表(老师)
+export function getEvaluationForTeacher(params) {
+  return request({
+    url: '/front/teacher/getEvaluationForTeacher',
+    method: 'get',
+    params:params
+  })
+}
+//
+export function getEvaluationListForTeacher(params) {
+  return request({
+    url: '/front/teacher/getEvaluationListForTeacher',
+    method: 'get',
+    params:params
+  })
+}
+
+// 学生考卷详情
+export function openExamineePaper(params) {
+  return request({
+    url: '/front/examination/openExamineePaper',
+    method: 'get',
+    params:params
+  })
+}
+
+
+export function loadExamineePaper(params) {
+  return request({
+    url: '/front/teacher/loadExamineePaper',
+    method: 'get',
+    params:params
+  })
+}
+
+
+// 竞赛卷详情
+export function competitionQuestions(params) {
+  return request({
+    url: '/front/competition/openCompetitionPaper',
+    method: 'get',
+    params:params
+  })
+}
+
+
+// 保存当前题目的作答信息
+export function commitExamineeQuestion(data) {
+  return request({
+    url: '/front/examination/commitExamineeQuestion',
+    method: 'post',
+    data:data
+  })
+}
+
+
+// 学生考卷提交
+export function commitExamineePaper(data) {
+  return request({
+    url: '/front/examination/commitExamineePaper',
+    method: 'post',
+    data:data
+  })
+}
+// 学生阅卷
+export function scoreExamineeQuestion(data) {
+  return request({
+    url: '/front/examination/scoreExamineeQuestions',
+    method: 'post',
+    data:data
+  })
+}
+// 老师阅卷
+export function teacherScoreExamineeQuestions(data) {
+  return request({
+    url: '/front/teacher/scoreExamineeQuestions',
+    method: 'post',
+    data:data
+  })
+}
+
+// 结束阅卷
+export function scoreFinish(data) {
+  return request({
+    url: '/front/examination/scoreFinish',
+    method: 'post',
+    params:data
+  })
+}

+ 58 - 0
api/webApi/subject.js

@@ -0,0 +1,58 @@
+import request from '@/utils/request'
+//
+
+// 新增/编辑 
+export function add(data) {
+  return request({
+    url: '/web/subject',
+    method: 'post',
+    data: data
+  })
+}
+// 新增/编辑 
+export function update(data) {
+  return request({
+    url: '/web/subject',
+    method: 'put',
+    data: data
+  })
+}
+// 详情 
+export function getInfo(resourcesId) {
+  return request({
+    url: '/web/subject/'+resourcesId,
+    method: 'get', 
+  })
+}
+// 删除 
+export function remove(resourcesId) {
+  return request({
+    url: '/web/subject/'+resourcesId,
+    method: 'delete', 
+  })
+}
+// 导出
+export function exportList(params) {
+  return request({
+    url: '/web/subject/export',
+    method: 'get', 
+    params:params
+  })
+} 
+// 列表
+export function list(params) {
+  return request({
+    url: '/front/subject/list',
+    method: 'get', 
+    params:params
+  })
+} 
+
+// /web/subject/getUserSubjectsList
+export function userList(params) {
+  return request({
+    url: '/web/subject/getUserSubjectsList',
+    method: 'get', 
+    params:params
+  })
+}

+ 146 - 0
api/webApi/volunteer.js

@@ -0,0 +1,146 @@
+import request from '@/utils/request'
+import config from "@/config";
+
+
+// 
+export function get(params) {
+    return request({
+        url: '/front/syzy/xkbm/get',
+        method: 'get',
+        params: params
+    })
+}
+
+// 志愿填报  科目和分数要求
+export function getVoluntaryData(params) {
+    return request({
+        url: '/front/syzy/zytb/getVoluntaryData',
+        method: 'get',
+        params: params
+    })
+}
+
+
+// 志愿填报  位次
+export function getRankByScore(params) {
+    return request({
+        url: '/front/syzy/yfyd/getRankByScore',
+        method: 'get',
+        params: params
+    })
+}
+
+// 志愿填报  01.填报批次
+export function zytbBatches(params) {
+    return request({
+        url: '/front/syzy/zytb/batches',
+        method: 'get',
+        params: params
+    })
+}
+
+// 获取推荐志愿表头
+export function getVoluntaryHeaders(params) {
+    return request({
+        url: '/front/syzy/zytb/getVoluntaryHeaders',
+        method: 'get',
+        params: params
+    })
+}
+
+// 志愿填报  志愿组
+export function getRecommendVoluntary(data, params) {
+    return request({
+        url: '/front/syzy/zytb/getRecommendVoluntary',
+        method: 'post',
+        data: data,
+        params: params
+    })
+}
+
+// 志愿填报  专业
+export function getVoluntaryMarjors(data) {
+    return request({
+        url: '/front/syzy/zytb/getVoluntaryMarjors',
+        method: 'post',
+        data: data
+    })
+}
+
+
+// 志愿填报  04.志愿保存
+export function saveZhiyuan(params) {
+    return request({
+        url: '/front/syzy/zytb/save',
+        method: 'post',
+        data: params
+    })
+}
+
+// 获取志愿明细
+export function getZhiyuanDetail(wishResId) {
+    return request({
+        url: '/front/syzy/zytb/recordDetail',
+        method: 'get',
+        params: {wishResId}
+    })
+}
+
+// 填报批次  筛选条件
+export function universityFilters(params) {
+    return request({
+        url: '/front/syzy/zytb/university/filters',
+        method: 'get',
+        data: params
+    })
+}
+
+
+// 查询我的志愿表
+export function selectZytbRecord(params) {
+    return request({
+        url: `/front/syzy/zytb/record`,
+        method: 'get',
+        params
+    })
+}
+
+/**
+ * 删除我的志愿表
+ * @param params
+ */
+export function delZytbRecord(params) {
+    return request({
+        url: `/front/syzy/zytb/record/del`,
+        method: 'get',
+        params
+    })
+}
+
+/* download voluntary simulated excel */
+export function downloadRecommendReport(params) {
+    return request({
+        url: `/front/syzy/zytb/export`,
+        method: 'get',
+        responseType: 'arrayBuffer',
+        params
+    })
+}
+
+export function getDownloadRecommendReportOptionsForWap2App(params) {
+    return {
+        url: config.serverBaseUrl + '/front/syzy/zytb/export',
+        params
+    }
+}
+
+export function getVoluntarySpecialProjectFilter(params) {
+    // params = { year } // 缺省年份返回当前年份的专项选项
+    // returns ['专项A', '专项B', '专项C', '不看专项']
+    // getRecommendVoluntary 新增参数specialProjects: Array // 原来的参数specialProjectNation,specialProjectLocal不动
+    return request({
+        url: `/front/syzy/zytb/specialProjectFilter`,
+        method: 'get',
+        params
+    })
+}

+ 749 - 0
api/webApi/webQue.js

@@ -0,0 +1,749 @@
+import request from '@/utils/request'
+
+
+//题库中心接口
+
+// 教学阶段列表
+export function pharseList() {
+	return request({
+		url: '/front/v2/pharse/list',
+		method: 'get',
+	})
+}
+// 课程列表
+export function subjectList() {
+	return request({
+		url: '/front/v2/subject/list',
+		method: 'get',
+	})
+}
+
+// 试卷--课程列表
+export function paperSubjectList() {
+	return request({
+		url: '/front/v2/papers/subjects',
+		method: 'get',
+	})
+}
+// 年级列表
+export function gradeList(query) {
+	return request({
+		url: '/front/v2/grade/list',
+		method: 'get',
+		params: query
+	})
+}
+
+// 版本列表
+export function editionList(query) {
+	return request({
+		url: '/front/v2/edition/list',
+		method: 'get',
+		params: query
+	})
+}
+
+// 排序后的--版本列表
+export function newEditionList(query) {
+	return request({
+		url: '/front/v2/edition/listOrdered',
+		method: 'get',
+		params: query
+	})
+}
+
+// 获得章节树
+export function treeList(query) {
+	return request({
+		url: '/front/v2/chapterTree/list',
+		method: 'get',
+		params: query
+	})
+}
+// 根据章节获取题库列表
+export function listByChapter(query) {
+	return request({
+		url: '/front/v2/questions/listByChapter',
+		method: 'get',
+		params: query
+	})
+}
+
+
+// 获取类型列表
+export function papersTypes(query) {
+	return request({
+		url: '/front/v2/papers/types',
+		method: 'get',
+		params: query
+	})
+}
+// 获取地区列表
+export function papersAreas(query) {
+	return request({
+		url: '/front/v2/papers/areas',
+		method: 'get',
+		params: query
+	})
+}
+// 获取试卷列表
+export function papersList(query) {
+	return request({
+		url: '/front/v2/papers/list',
+		method: 'get',
+		params: query
+	})
+}
+
+// 预览试卷
+export function preview(query) {
+	return request({
+		url: '/front/v2/papers/preview',
+		method: 'get',
+		params: query
+	})
+}
+// 收藏试卷
+export function papersCollect(query) {
+	let key = Object.keys(query)[0];
+	let value = query[key];
+	return request({
+		url: '/front/v2/papers/collect?' + key + '=' + value,
+		method: 'post',
+	})
+}
+// 取消收藏试卷
+export function papersCancelCollect(query) {
+	return request({
+		url: '/front/v2/papers/cancelCollect',
+		method: 'post',
+		params:query
+	})
+}
+
+// 年份列表
+export function papersYears(query) {
+	return request({
+		url: '/front/v2/papers/years',
+		method: 'get',
+		params: query
+	})
+}
+
+// 根据科目查找题型  /front/v2/questions/listQuestionTypesBySubject
+export function qtBySubject(query) {
+	return request({
+		url: '/front/v2/questions/listQuestionTypesBySubject',
+		method: 'get',
+		params: query
+	})
+}
+//  获取知识点分类树
+export function knowledgeTree(query) {
+	return request({
+		url: '/front/v2/knowledgeTree/list',
+		method: 'get',
+		params: query
+	})
+}
+
+// 根据知识点获取题库列表
+export function listByKnowledge(query) {
+	return request({
+		url: '/front/v2/questions/listByKnowledge',
+		method: 'get',
+		params: query
+	})
+}
+// 根据章节获取题库类型和数量
+export function countByChapter(query) {
+	return request({
+		url: '/front/v2/questions/countByChapter',
+		method: 'get',
+		params: query
+	})
+}
+
+// 根据知识点获取题型数量
+export function countByKnowledge(query) {
+	return request({
+		url: '/front/v2/questions/countByKnowledge',
+		method: 'get',
+		params: query
+	})
+}
+
+
+
+// 根据章节获取题目数量
+export function getQuestionsNumByChapter(query) {
+	return request({
+		url: '/front/v2/smartPractice/getQuestionsNumByChapter',
+		method: 'get',
+		params: query
+	})
+}
+// 根据知识点获取题目数量
+export function getQuestionsNumByKnowledge(query) {
+	return request({
+		url: '/front/v2/smartPractice/getQuestionsNumByKnowledge',
+		method: 'get',
+		params: query
+	})
+}
+
+
+// 根据章节获取题目用来练习
+export function getQuestionsByChapter(query) {
+	return request({
+		url: '/front/v2/smartPractice/getQuestionsByChapter',
+		method: 'get',
+		params: query
+	})
+}
+
+
+// 根据知识点获取题目用来练习
+export function getQuestionsByKnowledge(query) {
+	return request({
+		url: '/front/v2/smartPractice/getQuestionsByKnowledge',
+		method: 'get',
+		params: query
+	})
+}
+
+// 保存练习
+export function savePractice(query) {
+	return request({
+		url: '/front/v2/smartPractice/savePractice',
+		method: 'get',
+		data: query
+	})
+}
+
+
+// 取消收藏试题
+export function queCancelCollect(query) {
+	let key = Object.keys(query)[0];
+	let value = query[key];
+	return request({
+		url: '/front/v2/questions/cancelCollect?' + key + '=' + value,
+		method: 'post',
+	})
+}
+
+// 收藏试题
+export function queCollect(query) {
+	let key = Object.keys(query)[0];
+	let value = query[key];
+	return request({
+		url: '/front/v2/questions/collect?' + key + '=' + value,
+		method: 'post',
+	})
+}
+
+export function questionCollect(questionId) {
+	return request({
+		url: '/front/questions/collect',
+		method: 'post',
+		params: {questionId}
+	})
+}
+
+export function questionCancelCollect(questionId) {
+	return request({
+		url: '/front/questions/cancelCollect',
+		method: 'post',
+		params: {questionId}
+	})
+}
+
+
+
+export function paperCollect(paperId) {
+	return request({
+		url: '/front/papers/collect',
+		method: 'post',
+		params: {paperId}
+	})
+}
+
+export function paperCancelCollect(paperId) {
+	return request({
+		url: '/front/papers/cancelCollect',
+		method: 'post',
+		params: {paperId}
+	})
+}
+
+
+// 查询组卷记录
+export function paperRecords(query) {
+	return request({
+		url: '/front/v2/papers/paperRecords',
+		method: 'get',
+		params: query
+	})
+}
+
+// 获取试题蓝记录
+export function getQuestionCardList(query) {
+	return request({
+		url: '/front/v2/papers/getQuestionCardList',
+		method: 'get',
+		params: query
+	})
+}
+// 保存试题栏
+export function editQuestionCard(query) {
+	return request({
+		url: '/front/v2/papers/editQuestionCard',
+		method: 'post',
+		data: query,
+	})
+}
+// 清空试题栏
+export function deleteQuestionCard(query) {
+	return request({
+		url: '/front/v2/papers/deleteQuestionCard',
+		method: 'post',
+		data: query,
+	})
+}
+
+// 收藏涉及的学科, type: question/paper
+
+export function favSubjects(query) {
+	return request({
+		url: '/front/v2/favorites/subjects',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 收藏涉及的题型
+export function favQueTypes(query) {
+	return request({
+		url: '/front/v2/favorites/qtypes',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 收藏的问题列表
+export function favQuestions(query) {
+	return request({
+		url: '/front/favorites/questions',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 收藏的试卷列表
+export function favPapers(query) {
+	return request({
+		url: '/front/favorites/papers',
+		method: 'get',
+		params: query,
+	})
+}
+
+
+// 错题涉及的题型
+export function wrongTypes(query) {
+	return request({
+		url: '/front/v2/wrongBook/qtypes',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 错题涉及的学科
+export function wrongSubjects(query) {
+	return request({
+		url: '/front/v2/wrongBook/subjects',
+		method: 'get',
+		params: query,
+	})
+}
+
+
+// 错题列表
+export function wrongQuestions(query) {
+	return request({
+		url: '/front/v2/wrongBook/wrongQuestions',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 删除错题
+export function deleteWrongQuestion(query) {
+	return request({
+		url: '/front/v2/wrongBook/deleteWrongQuestion',
+		method: 'post',
+		params: query,
+	})
+}
+
+// 数据统计(总)
+export function summary(query) {
+	return request({
+		url: '/front/v2/studyRecord/summary',
+		method: 'get',
+		params: query,
+	})
+}
+
+
+// 数据统计-做题数量-按天
+export function questionStatsByDay(query) {
+	return request({
+		url: '/front/v2/studyRecord/questionStatsByDay',
+		method: 'get',
+		params: query,
+	})
+}
+
+//  数据统计-做题数量-按学科
+export function questionStatsBySubject(query) {
+	return request({
+		url: '/front/v2/studyRecord/questionStatsBySubject',
+		method: 'get',
+		params: query,
+	})
+}
+// 数据统计-视频观看时长 - 按天
+export function videoStatsByDay(query) {
+	return request({
+		url: '/front/v2/studyRecord/videoStatsByDay',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 数据统计-视频观看时长 - 按学科
+export function videoStatsBySubject(query) {
+	return request({
+		url: '/front/v2/studyRecord/videoStatsBySubject',
+		method: 'get',
+		params: query,
+	})
+}
+
+
+// 视频学习记录
+export function videoWatchRecords(query) {
+	return request({
+		url: '/front/v2/studyRecord/videoWatchRecords',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 知识点诊断记录
+export function knowRecords(query) {
+	return request({
+		url: '/front/v2/studyRecord/knowledgeDiagnoseRecords',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 试卷是否被收藏
+export function isCollected(query) {
+	return request({
+		url: '/front/v2/papers/isCollected',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 添加题目到试题栏
+export function addToQuestionCard(query) {
+	return request({
+		url: '/front/v2/papers/addToQuestionCard',
+		method: 'post',
+		params: query,
+	})
+}
+
+// 获取最底层的知识点
+export function listBottoms(query) {
+	return request({
+		url: '/front/v2/knowledgeTree/listBottoms',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 保存试卷
+export function savePaperInfo(query) {
+	return request({
+		url: '/front/v2/papers/savePaperInfo',
+		method: 'post',
+		data: query,
+	})
+}
+
+// 根据题目类型和数量获取题目
+export function getQuestionsByQTypeAndNum(data) {
+	return request({
+		url: '/front/v2/papers/getQuestionsByQTypeAndNum',
+		method: 'post',
+		data: data,
+	})
+}
+
+// 根据章节获取知识点
+export function knowByChapter(query) {
+	return request({
+		url: '/front/v2/knowledgeTree/listByChapter',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 举一反三
+export function drawInferences(query) {
+	return request({
+		url: '/front/v2/smartPractice/drawInferences',
+		method: 'get',
+		params: query,
+	})
+}
+export function listGrade(query) {
+	return request({
+		url: '/front/v2/papers/listGrade',
+		method: 'get',
+		params: query,
+	})
+}
+// 试卷--  年级列表
+export function paperListGrade(query) {
+	return request({
+		url: '/front/v2/papers/grades',
+		method: 'get',
+		params: query,
+	})
+}
+
+//  批次线--地域
+export function pckzxLocations(query) {
+	return request({
+		url: '/front/syzy/pckzx/locations',
+		method: 'get',
+		params: query,
+	})
+}
+
+//  批次线--录取年份
+export function pckzxYears(query) {
+	return request({
+		url: '/front/syzy/pckzx/years',
+		method: 'get',
+		params: query,
+	})
+}
+
+//  批次线--批次控制线列表
+export function pckzxList(query) {
+	return request({
+		url: '/front/syzy/pckzx/list',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 一分一段--地域
+export function yfydLocations(query) {
+	return request({
+		url: '/front/syzy/yfyd/locations',
+		method: 'get',
+		params: query,
+	})
+}
+// 一分一段--录取年份
+export function yfydYears(query) {
+	return request({
+		url: '/front/syzy/yfyd/years',
+		method: 'get',
+		params: query,
+	})
+}
+// 一分一段--科类
+export function yfydModes(query) {
+	return request({
+		url: '/front/syzy/yfyd/modes',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 一分一段--列表
+export function yfydList(query) {
+	return request({
+		url: '/front/syzy/yfyd/list',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 等效位次转化分
+export function getEquivalentScore(query) {
+	// query: { location: '湖南', year: 2024, mode: '物理', score: 580 }
+	// returns: { match: match item in `yfyd/list`, scores: [{year: 2023, seat: -, score}] 近3年等效位次和等效分},
+	// 注,如果当前year=2023年,应该返回 23 22 21 年的等效位次和等效分;
+	// 如果当前 24年,一分一段开放前应该返回 23 22 21,一分一段开放后应该返回 24 23 22。
+	return request({
+		url: '/front/syzy/yfyd/getEquivalentScore',
+		method: 'get',
+		params: query,
+	})
+}
+
+
+// 高考名词--详情
+export function gkmcDetail(query) {
+	return request({
+		url: '/front/syzy/gkmc/detail',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 高考名词--详情
+export function gkmc(query) {
+	return request({
+		url: '/front/syzy/gkmc/list',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 院校投档线--地域
+export function lineLocations(query) {
+	return request({
+		url: '/front/syzy/tdx/university/locations',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 院校投档线--层次
+export function lineLevels(query) {
+	return request({
+		url: '/front/syzy/tdx/university/levels',
+		method: 'get',
+		params: query,
+	})
+}
+// 院校投档线--科类
+export function lineTypes(query) {
+	return request({
+		url: '/front/syzy/tdx/university/types',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 院校投档线--录取年份
+export function lineYears(query) {
+	return request({
+		url: '/front/syzy/tdx/university/years',
+		method: 'get',
+		params: query,
+	})
+}
+
+export function universityList(query) {
+	return request({
+		url: '/front/syzy/tdx/university/list',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 专业投档线--录取年份
+export function majorLineYear(query) {
+	return request({
+		url: '/front/syzy/tdx/marjor/years',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 专业投档线--科类
+export function majorLineTypes(query) {
+	return request({
+		url: '/front/syzy/tdx/marjor/types',
+		method: 'get',
+		params: query,
+	})
+}
+
+// 专业投档线--层次
+export function majorLineLevels(query) {
+	return request({
+		url: '/front/syzy/tdx/marjor/levels',
+		method: 'get',
+		params: query,
+	})
+}
+
+// TODO: 不明确与WEB端整个webQue.js的差异在哪儿,先哪儿缺搬哪儿
+export function xkcxYears(query) {
+  return request({
+    url: '/front/syzy/xkcx/years',
+    method: 'get',
+    params: query,
+  })
+}
+
+export function getAiAdStudyVideoList(params) {
+  return request({
+    url: '/front/videoCourse/getAiAdStudyVideoList',
+    method: 'get',
+    params
+  })
+}
+
+export function getAiAdStudyQuestionList(params) {
+  return request({
+    url: '/front/v2/questions/getAiAdStudyQuestionList',
+    method: 'get',
+    params
+  })
+}
+
+export function smartSubjectList() {
+  return request({
+    url: '/front/v2/subject/list',
+    method: 'get',
+  })
+}
+
+
+export function getAiSubjectVideos(params) {
+  return request({
+    url: '/front/ai/getAiSubjectVideos',
+    method: 'get',
+    params
+  })
+}
+
+export function getAiSubjectPapers(params) {
+  return request({
+    url: '/front/ai/getAiSubjectPapers',
+    method: 'get',
+    params
+  })
+}
+
+export function xkcxlist(query) {
+	return request({
+		url: '/front/syzy/xkcx/list',
+		method: 'get',
+		params: query
+	})
+}

+ 156 - 0
api/webApi/webVideo.js

@@ -0,0 +1,156 @@
+import request from '@/utils/request'
+// 视频课程相关接口
+
+// IE 视频
+export function ieVideoSubjects(query) {
+    return request({
+        url: '/front/videoCourse/subjects',
+        method: 'get',
+        params: query
+    })
+}
+
+export function ieVideoKnowledge(query) {
+    return request({
+        url: '/front/videoCourse/knowledges',
+        method: 'get',
+        params: query
+    })
+}
+
+// 查询视频大类
+export function videoType() {
+    return request({
+        url: '/front/videoCourse/subjects',
+        method: 'get',
+    })
+}
+
+// 查询科目
+export function videoSubjects(query) {
+    return request({
+        url: '/front/videoCourse/courses',
+        method: 'get',
+        params: query
+    })
+}
+
+// 查询年级
+export function videoGrades(query) {
+    return request({
+        url: '/front/videoCourse/grades',
+        method: 'get',
+        params: query
+    })
+}
+
+// 查询版本
+export function videoVersions(query) {
+    return request({
+        url: '/front/videoCourse/versions',
+        method: 'get',
+        params: query
+    })
+}
+
+// 视频包列表
+export function packList(query) {
+    return request({
+        url: '/front/videoCourse/pack/list',
+        method: 'get',
+        params: query
+    })
+}
+
+// 视频列表 
+export function videoList(query) {
+    return request({
+        url: '/front/videoCourse/video/list',
+        method: 'get',
+        params: query
+    })
+}
+
+// 章节树
+export function getChapterTreeList(query) {
+    return request({
+        url: '/common/chapter/getChapterTreeList',
+        method: 'get',
+        params: query
+    })
+}
+
+// 视频详情 
+export function videoInfo(query) {
+    return request({
+        url: '/front/videoCourse/video/info/tree',
+        method: 'get',
+        params: query
+    })
+}
+
+// 视频详情 7.1
+export function videoInfoTree(query) {
+    return request({
+        url: '/front/videoCourse/video/info/tree',
+        method: 'get',
+        params: query
+    })
+}
+
+
+// 获取视频播放信息 
+export function getVideoPlayInfo(query) {
+    return request({
+        url: '/common/vod/getVideoPlayInfo',
+        method: 'get',
+        params: query
+    })
+}
+
+// 视频点播
+export function getVideoPlayAuth(query) {
+    return request({
+        url: '/common/vod/getVideoPlayAuth',
+        method: 'get',
+        params: query
+    })
+}
+
+// /prod-api/front/videoCourse/listForHomepageLogined
+// 08.2 首页视频列表(已登陆时)
+export function listForHomepageLogined() {
+    return request({
+        url: '/front/videoCourse/listForHomepageLogined',
+        method: 'get',
+        params: {
+            pageNum: 1,
+            pageSize: 4
+        }
+    })
+}
+
+// /prod-api/front/videoCourse/saveWatchRecord
+export function saveWatchRecord(sectionId, duration, percent, aliType) {
+    if (aliType == 'undefined' || typeof aliType == 'undefined') aliType = '' // 容错处理
+    return request({
+        url: '/front/videoCourse/saveWatchRecord',
+        method: 'post',
+        params: {
+            sectionId,
+            duration: Math.floor(duration),
+            percent: percent.toFixed(3),
+            type: aliType
+        },
+        custom: {toast: false} // 保持静默行为,出错时无需让用户知道
+    })
+}
+
+// 老师收藏视频
+export function collectVideoCourse(params) {
+    return request({
+        url: '/front/videoCourse/video/collectVideoCourse',
+        method: 'post',
+        params
+    })
+}

+ 30 - 0
common/modules/mx-app-links-config.js

@@ -0,0 +1,30 @@
+export default {
+	sysAppLinks: {
+		'micro-video': {
+			Android: {
+				pname: 'com.mm.whiteboard'
+			},
+			iOS: {
+				//action: 'taobao://'
+				url: 'https://a.app.qq.com/o/simple.jsp?pkgname=com.mm.whiteboard&fromcase=40002'
+			},
+			failUrl: 'https://a.app.qq.com/o/simple.jsp?pkgname=com.mm.whiteboard&fromcase=40002'
+		},
+		'hsqk-dili': {
+			Android: {
+				pname: 'com.cnhsqk.dili'
+			},
+			iOS: {
+				action: 'com.cnhsqk.dili://'
+			}
+		},
+		'hsqk-lishi': {
+			Android: {
+				pname: 'com.cnhsqk.history.student'
+			},
+			iOS: {
+				action: 'com.cnhsqk.history.student://'
+			}
+		}
+	}
+}

+ 43 - 0
common/modules/mx-banner-config.js

@@ -0,0 +1,43 @@
+export default {
+	indexIndexBanner: '/static/home/home_banner.png',
+	indexParentBanner: '/static/images/home/img_parent_banner@2x.png',
+	indexElectiveBanner: '/static/images/elective/img_new_gaokao_elective@2x.jpg',
+	indexCareerBanner: '/static/images/career/banner.png',
+	indexEvalBanner: '/static/images/eval-center/img_cepingzhongxin@2x.png',
+	indexOne2OneBanner: '/static/images/one2one/img_one2one_banner.png',
+	indexLibraryBanner: '/static/images/personal-center/personal_datacenter_banner.png',
+	indexAllTestBanner: '',
+	indexTopicCenterBanner: '',
+	indexIndexHeaderOption: {
+
+	},
+	indexCareerHeaderOption: {
+		disableFloat: true,
+		lineSize: 4,
+		space: -30
+	},
+	indexLibraryHeaderOption: {
+		disableFloat: false,
+		lineSize: 3,
+		space: -50
+	},
+	indexEvalHeaderOption: {
+		disableFloat: false,
+		lineSize: 4,
+		space: -50
+	},
+	indexOne2OneHeaderOption: {
+		disableFloat: false,
+		lineSize: 4,
+		space: -50
+	},
+	indexTopicCenterHeaderOption: {
+		title: '个人导学'
+	},
+	indexAllTestHeaderOption: {
+		title: '我的测评'
+	},
+	indexPersonalAiHeaderOption: {
+		title: 'AI助学'
+	}
+}

+ 31 - 0
common/modules/mx-block-index-all-test-config.js

@@ -0,0 +1,31 @@
+import widgets from '@/common/mx-block-widgets.js'
+
+export default {
+	indexAllTestBlocks: [{
+		...widgets.electiveTest,
+		satisfyStoreGetters: ['false']
+	}, {
+		...widgets.careerTest,
+		satisfyStoreGetters: ['false']
+	}, {
+		...widgets.electiveGuide,
+		satisfyStoreGetters: ['false']
+	},{
+		...widgets.hollandGuide
+	}, {
+		...widgets.mbtiGuide
+	}, {
+		...widgets.multiwayGuide,
+		satisfyStoreGetters: ['false']
+	}, {
+		...widgets.mentalHealthGuide
+	},{
+		...widgets.psychologyTest,
+		satisfyStoreGetters: ['isSenior', 'false'],
+		satisfyAny: false
+	}, {
+		...widgets.studyTest,
+		satisfyStoreGetters: ['isSenior', 'false'],
+		satisfyAny: false
+	}]
+}

+ 17 - 0
common/modules/mx-block-index-config.js

@@ -0,0 +1,17 @@
+export default {
+	indexIndexBlocks: [
+		{
+			is: 'carousel-banner'
+		},
+		{
+			is: 'schedule-steps',
+			satisfyStoreGetters: ['false']
+		},
+		{
+			is: 'news-tab'
+		},
+		{
+			is: 'news-top'
+		}
+	]
+}

+ 25 - 0
common/modules/mx-business-config.js

@@ -0,0 +1,25 @@
+export default {
+    collegeDetailPopups: {
+        double: {
+            title: '双高院校',
+            description: '',
+            content: '教育部在2019年推出了科学针对高职专科大学的"双高计划",意在建设具有中国特色的高水平高职学校和专业。' +
+                '双高院校第一轮建设单位共计202所(含双高院校A档、B档、C档,高水平专业A档、B档、C档),被称为专科中的双一流大学,值得考生们在报考时优先考虑。'
+        },
+        star: {
+            title: '院校综合竞争力星级',
+            description: '(数据仅供参考)',
+            content: '说明:数据来源于2022-2023年高职院校金平果排行榜。(仅展示前50%)<br/>' +
+                '评价指标:由5个一级指标、34个二级指标和100多个观测点组成,比往年增加了立德树人、学风建设、思政课程、' +
+                '示范基地、产学合作、上年优势专业、优秀教材、人才培养质量、产教融合、创新创业、1+X证书试点、' +
+                '本科教育试点、论文质量影响等指标或数据观测点。'
+        },
+        major: {
+            title: '专业排名',
+            description: '(数据仅供参考)',
+            content: '<strong>数据说明</strong>:示例 <span class="f-red">1</span>/213<br/>' +
+                '"1"为当前院校此专业排名<br/>' +
+                '"213"为全国开设此专业的院校总数(仅展示前50%、前30所院校)'
+        }
+    }
+}

+ 54 - 0
common/modules/mx-menus-index-config.js

@@ -0,0 +1,54 @@
+export default {
+    indexIndexMenus: [{
+        name: '题库',
+        path: '/pages/topic-center/index/index',
+        icon: '/static/home/library@2x.png',
+        iconMode: 'heightFix',
+        satisfyStoreGetters: ['!isK9Sensitive'],
+        useSvg: false
+    }, {
+        name: '志愿填报',
+        path: '/pages/ie/portal',
+        icon: '/static/home/form@2x.png',
+        useSvg: false
+    }, {
+        name: '自我测评',
+        path: '/pages/test-center/index/index',
+        icon: '/static/home/test@2x.png',
+        useSvg: false
+    }, {
+        name: '资讯',
+        path: '/pages/news/index/index',
+        icon: '/static/home/news@2x.png',
+        satisfyStoreGetters: ['!isCultural'],
+        useSvg: false
+    }, {
+        name: '查位次',
+        path: '/pages/career/query-segment/query-segment',
+        icon: '/static/home/news@2x.png',
+        satisfyStoreGetters: ['isCultural'],
+        useSvg: false
+    }, {
+        name: '视频',
+        path: '/pages/video-center/index/index',
+        icon: '/static/home/video@2x.png',
+        iconMode: 'heightFix',
+        satisfyStoreGetters: ['!isK9Sensitive'],
+        useSvg: false
+    }, {
+        name: '院校库',
+        path: '/pages/college-library/index/index',
+        icon: '/static/home/college@2x.png',
+        useSvg: false
+    }, {
+        name: '专业库',
+        path: '/pages/major-library/index/index',
+        icon: '/static/home/major@2x.png',
+        useSvg: false
+    }, {
+        name: '职业库',
+        path: '/pages/vocation-library/index/index',
+        icon: '/static/home/vocation@2x.png',
+        useSvg: false
+    }]
+}

+ 36 - 0
common/modules/mx-menus-index-topic-center-config.js

@@ -0,0 +1,36 @@
+export default {
+    indexTopicCenterMenus: [{
+        name: '知识点练习',
+        path: '',
+        icon: '/static/topic-center/paper_base.png',
+        nextData: {type: 2, testType: 0, title: '知识点练习', subjectId: ''}
+    }, {
+        name: '必刷题',
+        path: '/pages/topic-center/paper-objective/paper-objective',
+        icon: '/static/topic-center/paper_history.png',
+        nextData: {type: 6, testType: 0, subjectId: '', subjectName: ''},
+        // excludeSubjects: ['语文', '数学', '英语'],
+        satisfyStoreGetters: ['isCultural']
+    }, {
+        name: '模拟试卷',
+        path: '/pages/topic-center/paper-entry/paper-entry',
+        icon: '/static/topic-center/paper_simulate.png',
+        nextData: {paperType: '模拟试卷', subjectId: ''},
+        satisfyStoreGetters: []
+    }, {
+        name: '收藏夹',
+        path: '/pages/topic-center/topic-collection/topic-collection',
+        icon: '/static/topic-center/paper_collection.png',
+        nextData: {title: '收藏夹', subjectId: ''}
+    }, {
+        name: '错题本',
+        path: '/pages/topic-center/wrong-book/wrong-book',
+        icon: '/static/topic-center/paper_error_book.png',
+        nextData: {subjectId: ''}
+    }, {
+        name: '做题记录',
+        path: '/pages/topic-center/paper-record/paper-record',
+        icon: '/static/topic-center/paper_record.png',
+        nextData: {subjectId: ''}
+    }]
+}

+ 119 - 0
common/modules/mx-menus-personal-center-config.js

@@ -0,0 +1,119 @@
+export default {
+    indexPersonalCenterMenus: [
+        {
+            category: "other",
+            name: "基本资料",
+            icon: "/static/personal/icon_jibenziliao@2x.png",
+            path: "/pages/personal-center/basic-info/basic-info",
+            satisfyStoreGetters: [],
+            satisfyAny: false,
+        },
+        {
+            category: "other",
+            name: "修改密码",
+            icon: "/static/personal/icon_password@2x.png",
+            path: "/pages/personal-center/change-pwd/change-pwd",
+            satisfyStoreGetters: [],
+            satisfyAny: false,
+        },
+        {
+            category: "usual",
+            name: "错题本",
+            icon: "/static/personal/wrong_book.png",
+            path: "/pages/topic-center/wrong-book/wrong-book",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: ["!isK9Sensitive"],
+            satisfyAny: false,
+            nextData: {subjectId: ""},
+        },
+        {
+            category: "usual",
+            name: "做题记录",
+            icon: "/static/personal/test_records.png",
+            path: "/pages/topic-center/paper-record/paper-record",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: ["!isK9Sensitive"],
+            satisfyAny: false,
+            nextData: {subjectId: ""},
+        },
+        {
+            category: "usual",
+            name: "测评报告",
+            icon: "/static/personal/test_report.png",
+            path: "/pages/test-center/list/list",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: [],
+            satisfyAny: false,
+        },
+        {
+            category: "usual",
+            name: "我的收藏",
+            icon: "/static/personal/my_collected.png",
+            path: "/pages/personal-center/my-concerned/my-concerned",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: [],
+            satisfyAny: false,
+        },
+        {
+            category: "usual",
+            name: "我的志愿表",
+            icon: "/static/personal/my_simulated.png",
+            path: "/pages/ie/entry-ai-list/entry-ai-list",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: ["!isCultural"],
+            satisfyAny: false,
+        },
+        {
+            category: "usual",
+            name: "我的志愿表",
+            icon: "/static/personal/my_simulated.png",
+            path: "/pages/voluntary/list/list",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: ["isCultural"],
+            satisfyAny: false,
+        },
+        {
+            category: "usual",
+            name: "绑定会员卡",
+            icon: "/static/personal/bind_card.png",
+            path: "/pages/personal-center/bind-card/bind-card",
+            iconClass: "icon40",
+            titleClass: "f14",
+            satisfyStoreGetters: [],
+            satisfyAny: false,
+        },
+        // {
+        //     category: "usual",
+        //     name: "客服电话",
+        //     icon: "/static/personal/custom_tel.png",
+        //     iconClass: "icon40",
+        //     titleClass: "f14",
+        //     path: "400-0313-985",
+        //     handler: (m) => uni.makePhoneCall({phoneNumber: m.path}),
+        //     satisfyStoreGetters: [],
+        //     satisfyAny: false,
+        // },
+        {
+            category: "other",
+            name: "常见问题",
+            icon: "question-circle",
+            type: "FAQ",
+            satisfyStoreGetters: [],
+            satisfyAny: false,
+        },
+        {
+            category: "other",
+            name: "新手教程",
+            icon: "play-circle",
+            path: "/pages/personal-center/help-video/help-video",
+            satisfyStoreGetters: ["false"],
+            satisfyAny: false,
+        },
+    ],
+};

+ 44 - 0
common/modules/mx-other-config.js

@@ -0,0 +1,44 @@
+export default {
+    ossFileBase: '',
+    cameraAuthTips:
+        "(1)系统将获取相机权限,用于拍照等场景\n(2)系统将获取外部存储(含相册)读取权限,用于选择图片等场景",
+    scoreLockingTips: "", //'现在是志愿填报正式阶段,请准确填入您的高考分数和科目,确认后不可再修改',
+    scoreLockedTips: "", //'*志愿填报正式阶段不能修改分数与科目',
+    scoreRuleTips: "", //'*出分前分数与科目可以多次输入,出分后只能输入一次,系统使用视频可以在个人中心的使用教程查看',
+    inlineSiteFunctionPaths: {
+        protocolPrivacy: "/protocol/mxjb_privacy_IE.html",
+        protocolUser: "/protocol/mxjb_user_IE.html",
+        previewPDF: "/pdfView/index.html?src=",
+        FAQ: "/FAQ/FAQ_IE.html",
+    },
+    fileHelper: {
+        supportPDFTypes: ["pdf"],
+        supportOfficeTypes: [
+            "doc",
+            "xls",
+            "ppt",
+            "pdf",
+            "docx",
+            "xlsx",
+            "pptx",
+        ],
+        supportImageTypes: ["png", "jpg", "jpeg"],
+        isPDF: function (url) {
+            return this.supportPDFTypes.some((type) => url.endsWith(type));
+        },
+        isOffice: function (url) {
+            return this.supportOfficeTypes.some((type) => url.endsWith(type));
+        },
+        isImage: function (url) {
+            return this.supportImageTypes.some((type) => url.endsWith(type));
+        },
+        isFile: function (url) {
+            return this.isPDF(url) || this.isOffice(url);
+        },
+        getFileType: function (url) {
+            const lastDotIdx = url.lastIndexOf(".");
+            const suffix = url.substring(lastDotIdx + 1);
+            return suffix;
+        },
+    },
+};

+ 78 - 0
common/modules/mx-tiny-buttons-config.js

@@ -0,0 +1,78 @@
+export default {
+	sysRightItem: {
+		filter: {
+			icon: 'filter', // font-icon
+			text: '筛选',
+		},
+		about: {
+			icon: 'navigate',
+			text: ''
+		},
+		batch: {
+			icon: 'settings',
+			text: '批量'
+		},
+		plus: {
+			icon: 'paperplane',
+			text: '发送'
+		},
+		settings: {
+			icon: 'gear',
+			text: '设置'
+		},
+		history: {
+			icon: 'outline',
+			text: '历史'
+		},
+		add: {
+			icon: 'plusempty',
+			text: '新建'
+		},
+		refresh: {
+			icon: 'reload',
+			text: '刷新'
+		},
+		download: {
+			icon: 'download',
+			text: '下载'
+		},
+		edit: {
+			icon: 'compose',
+			text: '编辑'
+		}
+	},
+	sysSwipeAction: {
+		delete: {
+			text: '删除',
+			style: {
+				backgroundColor: 'var(--error-color)'
+			}
+		},
+		modify: {
+			text: '编辑',
+			style: {
+				backgroundColor: 'var(--primary-color)'
+			}
+		},
+		reply: {
+			text: '回复',
+			style: {
+				backgroundColor: 'var(--primary-color)'
+			}
+		}
+	},
+	sexOptions: [{
+		text: '男',
+		value: '0'
+	}, {
+		text: '女',
+		value: '1'
+	}],
+	yesOrNoOptions: [{
+		text: '是',
+		value: 1
+	}, {
+		text: '否',
+		value: 0
+	}]
+}

+ 628 - 0
common/mx-block-widgets.js

@@ -0,0 +1,628 @@
+import mxConst from '@/common/mxConst'
+
+export default {
+    // NOTE: 方便本地配置的时候重用
+
+    // 选科查询
+    selectCourseQuery: {
+        is: "mx-card-common",
+        gutter: 0,
+        lineSize: 1,
+        containerClasses: ['mb15'],
+        dataList: [{
+            height: '112px',
+            src: '/static/images/career/img_xuankechaxuxn.png',
+            path: '/pages/career/select-course/index'
+        }]
+    },
+    // 院校排名
+    collegeRanking: {
+        is: "mx-card-common",
+        gutter: 0,
+        lineSize: 1,
+        containerClasses: ['ml12 mr12 rd6'],
+        dataList: [{
+            height: '103px',
+            src: '/static/home/college-ranking@2x.png',
+            path: '/pages/college-library/index/index?data=' + JSON.stringify({
+                tab: 'rank'
+            })
+        }]
+    },
+    hollandGuide: {
+        is: "test-guide-card",
+        img: '/static/home/holland.png',
+        title: '专业倾向测评',
+        desc: 'Holland测评是一种基于职业兴趣的测评工具,根据被测者对六种职业兴趣类型的倾向性,提供对应的职业建议。',
+        bgColor: '#D8EDFD',
+        clazz: ['mx12'],
+        path: '/pages/test-center/holland/holland',
+        more: '/pages/test-center/list/list',
+        moreNext: {type: 'holland'}
+    },
+    mbtiGuide: {
+        is: "test-guide-card",
+        img: '/static/home/mbti.png',
+        title: '职业性格测评',
+        desc: 'MBTI测评是一种基于人格类型的测评工具,根据被测者对四种人格维度的偏好程度,判断其人格类型并提供相关建议。',
+        bgColor: '#D9E2FD',
+        clazz: ['mx12'],
+        path: '/pages/test-center/mbti/mbti',
+        more: '/pages/test-center/list/list',
+        moreNext: {type: 'mbti'}
+    },
+    multiwayGuide: {
+        is: "test-guide-card",
+        img: '/static/home/multiway-guide@2x.png',
+        title: '多元升学路径规划',
+        desc: '"多元录取"是新高考改革的核心,国家鼓励更多的学生能够结合自身的情况选择合适的升学路径,促进低分高就及科学的学业生涯规划。',
+        bgColor: '#75DDBD',
+        clazz: ['mx12'],
+        path: '/pages/career/multiway/multiway',
+        more: '/pages/career/multiway/history'
+    },
+    mentalHealthGuide: {
+        is: "test-guide-card",
+        img: '/static/home/mental-health.png',
+        title: '中学生心理健康诊断测验',
+        desc: '该测评是由华东师范大学心理学系教授周步成和其他心理学科研究人员,根据日本铃木清等人编制的"不安倾向诊断测验"进行修订,成为适应于我国中学学生标准化的《心理健康诊断测验》',
+        bgColor: '#E1FCDF',
+        clazz: ['mx12'],
+        path: '/pages/test-center/mental-health/mental-health',
+        more: '/pages/test-center/mental-health/history'
+    },
+    electiveGuide: {
+        is: "test-guide-card",
+        img: '/static/home/elective-guide@2x.png',
+        title: '选科测评',
+        desc: '新高考选科测评,从人生价值观、职业兴趣和知识兴趣三个维度,帮助你找到最适合的专业。',
+        bgColor: '#9595dd',
+        clazz: ['mx12'],
+        path: '/pages/elective/test/index/index'
+    },
+    // 选科测评
+    electiveTest: {
+        is: "mx-card-common",
+        lineSize: 1,
+        gutter: 0,
+        containerClasses: ['pl15', 'pr15', 'mb15'],
+        itemClasses: ['rd8', 'relative'],
+        header: {
+            title: '选科测评',
+            icon: '/static/images/elective/elective_block_select_icon.png',
+        },
+        titleBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatTitle,
+                color: '#336699'
+            }
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        descBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatDesc,
+                color: '#ff9966',
+                opacity: 1,
+                fontSize: '14px',
+                fontFamily: 'PingFangSC-Regular, PingFang SC',
+            }
+        },
+        descMapper: {
+            'desc': 'text'
+        },
+        dataList: [{
+            name: '',
+            desc: '',
+            src: '/static/images/elective/elective_block_test.png',
+            path: '/pages/elective/test/index/index',
+        }]
+    },
+    // 心理测评
+    psychologyTest: {
+        is: "mx-card-common",
+        lineSize: 1,
+        gutter: 0,
+        containerClasses: ['pl15', 'pr15', 'mb15'],
+        itemClasses: ['rd8', 'relative'],
+        header: {
+            title: '心理测评',
+            icon: '/static/images/elective/elective_block_select_icon.png',
+        },
+        titleBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatTitle,
+                color: '#336699'
+            }
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        dataList: [{
+            name: '心理测评',
+            src: '/static/images/elective/elective_block_test.png',
+            path: '/pages/elective/test/index/index',
+        }]
+    },
+    // 学业测评
+    studyTest: {
+        is: "mx-card-common",
+        lineSize: 1,
+        gutter: 0,
+        containerClasses: ['pl15', 'pr15', 'mb15'],
+        itemClasses: ['rd8', 'relative'],
+        header: {
+            title: '学业测评',
+            icon: '/static/images/elective/elective_block_select_icon.png',
+        },
+        titleBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatTitle,
+                color: '#336699'
+            }
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        dataList: [{
+            name: '学业测评',
+            src: '/static/images/elective/elective_block_test.png',
+            path: '/pages/elective/test/index/index',
+        }]
+    },
+    // 学科测评入口
+    evaluationSummary: {
+        is: 'mx-index-eval-summary',
+        categories: function (vm, config) {
+            return vm.storeGetterFilter(config.studentEvaluationTabs)
+        }
+    },
+    // 同步视频
+    videoSync: {
+        is: 'mx-card-video-sync',
+        lineSize: 2,
+        itemClasses: ['pl8', 'pr8', 'pt3', 'pb10', 'card-shadow'],
+        header: {
+            title: '视频课程',
+            icon: "/static/images/home/icon_mingshijingjiang@2x.png",
+            moreText: '所有视频>>',
+            path: '/pages/video-center/index/index'
+        },
+        imgMapper: {
+            'img': 'src'
+        },
+        titleBinder: {
+            // 不要使用lineHeight,手机端在单行文本时不兼容,改用margin控制
+            margin: '10px 0 5px 0',
+            lines: 1,
+            customStyle: mxConst.commonStyle.blockNormalTitle
+        },
+        titleMapper: {
+            'section_name': 'text'
+        },
+        dataList: []
+    },
+    // 热门专业
+    hotMajor: {
+        is: 'MxCardHotMajor',
+        lineSize: 2,
+        class: ['bg-white rd6 ml12 mr12'],
+        header: {
+            title: '热门专业'
+        },
+        imgBinder: {
+            height: '105px'
+        },
+        dataList: []
+    },
+    // 生涯视频
+    videoCareer: {
+        is: 'mx-card-video-career',
+        ref: 'careerVideo',
+        lineSize: 2,
+        class: ['bg-white', 'rd6', 'ml12', 'mr12'],
+        itemClasses: [],
+        header: {
+            title: '生涯课程',
+            // icon: "/static/images/home/icon_video@2x.png",
+            moreText: '查看全部 >',
+            path: '/pages/career/index/career-list?type=1'
+        },
+        imgBinder: {
+            classes: 'rd4'
+        },
+        imgMapper: {
+            'pictUrl': 'src'
+        },
+        titleBinder: {
+            // 不要使用lineHeight,手机端在单行文本时不兼容,改用margin控制
+            margin: '10px 0 5px 0',
+            lines: 1,
+            customStyle: mxConst.commonStyle.blockNormalTitle
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        descBinder: {
+            lines: 1,
+            prefixIcon: 'eye',
+            customStyle: mxConst.commonStyle.blockNormalDesc
+        },
+        descMapper: {
+            'plays': 'text'
+        },
+        dataList: []
+    },
+    gkzx: {
+        is: 'mx-card-gkzx',
+        lineSize: 1,
+        itemClasses: ['pl8', 'pr8', 'pt3', 'pb10', 'bd-b-1'],
+        header: {
+            title: '高考政策',
+            icon: "/static/images/home/icon_video@2x.png",
+            moreText: '更多>>',
+            path: '/pages/career/volunteer/info/list?type=高考政策'
+        },
+        titleBinder: {
+            // 不要使用lineHeight,手机端在单行文本时不兼容,改用margin控制
+            margin: '10px 0 5px 0',
+            lines: 1,
+            customStyle: mxConst.commonStyle.blockNormalTitle
+        },
+        titleMapper: {
+            'title': 'text'
+        },
+        dataList: []
+    },
+    videoGksp: {
+        is: 'mx-card-video-gksp',
+        lineSize: 2,
+        class: ['bg-white', 'rd6', 'ml12', 'mr12'],
+        itemClasses: [],
+        header: {
+            title: '高考政策',
+            // icon: "/static/images/home/icon_video@2x.png",
+            moreText: '查看全部 >',
+            path: '/pages/career/volunteer/new-gkvideo?type=高考政策'
+        },
+        imgBinder: {
+            mode: '',
+            width: '100%',
+            src: '/static/images/home/img_shegnyaguanli@2x.png' //default value
+        },
+        imgMapper: {
+            'coverUrl': 'src'
+        },
+        titleBinder: {
+            // 不要使用lineHeight,手机端在单行文本时不兼容,改用margin控制
+            margin: '10px 0 5px 0',
+            lines: 1,
+            customStyle: mxConst.commonStyle.blockNormalTitle
+        },
+        titleMapper: {
+            'title': 'text'
+        },
+        dataList: []
+    },
+    // 微课视频
+    videoMicro: {
+        is: 'mx-card-video-micro',
+        lineSize: 2,
+        itemClasses: ['pl8', 'pr8', 'pt3', 'pb10', 'card-shadow'],
+        header: {
+            title: '微课视频',
+            icon: "/static/images/home/icon_video@2x.png",
+            moreText: '更多>>',
+            path: '/pages/eval-center/personal-resource/index/index'
+        },
+        imgBinder: {
+            mode: '',
+            src: '/static/images/home/img_shegnyaguanli@2x.png' //default value
+        },
+        imgMapper: {
+            'resourcesCoverUrl': 'src'
+        },
+        titleBinder: {
+            // 不要使用lineHeight,手机端在单行文本时不兼容,改用margin控制
+            margin: '10px 0 5px 0',
+            lines: 1,
+            customStyle: mxConst.commonStyle.blockNormalTitle
+        },
+        titleMapper: {
+            'resourcesName': 'text'
+        },
+        dataList: []
+    },
+    // 生涯测评
+    careerTest: {
+        is: 'mx-card-common',
+        lineSize: 2,
+        itemClasses: ['rd4'],
+        header: {
+            title: '生涯测评',
+            icon: '/static/images/home/icon_shengyaceping@2x.png',
+            moreText: '测评记录>>',
+            path: '/pages/test-center/list/list'
+        },
+        dataList: [{
+            src: '/static/images/home/img_zhuanyexingquceping@2x.png',
+            path: '/pages/test-center/holland/holland'
+        }, {
+            src: '/static/images/home/img_zhiyexingquceping@2x.png',
+            path: '/pages/test-center/mbti/mbti'
+        }],
+    },
+    // 智能练习
+    intelligentExercise: {
+        is: 'mx-card-common',
+        lineSize: 2,
+        itemClasses: ['rd4'],
+        header: {
+            title: '智能练习',
+            icon: "/static/images/home/home_icon_tikuzhongxin@2x.png",
+        },
+        dataList: [{
+            mode: '',
+            src: '/static/images/home/img_tongbuzaixianlianxi@2x.png',
+            path: '/pages/topic-center/sync-online/sync-online?tabIndex=0'
+        }, {
+            mode: '',
+            src: '/static/images/home/img_zhishidianzaixianlianxi@2x.png',
+            path: '/pages/topic-center/sync-online/sync-online?tabIndex=1'
+        }],
+    },
+    paperWork: {
+        is: "mx-card-common",
+        lineSize: 1,
+        gutter: 0,
+        containerClasses: ['pl15', 'pr15', 'mb15'],
+        itemClasses: ['rd8', 'relative'],
+        header: {
+            title: '在线作业',
+            icon: '/static/images/topic-center/topic_center_icon_homework.png',
+        },
+        titleBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatTitle,
+                color: '#608EDF'
+            }
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        descBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatDesc,
+                color: '#FFA400',
+                opacity: 1,
+                fontSize: '14px',
+                fontFamily: 'PingFangSC-Regular, PingFang SC',
+            }
+        },
+        descMapper: {
+            'desc': 'text'
+        },
+        dataList: [{
+            name: '在线作业/视频作业/试卷作业',
+            desc: 'HOMEWORK ONLINE',
+            src: '/static/images/topic-center/topic_center_block_homework.png',
+            path: '/pages/topic-center/homework/index',
+        }]
+    },
+    paperWorkManage: {
+        is: "mx-card-common",
+        lineSize: 1,
+        gutter: 0,
+        containerClasses: ['pl15', 'pr15', 'mb15'],
+        itemClasses: ['rd8', 'relative'],
+        header: {
+            title: '作业管理',
+            icon: '/static/images/topic-center/topic_center_icon_homework.png',
+        },
+        titleBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatTitle,
+                color: '#608EDF'
+            }
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        descBinder: {
+            customStyle: {
+                ...mxConst.commonStyle.blockFloatDesc,
+                color: '#FFA400',
+                opacity: 1,
+                fontSize: '14px',
+                fontFamily: 'PingFangSC-Regular, PingFang SC',
+            }
+        },
+        descMapper: {
+            'desc': 'text'
+        },
+        dataList: [{
+            name: '发布作业/检查作业',
+            desc: 'HOMEWORK MANAGEMENT',
+            src: '/static/images/topic-center/topic_center_block_homework.png',
+            path: '/pages/topic-center/homework/manage',
+        }]
+    },
+    qualityPaper: {
+        is: 'mx-card-common',
+        lineSize: 2,
+        itemClasses: ['rd4'],
+        header: {
+            title: '精品试卷',
+            icon: "/static/images/home/icon_jingpinshijuan@2x.png",
+        },
+        dataList: [{
+            mode: '',
+            src: '/static/images/home/img_mingxiao@2x.png',
+            path: '/pages/topic-center/best-paper/index/index?tabIndex=0'
+        }, {
+            mode: '',
+            src: '/static/images/home/IMG_linianzhent@2x.png',
+            path: '/pages/topic-center/best-paper/index/index?tabIndex=1'
+        }]
+    },
+    // 高考志愿
+    simulatedVolunteer: {
+        is: "mx-card-common",
+        lineSize: 1,
+        gutter: 0,
+        itemClasses: ['rd8', 'rel'],
+        header: {
+            title: '高考志愿',
+            icon: '/static/images/career/icon_zhiyuan.png',
+        },
+        titleBinder: {
+            customStyle: mxConst.commonStyle.blockFloatTitle
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        descBinder: {
+            customStyle: mxConst.commonStyle.blockFloatDesc
+        },
+        descMapper: {
+            'desc': 'text'
+        },
+        dataList: [{
+            name: '志愿填报',
+            desc: 'SIMULATED VOLUNTEER',
+            src: '/static/images/career/img_zhiyuantianbao.png',
+            path: '/pages/voluntary/index/index',
+        }]
+    },
+    // 高考志愿F4
+    simulatedVolunteerF4: {
+        is: "mx-card-common",
+        lineSize: 4,
+        itemClasses: ['fx-cen-cen'],
+        imgBinder: {
+            width: '64px',
+            height: '64px',
+        },
+        titleBinder: {
+            margin: '10px 0 0 0',
+            lines: 1
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        dataList: [{
+            name: '招生计划',
+            src: '/static/images/career/icon_gaokaomingci.png',
+            path: '/pages/career/volunteer/plan/index'
+        }, {
+            name: '批次控制线',
+            src: '/static/images/career/icon_kongzhixian.png',
+            path: '/pages/career/volunteer/batch'
+        }, {
+            name: '一分一段',
+            src: '/static/images/career/icon_yifenyiduan.png',
+            path: '/pages/career/volunteer/yfyd'
+        }, {
+            name: '投档线',
+            src: '/static/images/career/icon_toudangxian.png',
+            path: '/pages/career/volunteer/admission-line'
+        }]
+    },
+    // 3库
+    collegeMajorVocation: {
+        is: "mx-card-common",
+        lineSize: 3,
+        itemClasses: ['fx-cen-cen'],
+        header: {
+            title: "院校专业",
+            icon: "/static/images/career/icon_star.png",
+        },
+        imgBinder: {
+            width: '64px',
+            height: '64px',
+        },
+        titleBinder: {
+            margin: '10px 0 0 0',
+            lines: 1
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        dataList: [{
+            name: '院校库',
+            src: '/static/images/career/icon_yuanxiaoku.png',
+            path: '/pages/college-library/index/index'
+        }, {
+            name: '专业库',
+            src: '/static/images/career/icon_zhuanyeku.png',
+            path: '/pages/major-library/index/index'
+        }, {
+            name: '职业库',
+            src: '/static/images/career/icon_zhiyeku.png',
+            path: '/pages/vocation-library/index/index'
+        }]
+    },
+    // 大数据选科
+    elective: {
+        is: "mx-card-common",
+        lineSize: 2,
+        itemClasses: ['fx-cen-cen'],
+        header: {
+            title: "大数据选科",
+            icon: "/static/images/career/icon_star.png",
+        },
+        imgBinder: {
+            width: '96px',
+            height: '96px',
+        },
+        titleBinder: {
+            margin: '10px 0 0 0',
+            customStyle: mxConst.commonStyle.blockNormalTitle
+        },
+        titleMapper: {
+            'name': 'text'
+        },
+        dataList: function (vm, config) {
+            return vm.storeGetterFilter(config.indexElectiveBlockOptions)
+        }
+    },
+    // 精准ai助学
+    PreciseAi: {
+        is: 'mx-card-common',
+        lineSize: 2,
+        itemClasses: ['rd4'],
+        header: {
+            title: '精准AI助学',
+            icon: "/static/images/home/home_icon_tikuzhongxin@2x.png",
+        },
+        dataList: [{
+            mode: '',
+            src: '/static/images/home/precise_micro.png',
+            path: '/pages/eval-center/ai/preciseAi/microVideo/index'
+        }, {
+            mode: '',
+            src: '/static/images/home/precise_pratise.png',
+            path: '/pages/eval-center/ai/preciseAi/practise/index'
+        }],
+    },
+    // 普通ai助学
+    CommonAi: {
+        is: 'mx-card-common',
+        lineSize: 2,
+        itemClasses: ['rd4'],
+        header: {
+            title: '普通AI助学',
+            icon: "/static/images/home/home_icon_tikuzhongxin@2x.png",
+        },
+        dataList: [{
+            mode: '',
+            src: '/static/images/home/common_micro.png',
+            path: '/pages/eval-center/ai/commonAi/microVideo/index'
+        }, {
+            mode: '',
+            src: '/static/images/home/common_pratise.png',
+            path: '/pages/eval-center/ai/commonAi/practise/index'
+        }],
+    }
+}

+ 15 - 0
common/mxConfig.js

@@ -0,0 +1,15 @@
+const config = {}
+
+// #ifdef VUE3
+const configModules = import.meta.glob('./modules/*.js')
+const tasks = Object.values(configModules).map(m => m().then(c => Object.assign(config, c.default)))
+Promise.all(tasks).then()
+// #endif
+
+// #ifndef VUE3
+const configModules = require.context('./modules', false, /\.js$/)
+const configs = configModules.keys().map(key => configModules(key).default)
+configs.forEach(module => Object.assign(config, module))
+// #endif
+
+export default config

+ 210 - 0
common/mxConst.js

@@ -0,0 +1,210 @@
+const consts = {
+    keyToken: 'mx-token',
+    keyUserInfo: 'mx-userInfo',
+    keyAppConfig: 'mx-appConfig',
+    keyCacheUniquePrefix: 'mx-transfer-cache-',
+    keyTheme: 'mx-theme',
+    keyParentIdentifier: 'from=wechat',
+    keyGuideRead: 'mx-guideRead-v1',
+    keyUserAgreed: 'mx-userAgreed',
+    keyDisplayPermission: 'mx-displayPermission',
+    keyOutTradeNo: 'mx-outTradeNo',
+    keyCulturalExamType: '职高对口升学',
+    propAppConfig: 'appConfig',
+    routes: {
+        index: {url: '/pages/index/index', type: 'tab'},
+        portal: {url: '/pages/ie/portal', type: 'tab'},
+        personalCenter: {url: '/pages/personal-center/index/index', type: 'tab'},
+        login: '/pages/login/login',
+        register: '/pages/login/register',
+        activate: '/pages/personal-center/bind-card/bind-card',
+        forgetPwd: '/pages/login/forget-pwd',
+        guide: {url: '/pages/index/guide', animationType: 'pop-in'},
+        bindMobile: {url: '/pages/login/bind-mobile', animationType: 'pop-in'},
+        bindProfile: '/pages/login/bind-profile',
+        setting: '/pages/personal-center/setting/setting',
+        basicInfo: '/pages/personal-center/basic-info/basic-info',
+        newsIndex: '/pages/news/index/index',
+        newsDetail: '/pages/news/detail/detail',
+        newsGroup: '/pages/news/group/group',
+        videoPlay: '/pages/video-center/play/play'
+    },
+    globalEvents: {
+        paperCompleted: 'globalEvents-paperCompleted',
+        collegeSelected: 'globalEvents-collegeSelected',
+        aiFilterShortcutClear: 'globalEvents-aiFilterShortcutClear',
+        voluntaryChanged: 'globalEvents-voluntaryChanged'
+    },
+    serverErrors: {
+        400: '请求无效 (Bad request)',
+        401: '操作未授权,请登陆',
+        402: '登陆凭据失效,请重新登陆',
+        403: '没有权限访问',
+        404: '请求资源不存在',
+        405: '权限不足,请开通VIP'
+    },
+    commonStyle: {
+        blockFloatTitle: {
+            position: 'absolute',
+            color: '#fff',
+            fontSize: '18px',
+            fontFamily: 'PingFangSC-Medium, PingFang SC',
+            left: '15px',
+            top: '15px'
+        },
+        blockFloatDesc: {
+            position: 'absolute',
+            color: '#fff',
+            fontSize: '18px',
+            fontFamily: 'PingFangSC-Medium, PingFang SC',
+            left: '15px',
+            top: '40px',
+            opacity: 0.5
+        },
+        blockNormalTitle: {
+            color: '#666666',
+            fontSize: '14px',
+            fontFamily: 'PingFangSC-Medium, PingFang SC',
+        },
+        blockNormalDesc: {
+            color: '#999999',
+            fontSize: '12px',
+            fontFamily: 'PingFangSC-Regular, PingFang SC'
+        }
+    },
+    enum: {
+        scoreLock: {
+            // 待锁定状态
+            locking: -1,
+            // 正常状态
+            unlock: 0,
+            // 已锁定状态
+            locked: 1
+        },
+        /// @description 心理健康测评
+        mentalHealthTestType: 4,
+        brochureType: {
+            introduction: 1,
+            enrollRule: 2,
+            examTime: 3,
+            work: 4,
+            otherRule: 5
+        },
+        ai: {
+            voluntaryType: {
+                ai: 'AI',
+                multiple: 'Multiple'
+            },
+            renderType: {
+                def: 0,
+                ai: 1,
+                calculate: 2
+            },
+            inputType: {
+                text: 'Text', // 普通文本,一般情况下输入均使用此类。数值也可以使用此类型,结合校验规则使用
+                score: 'Score', // AI的特殊输入类型,带分制的分数 // 此类输入之后可能涉及输入条件动态变化,NOTE:后面再考虑这种情况。
+                number: 'Number', // 数值,会限制键盘,但因为小数点之类的键盘并不太好控制,所以如果能不使用还是使用Text
+                radio: 'Radio', // radio单选。如果固定2个,用radio,否则用picker。一般超过3个会折行,影响美观
+                picker: 'Picker', // picker单选,一般使用Picker做单选。
+                checkbox: 'Checkbox', // 多选
+                eyesight: 'Eyesight' // 视力,特殊项,前端自己生成了选项
+            },
+            pickType: {
+                all: {
+                    text: '全部',
+                    value: 'All'
+                },
+                danger: {
+                    text: '冲刺型',
+                    value: 'Danger'
+                },
+                normal: {
+                    text: '稳妥型',
+                    value: 'Normal'
+                },
+                safety: {
+                    text: '保守型',
+                    value: 'Safety'
+                }
+            },
+            pickEmpty: {
+                enrollPass: {
+                    text: '无概率',
+                    value: '0'
+                },
+                new: {
+                    text: '新增',
+                    value: '1'
+                }
+            },
+            ruleCategory: {
+                none: {
+                    text: '未知',
+                    value: 'None'
+                },
+                enroll: {
+                    text: '录取规则',
+                    formText: '填写成绩',
+                    value: 'Enroll'
+                },
+                special: {
+                    text: '特殊要求',
+                    formText: '其它信息',
+                    value: 'Special'
+                }
+            },
+            ruleType: {
+                none: 'None', // 缺省类型
+                scoreTotal: 'ScoreTotal', // 总分,综合分
+                scoreUnion: 'ScoreUnion', // 学考,文化成绩
+                scoreBase: 'ScoreBase', // 校考,非学考,文化成绩
+                scoreSkill: 'ScoreSkill', // 技能分,非文化成绩
+                scoreSingle: 'ScoreSingle', // 单科成绩
+                special: 'Special', // 附加要求
+                readonly: 'Readonly', // 只读项,只展示,不参与判定
+                other: 'Other' // 未知项
+            }
+        },
+        simulatePickTypes: [{
+            text: '冲刺型',
+            value: '0'
+        }, {
+            text: '稳妥型',
+            value: '1'
+        }, {
+            text: '保守型',
+            value: '2'
+        }]
+    },
+    question: {
+        // 客观题类型
+        objectiveTypes: [1, 3, 6, 7],
+        // 客观题-单选类型
+        radioTypes: [1, 6, 7],
+        // 客观题-多选类型
+        checkboxTypes: [3],
+        // 转方法
+        isObjective: function (typeId) {
+            return this.objectiveTypes.some(t => t == typeId)
+        },
+        isRadio: function (typeId) {
+            return this.radioTypes.some(t => t == typeId)
+        },
+        isCheckbox: function (typeId) {
+            return this.checkboxTypes.some(t => t == typeId)
+        }
+    },
+    scrollIntoOption: {
+        block: 'center',
+        behavior: 'smooth'
+    },
+    recommendMajorSortFn: (a, b) => a.localPriority - b.localPriority
+}
+
+export function getTabRoutes() {
+    return Object.values(consts.routes)
+        .filter(r => r.type == 'tab')
+        .map(r => r.url)
+}
+
+export default consts

+ 95 - 0
common/webview.bridge.js

@@ -0,0 +1,95 @@
+
+const WebviewEvents = {
+  /**
+   *  获取平台信息
+   */
+  GET_PLATFORM: 'getPlatform',
+  /**
+  *  获取状态栏高度
+  */
+  GET_STATUS_BAR_HEIGHT: 'getStatusBarHeight',
+  /**
+   * 扫码
+   */
+  SCAN: 'scan',
+  /**
+   * 苹果支付
+   */
+  APPLE_PAY: 'applePay',
+  APPLE_PAY_RESTORE: 'applePayRestore',
+  // 微信是否安装
+  CHECK_WECHAT_INSTALLED: 'checkWechatInstalled',
+  GET_UPGRADE_INFO: 'getUpgradeInfo',
+  DOWNLOAD_UPGRADE: 'downloadAndInstall',
+  OPEN_IFRAME: 'openIframe'
+}
+function resetWindowCallback(callbackEvent) {
+  window[callbackEvent] = null;
+  delete window[callbackEvent];
+}
+async function getWebviewCallback(event, args) {
+  if (!uni.webView) {
+    return Promise.reject(new Error('uni环境异常,请检查uni.webview是否正确引入'));
+  }
+  if (window.platform === 'h5') {
+    return Promise.reject(new Error('请在app中使用'));
+  }
+  const callbackEvent = `webviewCallback_${event}`;
+  try {
+    return await new Promise((resolve, reject) => {
+      window[callbackEvent] = (data) => {
+        resolve(data);
+        resetWindowCallback(callbackEvent);
+      };
+      uni.webView.postMessage({
+        data: {
+          action: event,
+          data: {
+            callbackAction: callbackEvent,
+            data: args
+          }
+        }
+      });
+    });
+  } catch (error) {
+    return Promise.reject(new Error('webview初始化失败,请检查是否正确引入uni.webview'));
+  }
+}
+
+class WebviewBridge {
+  constructor() { }
+  getPaltform() {
+    return getWebviewCallback(WebviewEvents.GET_PLATFORM);
+  }
+  scan(...args) {
+    return getWebviewCallback(WebviewEvents.SCAN, args);
+  }
+  applePay(...args) {
+    return getWebviewCallback(WebviewEvents.APPLE_PAY, args);
+  }
+  applePayRestore(...args) {
+    return getWebviewCallback(WebviewEvents.APPLE_PAY_RESTORE, args);
+  }
+  checkWechatInstalled(...args) {
+    return getWebviewCallback(WebviewEvents.CHECK_WECHAT_INSTALLED, args);
+  }
+  getStatusBarHeight() {
+    return getWebviewCallback(WebviewEvents.GET_STATUS_BAR_HEIGHT);
+  }
+  getUpgradeInfo(...args) {
+    return getWebviewCallback(WebviewEvents.GET_UPGRADE_INFO, args);
+  }
+  openIframe(...args) {
+    return getWebviewCallback(WebviewEvents.OPEN_IFRAME, args);
+  }
+  downloadUpgrade(url, progressCb) {
+    window.onDownloadProgress = progressCb;
+    return getWebviewCallback(WebviewEvents.DOWNLOAD_UPGRADE, {
+      url,
+      progressCb: 'onDownloadProgress'
+    });
+  }
+}
+
+const webviewBridge = new WebviewBridge();
+window.webviewBridge = webviewBridge;

+ 218 - 0
components/m-drag/m-drag-vue2.vue

@@ -0,0 +1,218 @@
+<template>
+  <scroll-view class="m-drag" scroll-y :style="{ height: itemHeight * newList.length + 'px' }">
+    <view
+      v-for="(item, index) in newList"
+      :key="index"
+      class="m-drag-item"
+      :class="{ active: currentIndex === index }"
+      :style="{
+        top: itemYList[index].top + 'px'
+      }"
+    >
+      <slot :item="item" />
+      <view class="icon" @touchstart="touchStart($event, index)" @touchmove="touchMove" @touchend="touchEnd">
+        <i class="lines" />
+      </view>
+    </view>
+  </scroll-view>
+</template>
+<script>
+  export default {
+    props: {
+      // 每一项item高度
+      itemHeight: {
+        type: Number,
+        required: true
+      },
+      // 数据列表
+      list: {
+        type: Array,
+        required: true
+      },
+      // 是否只读
+      readonly: {
+        type: Boolean,
+        default: false
+      }
+    },
+    data() {
+      return {
+        // 数据
+        newList: [],
+        // 记录所有item的初始坐标
+        initialItemYList: [],
+        // 坐标数据
+        itemYList: [],
+        // 记录当前手指的垂直方向的坐标
+        touchY: 0,
+        // 记录当前操作的item数据
+        currentItemY: {},
+        // 当前操作的item的下标
+        currentIndex: -1
+      }
+    },
+    watch: {
+      list: {
+        handler(val) {
+          if (!val?.length) return
+          // 获取数据列表
+          this.newList = val
+          // 获取所有item的初始坐标
+          this.initialItemYList = this.getItemsY()
+          // 初始化坐标
+          this.itemYList = this.getItemsY()
+        },
+        immediate: true
+      }
+    },
+    created() {},
+    methods: {
+      /** @初始化各个item的坐标 **/
+      getItemsY() {
+        return this.list.map((item, i) => {
+          return {
+            left: 0,
+            top: i * this.itemHeight
+          }
+        })
+      },
+      /** @开始触摸 */
+      touchStart(event, index) {
+        // 只读
+        if (this.readonly) return
+        // H5拖拽时,禁止触发ios回弹
+        this.h5BodyScroll(false)
+        const [{ pageY }] = event.touches
+
+        // 记录数据
+        this.currentIndex = index
+        this.touchY = pageY
+        this.currentItemY = this.itemYList[index]
+      },
+      /** @手指滑动 **/
+      touchMove(event) {
+        // 只读
+        if (this.readonly) return
+        const [{ pageY }] = event.touches
+        const current = this.itemYList[this.currentIndex]
+        const prep = this.itemYList[this.currentIndex - 1]
+        const next = this.itemYList[this.currentIndex + 1]
+        // 获取移动差值
+        this.itemYList[this.currentIndex] = {
+          top: current.top + (pageY - this.touchY)
+        }
+        // 记录手指坐标
+        this.touchY = pageY
+        // 向下移动(超过下一个的1/2就进行换位)
+        if (next && current.top > next.top - this.itemHeight / 2) {
+          this.changePosition(this.currentIndex + 1)
+        } else if (prep && current.top < prep.top + this.itemHeight / 2) {
+          // 向上移动(超过上一个的1/2就进行换位)
+          this.changePosition(this.currentIndex - 1)
+        }
+      },
+      /** @手指松开 */
+      touchEnd() {
+        // 只读
+        if (this.readonly) return
+        // 传给父组件新数据
+        this.$emit('change', this.newList, this.newList[this.currentIndex])
+        // 将拖拽的item归位
+        this.itemYList[this.currentIndex] = this.initialItemYList[this.currentIndex]
+        this.currentIndex = -1
+        // H5开启ios回弹
+        this.h5BodyScroll(true)
+      },
+      /** @交换位置 **/
+      // index 需要与第几个下标交换位置
+      changePosition(index) {
+        // 记录当前拖拽的item数据
+        const tempItem = this.newList[this.currentIndex]
+        // 设置原来位置的item
+        this.newList[this.currentIndex] = this.newList[index]
+        // 将临时存放的数据设置好
+        this.newList[index] = tempItem
+
+        // 调整位置item
+        this.itemYList[index] = this.itemYList[this.currentIndex]
+        this.itemYList[this.currentIndex] = this.currentItemY
+
+        // 改变当前操作的的下标
+        this.currentIndex = index
+
+        // 记录新位置的数据
+        this.currentItemY = this.initialItemYList[this.currentIndex]
+      },
+      // h5 ios回弹
+      h5BodyScroll(flag) {
+        // #ifdef H5
+        document.body.style.overflow = flag ? 'initial' : 'hidden'
+        // #endif
+      }
+    }
+  }
+</script>
+
+<style scoped lang="scss">
+  .m-drag {
+    position: relative;
+    width: 100%;
+    ::-webkit-scrollbar {
+      display: none;
+    }
+    .m-drag-item {
+      position: absolute;
+      left: 0;
+      right: 0;
+      transition: all ease 0.25s;
+      display: flex;
+      align-items: center;
+      > :deep(view:not(.icon)) {
+        flex: 1;
+      }
+      .icon {
+        padding: 30rpx;
+        .lines {
+          background: #e0e0e0;
+          width: 20px;
+          height: 2px;
+          border-radius: 100rpx;
+          margin-left: auto;
+          position: relative;
+          display: block;
+          transition: all ease 0.25s;
+          &::before,
+          &::after {
+            position: absolute;
+            width: inherit;
+            height: inherit;
+            border-radius: inherit;
+            background: #e0e0e0;
+            transition: inherit;
+            content: '';
+            display: block;
+          }
+          &::before {
+            top: -14rpx;
+          }
+          &::after {
+            bottom: -14rpx;
+          }
+        }
+      }
+      // 拖拽中的元素,添加阴影、关闭动画、层级置顶
+      &.active {
+        box-shadow: 0 0 14rpx rgba(0, 0, 0, 0.08);
+        transition: initial;
+        z-index: 1;
+        .icon .lines {
+          background: #2e97f9;
+          &::before,
+          &::after {
+            background: #2e97f9;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 239 - 0
components/m-drag/m-drag.vue

@@ -0,0 +1,239 @@
+<template>
+    <scroll-view class="m-drag" scroll-y :style="{ height: itemHeight * state.newList.length + 'px' }">
+        <view
+            v-for="(item, index) in state.newList"
+            :key="index"
+            class="m-drag-item"
+            :class="{ active: state.currentIndex === index }"
+            :style="{ top: state.itemYList[index].top + 'px' }">
+            <slot v-bind="{item, index}"/>
+            <view class="icon" @touchstart="touchStart($event, index)" @touchmove="touchMove" @touchend="touchEnd">
+                <i class="lines"/>
+            </view>
+        </view>
+    </scroll-view>
+</template>
+
+<script setup>
+import {reactive, watch, toRefs} from 'vue';
+
+const emits = defineEmits(['change'])
+const props = defineProps({
+    // 每一项item高度
+    itemHeight: {
+        type: Number,
+        required: true
+    },
+    // 数据列表
+    list: {
+        type: Array,
+        required: true
+    },
+    // 是否只读
+    readonly: {
+        type: Boolean,
+        default: false
+    }
+})
+
+const state = reactive({
+    // 数据
+    newList: [],
+    // 记录所有item的初始坐标
+    initialItemYList: [],
+    // 坐标数据
+    itemYList: [],
+    // 记录当前手指的垂直方向的坐标
+    touchY: 0,
+    // 记录当前操作的item数据
+    currentItemY: {},
+    // 当前操作的item的下标
+    currentIndex: -1,
+    // 正在拖拽
+    dragging: false
+})
+
+watch(
+    () => props.list,
+    (val) => {
+        if (!val?.length) return
+        // 获取数据列表
+        state.newList = [...val] // copy array
+        // 获取所有item的初始坐标
+        state.initialItemYList = getItemsY()
+        // 初始化坐标
+        state.itemYList = getItemsY()
+    },
+    {
+        immediate: true
+    }
+)
+
+/** @初始化各个item的坐标 **/
+function getItemsY() {
+    return props.list.map((item, i) => {
+        return {
+            left: 0,
+            top: i * props.itemHeight
+        }
+    })
+}
+
+/** @开始触摸 */
+function touchStart(event, index) {
+    // 只读
+    if (props.readonly) return
+    // H5拖拽时,禁止触发ios回弹
+    h5BodyScroll(false)
+    const [{pageY}] = event.touches
+
+    // 记录数据
+    state.dragging = true
+    state.currentIndex = index
+    state.touchY = pageY
+    state.currentItemY = state.itemYList[index]
+}
+
+/** @手指滑动 **/
+function touchMove(event) {
+    // 只读
+    if (props.readonly) return
+    const [{pageY}] = event.touches
+    const current = state.itemYList[state.currentIndex]
+    const prep = state.itemYList[state.currentIndex - 1]
+    const next = state.itemYList[state.currentIndex + 1]
+    // 获取移动差值
+    state.itemYList[state.currentIndex] = {
+        top: current.top + (pageY - state.touchY)
+    }
+    // 记录手指坐标
+    state.touchY = pageY
+    // 向下移动(超过下一个的1/2就进行换位)
+    if (next && current.top > next.top - props.itemHeight / 2) {
+        changePosition(state.currentIndex + 1)
+    } else if (prep && current.top < prep.top + props.itemHeight / 2) {
+        // 向上移动(超过上一个的1/2就进行换位)
+        changePosition(state.currentIndex - 1)
+    }
+}
+
+/** @手指松开 */
+function touchEnd() {
+    // 只读
+    if (props.readonly) return
+    // 传给父组件新数据
+    emits('change', state.newList, props.list, state.newList[state.currentIndex])
+    // 将拖拽的item归位
+    state.itemYList[state.currentIndex] = state.initialItemYList[state.currentIndex]
+    state.currentIndex = -1
+    // H5开启ios回弹
+    h5BodyScroll(true)
+    state.dragging = false
+}
+
+/** @交换位置 **/
+// index 需要与第几个下标交换位置
+function changePosition(index) {
+    console.log(index)
+    // 记录当前拖拽的item数据
+    const tempItem = state.newList[state.currentIndex]
+    // 设置原来位置的item
+    state.newList[state.currentIndex] = state.newList[index]
+    // 将临时存放的数据设置好
+    state.newList[index] = tempItem
+
+    // 调整位置item
+    state.itemYList[index] = state.itemYList[state.currentIndex]
+    state.itemYList[state.currentIndex] = state.currentItemY
+
+    // 改变当前操作的的下标
+    state.currentIndex = index
+
+    // 记录新位置的数据
+    state.currentItemY = state.initialItemYList[state.currentIndex]
+}
+
+// h5 ios回弹
+function h5BodyScroll(flag) {
+    // #ifdef H5
+    document.body.style.overflow = flag ? 'initial' : 'hidden'
+    // #endif
+}
+
+defineExpose({...toRefs(state)})
+</script>
+
+<style scoped lang="scss">
+.m-drag {
+    position: relative;
+    width: 100%;
+
+    ::-webkit-scrollbar {
+        display: none;
+    }
+
+    .m-drag-item {
+        position: absolute;
+        left: 0;
+        right: 0;
+        transition: all ease 0.25s;
+        display: flex;
+        align-items: center;
+
+        > :deep(view:not(.icon)) {
+            flex: 1;
+        }
+
+        .icon {
+            padding: 30rpx;
+
+            .lines {
+                background: #e0e0e0;
+                width: 20px;
+                height: 2px;
+                border-radius: 100rpx;
+                margin-left: auto;
+                position: relative;
+                display: block;
+                transition: all ease 0.25s;
+
+                &::before,
+                &::after {
+                    position: absolute;
+                    width: inherit;
+                    height: inherit;
+                    border-radius: inherit;
+                    background: #e0e0e0;
+                    transition: inherit;
+                    content: '';
+                    display: block;
+                }
+
+                &::before {
+                    top: -14rpx;
+                }
+
+                &::after {
+                    bottom: -14rpx;
+                }
+            }
+        }
+
+        // 拖拽中的元素,添加阴影、关闭动画、层级置顶
+        &.active {
+            box-shadow: 0 0 14rpx rgba(0, 0, 0, 0.08);
+            transition: initial;
+            z-index: 1;
+
+            .icon .lines {
+                background: #2e97f9;
+
+                &::before,
+                &::after {
+                    background: #2e97f9;
+                }
+            }
+        }
+    }
+}
+</style>

+ 39 - 0
components/mx-bottom-buttons/mx-bottom-buttons.vue

@@ -0,0 +1,39 @@
+<template>
+    <view v-if="left||right" class="fx-row gap-30" :class="containerClass">
+        <uv-button v-if="left" :text="left" :type="leftType" :icon="leftIcon" :icon-color="leftType" plain
+                   v-bind="buttonStyle" @click="$emit('left')"/>
+        <uv-button v-if="right" :text="right" :type="rightType" :icon="rightIcon" icon-color="white"
+                   :loading="loading" v-bind="buttonStyle" @click="$emit('right')"/>
+    </view>
+</template>
+
+<script setup>
+import {computed} from 'vue';
+import {createPropDefine} from "@/utils";
+
+const props = defineProps({
+    // left 和 right 必须提供值才会显示
+    left: createPropDefine('取消'),
+    leftIcon: createPropDefine(undefined),
+    right: createPropDefine('保存'),
+    rightIcon: createPropDefine(undefined),
+    leftType: createPropDefine('error'),
+    rightType: createPropDefine('primary'),
+    loading: createPropDefine(false, Boolean)
+})
+defineEmits(['left', 'right'])
+
+const single = computed(() => !props.left || !props.right)
+const buttonStyle = computed(() => ({
+    customStyle: {
+        height: '44px',
+    },
+    shape: 'circle',
+    class: single.value ? '!w-1/2' : '!flex-1'
+}))
+const containerClass = computed(() => single.value ? 'fx-cen-cen' : 'fx-bet-cen')
+</script>
+
+<style scoped>
+
+</style>

+ 53 - 0
components/mx-buy-vip/mx-buy-vip.vue

@@ -0,0 +1,53 @@
+<template>
+    <uv-popup ref="popup" mode="center" bg-color="transparent" @close="close" :close-on-click-overlay="false">
+        <view class="fx-col items-center">
+            <view class="relative">
+                <uv-image width="280px" height="406px" mode="widthFix" :src="buyVipPopup"/>
+                <uv-button shape="circle" size="large" :text="`¥${price} 立即开通`" :style="btnBuyStyle"
+                           @click="gotoPay"/>
+            </view>
+            <uv-icon size="32" color="white" name="close-circle" @click="close" class="mt-80"/>
+        </view>
+    </uv-popup>
+</template>
+
+<script setup>
+import {ref} from 'vue'
+import {usePayment} from "@/hooks/usePayment";
+import {useTransfer} from "@/hooks/useTransfer";
+import buyVipPopup from '@/static/personal/buy_vip_popup.png'
+
+const popup = ref(null)
+const {price, payment} = usePayment()
+
+
+const btnBuyStyle = {
+    position: 'absolute',
+    backgroundColor: 'transparent',
+    border: 'none',
+    width: '180px',
+    bottom: '-2px',
+    left: '50px'
+}
+
+const open = function () {
+    popup.value.open()
+}
+
+const close = function () {
+    popup.value.close()
+    // NOTE: 只调用了popup的显示与隐藏
+    // usePayment会持续监听订单信息,直到获取到一个终止态,否则不能进行下一次payment响应
+    // TODO:待后续观察是否合适,看是否需要在这里释放支付的一些状态
+}
+
+function gotoPay() {
+  close();
+  const {transferTo} = useTransfer();
+  transferTo("/pages/personal-center/pay/pay");
+}
+
+defineExpose({open, close})
+</script>
+
+<style scoped></style>

+ 59 - 0
components/mx-condition-dropdown/mx-condition-dropdown-item.vue

@@ -0,0 +1,59 @@
+<template>
+    <view class="fx-row items-center gap-5" @click="handleClick" @touchmove.stop>
+        <text class="whitespace-nowrap relative" :class="{'text-primary': opening, 'text-fired': fired}">
+            {{ display }}
+        </text>
+        <uv-icon :name="opening?'arrow-up':'arrow-down'" :color="opening?'primary':undefined"/>
+    </view>
+</template>
+
+<script setup>
+import {computed} from 'vue';
+import {createPropDefine} from "@/utils";
+import {useInjectSearchModel} from "@/components/mx-condition/useSearchModelInjection";
+import {toValue} from "@vueuse/core";
+import {useInjectConditionDropdownPopup} from "@/components/mx-condition-dropdown/useConditionDropdownPopupInjection";
+
+const props = defineProps({
+    condition: createPropDefine({}, Object),
+    useValueAsTitle: createPropDefine(false, Boolean)
+})
+
+const {popup} = useInjectConditionDropdownPopup()
+const {queryParams} = useInjectSearchModel()
+const list = computed(() => props.condition.list)
+const config = computed(() => props.condition.config)
+const current = computed(() => toValue(queryParams)[config.value.key])
+const currentItems = computed(() => list.value.filter(i => [].concat(current.value).includes(getValue(i))))
+const display = computed(() => {
+    if (props.useValueAsTitle && currentItems.value.length == 1) return getLabel(currentItems.value[0])
+    return config.value.title
+})
+const fired = computed(() => {
+    // 命中的是默认条件
+    if (currentItems.value.length == 1 && !getValue(currentItems.value[0])) return false
+    // 有条件命中了
+    return !props.useValueAsTitle && currentItems.value.length > 0
+})
+const opening = computed(() => popup.value?.condition == props.condition) // 当前弹窗打开
+
+const getLabel = (item) => config.value.keyName ? item[config.value.keyName] : item
+const getValue = (item) => config.value.keyValue ? item[config.value.keyValue] : item
+
+const handleClick = () => {
+    popup.value.open(props.condition)
+}
+</script>
+
+<style scoped lang="scss">
+.text-fired::before {
+    content: ' ';
+    position: absolute;
+    top: 0;
+    right: -3px;
+    width: 6px;
+    height: 6px;
+    border-radius: 3px;
+    background-color: var(--error-light-color);
+}
+</style>

+ 120 - 0
components/mx-condition-dropdown/mx-condition-dropdown-popup.vue

@@ -0,0 +1,120 @@
+<template>
+    <uv-popup ref="popup" mode="top" :safe-area-inset-bottom="false" :offset-top="relTop" @change="handleChange">
+        <scroll-view scroll-y style="max-height: 35vh;">
+            <view class="w-screen px-30 py-10 box-border" @touchmove.stop>
+                <component :is="comp" v-model="model[config.key]" placement="column" icon-placement="right">
+                    <component :is="itemComp" v-for="i in list" :name="getValue(i)" :label="getLabel(i)"/>
+                </component>
+            </view>
+        </scroll-view>
+        <mx-bottom-buttons left-type="primary" left="重置" :right="right" class="p-30"
+                           @left="handleReset" @right="handleConfirm"/>
+    </uv-popup>
+</template>
+
+<script setup>
+import {ref, computed} from 'vue';
+import {useElementBounding} from "@vueuse/core";
+import {useInjectConditionDropdownPopup} from "@/components/mx-condition-dropdown/useConditionDropdownPopupInjection";
+import {deepClone, sleep} from "@/uni_modules/uv-ui-tools/libs/function";
+import {useInjectSearchModel} from "@/components/mx-condition/useSearchModelInjection";
+import UvCheckboxGroup from "@/uni_modules/uv-checkbox/components/uv-checkbox-group/uv-checkbox-group.vue";
+import UvRadioGroup from "@/uni_modules/uv-radio/components/uv-radio-group/uv-radio-group.vue";
+import UvCheckbox from "@/uni_modules/uv-checkbox/components/uv-checkbox/uv-checkbox.vue";
+import UvRadio from "@/uni_modules/uv-radio/components/uv-radio/uv-radio.vue";
+import {func} from "@/uni_modules/uv-ui-tools/libs/function/test";
+
+const popup = ref(null)
+const {bottom, container} = useInjectConditionDropdownPopup()
+const {queryParams} = useInjectSearchModel()
+const model = ref({}) // 存放queryParams副本
+const show = ref(false)
+
+const condition = ref(null)
+const list = computed(() => condition.value?.list || [])
+const config = computed(() => condition.value?.config || {})
+const multiple = computed(() => config.value?.multiple)
+const comp = computed(() => multiple.value ? UvCheckboxGroup : UvRadioGroup)
+const itemComp = computed(() => multiple.value ? UvCheckbox : UvRadio)
+
+const right = computed(() => {
+    if (!multiple.value) return '确定'
+    const cur = model.value[config.value.key]
+    if (cur.length < 1) return '确定'
+    return `确定(${cur.length})`
+})
+
+// TODO: 相对位置不精准
+// baseTop 理论上应该找当前容器的值,但目前筛选器是紧贴swiper容器的,所以取的是筛选器的顶部
+// 如果以后有定位不对的情况,请修正该值
+const {top: baseTop} = useElementBounding(container)
+const {bottom: baseBottom} = useElementBounding(bottom)
+const relTop = computed(() => baseBottom.value - baseTop.value - 1)
+
+const handleReset = () => {
+    // 重置本地model状态
+    if (multiple.value) model.value[config.value.key] = []
+    else model.value[config.value.key] = ''
+    if (config.value.defaultValue) {
+        let val = config.value.defaultValue
+        if (func(val)) val = val(condition.value)
+        model.value[config.value.key] = val
+    }
+}
+
+const handleConfirm = () => {
+    // 将本地model状态赋值回queryParams
+    const key = config.value.key
+    queryParams.value[key] = model.value[key]
+    close()
+}
+
+const open = function (cond) {
+    // console.log('open begin', new Date().getTime())
+    if (cond == condition.value && show.value) {
+        popup.value.close()
+        // console.log('open end', new Date().getTime())
+    } else {
+        condition.value = cond
+        // 每次打开都创建一个副本,用户确认后才将结果反向从model赋回queryParams
+        model.value = deepClone(queryParams.value)
+        popup.value.open()
+        // console.log('open end', new Date().getTime())
+    }
+}
+
+const close = function () {
+    // console.log('close begin', new Date().getTime())
+    popup.value.close()
+    // console.log('close end', new Date().getTime())
+}
+
+const handleChange = async function (e) {
+    // console.log('change begin', new Date().getTime())
+    show.value = e.show
+    await sleep()
+    if (!show.value) {
+        // always clean current condition&model while hidden.
+        condition.value = null
+        model.value = {}
+    }
+    // console.log('change end', new Date().getTime())
+}
+
+const getLabel = (item) => config.value.keyName ? item[config.value.keyName] : item
+const getValue = (item) => config.value.keyValue ? item[config.value.keyValue] : item
+
+defineExpose({open, close, show, condition})
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-checkbox-group),
+::v-deep(.uv-radio-group) {
+    .uv-checkbox-label--right,
+    .uv-radio-label--right {
+        padding-top: 20rpx;
+        padding-bottom: 20rpx;
+        border-bottom: 0.5px solid var(--border-color);
+    }
+}
+</style>

+ 47 - 0
components/mx-condition-dropdown/mx-condition-dropdown.vue

@@ -0,0 +1,47 @@
+<template>
+    <scroll-view :scroll-x="x" :style="{zIndex: z}">
+        <view class="bg-white h-[44px] text-xs text-main px-30" :class="layout">
+            <mx-condition-dropdown-item v-for="c in conditions" :condition="c" :use-value-as-title="useValueAsTitle"/>
+        </view>
+    </scroll-view>
+    <mx-condition-dropdown-popup ref="popup"/>
+    <uv-line ref="bottom" :style="{zIndex: z}"/>
+</template>
+
+<script setup>
+/*TODO:暂不要给mx-condition-dropdown套uv-form,会导致zIndex失效,待改善*/
+import {ref, getCurrentInstance, onMounted} from 'vue';
+import {createPropDefine} from "@/utils";
+import {useInjectSearchModel} from "@/components/mx-condition/useSearchModelInjection";
+import MxConditionDropdownItem from "@/components/mx-condition-dropdown/mx-condition-dropdown-item.vue";
+import MxConditionDropdownPopup from "@/components/mx-condition-dropdown/mx-condition-dropdown-popup.vue";
+import {useProvideConditionDropdownPopup} from "@/components/mx-condition-dropdown/useConditionDropdownPopupInjection";
+import {findAncestorComponentByName} from "@/utils/uni-helper";
+
+const props = defineProps({
+    border: createPropDefine(false, Boolean),
+    layout: createPropDefine('fx-row fx-bet-cen'),
+    useValueAsTitle: createPropDefine(false, Boolean),
+    x: createPropDefine(false, Boolean),
+    z: createPropDefine(20000, Number) // 防止被弹层遮挡
+})
+
+const bottom = ref(null) // 最底部的元素,用于定位弹层
+const popup = ref(null) // 弹层元素,将贴合bottom往下弹出
+// relative容器元素,确定弹出层的相对位置 // TODO:理论上这里应该使用外部的swiper
+const container = ref(null)
+const {conditions} = useInjectSearchModel()
+useProvideConditionDropdownPopup(popup, bottom, container)
+
+onMounted(() => {
+    const instance = getCurrentInstance()
+    const swiper = findAncestorComponentByName(instance, 'Swiper')
+    container.value = swiper || document.getElementById('app')
+})
+
+defineExpose({terminate: () => popup.value.close(), getShow: () => popup.value.show})
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
components/mx-condition-dropdown/useConditionDropdownPopupInjection.js

@@ -0,0 +1,13 @@
+import {injectLocal, provideLocal} from "@vueuse/core";
+
+const key = Symbol('CONDITION_DROPDOWN_POPUP')
+
+export const useProvideConditionDropdownPopup = (refOrGetter, bottomEleRef, containerEleRef) => {
+    const options = {popup: refOrGetter, bottom: bottomEleRef, container: containerEleRef}
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectConditionDropdownPopup = function () {
+    return injectLocal(key)
+}

+ 31 - 0
components/mx-condition/modules/conditionSharedConfig.js

@@ -0,0 +1,31 @@
+import {fnPlaceholder} from "@/utils/uni-helper";
+
+export const conditionSharedConfig = {
+    key: '',
+    // 标题名称
+    title: '',
+    // 如果queryParams参数中未初始化,是否在请求后自动初始化,初始化也和required相关,如果required=false
+    autoInit: true,
+    // 如果required=false,
+    allLabel: '不限',
+    // 获取数据的方法
+    handler: fnPlaceholder,
+    // 依赖项,在其之前dependentKeys必须准备好,将作为watch依据
+    dependentKeys: [],
+    // 非依赖项,不要求一定具备,但会和依赖项一起作用handler的请求参数
+    independentKeys: [],
+    // 如果是对象,则配置keyName作为显示用
+    keyName: '',
+    // 如果是对象,则配置keyValue作为传值用
+    keyValue: '',
+    // 校验规则,uv-form validation rule,array or object
+    required: false, // required 会自动生成非空校验,主要是方便用户改写这个属性
+    rule: [], // 如果有了required=true,这里就不需要重复添加非空校验了
+    // 隐藏,只是不显示(渲染),还是在工作的。
+    hidden: false,
+    // 多选
+    multiple: false,
+    // 重置时的默认条件 也可以配置方法// function(condition)
+    // TODO: 目前只用在了重置功能上,理论上也可以用在autoInit上,看后面的需要
+    defaultValue: ''
+}

+ 12 - 0
components/mx-condition/modules/useConditionCollegeFeatures.js

@@ -0,0 +1,12 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+
+export const useConditionCollegeFeatures = function (featuresRefOrGetter, options = {}) {
+    return {
+        ...conditionSharedConfig,
+        handler: () => featuresRefOrGetter,
+        key: 'features',
+        title: '院校层次',
+        allLabel: '', // 不需要填充`全部`
+        multiple: true
+    }
+}

+ 12 - 0
components/mx-condition/modules/useConditionCollegeLevel.js

@@ -0,0 +1,12 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+
+export const useConditionCollegeLevel = function (refOrGetter, options = {}) {
+    return {
+        ...conditionSharedConfig,
+        handler: () => refOrGetter,
+        key: 'level',
+        title: '学历层次',
+        allLabel: '', // 不需要填充`全部`
+        multiple: true
+    }
+}

+ 12 - 0
components/mx-condition/modules/useConditionCollegeLocation.js

@@ -0,0 +1,12 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+
+export const useConditionCollegeLocation = function (refOrGetter, options = {}) {
+    return {
+        ...conditionSharedConfig,
+        handler: () => refOrGetter,
+        key: 'location',
+        title: '院校省份',
+        allLabel: '', // 不需要填充`全部`
+        multiple: true
+    }
+}

+ 12 - 0
components/mx-condition/modules/useConditionCollegeNatureTypeCN.js

@@ -0,0 +1,12 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+
+export const useConditionCollegeNatureTypeCN = function (refOrGetter, options = {}) {
+    return {
+        ...conditionSharedConfig,
+        handler: () => refOrGetter,
+        key: 'natureTypeCN',
+        title: '办学类型',
+        allLabel: '', // 不需要填充`全部`
+        multiple: true
+    }
+}

+ 12 - 0
components/mx-condition/modules/useConditionCollegeType.js

@@ -0,0 +1,12 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+
+export const useConditionCollegeType = function (typeRefOrGetter, options = {}) {
+    return {
+        ...conditionSharedConfig,
+        handler: () => typeRefOrGetter,
+        key: 'type',
+        title: '院校类型',
+        allLabel: '', // 不需要填充`全部`
+        multiple: true
+    }
+}

+ 29 - 0
components/mx-condition/modules/useConditionPaperType.js

@@ -0,0 +1,29 @@
+/*
+*
+* */
+import {useUserStore} from "@/hooks/useUserStore";
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {cacheActions} from "@/hooks/defineCacheActions";
+import _ from "lodash";
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+
+export const useConditionPaperType = function (options = {}) {
+    const {isCultural} = useUserStore()
+    const {dispatchCache} = useCacheStore()
+
+    return {
+        ...conditionSharedConfig,
+        handler: async () => {
+            const result = await dispatchCache(cacheActions.getDicts, 'paper_type')
+            const source = [...result] // 创建source副本
+            // TODO:注意跟进,现在`必刷题`只提供给河南的职高对口
+            if (!isCultural.value) _.remove(source, i => i.dictValue == '必刷题')
+            return source
+        },
+        key: 'paperType',
+        title: '做题类型',
+        keyName: 'dictLabel',
+        keyValue: 'dictValue',
+        ...options
+    }
+}

+ 15 - 0
components/mx-condition/modules/useConditionPickType.js

@@ -0,0 +1,15 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+import MxConst from "@/common/mxConst";
+
+export const useConditionPickType = function (options = {}) {
+    return {
+        ...conditionSharedConfig,
+        key: 'pickType',
+        title: '概率筛选',
+        handler: () => MxConst.enum.simulatePickTypes,
+        immediate: true,
+        keyName: 'text',
+        keyValue: 'value',
+        allLabel: '所有'
+    }
+}

+ 18 - 0
components/mx-condition/modules/useConditionSegmentLocation.js

@@ -0,0 +1,18 @@
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {cacheActions} from "@/hooks/defineCacheActions";
+
+export const useConditionSegmentLocation = function (options = {}) {
+    const {dispatchCache} = useCacheStore()
+
+    return {
+        ...conditionSharedConfig,
+        handler: async () => await dispatchCache(cacheActions.getSectionLocations),
+        key: 'location',
+        title: '地域',
+        keyName: 'text',
+        keyValue: 'value',
+        required: true,
+        ...options
+    }
+}

+ 19 - 0
components/mx-condition/modules/useConditionSegmentMode.js

@@ -0,0 +1,19 @@
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+import {cacheActions} from "@/hooks/defineCacheActions";
+
+export const useConditionSegmentMode = function (options = {}) {
+    const {dispatchCache} = useCacheStore()
+
+    return {
+        ...conditionSharedConfig,
+        dependentKeys: ['year'],
+        independentKeys: ['location'],
+        handler: async (params) => await dispatchCache(cacheActions.getSectionModes, params),
+        key: 'mode',
+        title: '科类',
+        keyName: 'modeName',
+        keyValue: 'mode',
+        ...options
+    }
+}

+ 19 - 0
components/mx-condition/modules/useConditionSegmentYear.js

@@ -0,0 +1,19 @@
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {conditionSharedConfig} from "@/components/mx-condition/modules/conditionSharedConfig";
+import {cacheActions} from "@/hooks/defineCacheActions";
+
+export const useConditionSegmentYear = function (options = {}) {
+    const {dispatchCache} = useCacheStore()
+
+    return {
+        ...conditionSharedConfig,
+        dependentKeys: ['location'],
+        handler: async (params) => await dispatchCache(cacheActions.getSectionYears, params),
+        key: 'year',
+        title: '年份',
+        keyName: 'text',
+        keyValue: 'value',
+        required: true,
+        ...options
+    }
+}

+ 22 - 0
components/mx-condition/mx-condition-dropdown.vue

@@ -0,0 +1,22 @@
+<template>
+    <uv-drop-down ref="dropdown">
+        <uv-drop-down-item v-for="c in conditions" :label="c.config.title"/>
+        <uv-drop-down-popup/>
+    </uv-drop-down>
+</template>
+
+<script setup>
+import {ref, watch} from 'vue';
+import {useInjectPageScroll} from "@/hooks/usePageScrollInjection";
+import {useInjectSearchModel} from "@/components/mx-condition/useSearchModelInjection";
+
+const {scrollTop} = useInjectPageScroll()
+const {conditions} = useInjectSearchModel()
+const dropdown = ref(null)
+
+watch(scrollTop, () => dropdown.value.init()) // 位置修正
+</script>
+
+<style scoped>
+
+</style>

+ 23 - 0
components/mx-condition/mx-condition.vue

@@ -0,0 +1,23 @@
+<template>
+    <view class="h-90 fx-row items-center gap-40">
+        <component :is="wrapper" v-for="c in conditions" :prop="c.config.key">
+            <mx-picker v-model="queryParams[c.config.key]" :data="c.list"
+                       :label-prop="c.config.keyName" :value-prop="c.config.keyValue"
+                       :empty-display="c.config.title" class="!flex-none"/>
+        </component>
+    </view>
+</template>
+
+<script setup>
+import {computed} from 'vue'
+import {useInjectSearchModel} from "@/components/mx-condition/useSearchModelInjection";
+import UvFormItem from "@/uni_modules/uv-form/components/uv-form-item/uv-form-item.vue";
+
+const {conditions, queryParams, needValidation} = useInjectSearchModel()
+// 这里考虑到结构一致性,如果有校验需要,则用uv-form-item包装一下
+const wrapper = computed(() => needValidation.value ? UvFormItem : 'div')
+</script>
+
+<style scoped>
+
+</style>

+ 61 - 0
components/mx-condition/useConditionDataManager.js

@@ -0,0 +1,61 @@
+import {toValue, ref, watch, computed} from 'vue'
+import _ from "lodash";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {useConditionFactory} from "@/components/mx-condition/useConditionFactory";
+
+export const useConditionDataManager = function (configs, eventManager, queryParams, sharedData, console) {
+
+    const container = ref(new Map())
+    const rules = ref({})
+
+    watch(queryParams, async () => {
+        console.log('dataManager begin init', new Date().getTime())
+        const keys = Object.keys(queryParams.value)
+        if (!keys.length) return
+        const keysConfig = configs.map(c => c.key)
+        const missingKeys = _.difference(keys, keysConfig)
+        if (missingKeys.length) console.warn('missing keys: ' + missingKeys.toString())
+
+        // 没有被依赖的属性需要在改变时触发查询
+        // 依赖是在config中指定的,但是否没有被依赖需要遍历所有条件才能知道
+        const keysNotBeDependent = [...keys]
+        keys.forEach(key => {
+            const config = configs.find(c => c.key == key)
+            if (!config) return _.pull(keysNotBeDependent, key)
+            _.pull(keysNotBeDependent, ...config.dependentKeys)
+
+            const condition = useConditionFactory(config, eventManager, queryParams, sharedData, console)
+            container.value.set(key, condition)
+
+            // useConditionFactory可能会对规则进行加工
+            if (!empty(config.rule)) rules.value[config.key] = config.rule
+
+            // 必须条件立马加入workingList,防止事件过早触发
+            if (condition.config.required) eventManager.push('dataManager init of required', key)
+        })
+
+        // 一般来说必须得有1个非被依赖项,才能构成一个响应链,否则没有机会触发onSearch
+        // TODO:这里只是当1个警告,实际运行效果还待评估
+        if (!keysNotBeDependent.length) console.warn('At least one key must not be dependent!')
+        keysNotBeDependent.forEach(k => {
+            watch(() => toValue(queryParams)[k], () => eventManager.trigger('dataManager watch not be dependent of ' + k))
+        })
+    }, {immediate: true})
+
+    const conditions = computed(() => [...container.value.values()])
+    const filterConditions = computed(() => conditions.value.filter(c => !c.config.hidden))
+    const needValidation = computed(() => !empty(rules.value))
+
+    const reset = function () {
+        container.value.clear()
+        rules.value = {}
+    }
+
+    return {
+        container,
+        conditions: filterConditions,
+        rules,
+        needValidation,
+        reset
+    }
+}

+ 72 - 0
components/mx-condition/useConditionEventManager.js

@@ -0,0 +1,72 @@
+import {ref, watch} from 'vue'
+import {createEventHook} from "@vueuse/core";
+import _ from "lodash";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+
+export const useConditionEventManager = function (queryParams, validator, console) {
+    // 工作列表,工作列表清空时,表示条件已经准备好了
+    const workingList = ref([])
+    // 初次完成标识
+    const initialized = ref(false)
+    // 外抛事件,一般事件
+    const searchEvent = createEventHook()
+    // 初始化成功时的外抛事件,会优先于searchEvent外抛
+    const initEvent = createEventHook()
+
+    const push = function (reason, ...keys) {
+        workingList.value.push(...keys)
+        console.log('eventManager push reason:', reason, ', key:', keys + '', ', result:', workingList.value + '')
+    }
+
+    const pop = async (reason, key) => {
+        if (workingList.value.includes(key)) {
+            _.pull(workingList.value, key)
+            console.log('eventManager pop reason:', reason, ', key:', key, ', result:', workingList.value + '')
+
+            await trigger('working list check')
+        }
+    }
+
+    // loadData 引发的pop 与 dataManager 非被依赖项watch 有时会并发触发trigger
+    // 用triggerLock来控制并发只执行一次,TODO:待观察看有没有更好的方式
+    let triggerLock = false
+    const trigger = async (reason) => {
+        if (triggerLock) {
+            console.log('eventManager trigger ignore by lock,reason', reason)
+            return
+        }
+        triggerLock = true
+        console.log('eventManager trigger reason:', reason)
+
+        try {
+            // check trigger event
+            if (empty(workingList.value) && !empty(queryParams.value)) {
+                // 看当前是否数据有效
+                await validator.value()
+
+                if (!initialized.value) {
+                    console.log('eventManager init trigger')
+                    await initEvent.trigger()
+                    initialized.value = true
+                }
+                console.log('eventManager search trigger', new Date().getTime())
+                await searchEvent.trigger()
+            }
+        } finally {
+            triggerLock = false
+        }
+    }
+
+    const reset = function () {
+        workingList.value.length = 0
+    }
+
+    return {
+        push,
+        pop,
+        trigger,
+        reset,
+        onSearch: searchEvent.on,
+        onInit: initEvent.on
+    }
+}

+ 108 - 0
components/mx-condition/useConditionFactory.js

@@ -0,0 +1,108 @@
+import {ref, toValue, watch, nextTick, isRef} from 'vue';
+import {empty, func} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import _ from "lodash";
+import {fnPlaceholder} from "@/utils/uni-helper";
+
+
+// 一般情况下用queryParams即可,但有可能有些全局参数不在queryParams里面,则用sharedData注入进来
+// 通过dependentKeys和independentKeys,获取请求必须的参数
+// dependentKeys是必须参数,independentKeys是非必须参数
+export const useConditionFactory = function (config, eventManager, queryParams, sharedData, console) {
+    const {
+        key, handler, autoInit, required, rule, allLabel, title, multiple,
+        dependentKeys, independentKeys, keyName, keyValue
+    } = config
+
+    if (empty(key)) throw new Error('condition must define a key')
+    if (!func(handler)) throw new Error('handler must be a function')
+
+    const list = ref([])
+    if (required && !rule.some(r => r.required)) {
+        // 没有必填规则就自动创建一条
+        const requiredRule = {required: true, message: `${title}不能为空`, transform: (v) => v + ''}
+        config.rule = [...rule, requiredRule] // 不要直接修改rule,rule可能来自于conditionSharedConfig
+    }
+
+    const makePayload = async () => {
+        const payload = {}
+        const params = toValue(queryParams)
+        // 先检查依赖项
+        for (const key of dependentKeys) {
+            const val = params[key]
+            if (empty(val)) {
+                // 只是给一个警告,reject的方式太重了,会让后续开发认为产生了不可修复问题。
+                // 一般情况下缺必传字段,只需要等必传字段autoInit完成就会自动修复这个问题。
+                // 当然万一运行不符合预期,这里也确实是需要着重检查的点。
+                console.warn(`${config.key}: independent key ${key} is required, wait for next turn.`)
+                return new Promise(fnPlaceholder).then()
+                // return Promise.reject('some message')
+            }
+            payload[key] = val
+        }
+        for (const key of independentKeys) {
+            payload[key] = params[key]
+        }
+        return {...toValue(sharedData), ...payload}
+    }
+
+    const loadData = async () => {
+        eventManager.push('loadData calling', key)
+
+        const payload = await makePayload()
+        const results = await config.handler(payload)
+        console.log('dataManager data loaded', key, results)
+
+        const processData = function (results) {
+            if (allLabel && !required) {
+                const add = keyValue ? {[keyName]: allLabel, [keyValue]: ''} : allLabel
+                list.value = [add, ...results] // 不直接修改,可能会影响缓存结果
+            } else {
+                list.value = results
+            }
+
+            const current = toValue(queryParams)[key]
+            const currentInvalid = (val) => {
+                const valSource = list.value.map(i => keyValue ? i[keyValue] : i)
+                const diff = _.difference(valSource, [].concat(val))
+                console.log('loadData invalid current:', val, ', diff', diff)
+                return diff.length > 0
+            }
+            if (!empty(current) && currentInvalid(current)) {
+                console.log('loadData clear invalid', key, current)
+                queryParams.value[key] = multiple ? [] : ''
+            }
+            if (required && autoInit && empty(current)) {
+                const first = _.first(list.value)
+                const firstValue = keyValue ? first[keyValue] : first
+                if (first) queryParams.value[key] = multiple ? [firstValue] : firstValue
+                console.log('loadData auto init', key, firstValue)
+            }
+
+            eventManager.pop('loadData called', key)
+        }
+
+        if (isRef(results)) {
+            const pureResults = toValue(results)
+            if (empty(pureResults)) {
+                // 此时没有数据,说明后续可能会更新,使用watch监听即可
+                watch(results, (values) => processData(values))
+            } else {
+                processData(pureResults)
+            }
+        } else {
+            processData(results)
+        }
+    }
+
+    // 数据加载
+    if (!dependentKeys.length) {
+        // 无依赖项,直接调用。这里使用了nextTick做保险,万一立即进行校验,要保证uv-form渲染之后才能进行
+        nextTick(async () => await loadData())
+    } else {
+        // 有依赖项,建立监听,依赖变更时重新加载数据
+        const watches = dependentKeys.map(k => () => toValue(queryParams)[k])
+        watch(watches, async () => await loadData(), {immediate: true})
+    }
+
+    return {list, config}
+}

+ 43 - 0
components/mx-condition/useSearchModelInjection.js

@@ -0,0 +1,43 @@
+import {computed} from 'vue'
+import {injectLocal, provideLocal} from "@vueuse/core";
+import {useConditionEventManager} from "@/components/mx-condition/useConditionEventManager";
+import {useConditionDataManager} from "@/components/mx-condition/useConditionDataManager";
+import {fnPlaceholder} from "@/utils/uni-helper";
+
+const key = Symbol('SEARCH_MODEL_SERVICE')
+
+/* 给业务方使用,希望只是定制查询条件的动态参数与静态参数 */
+export const useProvideSearchModel = function (configs, queryParams, formRef = null, sharedData = {}) {
+    const silence = true
+    // console
+    const log = silence
+        ? {log: fnPlaceholder, warn: console.warn, error: console.error}
+        : {log: console.log, warn: console.warn, error: console.error}
+    // uv-form validation
+    const validator = computed(() => formRef?.value?.validate || fnPlaceholder)
+    // event manager for trigger onSearch/onInit
+    const eventManager = useConditionEventManager(queryParams, validator, log)
+    // request data manager. configs必须提供当前场景所需要的所有condition
+    const dataManager = useConditionDataManager(configs, eventManager, queryParams, sharedData, log)
+    const reset = () => {
+        dataManager.reset()
+        eventManager.reset()
+    }
+    const options = {
+        queryParams, // 原样返回
+        sharedData, // 原样返回
+        rules: dataManager.rules, // 给forms用的校验规则,因为利用uv-form的校验是最简便的
+        conditions: dataManager.conditions, // 显示条件选项的数据
+        needValidation: dataManager.needValidation, // 是否需要校验,和渲染uv-form有关
+        onSearch: eventManager.onSearch, // 搜索事件
+        onInit: eventManager.onInit, // 搜索事件,第一次
+        reset // 重置搜索条件状态
+    }
+    provideLocal(key, options)
+    window.searchModel = options
+    return options
+}
+
+export const useInjectSearchModel = function () {
+    return injectLocal(key)
+}

+ 60 - 0
components/mx-count-down/mx-count-down.vue

@@ -0,0 +1,60 @@
+<template>
+    <uv-button v-bind="binding" :disabled="counting" @click="handleClick">
+        <template v-if="counting" #default>
+            <uv-count-down ref="timer" :time="time" :auto-start="false" :format="format" @finish="counting=false"/>
+        </template>
+    </uv-button>
+</template>
+
+<script>
+import {nextTick, ref} from 'vue'
+import {buttonProps} from "@/uni_modules/uv-button/components/uv-button/uv-button.vue";
+import {blockRunner, createPropDefine} from "@/utils";
+
+export default {
+    name: "mx-count-down",
+    props: {
+        ...buttonProps,
+        time: createPropDefine(60 * 1000, Number),
+        format: createPropDefine('ss'),
+        type: createPropDefine('text'),
+        plain: createPropDefine(true, Boolean),
+        customStyle: createPropDefine({
+            width: '40px',
+            height: '32px',
+            color: 'var(--primary-deep-color)'
+        }, [Object, String]),
+        validateBlock: createPropDefine(null, Function)
+    },
+    emits: ['click'],
+    setup(props, {attrs, emit}) {
+        const binding = {
+            ...props,
+            ...attrs
+        }
+        const counting = ref(false)
+        const timer = ref(null)
+
+        const handleClick = async function () {
+            await blockRunner(props.validateBlock)
+            if (counting.value) return
+            counting.value = true // NOTE:这里手工控制,让template#default渲染出来timer才能挂钩
+            emit('click')
+            await nextTick() // NOTE: 等待template#default渲染之后
+            timer.value.start()
+        }
+        return {
+            binding,
+            timer,
+            counting,
+            handleClick
+        }
+    }
+}
+</script>
+
+<style scoped lang="scss">
+::v-deep .uv-count-down__text {
+    color: var(--primary-color);
+}
+</style>

+ 57 - 0
components/mx-echarts/mx-echarts.vue

@@ -0,0 +1,57 @@
+<template>
+    <vue3-echarts ref="echarts" :options="localOption" :canvas-id="canvasId" :style="style"/>
+</template>
+
+<script setup>
+import {ref, computed, onActivated, onDeactivated, watch} from 'vue'
+/* NOTE: 因为vue3-echarts在页面快速切换过程中渲染时会报错,用这个组件包装一下,加强容错性*/
+import {createPropDefine} from "@/utils";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {sleep} from "@/uni_modules/uv-ui-tools/libs/function";
+
+const props = defineProps({
+    option: createPropDefine(null, Object),
+    canvasId: createPropDefine('mx-echarts'),
+    style: createPropDefine({height: '240px'}, Object)
+})
+
+const localOption = ref({})
+const echarts = ref(null)
+const paused = ref(false)
+
+const success = computed(() => echarts.value?.chart)
+
+const syncOption = async () => {
+    const option = props.option
+    if (empty(option)) return
+
+    const valid = () => {
+        if (paused.value) return false
+        // 渲染成功也不用管了,如果要更精细控制,这里也要考虑
+        if (success.value && option == localOption.value) return false
+        return true
+    }
+
+    if (!valid()) return
+    await sleep(500)
+    // 双重判定
+    if (!valid()) return
+
+    localOption.value = option
+}
+
+onActivated(() => {
+    paused.value = false
+    syncOption()
+})
+onDeactivated(() => {
+    paused.value = true
+})
+// TODO:我们的业务场景一般都是一次性赋值,所以这里没有开启深度监听
+// Cannot access 'syncOption' before initialization // watch.immediate只能放在定义之后
+watch(() => props.option, () => syncOption(), {immediate: true})
+</script>
+
+<style scoped>
+
+</style>

+ 56 - 0
components/mx-form-item/mx-form-item.vue

@@ -0,0 +1,56 @@
+<template>
+    <uv-form-item :prop="prop">
+        <template v-if="!$slots.default">
+            <uv-input :model-value="modelValue" :disabled="disabled" input-align="right" border="bottom"
+                      disabled-color="transparent" :color="disabled?'var(--disabled-color)':undefined"
+                      :type="type" :placeholder="autoPlaceholder" @input="$emit('update:modelValue', $event)">
+                <template #prefix>
+                    <slot name="prefix">
+                        <view class="text-main font-bold break-keep fx-row gap-3">
+                            {{ label }}
+                            <uv-icon name="lock" v-if="disabled"/>
+                        </view>
+                    </slot>
+                </template>
+                <template v-if="$slots.suffix" #suffix>
+                    <slot name="suffix"/>
+                </template>
+            </uv-input>
+        </template>
+        <template v-else>
+            <view
+                class="flex-1 px-[10px] py-[7px] fx-row items-center mx-border-b">
+                <view class="text-main font-bold break-keep fx-row gap-3">
+                    {{ label }}
+                    <uv-icon name="lock" v-if="disabled"/>
+                </view>
+                <view class="flex-1">
+                    <slot/>
+                </view>
+            </view>
+        </template>
+    </uv-form-item>
+</template>
+
+<script setup>
+import {computed} from 'vue'
+import {createPropDefine} from "@/utils";
+
+const props = defineProps({
+    prop: createPropDefine(''),
+    label: createPropDefine(''),
+    placeholder: createPropDefine(''),
+    modelValue: createPropDefine('', [String, Number]),
+    disabled: createPropDefine(false, Boolean),
+    type: createPropDefine(undefined)
+})
+defineEmits(['input', 'update:modelValue'])
+
+const autoPlaceholder = computed(() => props.placeholder || '请输入' + props.label)
+</script>
+
+<style scoped lang="scss">
+::v-deep .uv-form-item__body__right__message {
+    margin-left: 0 !important;
+}
+</style>

+ 38 - 0
components/mx-index-menus/mx-index-menus-item.vue

@@ -0,0 +1,38 @@
+<template>
+    <view class="fx-col fx-cen-cen gap-8 h-[70px]">
+        <vue-svg-icons v-if="useSvg" :name="icon" :class="svgClass"/>
+        <uv-image :src="combineOssFile(icon)" :mode="imgMode" :width="imgWidth" :height="imgHeight"/>
+        <text class="break-keep" :class="titleClass">{{ name }}</text>
+    </view>
+</template>
+
+<script setup>
+import {computed} from "vue";
+import {combineOssFile, createPropDefine} from "@/utils";
+
+const props = defineProps({
+    icon: createPropDefine(''),
+    name: createPropDefine(''),
+    useSvg: createPropDefine(false, Boolean),
+    svgClass: createPropDefine('w-100 h-100'),
+    iconMode: createPropDefine('widthFix'),
+    iconSize: createPropDefine(42, [Number, String]),
+    titleClass: createPropDefine('text-main'),
+});
+
+const imgMode = computed(() =>
+    ["widthFix", "heightFix"].includes(props.iconMode)
+        ? props.iconMode
+        : "widthFix"
+);
+const imgWidth = computed(() =>
+    imgMode.value == "widthFix" ? props.iconSize : "auto"
+);
+const imgHeight = computed(() =>
+    imgMode.value == "heightFix" ? props.iconSize : "auto"
+);
+
+</script>
+
+<style scoped>
+</style>

+ 60 - 0
components/mx-index-menus/mx-index-menus.vue

@@ -0,0 +1,60 @@
+<template>
+    <view class="mx-30 mx-card bg-white overflow-hidden">
+        <view v-if="pageSize<=1" :class="containerWrapClass">
+            <mx-index-paged-menus v-bind="props" @click="handleMenuClick"/>
+        </view>
+        <uv-swiper v-else :height="swiperHeight" :indicator-active-color="theme.brands.primary" :list="pagedData"
+                   bg-color="white" circular indicator indicator-inactive-color="#ccc" interval="10000">
+            <template #default="{item}">
+                <mx-index-paged-menus class="flex-1 mb-60 mt-40" v-bind="props" @click="handleMenuClick"/>
+            </template>
+        </uv-swiper>
+    </view>
+</template>
+
+<script setup>
+import {computed} from "vue";
+import {shareProps} from "./shareProps";
+import _ from "lodash";
+import {useTheme} from "@/hooks/useTheme";
+import MxIndexPagedMenus from "@/components/mx-index-menus/mx-index-paged-menus.vue";
+import {useTransfer} from "@/hooks/useTransfer";
+import {func} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {openAppLink} from "@/utils/plus-helper";
+import {createPropDefine} from "@/utils";
+
+// NOTE:这样写idea才能正常提示!
+// defineProps(shareProps) 在本页template无法直接提示shareProps中的属性;无法在外部使用时提示包含的属性
+const props = defineProps({
+    ...shareProps,
+    containerWrapClass: createPropDefine('py-40 px-20', [String, Object]),
+    customAction: createPropDefine(false, Boolean)
+});
+const emits = defineEmits(["click"]);
+const {theme} = useTheme();
+const {transferTo} = useTransfer();
+
+const pagedData = computed(() => {
+    if (props.columns <= 0 || props.rows <= 0 || !props.data.length) return [props.data];
+    return _.chunk(props.data, props.columns * props.rows);
+});
+const pageSize = computed(() => pagedData.value.length);
+const swiperHeight = computed(() => props.rows * 70 + 60); // 这个高度并不太准,只保证少量几行没问题
+
+const handleMenuClick = function (menu) {
+    if (menu.customAction || props.customAction) return emits("click", menu);
+    if (func(menu.handler)) {
+        menu.handler(menu);
+        return;
+    }
+    if (!menu?.path) return;
+    if (menu.appLink) {
+        openAppLink(menu.path);
+        return;
+    }
+    transferTo(menu.path, menu.nextData);
+};
+</script>
+
+<style scoped>
+</style>

+ 21 - 0
components/mx-index-menus/mx-index-paged-menus.vue

@@ -0,0 +1,21 @@
+<template>
+    <view :class="[containerClass, gapClass]" class="grid">
+        <mx-index-menus-item v-for="item in props.data" v-bind="item" :icon-size="iconSize" :title-class="titleClass"
+                             @click="emits('click', item)"/>
+    </view>
+</template>
+
+<script setup>
+import {computed} from "vue";
+import {shareProps} from "./shareProps";
+import MxIndexMenusItem from "@/components/mx-index-menus/mx-index-menus-item.vue";
+
+const props = defineProps({...shareProps});
+const emits = defineEmits(["click"]);
+
+// NOTE: dynamic class name is not be allowed in tailwindcss, notice `safelist` in `tailwind.config`
+const containerClass = computed(() => "grid-cols-" + props.columns);
+</script>
+
+<style scoped>
+</style>

+ 10 - 0
components/mx-index-menus/shareProps.js

@@ -0,0 +1,10 @@
+import {createPropDefine} from "@/utils";
+
+export const shareProps = {
+    data: createPropDefine([], Array),
+    columns: createPropDefine(4, Number),
+    rows: createPropDefine(2, Number),// 0 表示没有限制
+    titleClass: createPropDefine('text-main'),
+    gapClass: createPropDefine('gap-y-20'),
+    iconSize: createPropDefine(42, [Number, String])
+};

+ 52 - 0
components/mx-login-form-item/mx-login-form-item.vue

@@ -0,0 +1,52 @@
+<template>
+    <uv-form-item :prop="prop">
+        <template #label>
+            <uv-text :text="label" :prefix-icon="icon" v-bind="titleBindings"/>
+        </template>
+        <uv-input v-model="model[prop]" v-bind="inputBindings" :placeholder="autoPlaceholder"
+                  :type="type" @update:modelValue="$emit('update:modelValue', $event)">
+            <template v-for="(s, name) in $slots" #[name]>
+                <slot :name="name"/>
+            </template>
+        </uv-input>
+    </uv-form-item>
+</template>
+
+<script setup>
+/*NOTE: 必须设置父级uv-form.labelPosition=`top`来确保该组件的布局*/
+import {computed, ref} from 'vue'
+import {createPropDefine} from "@/utils";
+import {useInjectFormData} from "@/pages/login/components/hooks/useFormDataInjection";
+
+const props = defineProps({
+    prop: createPropDefine(''),
+    modelValue: createPropDefine(''),
+    label: createPropDefine(''),
+    placeholder: createPropDefine(''),
+    icon: createPropDefine(''),
+    type: createPropDefine(undefined)
+})
+defineEmits(['update:modelValue'])
+
+const [model] = useInjectFormData()
+const autoPlaceholder = computed(() => props.placeholder || '请输入' + props.label)
+const titleBindings = ref({
+    color: 'var(--primary-color)',
+    size: 20,
+    iconStyle: {
+        fontSize: '24px',
+        color: 'var(--primary-color)'
+    }
+})
+const inputBindings = ref({
+    fontSize: 18,
+    clearable: true,
+    border: 'bottom',
+    customStyle: {padding: '10px 0'}
+})
+
+</script>
+
+<style scoped>
+
+</style>

+ 102 - 0
components/mx-nav-bar/mx-nav-bar.vue

@@ -0,0 +1,102 @@
+<template>
+    <uv-navbar v-bind="bindings">
+        <!--  slot 穿透  -->
+        <template v-for="name in Object.keys($slots)" #[name]>
+            <slot :name="name"/>
+        </template>
+        <template v-if="!$slots.center&&subTitle" #center>
+            <view class="max-w-[400rpx] text-center" :style="bindings.titleStyle">
+                <view class="truncate">{{ bindings.title }}</view>
+                <view class="text-3xs font-normal truncate">{{ subTitle }}</view>
+            </view>
+        </template>
+    </uv-navbar>
+</template>
+
+<script>
+import {computed} from 'vue';
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {navbarProps} from "@/uni_modules/uv-navbar/components/uv-navbar/uv-navbar.vue";
+import {useH5BackHome} from "@/components/mx-nav-bar/useH5BackHome";
+import {useTransfer} from "@/hooks/useTransfer";
+import {blockRunner, createPropDefine} from "@/utils";
+import {fnPlaceholder} from "@/utils/uni-helper";
+
+export default {
+    name: 'mx-nav-bar',
+    props: {
+        ...navbarProps,
+        mode: createPropDefine('light'),
+        subTitle: createPropDefine(''),
+        opacity: createPropDefine(1, Number),
+        // 用于支持左图标点击的AOP方法,支持返回boolean或者promise
+        leftClickBlock: createPropDefine(null, Function),
+        // leftClick&leftIcon被mx-nav-bar自行控制了,如果要隐藏left,设置leftDisabled=true
+        leftDisabled: createPropDefine(false, Boolean)
+    },
+    setup(props, {attrs}) {
+        const {hasPreviousPage, shouldShowHome} = useH5BackHome()
+        const {relaunch, transferBack} = useTransfer()
+
+        const darkMode = computed(() => props.mode == 'dark')
+        const lightMode = computed(() => props.mode == 'light')
+        const bindings = computed(() => {
+            const override = {...props, ...attrs}
+            // 主题色适配
+            if (darkMode.value) {
+                override.bgColor = 'var(--primary-color)'
+                override.titleStyle = {color: 'white', fontSize: '16px', fontWeight: 700}
+                override.leftIconColor = 'white'
+            } else {
+                override.bgColor = '#ffffff'
+                override.titleStyle = {color: 'var(--main-color)', fontSize: '16px', fontWeight: 700}
+                override.leftIconColor = 'var(--content-color)'
+            }
+            if (props.opacity == 0) {
+                override.bgColor = 'transparent'
+            } else if (props.opacity < 1) {
+                override.bgColor = uni.$uv.colorToRgba(override.bgColor, props.opacity)
+            }
+
+            if (!props.leftDisabled) {
+                // 自行控制后退逻辑
+                override.autoBack = false
+                override.leftIconSize = 20
+                if (hasPreviousPage.value) {
+                    override.leftIcon = 'arrow-left'
+                    if (empty(attrs.onLeftClick)) override.onLeftClick = handleNavBack
+                } else if (shouldShowHome.value) {
+                    override.leftIcon = 'home'
+                    override.leftIconSize = 22
+                    if (empty(attrs.onLeftClick)) override.onLeftClick = handleHomeClick
+                }
+            } else {
+                override.autoBack = false
+                override.leftIcon = ''
+                override.onLeftClick = fnPlaceholder
+            }
+
+            return override
+        })
+
+        async function handleNavBack() {
+            await blockRunner(props.leftClickBlock)
+            transferBack()
+        }
+
+        async function handleHomeClick() {
+            await blockRunner(props.leftClickBlock)
+            relaunch()
+        }
+
+        return {
+            darkMode,
+            lightMode,
+            bindings
+        }
+    }
+}
+
+</script>
+<style scoped>
+</style>

+ 19 - 0
components/mx-nav-bar/useH5BackHome.js

@@ -0,0 +1,19 @@
+import {computed} from 'vue'
+import {page, pages} from "@/uni_modules/uv-ui-tools/libs/function";
+import {getTabRoutes} from "@/common/mxConst";
+
+export function useH5BackHome() {
+    const hasPreviousPage = computed(() => {
+        const allPages = pages()
+        return allPages.length > 1
+    })
+
+    const shouldShowHome = computed(() => {
+        if (hasPreviousPage.value) return false
+        const tabs = getTabRoutes()
+        const current = page()
+        return !tabs.includes(current)
+    })
+
+    return {hasPreviousPage, shouldShowHome}
+}

+ 44 - 0
components/mx-paper/components/mx-paper-completion.vue

@@ -0,0 +1,44 @@
+<template>
+    <view class="h-[60px] fx-row fx-cen-cen gap-40 mx-shadow-up">
+        <uv-button :loading="loading" :text="text" type="primary" size="large" shape="circle"
+                   :custom-style="{width: '55vw', height: '44px'}" @click="handleComplete"/>
+    </view>
+</template>
+
+<script setup>
+import {computed} from 'vue'
+import {createPropDefine} from "@/utils";
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import {useInjectPaperNavigatorRef} from "@/components/mx-paper/components/usePaperNavigatorRefInjection";
+import {useInjectPaperNavigationService} from "@/components/mx-paper/usePaperNavigationServiceInjection";
+
+const props = defineProps({
+    continueName: createPropDefine('')
+})
+
+const {loading, allowAnswer, allowScore, commitPaper, scorePaper, triggerContinueNext} = useInjectPaperService()
+const {navigator} = useInjectPaperNavigatorRef()
+const {uncompletedCheckForSubmit} = useInjectPaperNavigationService()
+
+const text = computed(() => {
+    if (allowAnswer.value) return '完成'
+    if (allowScore.value) return '结束阅卷'
+    return props.continueName || '继续做题'
+})
+
+const handleComplete = async () => {
+    await uncompletedCheckForSubmit(navigator.value)
+
+    if (allowAnswer.value) {
+        commitPaper()
+    } else if (allowScore.value) {
+        scorePaper()
+    } else {
+        triggerContinueNext()
+    }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 43 - 0
components/mx-paper/components/mx-paper-navigator-popup.vue

@@ -0,0 +1,43 @@
+<template>
+    <mx-popup-template :title="title" v-bind="extBinding" ref="popup">
+        <view class="grid grid-cols-4 gap-20">
+            <mx-paper-tab-item v-for="q in list" :item="q" clean-mode class="!h-[44px] !w-auto"
+                               @click="handleNavigateQuestion(q)"/>
+        </view>
+    </mx-popup-template>
+</template>
+
+<script setup>
+import {ref} from 'vue'
+import MxPaperTabItem from "@/components/mx-paper/components/mx-paper-tab-item.vue";
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+
+const popup = ref(null)
+const title = ref('')
+const list = ref([])
+const extBinding = ref({})
+
+const {goToQuestion} = useInjectPaperService()
+
+const handleNavigateQuestion = function (q) {
+    goToQuestion(q)
+    close()
+}
+
+const open = function (questions, description, ext = {left: '', right: ''}) {
+    list.value = questions
+    title.value = description
+    extBinding.value = ext
+    popup.value.open()
+}
+
+const close = function () {
+    popup.value.close()
+}
+
+defineExpose({open, close})
+</script>
+
+<style scoped>
+
+</style>

+ 17 - 0
components/mx-paper/components/mx-paper-progress.vue

@@ -0,0 +1,17 @@
+<template>
+    <mx-progress :progress="progress" class="h-3 shadow-up shadow-green-100"/>
+</template>
+
+<script setup>
+import {computed} from 'vue';
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+
+const {questions} = useInjectPaperService()
+const {answeredQuestions} = useInjectQuestionService()
+const progress = computed(() => questions.value.length ? answeredQuestions.value.length / questions.value.length : 0)
+</script>
+
+<style scoped>
+
+</style>

+ 41 - 0
components/mx-paper/components/mx-paper-tab-item.vue

@@ -0,0 +1,41 @@
+<template>
+    <view class="w-full h-full px-15 fx-col fx-cen-cen rounded relative"
+          :class="tabClass">
+        <text class="text-sm">第{{ item.seq }}题</text>
+        <template v-if="!cleanMode">
+            <view v-if="allowAnswer&&isAnswered(item)"
+                  class="absolute top-5 right-5 h-15 w-15 rounded shadow shadow-green-100 bg-success"/>
+            <template v-else-if="isScored(item)">
+                <uv-icon v-if="isPassed(item)" name="checkmark-circle-fill" size="12" color="var(--success-color)"/>
+                <uv-icon v-else name="close-circle-fill" size="12" color="var(--error-color)"/>
+            </template>
+        </template>
+    </view>
+</template>
+
+<script setup>
+import {computed} from 'vue'
+import {createPropDefine} from "@/utils";
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+
+const props = defineProps({
+    // item is a question
+    item: createPropDefine({}, Object),
+    // clean mode 给弹层用,不用展示状态特效
+    cleanMode: createPropDefine(false, Boolean)
+})
+
+const {current, allowAnswer} = useInjectPaperService()
+const {isAnswered, isScored, isPassed} = useInjectQuestionService()
+
+const isActive = computed(() => current.value == props.item)
+const tabClass = computed(() =>
+    isActive.value && !props.cleanMode ?
+        ['text-primary bg-white shadow shadow-blue-100 bg-white']
+        : ['text-main bg-slate-100 shadow-xs'])
+</script>
+
+<style scoped>
+
+</style>

+ 13 - 0
components/mx-paper/components/usePaperNavigatorRefInjection.js

@@ -0,0 +1,13 @@
+import {injectLocal, provideLocal} from "@vueuse/core";
+
+const key = Symbol('PAPER_NAVIGATOR_POPUP')
+
+export const useProvidePaperNavigatorRef = function (popupRef) {
+    const options = {navigator: popupRef}
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectPaperNavigatorRef = function () {
+    return injectLocal(key)
+}

+ 97 - 0
components/mx-paper/mx-paper.vue

@@ -0,0 +1,97 @@
+<template>
+    <view class="page-content h-screen !bg-white">
+        <mx-nav-bar :title="paperName" :left-click-block="paperQuitBlock"/>
+        <slot name="top"/>
+        <mx-tabs-swiper ref="swiper" v-model="index" :tabs="questions" :tab-options="tabOptions" border
+                        :delay="false" :tabs-height="60" template="question">
+            <template #tab="scope">
+                <mx-paper-tab-item v-bind="scope"/>
+            </template>
+            <template #question="question">
+                <mx-question :question="question"/>
+            </template>
+        </mx-tabs-swiper>
+        <slot name="bottom">
+            <mx-question-score-subjective v-if="showScoreSubjective"/>
+            <mx-question-statistic/>
+            <mx-question-navigator/>
+            <mx-paper-completion v-if="!readonly" :continue-name="continueName"/>
+        </slot>
+        <mx-paper-navigator-popup ref="navigator"/>
+        <uv-safe-bottom/>
+    </view>
+</template>
+
+<script setup>
+import {ref, watch, computed} from 'vue'
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import MxPaperTabItem from "@/components/mx-paper/components/mx-paper-tab-item.vue";
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+import MxPaperCompletion from "@/components/mx-paper/components/mx-paper-completion.vue";
+import MxQuestionScoreSubjective from "@/components/mx-question/components/mx-question-score-subjective.vue";
+import MxQuestionNavigator from "@/components/mx-question/components/mx-question-navigator.vue";
+import MxQuestionStatistic from "@/components/mx-question/components/mx-question-statistic.vue";
+import MxPaperNavigatorPopup from "@/components/mx-paper/components/mx-paper-navigator-popup.vue";
+import {useProvidePaperNavigatorRef} from "@/components/mx-paper/components/usePaperNavigatorRefInjection";
+import {createPropDefine} from "@/utils";
+import {useInjectPaperNavigationService} from "@/components/mx-paper/usePaperNavigationServiceInjection";
+
+const props = defineProps({
+    // 底部按钮
+    readonly: createPropDefine(false, Boolean),
+    continueName: createPropDefine('')
+})
+
+const {
+    paperName,
+    allowScore,
+    questions,
+    index,
+    current,
+    isObjective
+} = useInjectPaperService()
+const {} = useInjectQuestionService()
+const swiper = ref(null)
+const navigator = ref(null)
+const isCurrentSubjective = computed(() => !isObjective(current.value))
+const showScoreSubjective = computed(() => allowScore.value && isCurrentSubjective.value)
+useProvidePaperNavigatorRef(navigator)
+const {uncompletedCheckForQuit} = useInjectPaperNavigationService()
+
+const tabOptions = {
+    scrollable: true,
+    keyName: 'seq',
+    lineHeight: 0.5,
+    lineWidth: 40,
+    lineColor: 'white'
+}
+
+const paperQuitBlock = async () => {
+    await uncompletedCheckForQuit(navigator.value)
+}
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-tabs) {
+    .uv-tabs__wrapper__nav {
+        /* 不能设置__nav的间隔相关属性,会导致uv-tabs内部无法准确计算位置 */
+        /* 重写__item的样式是没有关系的 */
+        &__item {
+            padding: 8px 4px;
+            box-sizing: border-box;
+
+            &:first-child {
+                padding-left: 8px;
+            }
+
+            &:nth-last-child(2) {
+                padding-right: 8px;
+            }
+        }
+
+        &__line {
+            box-shadow: 0px -2px 5px rgba(0, 0, 255, 0.6);
+        }
+    }
+}
+</style>

+ 178 - 0
components/mx-paper/usePaperInjection.js

@@ -0,0 +1,178 @@
+import {computed, ref} from 'vue'
+import {createEventHook, injectLocal, provideLocal} from "@vueuse/core";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {
+    commitExamineePaper,
+    commitExamineeQuestion,
+    scoreExamineeQuestion,
+    scoreFinish
+} from "@/api/webApi/studentEvaluating";
+import mxConst from "@/common/mxConst";
+import _ from "lodash";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+
+const key = Symbol('PAPER_SERVICE')
+
+// 尽量保持只需要依靠paper/paper.questions中计算的相关API在paperService中
+// 与userData计算相关的API集中的questionService中
+export const useProvidePaperService = function (paperDataRef, overrideService = {}) {
+    const paper = paperDataRef
+    // 因为注入服务时paper可能还没有返回,为了方便注入时的有明确的属性名参考,这里将可能有用的属性抄了一遍。
+    const props = {
+        paperName: computed(() => paper.value?.name),
+        allowAnswer: computed(() => paper.value?.allowAnswer),
+        allowScore: computed(() => paper.value?.allowScore),
+        questions: computed(() => paper.value?.questions || []),
+        examineeId: computed(() => paper.value?.examineeId),
+        examineeType: computed(() => paper.value?.examineeType),
+        scoringType: computed(() => paper.value?.scoringType),
+        remaining: computed(() => paper.value?.remaining),
+
+        // for fast query by questionId
+        questionsMap: computed(() => _.keyBy(props.questions.value, 'questionId'))
+    }
+
+    // 事件定义
+    const events = {
+        questionChange: createEventHook(), // display question change(index change)
+        answerChange: createEventHook(), // user answered question
+        answerComplete: createEventHook(), // all questions are answered
+        scoreChange: createEventHook(), // user scored question
+        scoreComplete: createEventHook(), // all questions are scored
+        continueNext: createEventHook() // continue next paper
+    }
+
+    // data & service define
+    const index = ref(0)
+    const loading = ref(false)
+    const current = computed(() => props.questions.value[index.value] || {})
+    const hasNext = computed(() => index.value < props.questions.value.length - 1)
+    const hasPrev = computed(() => index.value > 0)
+
+    // methods
+    const methods = {
+        reset: () => {
+            paper.value = {}
+            index.value = 0
+            loading.value = false
+        },
+        isObjective: (q) => mxConst.question.isObjective(q.typeId),
+        isCheckbox: (q) => mxConst.question.isCheckbox(q.typeId),
+        isRadio: (q) => mxConst.question.isRadio(q.typeId),
+        isAnswerCorrect: (q) => q.answers?.length && q.answer == q.answers[0].toString(),
+        tryGoNext: () => {
+            if (!hasNext.value) return toast('已经是最后一题了')
+            index.value += 1
+        },
+        tryGoPrev: () => {
+            if (!hasPrev.value) return toast('已经是第一题了')
+            index.value -= 1
+        },
+        goToQuestion: (q) => {
+            const idx = props.questions.value.indexOf(q)
+            if (idx > -1) index.value = idx
+        },
+        commitQuestion: async (commits) => {
+            if (empty(commits)) return
+            if (loading.value) return
+            try {
+                loading.value = true
+                const examineeId = props.examineeId.value
+                const examineeType = props.examineeType.value
+                const commit = {examineeId, examineeType, questions: commits}
+                await commitExamineeQuestion(commit)
+                await events.answerChange.trigger(commits)
+            } finally {
+                loading.value = false
+            }
+        },
+        commitPaper: async () => {
+            if (loading.value) return
+            try {
+                // uni.showLoading() // 不需要,交卷时有自定义弹窗
+                loading.value = true
+                const examineeId = props.examineeId.value
+                const examineeType = props.examineeType.value
+                const commit = {examineeId, examineeType}
+                await commitExamineePaper(commit)
+                await events.answerComplete.trigger()
+            } finally {
+                loading.value = false
+                // uni.hideLoading()
+            }
+        },
+        scoreQuestion: async (commits) => {
+            if (empty(commits)) return
+            if (loading.value) return
+            try {
+                loading.value = true
+                const examineeId = props.examineeId.value
+                const examineeType = props.examineeType.value
+                const commit = {examineeId, examineeType, questions: commits}
+                await scoreExamineeQuestion(commit)
+                await events.scoreChange.trigger(commits)
+            } finally {
+                loading.value = false
+            }
+        },
+        scorePaper: async () => {
+            if (loading.value) return
+            try {
+                uni.showLoading()
+                loading.value = true
+                const examineeId = props.examineeId.value
+                const examineeType = props.examineeType.value
+                const commit = {examineeId, examineeType}
+                await scoreFinish(commit)
+                await events.scoreComplete.trigger()
+            } finally {
+                loading.value = false
+                uni.hideLoading()
+            }
+        },
+        triggerContinueNext: async () => {
+            await events.continueNext.trigger()
+        }
+    }
+
+    // api that should combine `props` & `methods`
+    const extend = {
+        isAllObjective: computed(() => {
+            return props.questions.value.every(q => methods.isObjective(q))
+        }),
+        answerCompletedTips: computed(() => {
+            const type = props.scoringType.value + ''
+            const source = {
+                "1": `客观题已自动计分,主观题需要手动计分`,
+                "2": `此卷为老师阅卷,需要学生交卷后老师进行阅卷计分`,
+                "3": `此卷为系统阅卷,提交后系统自动计分`
+            }
+            return source[type] || source['1']
+        })
+    }
+
+    const options = {
+        loading,
+        paper,
+        ...props,
+        onAnswerChange: events.answerChange.on,
+        onAnswerComplete: events.answerComplete.on,
+        onScoreChange: events.scoreChange.on,
+        onScoreComplete: events.scoreComplete.on,
+        onContinueNext: events.continueNext.on,
+        index,
+        current,
+        hasNext,
+        hasPrev,
+        ...methods,
+        ...extend,
+        ...overrideService
+    }
+
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectPaperService = function () {
+    return injectLocal(key)
+}

+ 134 - 0
components/mx-paper/usePaperNavigationServiceInjection.js

@@ -0,0 +1,134 @@
+import {injectLocal, provideLocal} from "@vueuse/core";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import _ from "lodash";
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+
+const key = Symbol('PAPER_NAVIGATION_SERVICE')
+
+export const useProvidePaperNavigationService = (paperService, questionService, overrideService = {}) => {
+
+    const {goToQuestion, allowAnswer, allowScore, commitPaper, scorePaper} = useInjectPaperService()
+    const {unansweredQuestions, unscoredQuestions, doChunk} = useInjectQuestionService()
+
+    const questionsNavigationHandler = (navigator, list, label) => {
+        if (!list.length) return toast(`没有${label}的题`)
+        if (list.length == 1) return goToQuestion(_.first(list))
+        navigator.open(list, `${label}的题`)
+    }
+
+    const uncompletedCheckForQuit = async (navigator) => {
+        await doChunk()
+        return new Promise((resolve, reject) => {
+            if (allowAnswer.value) {
+                if (unansweredQuestions.value.length) {
+                    navigator.open(unansweredQuestions.value, '您还有未做的题', {
+                        left: '强制退出',
+                        right: '继续做题',
+                        onLeft: () => {
+                            navigator.close()
+                            resolve() // 允许用户继续执行后续逻辑
+                        },
+                        onRight: () => {
+                            navigator.close()
+                            goToQuestion(_.first(unansweredQuestions.value))
+                            reject('操作终止,用户选择继续做题')
+                        }
+                    })
+                } else {
+                    navigator.open([], '提示', {
+                        description: '您已经做完所有题,是否完成提交',
+                        left: '强制退出',
+                        right: '完成提交',
+                        onLeft: () => {
+                            navigator.close()
+                            resolve()
+                        },
+                        onRight: () => {
+                            navigator.close()
+                            commitPaper()
+                            reject('操作终止,用户选择完成提交')
+                        }
+                    })
+                }
+            } else if (allowScore.value) {
+                if (unscoredQuestions.value.length) {
+                    navigator.open(unscoredQuestions.value, '您还有未阅的题', {
+                        left: '强制退出',
+                        right: '继续阅卷',
+                        onLeft: () => {
+                            navigator.close()
+                            resolve() // 允许用户继续执行后续逻辑
+                        },
+                        onRight: () => {
+                            navigator.close()
+                            goToQuestion(_.first(unscoredQuestions.value))
+                            reject('操作终止,用户选择继续阅卷')
+                        }
+                    })
+                } else {
+                    navigator.open([], '提示', {
+                        description: '您已经阅完所有题,是否完成阅卷',
+                        left: '强制退出',
+                        right: '完成阅卷',
+                        onLeft: () => {
+                            navigator.close()
+                            resolve()
+                        },
+                        onRight: () => {
+                            navigator.close()
+                            scorePaper()
+                            reject('操作终止,用户选择完成阅卷')
+                        }
+                    })
+                }
+            } else {
+                resolve()
+            }
+        })
+    }
+
+    const uncompletedCheckForSubmit = async (navigator) => {
+        await doChunk()
+        return new Promise((resolve, reject) => {
+            if (allowAnswer.value) {
+                if (unansweredQuestions.value.length) {
+                    navigator.open(unansweredQuestions.value, '您还有未做的题', {
+                        left: '仍然提交',
+                        right: '继续做题',
+                        onLeft: () => {
+                            navigator.close()
+                            resolve() // 题没有做完是可以强制交卷的!
+                        },
+                        onRight: () => {
+                            navigator.close()
+                            goToQuestion(_.first(unansweredQuestions.value))
+                            reject('操作终止,用户选择继续做题') // 注意这里也是reject
+                        }
+                    })
+                    return // 只要有题未做,弹层都会阻止原提交行为
+                }
+            } else if (allowScore.value) {
+                if (unscoredQuestions.value.length) {
+                    navigator.open(unscoredQuestions.value, '未阅的题')
+                    reject() // 阅卷时必须给所有题打完分才行
+                    return
+                }
+            }
+            resolve()
+        })
+    }
+
+    const options = {
+        questionsNavigationHandler,
+        uncompletedCheckForQuit,
+        uncompletedCheckForSubmit,
+        ...overrideService
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectPaperNavigationService = () => {
+    return injectLocal(key)
+}

+ 127 - 0
components/mx-picker/mx-picker.vue

@@ -0,0 +1,127 @@
+<template>
+    <view class="flex-1 fx-row fx-end-cen gap-10" @click="handleClick">
+        <view v-if="valueMode" class="text-sm">{{ display }}</view>
+        <view v-else class="text-sm text-light">{{ placeholder }}</view>
+        <uv-icon name="arrow-down"/>
+        <uv-picker ref="picker" :columns="columns" :default-index="defaultIndex" :title="title" :key-name="labelProp"
+                   @confirm="handleConfirm" @change="handleChange"/>
+    </view>
+</template>
+
+<script setup>
+import {computed, getCurrentInstance, ref} from 'vue'
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import _ from "lodash";
+import {createPropDefine} from "@/utils";
+import {findTreePath} from "@/utils/tree-helper";
+import {autoFormValidate} from "@/utils/uni-helper";
+
+const picker = ref(null)
+const props = defineProps({
+    title: createPropDefine(''),
+    placeholder: createPropDefine(''),
+    data: createPropDefine([], Array),
+    modelValue: createPropDefine(null, [String, Number, Object]),
+    labelProp: createPropDefine(''),
+    valueProp: createPropDefine(''),
+    emptyDisplay: createPropDefine(''),
+    treeProp: createPropDefine('')
+})
+const emits = defineEmits(['change', 'update:modelValue'])
+
+const instance = getCurrentInstance()
+const valueMode = computed(() => !empty(props.modelValue) || !!props.emptyDisplay)
+const display = computed(() => {
+    if (empty(props.modelValue) && !!props.emptyDisplay) return props.emptyDisplay
+    return getLabel(current.value) || props.emptyDisplay
+})
+
+const currentPath = computed(() => findTreePath(props.data, item => getValue(item) == props.modelValue, props.treeProp))
+const current = computed(() => _.last(currentPath.value))
+const columns = ref([])
+const defaultIndex = ref([])
+
+const handleClick = function () {
+    setDefaultColumnsAndIndexs()
+    picker.value.open()
+}
+
+// columnIndex 正在改变的列 > index 改变后的序号 > indexs 当前选中的全部序号
+// > value 当前选中的全部元素 > values 当前columns
+const handleConfirm = function ({value}) {
+    const selected = _.last(value)
+    const selectedValue = getValue(selected)
+    emits('update:modelValue', selectedValue)
+    emits('change', selected)
+
+    // try trigger uv-form validate
+    autoFormValidate(instance, 'change')
+}
+
+const handleChange = function ({columnIndex, value}) {
+    if (!props.treeProp) return // 不是树形结构
+    if (columnIndex == value.length - 1) return // 操作的是最后一列
+    const changToValue = getValue(value[columnIndex])
+    const {cols, idxes} = calculateColumnsAndIndexs(changToValue)
+    console.log('calculateColumnsAndIndexs', cols, idxes)
+    columns.value = cols
+    picker.value.setIndexs(idxes, true)
+}
+
+const setDefaultColumnsAndIndexs = function () {
+    const {cols, idxes} = calculateColumnsAndIndexs(props.modelValue)
+    columns.value = cols
+    defaultIndex.value = idxes
+}
+
+/*
+* @description 根据给定的值计算picker所需要的columns和indexs
+* */
+const calculateColumnsAndIndexs = function (value, autoEnd = true) {
+    const {data, treeProp} = props
+    const cols = []
+    const idxes = []
+    const path = findTreePath(data, n => getValue(n) == value, treeProp)
+    // auto end // 指定的条件,可能查出来的是中间某列的值,此时自动帮它选中至最后一列
+    if (autoEnd && treeProp && path?.length) {
+        let final = _.last(path)
+        while (final[treeProp]?.length) {
+            final = final[treeProp][0]
+            path.push(final)
+        }
+    }
+    // modelValue logic
+    let prevNode = data
+    _.forEach(path, item => {
+        const idx = prevNode.findIndex(n => getValue(n) == getValue(item))
+        if (idx < 0) {
+            // 无效值,停止迭代
+            cols.length = 0
+            idxes.length = 0
+            return false // lodash.forEach接收到false返回会终止迭代
+        }
+        cols.push(prevNode)
+        idxes.push(idx)
+        prevNode = _.get(prevNode, idx + '.' + treeProp)
+    })
+    // default logic
+    if (!cols.length && data.length) {
+        prevNode = data
+        do {
+            cols.push(prevNode)
+            idxes.push(0)
+            prevNode = _.get(prevNode, '0.' + props.treeProp)
+        } while (prevNode)
+    }
+    return {cols, idxes}
+}
+
+const getValue = item => _.get(item, props.valueProp, item)
+const getLabel = item => _.get(item, props.labelProp, item)
+
+defineExpose({current, currentPath})
+</script>
+
+<style scoped>
+
+</style>

+ 45 - 0
components/mx-popup-template/mx-popup-template.vue

@@ -0,0 +1,45 @@
+<template>
+    <uv-popup ref="popup" v-bind="props" :safe-area-inset-bottom="mode=='bottom'"
+              @change="$emit('change',$event)" @mask-click="$emit('maskClick', $event)">
+        <view :class="containerClass">
+            <view class="mb-40 text-main text-xl text-center">{{ title }}</view>
+            <view v-if="description" class="mb-30 text-content">{{ description }}</view>
+            <scroll-view scroll-y style="max-height: 50vh">
+                <slot>
+                    <uv-parse v-if="content" :content="content" container-style="color:var(--main-color)"/>
+                </slot>
+            </scroll-view>
+            <mx-bottom-buttons :left="left" :right="right" :loading="loading" @left="$emit('left')"
+                               @right="$emit('right')" class="mt-40"/>
+        </view>
+    </uv-popup>
+</template>
+
+<script setup>
+import {ref} from 'vue'
+import {popupProps} from "@/uni_modules/uv-popup/components/uv-popup/uv-popup.vue";
+import {createPropDefine} from "@/utils";
+
+const props = defineProps({
+    ...popupProps,
+    mode: createPropDefine('center'),
+    round: createPropDefine(16, [Number, String]),
+    title: createPropDefine(''),
+    description: createPropDefine(''),
+    content: createPropDefine(''),
+    // left/right 没有默认值,不会显示底部按钮
+    left: createPropDefine('取消'),
+    right: createPropDefine('保存'),
+    loading: createPropDefine(false, Boolean),
+    containerClass: createPropDefine('w-[80vw] px-30 py-40')
+})
+const emits = defineEmits(['change', 'maskClick', 'left', 'right'])
+
+const popup = ref(null)
+
+defineExpose({open: () => popup.value.open(), close: () => popup.value.close()})
+</script>
+
+<style scoped>
+
+</style>

+ 21 - 0
components/mx-progress/mx-progress.vue

@@ -0,0 +1,21 @@
+<template>
+    <view class="progress-bar flex rounded" :style="{backgroundColor: bgColor}">
+        <view class="progress-bar-thumb h-full rounded" :style="{flex: progress, backgroundColor: activeColor}"></view>
+    </view>
+</template>
+
+<script setup>
+import {createPropDefine} from "@/utils";
+
+defineProps({
+    progress: createPropDefine(0, Number),
+    bgColor: createPropDefine('#CBFCEB'),
+    activeColor: createPropDefine('#0FD296')
+})
+</script>
+
+<style scoped lang="scss">
+.progress-bar-thumb {
+    transition: flex 0.5s ease;
+}
+</style>

+ 20 - 0
components/mx-question-content/components/mx-question-plain-option-group.vue

@@ -0,0 +1,20 @@
+<template>
+    <view class="flex flex-col">
+        <slot>
+            <text>{{ label }}</text>
+        </slot>
+    </view>
+</template>
+
+<script setup>
+import {createPropDefine} from "@/utils";
+
+defineProps({
+    name: createPropDefine(''),
+    label: createPropDefine('')
+})
+</script>
+
+<style scoped>
+
+</style>

+ 15 - 0
components/mx-question-content/components/mx-question-plain-option.vue

@@ -0,0 +1,15 @@
+<template>
+    <view>
+        <slot/>
+    </view>
+</template>
+
+<script>
+export default {
+    name: "mx-question-plain-option"
+}
+</script>
+
+<style scoped>
+
+</style>

+ 97 - 0
components/mx-question-content/components/mx-question-subjective.vue

@@ -0,0 +1,97 @@
+<template>
+    <view class="py-20 fx-col gap-20">
+        <view v-if="!disabled" class="fx-row fx-bet-cen">
+            <view class="fx-row">
+                <!-- NOTE:uv-subsection必须有外部宽度才能正常工作 -->
+                <mx-subsection v-model="current" :list="list" key-name="text" width="50vw" @change="handleTypeChange">
+                    <template #="{item,style}">
+                        <view class="flex flex-row items-center gap-5">
+                            <uv-icon :name="item.icon" :color="style.color"/>
+                            <text>{{ item.text }}</text>
+                        </view>
+                    </template>
+                </mx-subsection>
+            </view>
+            <uv-tags icon="reload" type="error" plain text="重做" custom-style="height:28px" @click="handleRedo"/>
+        </view>
+        <uv-textarea v-if="current==0" :model-value="modelValue" :disabled="disabled" height="150" :count="!disabled"
+                     maxlength="500" :placeholder="disabled?'':'这里输入答案'"
+                     @update:modelValue="handleAnswerChange"/>
+        <view v-else class="h-[169px] p-[10px] box-border mx-border rounded fx-col relative">
+            <text v-if="imageLimit>1" class="absolute bottom-[2px] right-[5px] text-tips text-2xs">
+                {{ attachments.length }}/{{ imageLimit }}
+            </text>
+            <view class="flex-1 grid gap-10" :class="`grid-cols-${imageLimit}`">
+                <view v-for="(i, idx) in attachments">
+                    <uv-image :src="i" width="100%" height="auto" mode="widthFix"
+                              @click="previewImage(attachments, idx)"/>
+                </view>
+                <view v-if="allowImages&&!disabled" class="fx-col fx-cen-cen">
+                    <uv-icon name="plus" size="60" color="var(--light-color)" @click="handleAddImage"/>
+                </view>
+            </view>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {ref, onMounted, computed} from 'vue'
+import {createPropDefine} from "@/utils";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import {useChooseImage} from "@/hooks/useChooseImage";
+
+const props = defineProps({
+    modelValue: createPropDefine(''),
+    attachments: createPropDefine([], [Array, String]),
+    disabled: createPropDefine(false, Boolean)
+})
+const emits = defineEmits(['update:modelValue', 'update:attachments'])
+
+const current = ref(0)
+const imageLimit = ref(3)
+const list = [
+    {icon: 'edit-pen', text: '手动答题'},
+    {icon: 'camera', text: '拍照答题'}
+]
+const {chooseImage, previewImage} = useChooseImage()
+const noImages = computed(() => empty(props.attachments))
+const allowImages = computed(() => imageLimit.value - props.attachments.length)
+
+const handleTypeChange = (index) => {
+    if (index == 1 && noImages.value) handleAddImage()
+}
+
+const handleAddImage = () => {
+    if (allowImages.value <= 0) return toast(`最多上传${imageLimit.value}张图片!`)
+    chooseImage((result) => {
+        const arr = []
+        if (!empty(props.attachments)) arr.push(...props.attachments)
+        handleAttachmentsChange(arr.concat(result.msg))
+    }, 6, 1)
+}
+
+const handleAnswerChange = (val) => {
+    emits('update:modelValue', val)
+    emits('update:attachments', [])
+}
+
+const handleAttachmentsChange = (val) => {
+    emits('update:modelValue', '')
+    emits('update:attachments', val)
+}
+
+const handleRedo = () => {
+    emits('update:modelValue', '')
+    emits('update:attachments', [])
+}
+
+onMounted(() => {
+    // 初始化时,根据当前答题情况选中
+    if (!empty(props.attachments)) current.value = 1
+})
+</script>
+
+<style scoped>
+
+</style>

+ 173 - 0
components/mx-question-content/mx-question-content.vue

@@ -0,0 +1,173 @@
+<template>
+    <view ref="questionEl" class="mx-question-content flex flex-col gap-40 text-content">
+        <view v-html="titleWithSeq"/>
+        <view class="mx-question-content-opt" :class="{'pl-30': deep>0}">
+            <template v-if="question.options?.length">
+                <slot name="options" v-bind="{options: optionsWithCode, deep, disabled}">
+                    <component :is="optGroupComponent" :modelValue="modelValue" :disabled="disabled" placement="column"
+                               :class="groupClass" @change="emits('update:modelValue', $event)">
+                        <component :is="optComponent" v-for="op in optionsWithCode" :name="op.code"
+                                   :class="calcOptionClass(op)">
+                            <view v-html="op.option" :class="textClass"/>
+                        </component>
+                    </component>
+                </slot>
+            </template>
+            <template v-if="question.subQuestions?.length">
+                <mx-question-content v-for="sub in question.subQuestions" :question="sub" :deep="deep+1"
+                                     :disabled="disabled">
+                    <template v-for="(fn, name) in $slots" #[name]="scope">
+                        <slot :name="name" v-bind="scope"/>
+                    </template>
+                </mx-question-content>
+            </template>
+        </view>
+        <mx-question-subjective v-if="useSubjectiveStyle" :modelValue="modelValue" :attachments="attachments"
+                                :disabled="disabled" @update:modelValue="emits('update:modelValue', $event)"
+                                @update:attachments="emits('update:attachments', $event)"/>
+    </view>
+</template>
+
+<script setup>
+import {computed, onMounted, ref, watch} from 'vue'
+import {createPropDefine} from "@/utils";
+import _ from 'lodash';
+import UvCheckboxGroup from "@/uni_modules/uv-checkbox/components/uv-checkbox-group/uv-checkbox-group.vue";
+import UvRadioGroup from "@/uni_modules/uv-radio/components/uv-radio-group/uv-radio-group.vue";
+import MxQuestionPlainOptionGroup from "@/components/mx-question-content/components/mx-question-plain-option-group.vue";
+import UvRadio from "@/uni_modules/uv-radio/components/uv-radio/uv-radio.vue";
+import UvCheckbox from "@/uni_modules/uv-checkbox/components/uv-checkbox/uv-checkbox.vue";
+import MxQuestionPlainOption from "@/components/mx-question-content/components/mx-question-plain-option.vue";
+import mxConst from "@/common/mxConst";
+import MxQuestionSubjective from "@/components/mx-question-content/components/mx-question-subjective.vue";
+import {useMathJaxService} from "@/hooks/useMathJaxService";
+import {useInjectQuestionOptionFormatter} from "@/components/mx-question-content/useQuestionOptionInjection";
+import {useInjectMathJaxSwitch} from "@/components/mx-question-content/useMathJaxSwitchInjection";
+import {func} from "@/uni_modules/uv-ui-tools/libs/function/test";
+
+/*NOTE:递归调用目前没有做事件穿透,所以子题还不支持作答
+NOTE: mx-question-content 支持MathJax
+* */
+
+const props = defineProps({
+    question: createPropDefine({}, Object),
+    deep: createPropDefine(0, Number),
+    disabled: createPropDefine(false, Boolean),
+    modelValue: createPropDefine('', [String, Number, Array]),
+    attachments: createPropDefine([], [String, Array]),
+    sysAnswer: createPropDefine('', [String, Array]),
+    readonly: createPropDefine(false, Boolean)
+})
+const emits = defineEmits(['update:modelValue', 'update:attachments'])
+
+const questionEl = ref(null)
+const {updateMathJax} = useMathJaxService()
+const {formatter, optionClass} = useInjectQuestionOptionFormatter()
+const {disabled: disabledMathJax} = useInjectMathJaxSwitch()
+
+const titleWithSeq = computed(() => {
+    const {title, seq, type, questionId} = props.question
+    const arr = []
+    if (seq) arr.push(`(${seq})`)
+    if (type) arr.push(`[${type}]`)
+    if (questionId) arr.push(`[${questionId}]`)
+    arr.push(title)
+    return arr.join('')
+})
+
+const optionsWithCode = computed(() => {
+    const {options} = props.question
+    return formatter(options)
+})
+
+const useMultipleStyle = computed(() => {
+    const {deep, readonly, question: {typeId}} = props
+    return deep == 0 && !readonly && mxConst.question.isCheckbox(typeId)
+})
+
+const useRadioStyle = computed(() => {
+    // 子题都算主观题, 学生手写作答
+    const {deep, readonly, question: {typeId}} = props
+    return deep == 0 && !readonly && mxConst.question.isRadio(typeId)
+})
+
+const useSubjectiveStyle = computed(() => {
+    // 一级题的主观题渲染输入框
+    const {deep, readonly, question: {typeId}} = props
+    return deep == 0 && !readonly && !mxConst.question.isObjective(typeId)
+})
+
+const optGroupComponent = computed(() => {
+    if (useRadioStyle.value) return UvRadioGroup
+    if (useMultipleStyle.value) return UvCheckboxGroup
+    return MxQuestionPlainOptionGroup // 为了对齐UvCheckboxGroup/UvRadioGroup
+})
+
+const optComponent = computed(() => {
+    if (useRadioStyle.value) return UvRadio
+    if (useMultipleStyle.value) return UvCheckbox
+    return MxQuestionPlainOption // 为了对齐UvCheckbox/UvRadio
+})
+
+const groupClass = computed(() => {
+    return ['gap-20']
+})
+
+const textClass = computed(() => {
+    return []
+})
+
+onMounted(() => updateMathJaxIfNeeded())
+watch(() => props.question, () => updateMathJaxIfNeeded())
+
+const updateMathJaxIfNeeded = () => {
+    if (disabledMathJax) return
+    updateMathJax(questionEl.value?.$el)
+}
+
+const calcOptionClass = function (op) {
+    const defaultClass = 'px-15 py-20 border border-solid rounded'
+    const injectClass = func(optionClass) ? optionClass(props.question) : optionClass
+    const classes = [injectClass === undefined ? defaultClass : injectClass]
+    if (props.sysAnswer) {
+        // 如果有传入标准答案,则突出显示正确错误
+        if (op.code == props.modelValue || props.modelValue.includes(op.code)) {
+            // classes.push('border-primary')
+            if (op.code != props.sysAnswer && !props.sysAnswer.includes(op.code)) {
+                // 答错了
+                classes.push('bg-[#f9e6e6] text-[#571515] border-[#e6c3c3]')
+            }
+        }
+        if (op.code == props.sysAnswer || props.sysAnswer.includes(op.code)) {
+            // 正确答案
+            classes.push('bg-[#e0f9e6] text-[#155724] border-[#c3e6cb]')
+        }
+    } else {
+        classes.push('border-border')
+    }
+    return classes
+}
+
+const tryRandomAnswer = () => {
+    if (useRadioStyle.value) {
+        // 随机选1个
+        const rd = _.sample(optionsWithCode.value)
+        emits('update:modelValue', rd.code)
+    } else if (useMultipleStyle.value) {
+        // 随机选N个
+        const num = _.random(1, optionsWithCode.value.length + 1)
+        const rd = _.sampleSize(optionsWithCode.value, num)
+        // 与原选项保持顺序并提取code
+        const codes = optionsWithCode.value.filter(i => rd.includes(i)).map(i => i.code)
+        emits('update:modelValue', codes)
+    }
+}
+
+defineExpose({tryRandomAnswer})
+</script>
+
+<style scoped lang="scss">
+.mx-question-content + .mx-question-content {
+    margin-top: 40rpx;
+}
+</style>

+ 14 - 0
components/mx-question-content/useMathJaxSwitchInjection.js

@@ -0,0 +1,14 @@
+import {injectLocal, provideLocal} from "@vueuse/core";
+
+const key = Symbol('MATH_JAX_SWITCH')
+
+export const useProvideMathJaxSwitch = (disabled = false) => {
+    const options = {disabled}
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectMathJaxSwitch = () => {
+    // 默认是开放公式的,除非明确不会用到公式,否则不用主动注入该开关
+    return injectLocal(key, {disabled: false})
+}

+ 25 - 0
components/mx-question-content/useQuestionOptionInjection.js

@@ -0,0 +1,25 @@
+import {func} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {injectLocal, provideLocal} from "@vueuse/core";
+import {numberToLetter} from "@/utils";
+
+const key = Symbol('QUESTION_OPTION_SERVICE')
+
+export const useProvideQuestionOptionFormatter = function (formatter, optionClass) {
+    if (!func(formatter)) throw new Error('formatter must be a function')
+    const options = {formatter, optionClass}
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectQuestionOptionFormatter = function () {
+    // 提供了默认注入,默认注入是题库的组织格式
+    return injectLocal(key, {
+        formatter: (options) => {
+            return options.map((opt, idx) => {
+                const code = numberToLetter(idx)
+                return {code, option: code + '、' + opt}
+            })
+        },
+        optionClass: undefined
+    })
+}

+ 36 - 0
components/mx-question/components/mx-question-collect.vue

@@ -0,0 +1,36 @@
+<template>
+    <mx-tag-button :text="collected?'已收藏':'收藏'" :icon="collected?'heart-fill':'heart'" @click="handleToggle"/>
+</template>
+
+<script setup>
+import {computed} from 'vue'
+import {createPropDefine} from "@/utils";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import {questionCancelCollect, questionCollect} from "@/api/webApi/webQue";
+
+const props = defineProps({
+    question: createPropDefine({}, Object)
+})
+const emits = defineEmits(['change'])
+
+const id = computed(() => props.question.questionId || props.question.id)
+const collected = computed(() => props.question.collect)
+let loading = false
+
+const handleToggle = async () => {
+    if (loading) return toast('请稍候')
+    try {
+        loading = true
+        const op = collected.value ? questionCancelCollect : questionCollect
+        await op(id.value)
+        props.question.collect = !collected.value
+        emits('change', props.question.collect)
+    } finally {
+        loading = false
+    }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 51 - 0
components/mx-question/components/mx-question-correct-popup.vue

@@ -0,0 +1,51 @@
+<template>
+    <mx-popup-template ref="popup" title="问题纠错" right="提交" @left="close" @right="handleSubmit">
+        <uv-form ref="form" :model="model" :rules="rules">
+            <mx-form-item v-model="model.questionid" disabled label="问题编号"/>
+            <uv-form-item prop="remark">
+                <uv-textarea v-model="model.remark" count maxlength="200" placeholder="在这里填写问题描述"/>
+            </uv-form-item>
+        </uv-form>
+    </mx-popup-template>
+</template>
+
+<script setup>
+import {ref} from 'vue'
+import {correctQuestion} from "@/api/webApi/front";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+
+const popup = ref(null)
+const form = ref(null)
+const model = ref({questionid: '', remark: ''})
+const loading = ref(false)
+const rules = {
+    remark: [{required: true, message: '问题描述不能为空'}, {max: 200, message: '问题描述不超过200字'}]
+}
+
+const open = (question) => {
+    const id = question.questionId || question.id
+    if (id != model.value.questionid) {
+        // reset when question changed
+        model.value.questionid = id
+        model.value.remark = ''
+    }
+    popup.value.open()
+}
+
+const close = () => {
+    popup.value.close()
+}
+
+const handleSubmit = async () => {
+    await form.value.validate()
+    await correctQuestion(model.value)
+    toast('保存成功,等待工作人员处理。')
+    model.value.remark = '' // clean if success commit.
+    close()
+}
+defineExpose({open, close})
+</script>
+
+<style scoped>
+
+</style>

+ 26 - 0
components/mx-question/components/mx-question-correct.vue

@@ -0,0 +1,26 @@
+<template>
+    <mx-tag-button text="纠错" icon="info-circle" @click="openCorrectPopup"/>
+</template>
+
+<script setup>
+import {computed} from 'vue'
+import {createPropDefine} from "@/utils";
+import {useSingletonCorrectQuestion} from "@/hooks/useSingletonComponent";
+
+const props = defineProps({
+    question: createPropDefine({}, Object)
+})
+const emits = defineEmits(['change'])
+
+const id = computed(() => props.question.questionId || props.question.id)
+
+const openCorrectPopup = () => {
+    if (!id.value) return
+    const correctPopup = useSingletonCorrectQuestion()
+    correctPopup.open(props.question)
+}
+</script>
+
+<style scoped>
+
+</style>

+ 19 - 0
components/mx-question/components/mx-question-navigator.vue

@@ -0,0 +1,19 @@
+<template>
+    <view class="h-[50px] grid grid-cols-4 items-center bg-bg">
+        <mx-question-collect :question="current"/>
+        <mx-question-correct :question="current"/>
+        <mx-tag-button :type="hasPrev?'primary':'info'" icon="arrow-left" text="上一题" @click="tryGoPrev"/>
+        <mx-tag-button :type="hasNext?'primary':'info'" icon="arrow-right" text="下一题" @click="tryGoNext"/>
+    </view>
+</template>
+
+<script setup>
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import MxQuestionCorrect from "@/components/mx-question/components/mx-question-correct.vue";
+import MxQuestionCollect from "@/components/mx-question/components/mx-question-collect.vue";
+
+const {current, hasNext, hasPrev, tryGoNext, tryGoPrev} = useInjectPaperService()
+</script>
+
+<style scoped lang="scss">
+</style>

+ 55 - 0
components/mx-question/components/mx-question-parse.vue

@@ -0,0 +1,55 @@
+<template>
+    <view ref="parseEl" class="mx-question-parse fx-col text-content gap-40">
+        <view v-if="question.knowledge">
+            <view class="text-primary">[知识点]</view>
+            <view class="mt-10" v-html="question.knowledge"/>
+        </view>
+        <view v-if="sysAnswer">
+            <view class="text-primary">[正确答案]</view>
+            <view class="mt-10" v-html="sysAnswer"/>
+        </view>
+        <view v-if="question.parse">
+            <view class="text-primary">[解析]</view>
+            <view class="mt-10" v-html="question.parse"/>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {computed, onMounted, ref, watch} from 'vue'
+import {createPropDefine} from "@/utils";
+import {array, empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {useMathJaxService} from "@/hooks/useMathJaxService";
+import {useInjectMathJaxSwitch} from "@/components/mx-question-content/useMathJaxSwitchInjection";
+
+/*
+NOTE: mx-question-parse 支持MathJax
+* */
+
+const props = defineProps({
+    question: createPropDefine({}, Object)
+})
+
+const parseEl = ref(null)
+const {updateMathJax} = useMathJaxService()
+const {disabled: disabledMathJax} = useInjectMathJaxSwitch()
+
+const sysAnswer = computed(() => {
+    if (array(props.question.answers) && !empty(props.question.answers)) {
+        return props.question.answers[0]
+    }
+    return ''
+})
+
+onMounted(() => updateMathJaxIfNeeded())
+watch(() => props.question, () => updateMathJaxIfNeeded())
+
+const updateMathJaxIfNeeded = () => {
+    if (disabledMathJax) return
+    updateMathJax(parseEl.value?.$el)
+}
+</script>
+
+<style scoped>
+
+</style>

+ 52 - 0
components/mx-question/components/mx-question-score-subjective.vue

@@ -0,0 +1,52 @@
+<template>
+    <view class="h-[50px] px-30 fx-row fx-bet-cen bg-bg">
+        <view class="text-tips text-sm">请为该题评分</view>
+        <view class="fx-row items-center text-content gap-10">
+            <text>错误</text>
+            <uv-switch v-model="wrongSwitchOn" active-color="var(--error-color)" @change="handleWrong"/>
+            <text class="ml-10">正确</text>
+            <uv-switch v-model="correctSwitchOn" active-color="var(--success-color)" @change="handleCorrect"/>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {ref, watch} from 'vue'
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+
+const {current} = useInjectPaperService()
+const {getUserData, isScored, isPassed} = useInjectQuestionService()
+
+const wrongSwitchOn = ref(false)
+const correctSwitchOn = ref(false) // 通过只需要1个判定就可以了
+
+watch(current, () => {
+    wrongSwitchOn.value = isScored(current.value) && !isPassed(current.value)
+    correctSwitchOn.value = isPassed(current.value)
+}, {immediate: true})
+
+const handleWrong = async () => {
+    const userData = getUserData(current.value)
+    if (wrongSwitchOn.value) {
+        correctSwitchOn.value = false
+        userData.score = 0
+    } else {
+        userData.score = ''
+    }
+}
+
+const handleCorrect = async () => {
+    const userData = getUserData(current.value)
+    if (correctSwitchOn.value) {
+        wrongSwitchOn.value = false
+        userData.score = current.value.scoreTotal * 1
+    } else {
+        userData.score = ''
+    }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 123 - 0
components/mx-question/components/mx-question-statistic.vue

@@ -0,0 +1,123 @@
+<template>
+    <view class="h-[50px] px-30 bg-bg flex items-end">
+        <view class="flex-1 grid items-center gap-30" :class="`grid-cols-${tags.length}`">
+            <view v-for="tag in tags" :class="tag.clazz" @click="tag.handler"
+                  class="h-[44px] fx-row fx-cen-cen gap-5 shadow-sm bg-white rounded">
+                <text class="text-3xs text-tips -mb-10">{{ tag.label }}</text>
+                <text class="text-2xl" :class="tag.valueClazz">{{ tag.value }}</text>
+            </view>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {ref, watch} from 'vue'
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+import {fnPlaceholder} from "@/utils/uni-helper";
+import _ from "lodash";
+import {useInjectPaperNavigatorRef} from "@/components/mx-paper/components/usePaperNavigatorRefInjection";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import {useInjectPaperNavigationService} from "@/components/mx-paper/usePaperNavigationServiceInjection";
+
+const {
+    allowAnswer,
+    allowScore,
+    questions
+} = useInjectPaperService()
+const {
+    answeredQuestions,
+    unansweredQuestions,
+    scoredQuestions,
+    unscoredQuestions,
+    passedQuestions,
+    unpassedQuestions,
+} = useInjectQuestionService()
+const {navigator} = useInjectPaperNavigatorRef()
+const {questionsNavigationHandler} = useInjectPaperNavigationService()
+
+const tags = ref([])
+
+watch([answeredQuestions, unansweredQuestions,
+    scoredQuestions, unscoredQuestions,
+    passedQuestions, unpassedQuestions,
+    allowAnswer, allowScore], ([answered, unanswered, scored, unscored, passed, unpassed]) => {
+    tags.value.length = 0 // reset
+    if (allowAnswer.value) {
+        tags.value.push({
+            label: '已做',
+            value: answered.length,
+            valueClazz: ['text-primary'],
+            handler: () => questionsNavigationHandler(navigator.value, answered, '已做')
+        })
+        tags.value.push({
+            label: '未做',
+            value: unanswered.length,
+            valueClazz: ['text-warning'],
+            handler: () => questionsNavigationHandler(navigator.value, unanswered, '未做')
+        })
+        let rate = 0
+        const total = questions.value.length
+        if (total > 0) rate = Math.round(answered.length * 100 / total)
+        tags.value.push({
+            label: '完成率',
+            value: rate + '%',
+            valueClazz: ['text-success'],
+            handler: fnPlaceholder
+        })
+    } else if (allowScore.value) {
+        tags.value.push({
+            label: '已阅',
+            value: scored.length,
+            valueClazz: ['text-primary'],
+            handler: () => questionsNavigationHandler(navigator.value, scored, '已阅')
+        })
+        tags.value.push({
+            label: '未阅',
+            value: unscored.length,
+            valueClazz: ['text-warning'],
+            handler: () => questionsNavigationHandler(navigator.value, unscored, '未阅')
+        })
+        tags.value.push({
+            label: '正确',
+            value: passed.length,
+            valueClazz: ['text-success'],
+            handler: () => questionsNavigationHandler(navigator.value, passed, '正确')
+        })
+        tags.value.push({
+            label: '错误',
+            value: unpassed.length,
+            valueClazz: ['text-error'],
+            handler: () => questionsNavigationHandler(navigator.value, unpassed, '错误')
+        })
+    } else {
+        tags.value.push({
+            label: '正确',
+            value: passed.length,
+            valueClazz: ['text-success'],
+            handler: () => questionsNavigationHandler(navigator.value, passed, '正确')
+        })
+        tags.value.push({
+            label: '错误',
+            value: unpassed.length,
+            valueClazz: ['text-error'],
+            handler: () => questionsNavigationHandler(navigator.value, unpassed, '错误')
+        })
+        let rate = 0
+        const sumTotal = _.sumBy(questions.value, q => q.scoreTotal * 1)
+        const sumScore = _.sumBy(scored, q => q.score * 1)
+        if (sumTotal > 0) rate = Math.round(sumScore * 100 / sumTotal)
+        tags.value.push({
+            label: '得分率',
+            value: rate + '%',
+            valueClazz: ['text-primary'],
+            handler: fnPlaceholder
+        })
+    }
+})
+
+</script>
+
+<style scoped>
+
+</style>

+ 65 - 0
components/mx-question/mx-question.vue

@@ -0,0 +1,65 @@
+<template>
+    <view class="mx-question tabs-swiper-content">
+        <mx-question-content ref="q" v-model="userData.answer" :disabled="!allowAnswer" :sys-answer="sysAnswerIf"
+                             v-model:attachments="userData.attachments" :question="question" class="p-40"/>
+        <mx-question-parse v-if="!allowAnswer" :question="question" class="p-40"/>
+    </view>
+</template>
+
+<script setup>
+import {computed, watch, ref, nextTick} from 'vue'
+import {createPropDefine} from "@/utils";
+import {useInjectQuestionService} from "@/components/mx-question/useQuestionInjection";
+import {useInjectPaperService} from "@/components/mx-paper/usePaperInjection";
+import {array} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import MxQuestionParse from "@/components/mx-question/components/mx-question-parse.vue";
+import {sleep} from "@/uni_modules/uv-ui-tools/libs/function";
+
+const props = defineProps({
+    question: createPropDefine({}, Object),
+    autoNext: createPropDefine(true, Boolean),
+    // 自动随机答题,有时候题太多了,测试不方便,注意随时关闭 !important.
+    autoAnswer: createPropDefine(false, Boolean)
+})
+
+const q = ref(null)
+const {isRadio, tryGoNext, index, current, questions, hasNext, allowAnswer} = useInjectPaperService()
+const {getUserData, pushChunk, isAnswered, isScored} = useInjectQuestionService()
+const userData = computed(() => getUserData(props.question) || {})
+const sysAnswerIf = computed(() => allowAnswer.value ? '' : array(props.question.answers) ? props.question.answers[0] : '')
+
+watch(index, async () => {
+    if (!props.autoAnswer) return
+    if (isAnswered(current)) return
+    await nextTick()
+    q.value?.tryRandomAnswer()
+}, {immediate: true})
+
+watch([() => userData.value.answer, () => userData.value.attachments], async () => {
+    pushChunk(userData.value)
+    // 自动前往一下题
+    if (props.autoNext && isRadio(props.question) && hasNext.value) {
+        const next = questions.value[index.value + 1]
+        if (!isAnswered(next)) {
+            await sleep(300)
+            tryGoNext()
+        }
+    }
+})
+
+watch(() => userData.value.score, async () => {
+    pushChunk(userData.value)
+    // 自动前往下一题
+    if (props.autoNext && hasNext.value) {
+        const next = questions.value[index.value + 1]
+        if (!isScored(next)) {
+            await sleep(300)
+            tryGoNext()
+        }
+    }
+})
+</script>
+
+<style scoped>
+
+</style>

+ 286 - 0
components/mx-question/useQuestionInjection.js

@@ -0,0 +1,286 @@
+import {computed, ref, watch} from 'vue'
+import {injectLocal, provideLocal} from "@vueuse/core";
+import {fnPlaceholder} from "@/utils/uni-helper";
+import _ from "lodash";
+import {array, empty, number} from "@/uni_modules/uv-ui-tools/libs/function/test";
+
+const key = Symbol('QUESTION_SERVICE')
+
+// 从外部提供一个存储答题和提交的触发机制
+// questionService依赖于paperService,主要用来分担一部分paperService的功能
+// 与答案和打分相关的一些API集中在了这个服务
+// 尽量保持只需要依靠paper/paper.questions中计算的相关API在paperService中
+// 与userData计算相关的API集中的questionService中
+export const useProvideQuestionService = function (
+    paperService,
+    chunkSize = 10,
+    enableDuration = true,
+    overrideService = {}) {
+    const {
+        allowAnswer,
+        allowScore,
+        index,
+        questions,
+        questionsMap,
+        isCheckbox,
+        commitQuestion,
+        scoreQuestion,
+        isObjective,
+        isAnswerCorrect,
+        goToQuestion,
+        scorePaper
+    } = paperService
+
+    const container = ref({})
+    const committing = ref([])
+    const answerProps = ref(['answer', 'attachments', 'duration', 'questionId'])
+    const scoreProps = ref(['answer', 'attachments', 'score', 'questionId']) // 这个answer/attachments是显示用的,不会提交
+    const errorCount = ref(0)
+    const chunk = computed(() => (errorCount.value + 1) * chunkSize)
+    const timestampMap = new Map() // 另一个结构体存放时间,不污染container中的userData
+    const errorLimit = 3 // 如果提交API连续超过3次失败,则不用再重试了
+
+    const createUserDataFn = computed(() => {
+        if (allowAnswer.value && allowScore.value)
+            throw new Error('allowAnswer,allowScore can not be `true` at the same time!')
+        if (!allowAnswer.value && !allowScore.value) return (q) => q // 原样返回,因为不会涉及到更改
+
+        if (allowAnswer.value) return (question) => _.pick(question, answerProps.value)
+        if (allowScore.value) return (question) => _.pick(question, scoreProps.value)
+    })
+
+    const getUserData = function (q) {
+        let userData = container.value[q.questionId]
+        if (userData) return userData
+        userData = createUserDataFn.value(q)
+        encodeToUserData(q, userData)
+        container.value[q.questionId] = userData
+        return userData
+    }
+
+    const cleanUserData = function () {
+        container.value = {}
+        committing.value = []
+        timestampMap.clear()
+    }
+
+    const isAnswered = function (q) {
+        const data = getUserData(q)
+        return !empty(data.answer) || !empty(data.attachments)
+    }
+
+    const isScored = function (q) {
+        const data = getUserData(q)
+        return number(data.score)
+    }
+
+    const answeredPartition = computed(() => _.partition(questions.value, q => isAnswered(q)))
+    const answeredQuestions = computed(() => answeredPartition.value[0])
+    const unansweredQuestions = computed(() => answeredPartition.value[1])
+    const isAllAnswered = computed(() => questions.value.length == answeredQuestions.value.length)
+
+    const scoredPartition = computed(() => _.partition(questions.value, q => isScored(q)))
+    const scoredQuestions = computed(() => scoredPartition.value[0])
+    const unscoredQuestions = computed(() => scoredPartition.value[1])
+    const isAllScored = computed(() => questions.value.length == scoredQuestions.value.length)
+
+    const passedPartition = computed(() => _.partition(scoredQuestions.value, q => isPassed(q)))
+    const passedQuestions = computed(() => passedPartition.value[0])
+    const unpassedQuestions = computed(() => passedPartition.value[1])
+
+    const isCompleteLocal = (q) =>
+        (allowAnswer.value && isAnswered(q)) ||
+        (allowScore.value && isScored(q))
+    const isAllLocalComplete = computed(() =>
+        (allowAnswer.value && isAllAnswered.value) ||
+        (allowScore.value && isAllScored.value)
+    )
+    const allUncompleteLocalQuestions = computed(() => {
+        if (allowAnswer.value) return unansweredQuestions.value
+        if (allowScore.value) return unscoredQuestions.value
+        return []
+    })
+
+    const isPassed = (q) => {
+        const userData = getUserData(q)
+        if (!number(userData.score)) return false
+        return userData.score >= q.scoreTotal * 0.8
+    }
+
+    const encodeToUserData = (q, userData) => {
+        // 与decodeFromUserData对应,有些字段,前后台需要转义
+        // 多选兼容视图类型,要注意这里,转换answer是因为组件不支持string类型的传型,提交时会适配后台
+        if (isCheckbox(q)) userData.answer = empty(userData.answer) ? [] : q.answer.split(',')
+        // attachments做兼容性转换
+        userData.attachments = empty(userData.attachments)
+            ? []
+            : array(userData.attachments)
+                ? userData.attachments
+                : q.attachments.split(',')
+    }
+
+    const decodeFromUserData = (userData) => {
+        // 与encodeToUserData对应,提交或反向赋值时要使用
+        if (allowAnswer.value) {
+            return {
+                ...userData,
+                answer: userData.answer?.toString(), // 兼容多选
+                attachments: userData.attachments // 图片提交时不用转
+            }
+        }
+        if (allowScore.value) {
+            const copy = {...userData}
+            delete copy.answer // 打分时不需要提交答案
+            delete copy.attachments
+            return copy
+        }
+        return userData
+    }
+
+    /*@description 本地提交答案,达到chunkSize时自动向服务端同步
+    * */
+    const pushChunk = function (userData) {
+        // 结算duration
+        if (allowAnswer.value && enableDuration) {
+            const duration = userData.duration || 0
+            const diffSeconds = (new Date().getTime() - timestampMap.get(userData.questionId)) / 1000
+            userData.duration = Math.round(duration + diffSeconds)
+        }
+
+        // chunk 机制只关心分段提交行为,并不关心提交的数据值
+        // 因为userData在container中是唯一引用,所以用户的答题或者打分可能会反复保存
+        // 但只要保持最后一次操作后,有提交行为,即可保证userData均与服务端同步了
+        if (committing.value.includes(userData)) return
+        committing.value.push(userData)
+
+        // chunk = 0 不执行分段提交,要等forceChunk调用
+        if (chunk.value
+            && errorCount.value <= errorLimit
+            && committing.value.length >= chunk.value) {
+            return doChunk()
+        }
+    }
+
+    const rollbackChunk = function (commits) {
+        // 不触发,只是回加,doChunk等一下次自然机会
+        _.remove(committing.value, item => commits.includes(item))
+        committing.value.push(...commits)
+    }
+
+    /*
+    * @description 同步本地数据到服务端
+    * */
+    const doChunk = function () {
+        if (empty(committing.value)) return
+
+        // prepare commit data, 需要兼容多选,所以重组了对象,后面使用的时候要小心引用变更
+        const rawCommits = [...committing.value]
+        const commits = rawCommits.map(decodeFromUserData)
+        committing.value.length = 0
+
+        const commitFn = allowAnswer.value
+            ? commitQuestion
+            : allowScore.value
+                ? scoreQuestion
+                : fnPlaceholder
+        return commitFn(commits)
+            .then(res => {
+                errorCount.value = 0
+
+                // sync props to realtime
+                commits.forEach(userData => {
+                    const raw = questionsMap.value[userData.questionId]
+                    _.assign(raw, userData)
+                })
+
+            })
+            .catch(e => {
+                console.log('commitQuestion failed', e, rawCommits)
+                errorCount.value += 1 // 扩容延缓提交
+
+                // rollback to committing
+                rollbackChunk(rawCommits)
+                throw e // 原样抛出,使得外部调用await doChunk时能正常中断
+            })
+    }
+
+    const getAllCommits = () => questions.value.map(q => userDataToCommit(getUserData(q)))
+
+    // hooks
+    // 完成答题计时功能
+    // NOTE:如果页面上要显示计时,只需要在当前题上,从userData.duration按秒计数即可。
+    watch([index, questions], ([newIndex], [oldIndex]) => {
+        if (enableDuration && allowAnswer.value) { // 只有答题开放此功能
+            if (oldIndex != undefined) {
+                // 旧题清空时间戳
+                const old = questions.value[oldIndex]
+                if (old) timestampMap.delete(old.questionId)
+            }
+            // 新进入的题重新打时间戳
+            // 如果在这期间,用户有pushChunk行为,则会结算duration
+            const q = questions.value[newIndex]
+            timestampMap.set(q.questionId, new Date().getTime())
+        }
+    })
+
+    // 完成客观题自动评分
+    // 如果全是客观题,直接自动完成阅卷
+    watch([allowScore, questions], async () => {
+        if (allowScore.value) {
+            const targets = questions.value
+                .filter(q => isObjective(q) && !isScored(q))
+            if (targets.length) {
+                // 提交所有客观题
+                const commits = targets.map(q => {
+                    const userData = getUserData(q)
+                    userData.score = isAnswerCorrect(q) ? q.scoreTotal * 1 : 0
+                    return userData
+                })
+                rollbackChunk(commits)
+                await doChunk()
+            }
+            const nextTargets = unscoredQuestions.value
+            if (nextTargets.length) {
+                goToQuestion(_.first(nextTargets))
+            } else if (isAllScored) {
+                // 如果全是客观题,直接结束阅卷
+                await scorePaper()
+                index.value = 0 // 回到第1题
+            }
+        }
+    })
+
+    const options = {
+        container,
+        committing,
+        createUserDataFn,
+        cleanUserData,
+        getUserData,
+        getAllCommits,
+        pushChunk,
+        doChunk,
+        isAnswered,
+        isScored,
+        isAllAnswered,
+        isAllScored,
+        isCompleteLocal,
+        isAllLocalComplete,
+        isPassed,
+        answeredQuestions,
+        unansweredQuestions,
+        scoredQuestions,
+        unscoredQuestions,
+        passedQuestions,
+        unpassedQuestions,
+        allUncompleteLocalQuestions,
+        encodeToUserData,
+        decodeFromUserData,
+        ...overrideService
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectQuestionService = function () {
+    return injectLocal(key)
+}

+ 37 - 0
components/mx-question/useQuestionTranslate.js

@@ -0,0 +1,37 @@
+import {array} from "@/uni_modules/uv-ui-tools/libs/function/test";
+
+export function useQuestionTranslate(question) {
+    // 将旧的题属性转换为新题属性,以方便
+    // 使用mx-question-content 和mx-question-parse
+    // 自动获得MathJax支持
+    const makeOptions = () => {
+        const result = []
+        if (array(question.options)) result.push(...question.options)
+        const opts = Object.keys(question)
+            .filter(k => k.startsWith('option') && k != 'options' && question[k])
+            .sort()
+            .map(k => question[k])
+        result.push(...opts)
+        return result
+    }
+
+    const fixTypeId = (q) => {
+        if (q.typeId) return
+        if (['单选题', '选择题', '判断题'].includes(q.type)) return q.typeId = 1
+        if (['多选题'].includes(q.type)) return q.typeId = 3
+    }
+
+    const formatted = {
+        ...question,
+        questionId: question.id || question.questionId,
+        type: question.qtpye || question.type,
+        options: makeOptions(),
+        answers: [question.answer1, question.answer2],
+        knowledge: question.knownledgeName || question.knowledges || question.knowledge,
+        _raw: question
+    }
+
+    fixTypeId(formatted)
+
+    return formatted
+}

+ 46 - 0
components/mx-search/mx-search.vue

@@ -0,0 +1,46 @@
+<template>
+    <uv-search v-bind="{...props, ...attrs}">
+        <template v-for="name in Object.keys($slots)" #[name]>
+            <slot :name="name"/>
+        </template>
+        <template v-if="!$slots.suffix" #suffix>
+            <uv-button type="primary" shape="circle" size="mini" :text="actionText" @click="handleSearch"/>
+        </template>
+    </uv-search>
+</template>
+
+<script setup>
+import {useAttrs} from 'vue';
+import {createPropDefine} from "@/utils";
+import {func} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {searchProps} from "@/uni_modules/uv-search/components/uv-search/uv-search.vue";
+
+// 事件会自然穿透,因为默认inheritAttrs=true
+// 因为外部事件会被分别为attrs,但如果这里也申明事件,则attrs中就会接收不到
+const props = defineProps({
+    ...searchProps,
+    showAction: createPropDefine(false, Boolean)
+})
+const attrs = useAttrs()
+
+const handleSearch = () => {
+    // 因为这里用了事件穿透的方式,这里就不要emit外抛了,直接调用attrs中的方法就可以了
+    if (func(attrs.onSearch)) attrs.onSearch()
+}
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-search) {
+    .uv-search__content {
+        padding-right: 3px;
+
+        .uv-button--mini {
+            height: 28px;
+        }
+
+        .uv-button__text {
+            font-size: 12px !important;
+        }
+    }
+}
+</style>

+ 63 - 0
components/mx-steps/mx-steps.vue

@@ -0,0 +1,63 @@
+<template>
+    <uv-steps :current="step" dot>
+        <uv-steps-item v-for="(s, i) in steps" :title="getTitle(s)" :class="{'step-passed': i<=step}"/>
+    </uv-steps>
+</template>
+
+<script setup>
+import {createPropDefine} from "@/utils";
+
+const props = defineProps({
+    steps: createPropDefine([], Array),
+    step: createPropDefine(0, Number),
+    keyName: createPropDefine('')
+})
+
+const getTitle = (item) => props.keyName ? item[props.keyName] : item
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-steps) {
+    .uv-steps-item__line--row {
+        top: 12px !important;
+    }
+
+    .uv-steps-item__wrapper--row--dot {
+        width: 25px !important;
+        height: 25px !important;
+        background-color: transparent !important;
+
+        .uv-steps-item__wrapper__dot {
+            width: 15px !important;
+            height: 15px !important;
+        }
+    }
+
+    .uv-steps-item__content {
+        .uv-text__value {
+            font-size: 14px !important;
+        }
+    }
+
+    .step-passed {
+        .uv-steps-item__wrapper__dot {
+            position: relative;
+
+            &::before {
+                content: ' ';
+                position: absolute;
+                background: #FFFFFF;
+                left: 5px;
+                top: 5px;
+                border-radius: 2.5px;
+                width: 5px;
+                height: 5px;
+            }
+        }
+
+        .uv-steps-item__content .uv-text__value {
+            color: var(--primary-color);
+        }
+    }
+}
+</style>

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