Parcourir la source

isVHS - fit college lib.

abpcoder il y a 1 mois
Parent
commit
0cc2b7b771
41 fichiers modifiés avec 3936 ajouts et 144 suppressions
  1. 1 2
      src/pagesOther/pages/university/detail/components/college-info.vue
  2. 3 1
      src/pagesOther/pages/university/detail/components/college-profile.vue
  3. 129 122
      src/pagesOther/pages/university/detail/components/plan-enroll-list.vue
  4. 19 17
      src/pagesOther/pages/university/detail/detail.vue
  5. 3 2
      src/pagesOther/pages/university/index/components/plus/college-item.vue
  6. 30 0
      src/pagesOther/pages/vhs/components/SearchMajorInjectionMixin.js
  7. 86 0
      src/pagesOther/pages/vhs/components/SearchMajorProviderMixin.js
  8. 166 0
      src/pagesOther/pages/vhs/detail/detail.vue
  9. 351 0
      src/pagesOther/pages/vhs/edit/edit.vue
  10. 180 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryAssistantInjection.js
  11. 112 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryCartInjection.js
  12. 85 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryFormInjection.js
  13. 48 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryHeaderInjection.js
  14. 5 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryMajorGroupIdentifier.js
  15. 90 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryMajorHighlightInjection.js
  16. 15 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryPageDataFormat.js
  17. 84 0
      src/pagesOther/pages/vhs/hooks/useVoluntarySearchInjection.js
  18. 170 0
      src/pagesOther/pages/vhs/hooks/useVoluntarySortService.js
  19. 20 0
      src/pagesOther/pages/vhs/hooks/useVoluntaryStepInjection.js
  20. 77 0
      src/pagesOther/pages/vhs/index/components/batch-step.vue
  21. 226 0
      src/pagesOther/pages/vhs/index/components/cart-step.vue
  22. 20 0
      src/pagesOther/pages/vhs/index/components/course-selector.vue
  23. 129 0
      src/pagesOther/pages/vhs/index/components/major-popup.vue
  24. 73 0
      src/pagesOther/pages/vhs/index/components/recommend-filter-college.vue
  25. 161 0
      src/pagesOther/pages/vhs/index/components/recommend-filter-extra.vue
  26. 249 0
      src/pagesOther/pages/vhs/index/components/recommend-filter-group.vue
  27. 137 0
      src/pagesOther/pages/vhs/index/components/recommend-filter-major.vue
  28. 55 0
      src/pagesOther/pages/vhs/index/components/recommend-filter-pick-type.vue
  29. 109 0
      src/pagesOther/pages/vhs/index/components/recommend-score-range.vue
  30. 169 0
      src/pagesOther/pages/vhs/index/components/score-batch-popup.vue
  31. 113 0
      src/pagesOther/pages/vhs/index/components/score-form.vue
  32. 70 0
      src/pagesOther/pages/vhs/index/components/score-step.vue
  33. 48 0
      src/pagesOther/pages/vhs/index/components/voluntary-bottom.vue
  34. 373 0
      src/pagesOther/pages/vhs/index/components/voluntary-cart-popup.vue
  35. 82 0
      src/pagesOther/pages/vhs/index/components/voluntary-history-list.vue
  36. 96 0
      src/pagesOther/pages/vhs/index/components/voluntary-item.vue
  37. 19 0
      src/pagesOther/pages/vhs/index/components/voluntary-search.vue
  38. 60 0
      src/pagesOther/pages/vhs/index/index.vue
  39. 68 0
      src/pagesOther/pages/vhs/list/list.vue
  40. 3 0
      src/store/userStore.ts
  41. 2 0
      src/types/user.ts

+ 1 - 2
src/pagesOther/pages/university/detail/components/college-info.vue

@@ -75,7 +75,6 @@ const skeleton = [
   }
 ]
 const userStore = useUserStore()
-const isCultural = ref(false) //useUserStore() // 这是早先兼容河南文化类填报时的字段
 const highlights = ['双高']
 const tagAttrs = {
   type: 'info',
@@ -104,7 +103,7 @@ const bxTags = computed(() => {
 })
 
 const isSpecialTag = (tag: string) => {
-  return !isCultural.value && tag == props.info.bxType
+  return !userStore.isVHS && tag == props.info.bxType
 }
 
 const isHighlight = (tag: string) => {

+ 3 - 1
src/pagesOther/pages/university/detail/components/college-profile.vue

@@ -28,7 +28,7 @@
           custom-class="w-335 h-200" @click="handlePreview(i)" />
       </view>
     </view>
-    <view class="mt-30">
+    <view v-if="!isVHS" class="mt-30">
       <view class="text-32 font-bold text-fore-title">开设专业</view>
       <uv-gap v-if="loading" height="15" />
       <uv-skeleton v-if="loading" :title="false" rows="3" rows-height="30" :rows-width="['100%', '100%', '100%']" />
@@ -79,6 +79,7 @@ import { MajorItem } from "@/types/major";
 import UvReadMore from "@/uni_modules/uv-read-more/components/uv-read-more/uv-read-more.vue";
 import UvActionSheet from "@/uni_modules/uv-action-sheet/components/uv-action-sheet/uv-action-sheet.vue";
 import IePopup from "@/components/ie-popup/ie-popup.vue";
+import {useUserStore} from "@/store/userStore";
 
 interface ActionItem {
   id: string;
@@ -111,6 +112,7 @@ const baseInfo = computed<University>(() => detail.value.baseInfo || {})
 const professions = computed<UniversityProfession[]>(() => detail.value.professions || [])
 const more = ref<InstanceType<typeof UvReadMore>>()
 const actionSheet = ref<InstanceType<typeof UvActionSheet>>()
+const {isVHS} = useUserStore()
 const buttonClass = "flex-1 py-16 rounded-lg flex justify-center items-center gap-8"
 
 const popup = ref<InstanceType<typeof IePopup>>()

+ 129 - 122
src/pagesOther/pages/university/detail/components/plan-enroll-list.vue

@@ -1,114 +1,121 @@
 <template>
-  <view>
-    <z-paging ref="paging" v-model="results" bg-color="white" :fixed="false" :use-page-scroll="true" :auto="false"
-      :refresher-enabled="false" @query="handleQuery">
-      <view class="flex items-center gap-20 px-30 py-20">
-        <ie-picker v-model="queryParams.year" :list="years" width="w-fit" icon="arrow-down" placeholder="年份"
-          custom-class="px-30 py-10 bg-primary-100 rounded" @change="handleYearChange" />
-        <ie-picker v-model="queryParams.level" :list="levels" width="w-fit" icon="arrow-down" placeholder="级别"
-          custom-class="px-30 py-10 bg-primary-100 rounded" @change="handleLevelChange" />
-        <ie-picker v-model="queryParams.group" :list="groups" width="w-fit" icon="arrow-down" placeholder="专业组"
-          custom-class="px-30 py-10 bg-primary-100 rounded" @change="handleGroupChange" />
-      </view>
-      <view v-for="([label, values], idx) in groupedResults" :key="label" class="px-30">
-        <uv-divider v-if="idx > 0" />
-        <view class="flex flex-col gap-20">
-          <view v-for="(item, i) in values" :key="i" class="bg-back-light p-24 rounded-xl">
-            <view class="text-fore-title">
-              <text class="font-bold text-30">{{ item.majorName }}</text>
-              <text v-if="item.specialProject" class="text-24">({{ item.specialProject }})</text>
+    <view>
+        <z-paging ref="paging" v-model="results" bg-color="white" :fixed="false" :use-page-scroll="true" :auto="false"
+                  :refresher-enabled="false" @query="handleQuery">
+            <view class="flex items-center gap-20 px-30 py-20">
+                <uv-tags v-if="tag" :text="tag" size="large" plain plain-fill />
+                <ie-picker v-model="queryParams.year" :list="years" width="w-fit" icon="arrow-down" placeholder="年份"
+                           custom-class="px-30 py-10 bg-primary-100 rounded" @change="handleYearChange"/>
+                <ie-picker v-model="queryParams.level" :list="levels" width="w-fit" icon="arrow-down" placeholder="级别"
+                           custom-class="px-30 py-10 bg-primary-100 rounded" @change="handleLevelChange"/>
+                <ie-picker v-if="!isVHS" v-model="queryParams.group" :list="groups" width="w-fit" icon="arrow-down"
+                           placeholder="专业组"
+                           custom-class="px-30 py-10 bg-primary-100 rounded" @change="handleGroupChange"/>
             </view>
-            <view v-if="item.marjorDirection" class="text-primary text-24">
-              {{ item.marjorDirection }}
-            </view>
-            <view v-if="mode == 'plan'" class="mt-12 text-22 flex gap-30">
-              <view v-if="item.majorGroup" class="font-bold">{{ item.majorGroup }}</view>
-              <view>
-                学费
-                <text class="font-bold">{{ item.fee || '-' }}</text>
-                /年
-              </view>
-              <view>
-                学制
-                <text class="font-bold">{{ item.xueZhi || '-' }}</text>
-                年
-              </view>
-            </view>
-            <view v-if="mode == 'enroll' && item.majorGroup" class="mt-12 text-22 flex gap-30">
-              <view v-if="item.majorGroup" class="font-bold">{{ item.majorGroup }}</view>
-            </view>
-            <view class="mt-20 grid gap-20" :class="`grid-cols-` + descriptors.length">
-              <plan-enroll-descriptor v-for="d in descriptors" :key="d.title" :title="d.title" :value="item[d.prop as keyof typeof item]"
-                :title-only="d.titleOnly" @click="handleRuleClick(d, item)" />
-            </view>
-            <view v-if="mode == 'plan' && isLatestYear" class="mt-20 flex justify-between items-center gap-30">
-              <view class="h-80 flex-1 border border-solid border-primary rounded-full text-primary
+            <view v-for="([label, values], idx) in groupedResults" :key="label" class="px-30">
+                <uv-divider v-if="idx > 0"/>
+                <view class="flex flex-col gap-20">
+                    <view v-for="(item, i) in values" :key="i" class="bg-back-light p-24 rounded-xl">
+                        <view class="text-fore-title">
+                            <text class="font-bold text-30">{{ item.majorName }}</text>
+                            <text v-if="item.specialProject" class="text-24">({{ item.specialProject }})</text>
+                        </view>
+                        <view v-if="item.marjorDirection" class="text-primary text-24">
+                            {{ item.marjorDirection }}
+                        </view>
+                        <view v-if="mode == 'plan'&&!isVHS" class="mt-12 text-22 flex gap-30">
+                            <view v-if="item.majorGroup" class="font-bold">{{ item.majorGroup }}</view>
+                            <view>
+                                学费
+                                <text class="font-bold">{{ item.fee || '-' }}</text>
+                                /年
+                            </view>
+                            <view>
+                                学制
+                                <text class="font-bold">{{ item.xueZhi || '-' }}</text>
+                                年
+                            </view>
+                        </view>
+                        <view v-if="mode == 'enroll' && item.majorGroup" class="mt-12 text-22 flex gap-30">
+                            <view v-if="item.majorGroup" class="font-bold">{{ item.majorGroup }}</view>
+                        </view>
+                        <view class="mt-20 grid gap-20" :class="`grid-cols-` + descriptors.length">
+                            <plan-enroll-descriptor v-for="d in descriptors" :key="d.title" :title="d.title"
+                                                    :value="item[d.prop as keyof typeof item]"
+                                                    :title-only="d.titleOnly" @click="handleRuleClick(d, item)"/>
+                        </view>
+                        <view v-if="mode == 'plan' && isLatestYear && !isVHS"
+                              class="mt-20 flex justify-between items-center gap-30">
+                            <view class="h-80 flex-1 border border-solid border-primary rounded-full text-primary
                             text-30 flex justify-center items-center" @click="handleAddVoluntary(item)">
-                加入志愿表
-              </view>
-              <view class="h-80 flex-1 bg-gradient-to-r from-primary-500 to-primary rounded-full text-white
+                                加入志愿表
+                            </view>
+                            <view class="h-80 flex-1 bg-gradient-to-r from-primary-500 to-primary rounded-full text-white
                             text-30 flex justify-center items-center" @click="handleRateVoluntary(item)">
-                测录取概率
-              </view>
+                                测录取概率
+                            </view>
+                        </view>
+                    </view>
+                </view>
             </view>
-          </view>
-        </view>
-      </view>
-    </z-paging>
-  </view>
+        </z-paging>
+    </view>
 </template>
 
 <script setup lang="ts">
 import _ from 'lodash';
-import { HistoryMode, IPlanEnrollDescriptor, IPlanEnrollHistory, University, UniversityDetail } from "@/types/university";
+import {HistoryMode, IPlanEnrollDescriptor, IPlanEnrollHistory, University, UniversityDetail} from "@/types/university";
 import PlanEnrollDescriptor from "@/pagesOther/pages/university/detail/components/plus/plan-enroll-descriptor.vue";
-import { UNIVERSITY_DETAIL } from "@/types/injectionSymbols";
-import { addVoluntary } from "@/api/modules/voluntary";
-import { SelectedUniversityMajor } from "@/types/voluntary";
-import { useTransferPage } from "@/hooks/useTransferPage";
-import { routes } from "@/common/routes";
-import { useAuth } from "@/hooks/useAuth";
-import { EnumUserRole } from "@/common/enum";
+import {UNIVERSITY_DETAIL} from "@/types/injectionSymbols";
+import {addVoluntary} from "@/api/modules/voluntary";
+import {SelectedUniversityMajor} from "@/types/voluntary";
+import {useTransferPage} from "@/hooks/useTransferPage";
+import {routes} from "@/common/routes";
+import {useAuth} from "@/hooks/useAuth";
+import {EnumUserRole} from "@/common/enum";
+import {useUserStore} from "@/store/userStore";
 
-const { hasPermission } = useAuth();
+const {hasPermission} = useAuth();
 
 const props = withDefaults(defineProps<{
-  mode: HistoryMode;
-  list: IPlanEnrollHistory[];
+    mode: HistoryMode;
+    list: IPlanEnrollHistory[];
 }>(), {
-  mode: 'plan',
-  list: () => []
+    mode: 'plan',
+    list: () => []
 })
 
-const { transferTo } = useTransferPage()
+const {transferTo} = useTransferPage()
 const detail = inject(UNIVERSITY_DETAIL, ref({} as UniversityDetail))
 const baseInfo = computed<University>(() => detail.value.baseInfo)
 const paging = ref<ZPagingInstance>()
-const isCultural = ref(false) // 河南文化类遗留结构
-const tag = computed(() => isCultural.value && '') // examMajorName 河南文化类遗留结构
+const {isVHS, examMajorName} = useUserStore()
+const tag = computed(() => isVHS && examMajorName)
 const descriptors = computed<IPlanEnrollDescriptor[]>(() => {
-  const enrollShared = [{ title: '录取', prop: 'realNum' }, { title: '最低分', prop: 'minScore' }]
-  const cols: IPlanEnrollDescriptor[] = props.mode == 'plan'
-    ? [{ title: '计划', prop: 'planNum' }, { title: '招录比', prop: 'acceptanceRate' }]
-    : isCultural.value
-      ? [...enrollShared, { title: '最低位', prop: 'minSeat' }]
-      : [...enrollShared]
-  if (!isCultural.value) cols.push({ title: '录取规则', prop: '', titleOnly: true })
-  return cols
+    const enrollShared = [{title: '录取', prop: 'realNum'}, {title: '最低分', prop: 'minScore'}]
+    const cols: IPlanEnrollDescriptor[] = props.mode == 'plan'
+        ? isVHS
+            ? [{title: '计划', prop: 'planNum'}, {title: '学制', prop: 'xueZhi'}, {title: '学费', prop: 'fee'}]
+            : [{title: '计划', prop: 'planNum'}, {title: '招录比', prop: 'acceptanceRate'}]
+        : isVHS
+            ? [...enrollShared, {title: '最低位', prop: 'minSeat'}]
+            : [...enrollShared]
+    if (!isVHS) cols.push({title: '录取规则', prop: '', titleOnly: true})
+    return cols
 })
 
 const results = ref<IPlanEnrollHistory[]>([])
 const groupedResults = computed(() => Object.entries(_.groupBy(results.value, i => i.majorGroup)))
-const queryParams = ref({ year: '', level: '', group: '' })
+const queryParams = ref({year: '', level: '', group: ''})
 const years = computed(() => _.orderBy(_.uniq(_.map(props.list, i => i.year)), [], ['desc'])
-  .map(i => ({ label: i, value: i })))
+    .map(i => ({label: i, value: i})))
 const levels = computed(() => _.uniq(_.map(props.list.filter(i => i.year == queryParams.value.year), i => i.level))
-  .map(i => ({ label: i, value: i })))
+    .map(i => ({label: i, value: i})))
 const groups = computed(() => [{
-  label: '不限',
-  value: ''
+    label: '不限',
+    value: ''
 }, ..._.uniq(_.map(props.list.filter(i => i.year == queryParams.value.year && i.level == queryParams.value.level), i => i.majorGroup))
-  .map(g => ({ label: g, value: g }))])
+    .map(g => ({label: g, value: g}))])
 const isLatestYear = computed(() => queryParams.value.year == years.value[0]?.value)
 
 const listOfYear = computed(() => queryParams.value.year ? props.list.filter(i => i.year == queryParams.value.year) : [])
@@ -116,22 +123,22 @@ const listOfLevel = computed(() => queryParams.value.level ? listOfYear.value.fi
 const listOfGroup = computed(() => !queryParams.value.group ? listOfLevel.value : listOfLevel.value.filter(i => i.majorGroup == queryParams.value.group))
 
 const handleYearChange = () => {
-  paging.value?.reload()
+    paging.value?.reload()
 }
 
 const handleLevelChange = () => {
-  paging.value?.reload()
+    paging.value?.reload()
 }
 
 const handleGroupChange = () => {
-  paging.value?.reload()
+    paging.value?.reload()
 }
 
 const handleQuery = () => {
-  // console.log('plan enroll list: query', props.mode, queryParams.value)
-  // 到这里时listOfLevel应该已经动态计算完毕了
-  // onSearch, results结构都是多余的,这里只是为了实验z-paging的虚拟列表
-  paging.value?.completeByNoMore(listOfGroup.value, true)
+    // console.log('plan enroll list: query', props.mode, queryParams.value)
+    // 到这里时listOfLevel应该已经动态计算完毕了
+    // onSearch, results结构都是多余的,这里只是为了实验z-paging的虚拟列表
+    paging.value?.completeByNoMore(listOfGroup.value, true)
 }
 
 const handleRuleClick = (descriptor: IPlanEnrollDescriptor, history: IPlanEnrollHistory) => {
@@ -145,51 +152,51 @@ const handleRuleClick = (descriptor: IPlanEnrollDescriptor, history: IPlanEnroll
 }
 
 const handleAddVoluntary = async (item: IPlanEnrollHistory) => {
-  const hasAuth = hasPermission([EnumUserRole.VIP]);
-  if (!hasAuth) {
-    return;
-  }
-  const { code } = baseInfo.value
-  await addVoluntary({ universityId: code, majorId: item.id + '' })
-  uni.$ie.showSuccess('保存成功')
+    const hasAuth = hasPermission([EnumUserRole.VIP]);
+    if (!hasAuth) {
+        return;
+    }
+    const {code} = baseInfo.value
+    await addVoluntary({universityId: code, majorId: item.id + ''})
+    uni.$ie.showSuccess('保存成功')
 }
 
 const handleRateVoluntary = (item: IPlanEnrollHistory) => {
-  const hasAuth = hasPermission([EnumUserRole.VIP]);
-  if (!hasAuth) {
-    return;
-  }
-  const selected: SelectedUniversityMajor = {
-    universityId: baseInfo.value.code,
-    universityLogo: baseInfo.value.logo,
-    universityName: baseInfo.value.name,
-    majorId: item.id + '',
-    majorName: item.majorName,
-    majorGroup: item.majorGroup,
-    majorAncestors: '', // 测算部分这个字段不重要
-    info: baseInfo.value
-  }
-  transferTo(routes.voluntaryIndex, { bigData: { selected } })
+    const hasAuth = hasPermission([EnumUserRole.VIP]);
+    if (!hasAuth) {
+        return;
+    }
+    const selected: SelectedUniversityMajor = {
+        universityId: baseInfo.value.code,
+        universityLogo: baseInfo.value.logo,
+        universityName: baseInfo.value.name,
+        majorId: item.id + '',
+        majorName: item.majorName,
+        majorGroup: item.majorGroup,
+        majorAncestors: '', // 测算部分这个字段不重要
+        info: baseInfo.value
+    }
+    transferTo(routes.voluntaryIndex, {bigData: {selected}})
 }
 
 onMounted(() => {
-  watch(() => props.list, async (list) => {
-    // console.log('plan enroll list: watch', props.mode, list)
-    if (list.length) {
-      queryParams.value.year = years.value[0].value
-      queryParams.value.level = levels.value[0].value
-    }
-    paging.value?.reload();
-  }, { immediate: true })
+    watch(() => props.list, async (list) => {
+        // console.log('plan enroll list: watch', props.mode, list)
+        if (list.length) {
+            queryParams.value.year = years.value[0].value
+            queryParams.value.level = levels.value[0].value
+        }
+        paging.value?.reload();
+    }, {immediate: true})
 })
 
 onPageScroll((e: any) => {
-  paging.value?.updatePageScrollTop(e.scrollTop)
+    paging.value?.updatePageScrollTop(e.scrollTop)
 });
 </script>
 
 <style scoped lang="scss">
 ::v-deep .uv-button-wrapper {
-  flex: 1
+    flex: 1
 }
 </style>

+ 19 - 17
src/pagesOther/pages/university/detail/detail.vue

@@ -40,6 +40,7 @@ import CollegeProfile from "@/pagesOther/pages/university/detail/components/coll
 import CollegeBrochure from "@/pagesOther/pages/university/detail/components/college-brochure.vue";
 import CollegeExam from "@/pagesOther/pages/university/detail/components/college-exam.vue";
 import PlanEnrollList from "@/pagesOther/pages/university/detail/components/plan-enroll-list.vue";
+import {useUserStore} from "@/store/userStore";
 
 const {prevData} = useTransferPage()
 const {baseStickyTop} = useNavbar()
@@ -50,25 +51,26 @@ const enrollList = computed(() => detail.value.enrollHistories || [])
 const majorTree = ref<MajorItem[]>([])
 const loading = ref(true)
 const appStore = useAppStore()
+const userStore = useUserStore()
 
 const current = ref(0)
-const tabHeight = computed(() => appStore.sysInfo.screenHeight - baseStickyTop.value)
-const tabs = ref<SwiperTabItem[]>([{
-    name: '概况',
-    slot: 'profile'
-}, {
-    name: '简章',
-    slot: 'brochure'
-}, {
-    name: '计划',
-    slot: 'plan'
-}, {
-    name: '录取',
-    slot: 'enroll'
-}, {
-    name: '考试大纲',
-    slot: 'exam'
-}])
+const tabs = computed<SwiperTabItem[]>(() => {
+    const common = [{
+        name: '概况',
+        slot: 'profile'
+    }, {
+        name: '简章',
+        slot: 'brochure'
+    }, {
+        name: '计划',
+        slot: 'plan'
+    }, {
+        name: '录取',
+        slot: 'enroll'
+    }]
+    if (!userStore.isVHS) common.push({name: '考试大纲', slot: 'exam'})
+    return common
+})
 const skeleton = [
     {
         type: 'line',

+ 3 - 2
src/pagesOther/pages/university/index/components/plus/college-item.vue

@@ -22,6 +22,7 @@
 <script setup lang="ts">
 import _ from 'lodash';
 import { University } from "@/types/university";
+import {useUserStore} from "@/store/userStore";
 
 const props = withDefaults(defineProps<{
   item: University;
@@ -37,7 +38,7 @@ const props = withDefaults(defineProps<{
 })
 const emits = defineEmits(['tag', 'click'])
 
-const isCultural = ref(false) //useUserStore() // 这是早先兼容河南文化类填报时的字段
+const {isVHS} = useUserStore()
 const showName = computed(() => !props.hiddenName)
 const showStar = computed(() => !props.hiddenStar && props.item.star)
 
@@ -69,7 +70,7 @@ const bxTags = computed(() => {
 })
 
 const isSpecialTag = (tag: string) => {
-  return !isCultural.value && tag == props.item.bxType
+  return !isVHS && tag == props.item.bxType
 }
 
 const isHighlight = (tag: string) => {

+ 30 - 0
src/pagesOther/pages/vhs/components/SearchMajorInjectionMixin.js

@@ -0,0 +1,30 @@
+export default {
+    inject: [
+        'fetchSearchingMajors',
+        'updateCheckedList',
+        'snapshotSearchingMajorWhenApply',
+        'isSearchingMajorFired',
+        'isFormedMajorFired',
+        'ensureMajorFullTree',
+        'getMajorTree'
+    ],
+    computed: {
+        majorTree() {
+            // noinspection JSUnresolvedFunction
+            return this.getMajorTree()
+        },
+        searchingMajors() {
+            // noinspection JSUnresolvedFunction
+            return this.fetchSearchingMajors()
+        },
+        checkedList() {
+            return this.searchingMajors.checkedList
+        },
+        majorTreeChildren() {
+            return this.searchingMajors.majorTreeChildren
+        },
+        formedMajors() {
+            return this.searchingMajors.formedMajors
+        }
+    }
+}

+ 86 - 0
src/pagesOther/pages/vhs/components/SearchMajorProviderMixin.js

@@ -0,0 +1,86 @@
+// noinspection JSIgnoredPromiseFromCall
+export default {
+  data() {
+    return {
+      checkedList: [],
+      formedMajors: {},
+      majorTree: []
+    }
+  },
+  computed: {
+    majorTreeChildren() {
+      // extend majorTree with children property. use name as key, and children as value.
+      // put it here to avoid re-compute and re-build big-data instead of in InjectionMixin.
+      const result = {}
+      this.majorTree.forEach(category => {
+        category.children.forEach(group => {
+          result[group.name] = group.children.map(item => item.name)
+        })
+      })
+      return result
+    },
+    searchingMajors() {
+      // below is a structure of searchingMajors, waiting for override.
+      return {
+        // top search condition checked major list, for search result major highlight mark.
+        checkedList: this.checkedList,
+        majorTreeChildren: this.majorTreeChildren,
+        // Key: majorId, Value: currentSearching copy while major is selected.
+        formedMajors: this.formedMajors
+      }
+    }
+  },
+  provide() {
+    return {
+      getMajorTree: () => this.majorTree,
+      fetchSearchingMajors: () => this.searchingMajors,
+      updateCheckedList: this.updateCheckedList,
+      snapshotSearchingMajorWhenApply: this.snapshotSearchingMajorWhenApply,
+      isSearchingMajorFired: this.isSearchingMajorFired,
+      isFormedMajorFired: this.isFormedMajorFired,
+      ensureMajorFullTree: this.ensureMajorFullTree
+    }
+  },
+  methods: {
+    updateCheckedList(checkedList) {
+      this.checkedList = checkedList
+    },
+    snapshotSearchingMajorWhenApply(major) {
+      // keep currentSearching if major selected, remove if not.
+      if (major.selected) {
+        if (this.isSearchingMajorFired(major)) {
+          // make a copy of currentSearching, and keep it.
+          this.$set(this.formedMajors, major.id, [...this.checkedList])
+          return
+        }
+      }
+      // remove it if exists.
+      this.$delete(this.formedMajors, major.id)
+    },
+    isSearchingMajorFired(major) {
+      return this.checkedList.includes(major.marjorName) ||
+        this.checkedList.some(item => this.majorTreeChildren[item]?.includes(major.marjorName))
+    },
+    isFormedMajorFired(major) {
+      return this.formedMajors[major.id]?.includes(major.marjorName) ||
+        this.formedMajors[major.id]?.some(item => this.majorTreeChildren[item]?.includes(major.marjorName))
+    },
+    async ensureMajorFullTree(batch) {
+      const params = {level: 3, batch}
+      this.majorTree = await this.$store.cache.dispatch('cachedData/getMajorFullTree', params)
+    },
+    resolveFormedMajorsFromSavedData(data) {
+      const results = {}
+      const highlighted = data.detail?.batch?.highlightMajorIds || []
+      data.detail?.batch?.wishes?.forEach(group => {
+        group.marjors.forEach(major => {
+          // Do not use ===, id is not concerned with type of string or number
+          if (highlighted.some(id => id == major.id)) {
+            results[major.id] = [major.name]
+          }
+        })
+      })
+      return results
+    }
+  }
+}

+ 166 - 0
src/pagesOther/pages/vhs/detail/detail.vue

@@ -0,0 +1,166 @@
+<template>
+    <z-paging ref="paging" v-model="list" :auto="false" auto-show-system-loading @query="handleQuery">
+        <template #top>
+            <mx-nav-bar :title="prevData.name"/>
+        </template>
+        <view class="fx-col gap-20 p-20">
+            <voluntary-item v-for="item in list" :item="item" @major="openMajorPopup(item)" @notify="showNotify"/>
+        </view>
+        <major-popup ref="majorPopup" readonly @notify="showNotify"/>
+        <uv-notify ref="notifier"/>
+        <template #bottom>
+            <mx-bottom-buttons left="编辑" left-type="primary" right="下载" class="h-[60px] px-30 bg-white mx-shadow-up"
+                               left-icon="edit-pen" right-icon="download" @left="handleEdit" @right="handleDownload"/>
+        </template>
+    </z-paging>
+</template>
+
+<script setup>
+import {ref, onMounted} from 'vue';
+import {
+    downloadRecommendReport,
+    getDownloadRecommendReportOptionsForWap2App,
+    getRecommendVoluntary,
+    getVoluntaryMarjors,
+    getZhiyuanDetail,
+} from '@/api/webApi/volunteer.js'
+import MxConst from '@/common/MxConst'
+import MajorPopup from '../index/components/major-popup.vue'
+import VoluntaryItem from "../index/components/voluntary-item.vue";
+import {useProvideTransfer} from "@/hooks/useTransfer";
+import {useVoluntaryMajorGroupIdentifier} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorGroupIdentifier";
+import {useProvideVoluntaryMajorHighlight} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorHighlightInjection";
+import {useProvideVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+import {useProvideVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {useVoluntaryPageDataFormat} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryPageDataFormat";
+import {useUserStore} from "@/hooks/useUserStore";
+import {alertAsync} from "@/utils/uni-helper";
+import {useEnvStore} from "@/hooks/useEnvStore";
+import {useDownload} from "@/hooks/useDownload";
+
+const {isWap2App, isH5} = useEnvStore()
+const {currentUser, isScoreLocked} = useUserStore()
+const {prevData, transferTo, onPageCallback} = useProvideTransfer()
+const {downloadBlobFileForWap2app, downloadBlobFile} = useDownload()
+const paging = ref(null)
+const notifier = ref(null)
+const majorPopup = ref(null)
+
+const {resolveFormedMajorsFromSavedData} = useProvideVoluntaryMajorHighlight(true)
+const {ensureHistoryYearsFromPageData} = useProvideVoluntaryHeader(true)
+const {id, name, total, list} = useProvideVoluntaryCart()
+
+const loadDataById = async (id) => {
+    const res = await getZhiyuanDetail(id)
+    prevData.value = res.data
+    await loadDataByCache()
+}
+
+const loadDataByCache = async () => {
+    useVoluntaryPageDataFormat(prevData)
+    resolveFormedMajorsFromSavedData(prevData.value)
+    await ensureHistoryYearsFromPageData(prevData.value)
+
+    // ready for reload voluntary detail data
+    paging.value.reload()
+}
+
+const handleQuery = (pageNum, pageSize) => {
+    const {id, mode, score, batchName} = prevData.value
+    const data = {wishResId: id, mode, score, batchName}
+    getRecommendVoluntary(data, {pageNum, pageSize}).then(res => {
+        const rows = res.rows.map(item => {
+            item.isExpand = false
+            item.majors = []
+            item.selectedCount = 0
+            item.recruitPlan = item.recruitPlan || {planCount: '-'}
+            useVoluntaryMajorGroupIdentifier(item)
+            return item
+        })
+        paging.value.completeByTotal(rows, res.total)
+    }).catch(e => paging.value.complete(false))
+}
+
+const showNotify = (message) => {
+    const msg = message || '未发布详细的征集信息'
+    notifier.value.show({
+        message: msg,
+        type: 'warning',
+        top: 1
+    })
+}
+
+const openMajorPopup = (item) => {
+    if (item.isExpand) {
+        return majorPopup.value.open(item)
+    } else {
+        item.isExpand = true
+        loadMajorDetails(item)
+    }
+}
+
+const loadMajorDetails = (item) => {
+    uni.showLoading()
+    const {batchName, mode, id} = prevData.value
+    getVoluntaryMarjors({
+        batchName: batchName,
+        mode: mode,
+        wishResId: id,
+        collegeCode: item.recruitPlan.collegeCode,
+        jCode: item.jCode
+    }).then(res => {
+        item.majors = res.data.map(item => {
+            item.selected = false
+            return item
+        })
+        openMajorPopup(item)
+    }).finally(() => uni.hideLoading())
+}
+
+const handleEdit = async () => {
+    if (isScoreLocked.value) {
+        const {score, mode} = currentUser.value
+        if (prevData.value.score != score || prevData.value.mode != mode) {
+            return alertAsync('现在是志愿填报高峰期,此志愿表与您的考试分数不符,为保证系统稳定性,填报期间您不能修改这张志愿表')
+        }
+    }
+    if (prevData.value.obsoleted) {
+        return alertAsync('此志愿表已经过期,不能使用修改功能')
+    }
+    const next = {...prevData.value, callback: MxConst.globalEvents.voluntaryChanged}
+    transferTo('/pages/voluntary/edit/edit', next, null, true)
+}
+
+const handleDownload = async () => {
+    const {name, score, batchName, id} = prevData.value
+    const fileName = `${name}-${score}-${batchName}`
+    const params = {wishResId: id}
+    if (isWap2App.value) {
+        const opt = getDownloadRecommendReportOptionsForWap2App(params)
+        downloadBlobFileForWap2app(opt, fileName)
+    } else if (isH5.value) {
+        const rep = await downloadRecommendReport(params)
+        downloadBlobFile(rep, fileName)
+    } else {
+        console.error('unexpected env, neither wap2app nor h5')
+    }
+}
+
+onMounted(() => {
+    if (prevData.value.id) {
+        // This is transferred from creation
+        loadDataById(prevData.value.id)
+    } else {
+        loadDataByCache()
+    }
+})
+onPageCallback((change) => {
+    if (change) loadDataById(prevData.value.id)
+})
+</script>
+
+<style lang="scss" scoped>
+::v-deep .zp-page-bottom-container {
+    z-index: 10;
+}
+</style>

+ 351 - 0
src/pagesOther/pages/vhs/edit/edit.vue

@@ -0,0 +1,351 @@
+<template>
+    <view class="page-content h-screen">
+        <cart-step ref="cart" edit-mode>
+            <template #top>
+                <mx-nav-bar :title="title" :sub-title="name" :left-click-block="confirmBackChange"
+                            style="z-index: 20000"/>
+            </template>
+        </cart-step>
+    </view>
+</template>
+
+<script setup>
+import {ref, computed, onMounted} from 'vue';
+import {zytbBatches} from '@/api/webApi/volunteer'
+import MxConst from "@/common/MxConst";
+import {useProvideTransfer} from "@/hooks/useTransfer";
+import {useVoluntaryPageDataFormat} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryPageDataFormat";
+import {useProvideVoluntaryMajorHighlight} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorHighlightInjection";
+import {useProvideVoluntaryStep} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryStepInjection";
+import {useProvideVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+import {useProvideVoluntaryData} from "@/hooks/useVoluntaryDataInjection";
+import {useProvideVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {useProvideVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useProvideVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import {useVoluntaryMajorGroupIdentifier} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorGroupIdentifier";
+import CartStep from "@/pagesOther/pages/voluntary/index/components/cart-step.vue";
+
+const cart = ref(null)
+const {prevData, transferBack, callbackEventData} = useProvideTransfer()
+const year = computed(() => prevData.value?.detail?.year)
+const {isMock} = useProvideVoluntaryHeader()
+const stepSvc = useProvideVoluntaryStep('志愿编辑')
+const dataSvc = useProvideVoluntaryData(year)
+const formSvc = useProvideVoluntaryForm()
+const cartSvc = useProvideVoluntaryCart()
+const highlightSvc = useProvideVoluntaryMajorHighlight()
+const {save, onComplete} = useProvideVoluntaryAssistant(stepSvc, dataSvc, formSvc, cartSvc, highlightSvc)
+
+const {title, currentStep} = stepSvc
+const {model, batch, mode} = formSvc
+const {id, name, selectedList, defaultSort} = cartSvc
+const {resolveFormedMajorsFromSavedData} = highlightSvc
+
+const tryFixScoreMissing = async () => {
+    const {score1, score2} = batch.value
+    if (score1 && score2) return
+    const {year} = prevData.value.detail
+    const {score} = model.value
+    // NOTE: try to fix score range in edit mode, because old versions `scorel` defined in the API model.
+    const res = await zytbBatches({year, score, mode: toValue(mode)})
+    const match = res.rows.find(r => r.batch == this.batch.batch)
+    if (match) {
+        batch.value.score1 = match.score1
+        batch.value.score2 = match.score2
+    }
+}
+const reloadScoreAndMode = () => {
+    // 编辑模式下应该使用prevData中的数据
+    const {mode, score, detail: {seatInput}} = prevData.value
+    const modeParams = mode?.split(',') || []
+    model.value.score = score
+    model.value.firstSubject = modeParams[0]
+    model.value.lastSubject = modeParams.slice(1) || []
+    model.value.seatInput = seatInput || 0
+}
+const reloadBatch = () => {
+    const {batch: batchId, batchName, detail} = prevData.value
+    batch.value = {
+        batch: batchId,
+        batchName,
+        name: batchName,
+        score1: detail.batch.score1 || detail.batch.scorel,
+        score2: detail.batch.score2,
+        year: detail.year
+    }
+}
+const reloadSelectedList = () => {
+    const detail = prevData.value.detail
+    const wishes = detail.batch.wishes
+    // 反向转化选中专业,参照index的保存逻辑
+    const restoredWishes = wishes.map(group => ({
+        uniqueCode: group.uniqueCode,
+        pickType: group.pickType,
+        enrollRatio: group.enrollRatio,
+        enrollRatioText: group.enrollRatioText,
+        jCode: group.jCode,
+        university: {
+            id: group.universityId,
+            name: group.name,
+            code: group.code,
+            ranking: group.ranking,
+            rankingOfEdu: group.rankingOfEdu
+        },
+        history: {
+            seat: group.seat
+        },
+        recruitPlan: {
+            collegeCode: group.collegeCode
+        },
+        // marjors is a write error of history issue
+        majors: group.marjors.map((m, index) => ({
+            selected: true,
+            localPriority: index + 1,
+            id: m.id,
+            marjorBelongs: m.code,
+            marjorName: m.name,
+            enrollRatio: m.enrollRatio,
+            enrollRatioText: m.enrollRatioText,
+            enrollFluctuate: m.enrollFluctuate,
+            history: {
+                submitMajorId: m.submitMajorId
+            }
+        }))
+    }))
+    restoredWishes.forEach(useVoluntaryMajorGroupIdentifier)
+    selectedList.value = restoredWishes
+    defaultSort.value = selectedList.value.map(g => g.uniqueCode)
+}
+
+const isSelectedListChanged = () => {
+    const old = prevData.value.detail.batch.wishes.map(g => ({
+        uniqueCode: g.uniqueCode,
+        majors: [...g.marjors].sort(MxConst.recommendMajorSortFn).map(m => m.id)
+    }))
+    const current = selectedList.value.map(g => ({
+        uniqueCode: g.uniqueCode,
+        majors: [...g.majors].filter(m => m.selected).sort(MxConst.recommendMajorSortFn).map(m => m.id)
+    }))
+    console.log('aop compare selected old and current', old, current)
+    // validate the sequence and unique code/id of major group and major item
+    return JSON.stringify(old) != JSON.stringify(current)
+}
+
+const confirmBackChange = async () => {
+    await cart.value.checkPopupBlock()
+    if (isSelectedListChanged()) {
+        await cart.value.confirmSave()
+    }
+}
+
+onMounted(() => {
+    useVoluntaryPageDataFormat(prevData)
+    resolveFormedMajorsFromSavedData(prevData.value)
+    id.value = prevData.value.id
+    name.value = prevData.value.name
+    isMock.value = prevData.value.detail.isMock
+    reloadScoreAndMode()
+    reloadBatch()
+    reloadSelectedList()
+    tryFixScoreMissing()
+    //
+    currentStep.value = 2
+})
+onComplete(() => {
+    callbackEventData.value = true
+    transferBack()
+})
+// export default {
+//     extends: VoluntaryIndex,
+//     mixins: [mxTransferPageMixins],
+//     data() {
+//         return {
+//             currentStep: 2,
+//             resetCalled: false // reset only need to call once in edit mode
+//         }
+//     },
+//     computed: {
+//         voluntaryDataCalculate() {
+//             // NOTE: overriding by history voluntary data in edit mode
+//             const param = this.voluntaryData || {}
+//             const defaultLimit = {groups: 9999, profession: 9999}
+//             // noinspection JSUnresolvedVariable
+//             const historyParam = {
+//                 sort: this.prevData.detail.batch?.sort || '',
+//                 firedLimit: ext.arrayFirst(this.prevData.detail.batch?.paramBatches) || defaultLimit
+//             }
+//             return {
+//                 ...param,
+//                 ...historyParam
+//             }
+//         },
+//         batchScoreRangeCalculate() {
+//             // NOTE: overriding by history batch score range in edit mode
+//             const min = this.batchMinScore
+//             const max = this.prevData.detail?.batch?.scoreBatchLimit || this.voluntaryData?.maxScore
+//             return [min, max]
+//         },
+//         batchMinScore() {
+//             return this.batch.score2 || this.batch.score1 || this.batch.scorel
+//         }
+//     },
+//     watch: {
+//         'filter': function () {
+//             // 这里与index作区分,因为filter数据可能还没有准备好
+//             if (this.batchMinScore && !this.resetCalled) {
+//                 this.resetCalled = true
+//                 this.reset()
+//             }
+//         },
+//         'batchMinScore': function () {
+//             // if filter is ready first, call reset here.
+//             if (!this.resetCalled && Object.keys(this.filter).length) {
+//                 this.resetCalled = true
+//                 this.reset()
+//             }
+//         }
+//     },
+//     async mounted() {
+//         this.title = '志愿编辑'
+//         this.subTitle = this.prevData.name
+//         this.id = this.prevData.id
+//         // super中会调用 reloadScoreAndMode
+//         this.reloadBatch()
+//         this.reloadSelectedList()
+//         this.simulateFormedMajors()
+//         await this.tryFixScoreMissing()
+//     },
+//     methods: {
+//         prepareData() {
+//             // important: string to json
+//             if (typeof this.prevData.userSnapshot === 'string') {
+//                 this.prevData.userSnapshot = JSON.parse(this.prevData.userSnapshot)
+//             }
+//             if (typeof this.prevData.detail === 'string') {
+//                 const parsedData = JSON.parse(this.prevData.detail)
+//                 parsedData.batch?.wishes?.forEach(this.ensureMajorGroupIdentifier)
+//                 this.prevData.detail = parsedData
+//             }
+//             this.isMockHeader = this.prevData.detail.isMock || false
+//         },
+//         prevStep() {
+//             const next = () => {
+//                 const pages = getCurrentPages()
+//                 return pages.length > 1 ? uni.navigateBack() : this.transferToIndex()
+//             }
+//             this.confirmSave(next)
+//         },
+//         async tryFixScoreMissing() {
+//             if (this.batchMinScore) return
+//             // NOTE: try to fix score range in edit mode, because old versions `scorel` defined in the API model.
+//             const res = await zytbBatches({
+//                 year: this.prevData.detail.year,
+//                 score: this.scoreProps.score,
+//                 mode: this.form.mode
+//             })
+//             const match = res.rows.find(r => r.batch == this.batch.batch)
+//             if (match) {
+//                 this.batch.score1 = match.score1
+//                 this.batch.score2 = match.score2
+//             }
+//         },
+//         async reloadScoreAndMode() {
+//             // 编辑模式下应该使用prevData中的数据
+//             const {
+//                 mode,
+//                 score,
+//                 detail: {seatInput}
+//             } = this.prevData
+//             const modeParams = mode?.split(',') || []
+//             this.scoreProps.score = score
+//             this.scoreProps.firstSubject = modeParams[0]
+//             this.scoreProps.lastSubject = modeParams.slice(1) || []
+//             this.scoreProps.seatInput = seatInput || 0
+//
+//             await sleep(50)
+//             this.scoreProps.init = true // begin seat monitor after init
+//         },
+//         reloadBatch() {
+//             const {
+//                 batch,
+//                 batchName
+//             } = this.prevData
+//             this.batch = {
+//                 batch,
+//                 batchName,
+//                 name: batchName,
+//                 score1: this.prevData.detail.batch.score1 || this.prevData.detail.batch.scorel,
+//                 score2: this.prevData.detail.batch.score2,
+//                 year: this.prevData.detail.year
+//             }
+//             this.form = {
+//                 batch: this.batch.batch,
+//                 mode: this.mode
+//             }
+//         },
+//         reloadSelectedList() {
+//             const detail = this.prevData.detail
+//             const wishes = detail.batch.wishes
+//             // 反向转化选中专业,参照index的保存逻辑
+//             const restoredWishes = wishes.map(group => ({
+//                 uniqueCode: group.uniqueCode,
+//                 pickType: group.pickType,
+//                 enrollRatio: group.enrollRatio,
+//                 enrollRatioText: group.enrollRatioText,
+//                 jCode: group.jCode,
+//                 university: {
+//                     id: group.universityId,
+//                     name: group.name,
+//                     code: group.code,
+//                     ranking: group.ranking,
+//                     rankingOfEdu: group.rankingOfEdu
+//                 },
+//                 history: {
+//                     seat: group.seat
+//                 },
+//                 recruitPlan: {
+//                     collegeCode: group.collegeCode
+//                 },
+//                 // marjors is a write error of history issue
+//                 majors: group.marjors.map((m, index) => ({
+//                     selected: true,
+//                     localPriority: index + 1,
+//                     id: m.id,
+//                     marjorBelongs: m.code,
+//                     marjorName: m.name,
+//                     enrollRatio: m.enrollRatio,
+//                     enrollRatioText: m.enrollRatioText,
+//                     enrollFluctuate: m.enrollFluctuate,
+//                     history: {
+//                         submitMajorId: m.submitMajorId
+//                     }
+//                 }))
+//             }))
+//             restoredWishes.forEach(this.ensureMajorGroupIdentifier)
+//             this.selectedList = restoredWishes
+//             this.defaultSort = this.selectedList.map(g => g.uniqueCode)
+//         },
+//         // simulate formed majors for highlight major
+//         simulateFormedMajors() {
+//             this.formedMajors = this.resolveFormedMajorsFromSavedData(this.prevData)
+//         },
+//         // override the NewSimulatedVolunteer
+//         isSelectedListChanged() {
+//             const old = this.prevData.detail.batch.wishes.map(g => ({
+//                 jCode: g.jCode,
+//                 majors: [...g.marjors].sort(MxConst.recommendMajorSortFn).map(m => m.id)
+//             }))
+//             const current = this.selectedList.map(g => ({
+//                 jCode: g.jCode,
+//                 majors: [...g.majors].filter(m => m.selected).sort(MxConst.recommendMajorSortFn).map(m => m.id)
+//             }))
+//             console.log('aop compare selected old and current', old, current)
+//             // validate the sequence and unique code/id of major group and major item
+//             return JSON.stringify(old) != JSON.stringify(current)
+//         }
+//     }
+// }
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 180 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryAssistantInjection.js

@@ -0,0 +1,180 @@
+import {computed} from 'vue';
+import {createEventHook, injectLocal, provideLocal, toValue} from "@vueuse/core";
+import {sleep, toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import MxConst from "@/common/mxConst";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {saveZhiyuan} from "@/api/webApi/volunteer";
+
+const key = Symbol('VOLUNTARY_ASSISTANT')
+export const useProvideVoluntaryAssistant = function (stepSvc, dataSvc, formSvc, cartSvc, highlightSvc, scrollHeight) {
+
+    const {title, currentStep} = stepSvc
+    const {id, name, locking, selectedList, resetCart} = cartSvc
+    const {voluntaryData} = dataSvc
+    const {batch, model, mode, batchesList} = formSvc
+    const {formedMajors, resetHighlight} = highlightSvc
+    const beforeBack = createEventHook()
+    const afterBack = createEventHook()
+    const beforeForward = createEventHook()
+    const afterForward = createEventHook()
+    const complete = createEventHook()
+
+    const handleBack = async function () {
+        if (currentStep.value == 0) return
+        await beforeBack.trigger()
+        currentStep.value -= 1
+        await afterBack.trigger()
+    }
+
+    const handleForward = async function () {
+        if (currentStep.value == 2) return
+        await beforeForward.trigger()
+        currentStep.value += 1
+        await afterForward.trigger()
+    }
+
+    const navBinding = computed(() => ({
+        title: title.value,
+        subTitle: name.value,
+        leftIcon: currentStep.value > 0 ? 'arrow-left' : '',
+        onLeftClick: handleBack
+    }))
+
+    const voluntaryDataCalculate = computed(() => {
+        // NOTE: will override by history voluntary data in edit mode
+        const param = toValue(voluntaryData) || {}
+        const {batch: batchId} = toValue(batch)
+        const defaultLimit = {groups: 9999, profession: 9999}
+        // noinspection JSUnresolvedVariable
+        const firedLimit = param.batches?.find(i => i.batch == batchId) || defaultLimit
+        return {
+            ...param,
+            firedLimit
+        }
+    })
+
+    const calculateScoreBatchRange = computed(() => {
+        // for re-use
+        const vData = toValue(voluntaryData)
+        const vBatch = toValue(batch)
+        if (empty(vData) || vBatch.batch) return [0, 0]
+        const maxScore = vData.maxScore
+        const minBatch = vBatch.score2 || vBatch.score1
+        let maxBatch = maxScore
+        const batchIndex = batchesList.value.indexOf(this.batch)
+        if (batchIndex > 0) {
+            const upBatch = batchesList.value[batchIndex - 1]
+            maxBatch = upBatch.score2 || upBatch.score1
+        }
+        return [minBatch, Math.min(maxScore, maxBatch)]
+    })
+
+    const save = async (isMockHeader) => {
+        if (toValue(id) && !toValue(name)) {
+            toast('请设置志愿表名称')
+            return Promise.reject(false)
+        }
+        const wishes = selectedList.value.map(item => ({
+            uniqueCode: item.uniqueCode,
+            universityId: item.university.id,
+            collegeCode: item.recruitPlan.collegeCode,
+            code: item.university.code,
+            jCode: item.jCode,
+            name: item.university.name,
+            pickType: item.pickType,
+            enrollRatio: item.enrollRatio,
+            enrollRatioText: item.enrollRatioText,
+            ranking: item.university.ranking,
+            rankingOfEdu: item.university.rankingOfEdu,
+            seat: item.history?.seat,
+            marjors: item.majors
+                .filter(major => major.selected)
+                .sort(MxConst.recommendMajorSortFn)
+                .map(major => ({
+                    id: major.id,
+                    code: major.marjorBelongs,
+                    name: major.marjorName,
+                    pickType: item.pickType,
+                    submitMajorId: major.history?.id,
+                    enrollRatio: major.enrollRatio,
+                    enrollRatioText: major.enrollRatioText,
+                    enrollFluctuate: major.enrollFluctuate
+                }))
+        }))
+        if (wishes.length < 1) {
+            // NOTE: 职高对口院校较少,降低填报限制
+            toast('至少选择1个志愿组')
+            return Promise.reject(false)
+        }
+        locking.value = true
+        uni.showLoading()
+        const vBatch = toValue(batch)
+        const vModel = toValue(model)
+        const vMode = toValue(mode)
+        const data = {
+            batch: vBatch.batch,
+            detail: {
+                batch: {
+                    batch: vBatch.batch,
+                    name: vBatch.name,
+                    score1: vBatch.score1 || '',
+                    score2: vBatch.score2 || '',
+                    scoreBatchLimit: calculateScoreBatchRange.value[1],
+                    recommand: true,
+                    scores: [],
+                    wishes: wishes,
+                    sort: voluntaryDataCalculate.value.sort,
+                    paramBatches: [voluntaryDataCalculate.value.firedLimit],
+                    highlightMajorIds: Object.keys(formedMajors.value)
+                },
+                mode: vMode,
+                score: vModel.score,
+                seatInput: vModel.seatInput,
+                year: vBatch.year,
+                isMock: isMockHeader
+            },
+            id: id.value,
+            name: name.value
+        }
+        let res = null
+        try {
+            res = await saveZhiyuan(data)
+            toast('保存成功,即将跳转3')
+            await sleep(1000)
+            toast('保存成功,即将跳转2')
+            await sleep(1000)
+            toast('保存成功,即将跳转1')
+            await sleep(500)
+            await complete.trigger(res.data)
+        } finally {
+            locking.value = false
+            uni.hideLoading()
+        }
+    }
+
+    const resetAll = () => {
+        resetCart()
+        resetHighlight()
+    }
+
+    const options = {
+        navBinding,
+        handleBack,
+        handleForward,
+        voluntaryDataCalculate,
+        save,
+        resetAll,
+        scrollHeight,
+        onBeforeBack: beforeBack.on,
+        onAfterBack: afterBack.on,
+        onBeforeForward: beforeForward.on,
+        onAfterForward: afterForward.on,
+        onComplete: complete.on
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntaryAssistant = function (useDefaultVal) {
+    return injectLocal(key, useDefaultVal ? {voluntaryDataCalculate: {}} : undefined)
+}

+ 112 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryCartInjection.js

@@ -0,0 +1,112 @@
+import {ref} from 'vue';
+import _ from 'lodash';
+import {createEventHook, injectLocal, provideLocal} from "@vueuse/core";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+
+const key = Symbol('VOLUNTARY_CART')
+
+export const useProvideVoluntaryCart = function (initId, n = '') {
+    const id = ref(initId)
+    const name = ref(n)
+    const locking = ref(false)
+    const total = ref(0)
+    const list = ref([])
+    const selectedList = ref([])
+    const defaultSort = ref([])
+    const majorCounter = ref(0) // 本地排序使用
+
+    const resetCart = () => {
+        total.value = 0
+        list.value = []
+        selectedList.value = []
+        majorCounter.value = 0
+    }
+
+    const syncMajorGroupToSelected = (rows) => {
+        if (selectedList.value.length) {
+            rows.forEach(group => {
+                const matcher = s => s.uniqueCode == group.uniqueCode
+                const matchIdx = selectedList.value.findIndex(matcher)
+                if (matchIdx > -1) {
+                    const match = selectedList.value[matchIdx]
+                    group.majors = match.majors
+                    group.isExpand = match.isExpand
+                    // replace in selectedList
+                    selectedList.value.splice(matchIdx, 1, group)
+                }
+            })
+        }
+    }
+
+    const syncMajorsToSelectedGroup = (majors, majorGroup) => {
+        if (majorGroup.majors.length) {
+            majors.forEach(m => {
+                const match = majorGroup.majors.find(cache => m.id == cache.id && cache.selected)
+                m.selected = !!match
+                m.localPriority = match?.localPriority || 0 // 用于本地排序,待提交时,按此字段排序
+            })
+        } else {
+            majors.forEach(m => {
+                m.selected = false
+                m.localPriority = 0
+            })
+        }
+    }
+
+    const recalculatePureSelectedList = async () => {
+        // 过滤掉selectedList中,其majors元素全部selected=false的项
+        // 使用splice操作,防止更改selectedList引用
+        const abandonGroups = selectedList.value.filter(g => g.majors.every(m => !m.selected))
+        _.pull(selectedList.value, ...abandonGroups)
+        return !!abandonGroups.length
+    }
+
+    const toggleMajorSelected = async (major, majorGroup, options) => {
+        // validation
+        if (!major.selected) {
+            // major is unselected
+            if (majorGroup.majors.filter(major => major.selected).length >= options.firedLimit.profession) {
+                toast(`每个专业组最多选择${options.firedLimit.profession}个专业`)
+                return Promise.reject('profession over limitation.')
+            }
+            if (!selectedList.value.includes(majorGroup)) {
+                // major & majorGroup are both unselected
+                if (selectedList.value.length >= options.firedLimit.groups) {
+                    toast(`最多选择${options.firedLimit.groups}个专业组`)
+                    return Promise.reject('group over limitation')
+                }
+            }
+        }
+        // toggle major selected state
+        major.selected = !major.selected
+        if (major.selected) {
+            // set majorCounter with max value of major.localPriority in majorGroup // this logic is for edit mode
+            majorCounter.value = Math.max(majorCounter.value, ...majorGroup.majors.filter(m => m.selected).map(m => m.localPriority))
+            // make current major localPriority to max value
+            major.localPriority = ++majorCounter.value
+            // if major is a new select element, add its parent to selectedList
+            if (!selectedList.value.includes(majorGroup)) {
+                selectedList.value.push(majorGroup)
+            }
+            return
+        }
+        // if major is unselect, recalculate selectedList
+        await recalculatePureSelectedList()
+    }
+
+    const options = {
+        id, name, locking,
+        total, list, selectedList, majorCounter,
+        defaultSort, resetCart,
+        syncMajorsToSelectedGroup,
+        syncMajorGroupToSelected,
+        recalculatePureSelectedList,
+        toggleMajorSelected
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntaryCart = function () {
+    return injectLocal(key)
+}

+ 85 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryFormInjection.js

@@ -0,0 +1,85 @@
+import {ref, computed, watch} from 'vue';
+import {injectLocal, provideLocal, toValue} from "@vueuse/core";
+import {array, empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {useUserStore} from "@/hooks/useUserStore";
+import {zytbBatches} from "@/api/webApi/volunteer";
+import {useCacheStore} from "@/hooks/useCacheStore";
+
+const key = Symbol('VOLUNTARY_FORM')
+
+export const useProvideVoluntaryForm = function () {
+    const {currentUser} = useUserStore()
+    const {dispatchCache} = useCacheStore()
+
+    const model = ref({
+        firstSubject: '',
+        lastSubject: [],
+        score: '',
+        rank: {},
+        seatInput: ''
+    })
+    const batch = ref({})
+    const batchList = ref([])
+
+    const courses = computed(() => {
+        return getCourses(toValue(model))
+    })
+    const mode = computed(() => {
+        return courses.value.toString()
+    })
+    const simpleMode = computed(() => {
+        if (courses.value.length === 1) return courses.value.toString()
+        return courses.value.map(i => i.substring(0, 1)).join('')
+    })
+
+    const getCourses = (modelVal) => {
+        const results = []
+        const {firstSubject, lastSubject} = modelVal
+        if (!empty(firstSubject)) results.push(firstSubject)
+        if (!empty(lastSubject) && array(lastSubject)) results.push(...lastSubject)
+        return results
+    }
+
+    const getMode = (modelVal) => {
+        return getCourses(modelVal).toString()
+    }
+
+    const reloadScoreAndMode = async () => {
+        const {mode, score, seatInput} = toValue(currentUser)
+        const modeParams = mode?.split(',') || []
+        model.value.score = score || ''
+        model.value.firstSubject = modeParams[0]
+        model.value.lastSubject = modeParams.slice(1) || []
+        model.value.seatInput = (seatInput || 0) * 1 // This value may be changed by setRankByScore feature
+        if (!score) model.value.seatInput = ''
+    }
+
+    const loadBatchList = async () => {
+        const m = toValue(mode)
+        const {score} = toValue(model)
+        const res = await dispatchCache(zytbBatches, {mode: m, score})
+        batchList.value = res.rows
+    }
+
+    const getBatchList = async (modelVal) => {
+        const m = getMode(modelVal)
+        const {score} = modelVal
+        const res = await dispatchCache(zytbBatches, {mode: m, score})
+        return res.rows
+    }
+
+    watch(currentUser, () => reloadScoreAndMode(), {immediate: true})
+
+    const options = {
+        model, batch, batchList,
+        mode, simpleMode,
+        loadBatchList,
+        getBatchList
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntaryForm = function () {
+    return injectLocal(key)
+}

+ 48 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryHeaderInjection.js

@@ -0,0 +1,48 @@
+import {computed, ref, watch} from 'vue';
+import {injectLocal, provideLocal, toValue} from "@vueuse/core";
+import {empty} from "@/uni_modules/uv-ui-tools/libs/function/test";
+import {getVoluntaryHeaders} from "@/api/webApi/volunteer";
+import {useInjectVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {useInjectVoluntaryStep} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryStepInjection";
+
+const key = Symbol('VOLUNTARY_HEADER')
+
+export const useProvideVoluntaryHeader = () => {
+    const cols = ref([])
+    const isMock = ref(undefined) // unset status
+    const {dispatchCache} = useCacheStore()
+
+    const historyYears = computed(() => {
+        if (empty(cols.value)) return []
+        return cols.value
+            .filter(col => col.includes('&'))
+            .map(col => col.split('&')[0])
+    })
+    const headerPlanYear = computed(() => {
+        if (empty(cols.value)) return ''
+        const suffix = '招生计划'
+        const col = cols.value.find(col => col.endsWith(suffix))
+        return col?.replace(suffix, '') || ''
+    })
+
+    const ensureHistoryYears = async (payload) => {
+        const res = await dispatchCache(getVoluntaryHeaders, payload)
+        cols.value = res.data
+        isMock.value = res.isMock
+    }
+
+    const ensureHistoryYearsFromPageData = async (prevData) => {
+        const {mode, detail: {year, isMock}} = prevData
+        const headerPayload = {mode, year, isMock: isMock || false}
+        await ensureHistoryYears(headerPayload)
+    }
+
+    const options = {cols, isMock, headerPlanYear, historyYears, ensureHistoryYears, ensureHistoryYearsFromPageData}
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntaryHeader = () => {
+    return injectLocal(key)
+}

+ 5 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryMajorGroupIdentifier.js

@@ -0,0 +1,5 @@
+export const useVoluntaryMajorGroupIdentifier = function (majorGroup) {
+    if (!majorGroup || majorGroup.uniqueCode) return
+    const ids = [majorGroup.collegeCode || majorGroup.recruitPlan?.collegeCode, majorGroup.jCode]
+    majorGroup.uniqueCode = ids.join(':')
+}

+ 90 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryMajorHighlightInjection.js

@@ -0,0 +1,90 @@
+import {ref, computed} from 'vue';
+import {injectLocal, provideLocal} from "@vueuse/core";
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {cacheActions} from "@/hooks/defineCacheActions";
+
+const key = Symbol('VOLUNTARY_MAJOR_HIGHLIGHT')
+
+export const useProvideVoluntaryMajorHighlight = (readonly) => {
+    const {dispatchCache} = useCacheStore()
+    const checkedList = ref([])
+    const formedMajors = ref({})
+    const majorTree = ref([])
+
+    const resetHighlight = () => {
+        checkedList.value = []
+        formedMajors.value = {}
+    }
+
+    const majorTreeChildren = computed(() => {
+        // extend majorTree with children property. use name as key, and children as value.
+        // put it here to avoid re-compute and re-build big-data instead of in InjectionMixin.
+        const categoryMap = {}
+        majorTree.value.forEach(category => {
+            category.children.forEach(group => {
+                categoryMap[group.name] = group.children.map(item => item.name)
+            })
+        })
+        return categoryMap
+    })
+
+    const updateCheckedList = (conditionMajors) => {
+        checkedList.value = conditionMajors
+    }
+    const snapshotSearchingMajorWhenApply = (major) => {
+        // keep currentSearching if major selected, remove if not.
+        if (major.selected) {
+            if (isSearchingMajorFired(major)) {
+                // make a copy of currentSearching, and keep it.
+                return formedMajors.value[major.id] = [...checkedList.value]
+            }
+        }
+        // remove it if exists.
+        delete formedMajors.value[major.id]
+    }
+    const isSearchingMajorFired = (major) => {
+        // 编辑模式下,列表的高亮即是表单的高亮(详情列表)
+        if (readonly) return isFormedMajorFired(major)
+        return checkedList.value.includes(major.marjorName) ||
+            checkedList.value.some(item => majorTreeChildren.value[item]?.includes(major.marjorName))
+    }
+    const isFormedMajorFired = (major) => {
+        return formedMajors.value[major.id]?.includes(major.marjorName) ||
+            formedMajors.value[major.id]?.some(item => majorTreeChildren.value[item]?.includes(major.marjorName))
+    }
+    const ensureMajorFullTree = async (batch) => {
+        const params = {level: 3, batch}
+        majorTree.value = dispatchCache(cacheActions.getMajorTree, params)
+    }
+    const resolveFormedMajorsFromSavedData = (data) => {
+        const formed = {}
+        const highlighted = data.detail?.batch?.highlightMajorIds || []
+        data.detail?.batch?.wishes?.forEach(group => {
+            group.marjors.forEach(major => {
+                // Do not use ===, id is not concerned with type of string or number
+                if (highlighted.some(id => id == major.id)) {
+                    formed[major.id] = [major.name]
+                }
+            })
+        })
+        formedMajors.value = formed
+        return formed
+    }
+
+    const options = {
+        checkedList, formedMajors, majorTree, majorTreeChildren,
+        updateCheckedList,
+        snapshotSearchingMajorWhenApply,
+        isSearchingMajorFired,
+        isFormedMajorFired,
+        ensureMajorFullTree,
+        resolveFormedMajorsFromSavedData,
+        resetHighlight
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntaryMajorHighlight = () => {
+    return injectLocal(key)
+}

+ 15 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryPageDataFormat.js

@@ -0,0 +1,15 @@
+import {useVoluntaryMajorGroupIdentifier} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorGroupIdentifier";
+
+export const useVoluntaryPageDataFormat = (prevData) => {
+    if (typeof prevData.value.userSnapshot === 'string') {
+        prevData.value.userSnapshot = JSON.parse(prevData.value.userSnapshot)
+    }
+    if (typeof prevData.value.detail === 'string') {
+        const parsedData = JSON.parse(prevData.value.detail)
+        parsedData.batch?.wishes?.forEach(useVoluntaryMajorGroupIdentifier)
+        prevData.value.detail = parsedData
+    }
+    prevData.value = prevData.value || {}
+    prevData.value.userSnapshot = prevData.value.userSnapshot || {}
+    prevData.value.detail = prevData.value.detail || {}
+}

+ 84 - 0
src/pagesOther/pages/vhs/hooks/useVoluntarySearchInjection.js

@@ -0,0 +1,84 @@
+import {ref, computed, onMounted} from 'vue';
+import {injectLocal, provideLocal, toValue} from "@vueuse/core";
+import {useProvideSearchModel} from "@/components/mx-condition/useSearchModelInjection";
+import {filters} from "@/api/webApi/collegemajor";
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {useConditionPickType} from "@/components/mx-condition/modules/useConditionPickType";
+import {useConditionCollegeFeatures} from "@/components/mx-condition/modules/useConditionCollegeFeatures";
+import {useConditionCollegeType} from "@/components/mx-condition/modules/useConditionCollegeType";
+import {useConditionCollegeNatureTypeCN} from "@/components/mx-condition/modules/useConditionCollegeNatureTypeCN";
+import {useConditionCollegeLocation} from "@/components/mx-condition/modules/useConditionCollegeLocation";
+
+const key = Symbol('VOLUNTARY_SEARCH')
+
+export const useProvideVoluntarySearch = () => {
+
+    const queryDefault = {
+        majors: [],
+        pickType: '',
+        minScore: '',
+        maxScore: '',
+        sinoforeign: '',
+        collect: '',
+        specialProjectNation: '',
+        specialProjectLocal: '',
+        specialProjects: '',
+        // props of university
+        name: '',
+        location: [],
+        natureTypeCN: [],
+        type: [],
+        features: []
+    }
+    const queryParams = ref({})
+    const formatQueryParams = () => {
+        const vProps = ['majors', 'pickType', 'minScore', 'maxScore', 'sinoforeign', 'collect',
+            'specialProjectNation', 'specialProjectLocal', 'specialProjects']
+        const uProps = ['name', 'location', 'natureTypeCN', 'type', 'features']
+        const input = toValue(queryParams)
+        const output = {}
+        vProps.forEach(p => output[p] = input[p])
+        output.university = {}
+        uProps.forEach(p => output.university[p] = input[p].toString())
+        return output
+    }
+
+    const filter = ref({})
+    const {dispatchCache} = useCacheStore()
+
+    const {onSearch, conditions, reset: resetCore} = useProvideSearchModel([
+        useConditionPickType(),
+        // 因为填报里的数据是后触发的,所以需要配置isImmediate=true
+        useConditionCollegeFeatures(computed(() => filter.value['features'])),
+        useConditionCollegeType(computed(() => filter.value['types'])),
+        useConditionCollegeNatureTypeCN(computed(() => filter.value['natureTypes'])),
+        useConditionCollegeLocation(computed(() => filter.value['locations']))
+    ], queryParams)
+
+    const reset = () => {
+        resetCore()
+        queryParams.value = {
+            ...queryDefault
+        }
+    }
+
+    onMounted(async () => {
+        const res = await dispatchCache(filters)
+        filter.value = {...res.data} // 创建副本,防止不能正确触发searchModelService
+    })
+
+    const options = {
+        queryParams,
+        formatQueryParams,
+        conditions,
+        onSearch,
+        reset
+    }
+
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntarySearch = () => {
+    return injectLocal(key)
+}

+ 170 - 0
src/pagesOther/pages/vhs/hooks/useVoluntarySortService.js

@@ -0,0 +1,170 @@
+import {computed, ref, watch} from 'vue';
+import {toValue} from "@vueuse/core";
+import {useInjectVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {useInjectVoluntaryData} from "@/hooks/useVoluntaryDataInjection";
+import MxConst from "@/common/mxConst";
+
+export const useVoluntarySortService = function (popupRef, defaultSort = []) {
+
+    const {selectedList} = useInjectVoluntaryCart()
+    const {voluntaryData} = useInjectVoluntaryData()
+    const showPopup = computed(() => toValue(popupRef).showPopup)
+    const cancelSortText = computed(() => toValue(defaultSort).length ? '重置排序' : '清除排序')
+
+    // sort related
+    const firedSorts = ref([])
+    const localSort = ref([]) // 这里放本地手工操作影响的排序结果
+    const sortList = ref([{
+        name: '录取概率从低到高',
+        short: '录取率',
+        icon: 'arrow-downward',
+        compare: (a, b) => {
+            const aRatio = a.enrollRatio * 1
+            const bRatio = b.enrollRatio * 1
+            return aRatio - bRatio
+        }
+    }, {
+        name: '录取概率从高到低',
+        short: '录取率',
+        icon: 'arrow-upward',
+        compare: (a, b) => {
+            const aRatio = a.enrollRatio * 1
+            const bRatio = b.enrollRatio * 1
+            return bRatio - aRatio
+        }
+    }, {
+        name: '最低位次从低到高',
+        short: '位次',
+        icon: 'arrow-downward',
+        compare: (a, b) => {
+            const aSeat = a.history?.seat || 9999
+            const bSeat = b.history?.seat || 9999
+            return bSeat - aSeat // 数字越小位次越高
+        }
+    }, {
+        name: '最低位次从高到低',
+        short: '位次',
+        icon: 'arrow-upward',
+        compare: (a, b) => {
+            const aSeat = a.history?.seat || -1
+            const bSeat = b.history?.seat || -1
+            return aSeat - bSeat
+        }
+    }, {
+        name: '院校排名从低到高',
+        short: '院校排名',
+        icon: 'arrow-downward',
+        compare: (a, b) => {
+            const aRank = a.university.ranking * 1
+            const bRank = b.university.ranking * 1
+            return bRank - aRank // 数字越小排名越高
+        }
+    }, {
+        name: '院校排名从高到低',
+        short: '院校排名',
+        icon: 'arrow-upward',
+        compare: (a, b) => {
+            const aRank = a.university.ranking * 1
+            const bRank = b.university.ranking * 1
+            return aRank - bRank
+        }
+    }])
+    const resetCompare = (a, b) => {
+        const restoreSort = toValue(localSort).length ? toValue(localSort) : toValue(defaultSort)
+        let aIdx = restoreSort.findIndex(id => id == a.uniqueCode)
+        let bIdx = restoreSort.findIndex(id => id == b.uniqueCode)
+        if (aIdx === -1) aIdx = 9999
+        if (bIdx === -1) bIdx = 9999
+        return aIdx - bIdx
+    }
+    const sequenceOptions = computed(() => {
+        // Note: Here we return a 2 dimension array, because `u-picker` is a multi-column picker
+        return selectedList.value.map((item, index) => ({
+            value: index,
+            text: generateSeq(index)
+        }))
+    })
+
+    // methods
+    const generateSeq = (index) => {
+        switch (toValue(voluntaryData).sort) {
+            case 'letter':
+                return String.fromCharCode(65 + index)
+            default:
+                return index + 1
+        }
+    }
+    const sortInterrupt = (sorts = null) => {
+        firedSorts.value = []
+        localSort.value = sorts || toValue(selectedList).map(g => g.uniqueCode)
+    }
+    const _orderByAndThen = () => {
+        const sortsRef = toValue(firedSorts)
+        toValue(selectedList).sort((a, b) => {
+            for (let idx = 0; idx < sortsRef.length; idx++) {
+                const curSort = sortsRef[idx]
+                const curCompare = curSort.compare(a, b)
+                if (curCompare === 0) continue // 如果先选条件相等才比较下一条
+                return curCompare
+            }
+            return 0
+        })
+    }
+    const handleSort = (sort) => {
+        // 搜索已经存在的互斥条件
+        const sortsRef = toValue(firedSorts)
+        const idx = sortsRef.findIndex(s => s.short == sort.short)
+        if (idx != -1) sortsRef.splice(idx, 1)
+        sortsRef.push(sort)
+        _orderByAndThen()
+    }
+    const handleSortRemove = (sort) => {
+        const sortsRef = toValue(firedSorts)
+        const idx = sortsRef.indexOf(sort)
+        sortsRef.splice(idx, 1)
+        toValue(selectedList).sort(resetCompare)
+        _orderByAndThen()
+    }
+    const handleSortReset = () => {
+        toValue(selectedList).sort(resetCompare)
+        firedSorts.value = []
+    }
+
+    const handleMoveUp = (group) => {
+        const idx = selectedList.value.indexOf(group)
+        const target = idx - 1
+        if (target > -1) {
+            selectedList.value.splice(idx, 1)
+            selectedList.value.splice(target, 0, group)
+            sortInterrupt()
+        }
+    }
+    const handleMoveDown = (group) => {
+        const idx = selectedList.value.indexOf(group)
+        const target = idx + 1
+        if (target < selectedList.value.length) {
+            selectedList.value.splice(idx, 1)
+            selectedList.value.splice(target, 0, group)
+            sortInterrupt()
+        }
+    }
+
+    const getSelectedSortedMajors = (group) => {
+        return group.majors.filter(m => m.selected).sort(MxConst.recommendMajorSortFn)
+    }
+
+    // hooks
+    watch(() => toValue(selectedList).map(g => g.uniqueCode), (sorts) => {
+        if (!showPopup.value) {
+            sortInterrupt(sorts)
+        }
+    })
+
+    return {
+        firedSorts, localSort, sortList, cancelSortText,
+        sequenceOptions, generateSeq, sortInterrupt,
+        handleSort, handleSortReset, handleSortRemove,
+        getSelectedSortedMajors,
+        handleMoveDown, handleMoveUp
+    }
+}

+ 20 - 0
src/pagesOther/pages/vhs/hooks/useVoluntaryStepInjection.js

@@ -0,0 +1,20 @@
+import {ref} from 'vue';
+import {injectLocal, provideLocal} from "@vueuse/core";
+
+const key = Symbol('VOLUNTARY_STEP')
+
+export const useProvideVoluntaryStep = (t = '志愿填报', step = 0) => {
+    const title = ref(t)
+    const currentStep = ref(step)
+
+    const options = {
+        title,
+        currentStep
+    }
+    provideLocal(key, options)
+    return options
+}
+
+export const useInjectVoluntaryStep = () => {
+    return injectLocal(key)
+}

+ 77 - 0
src/pagesOther/pages/vhs/index/components/batch-step.vue

@@ -0,0 +1,77 @@
+<template>
+    <view class="p-30 fx-col gap-30">
+        <view class="bg-gradient-to-r from-primary-deep to-primary-light px-20 py-30 rounded-lg">
+            <view class="text-white font-bold">我的成绩</view>
+            <view class="mt-20 fx-row fx-bet-cen text-white text-2xs">
+                <text>省份:{{ userSnapshot.provinceName }}</text>
+                <text>专业类别:{{ userSnapshot.examMajorName }}</text>
+                <text>总分:{{ model.score }}</text>
+            </view>
+        </view>
+        <view class="text-center mt-50">(二)选择填报批次</view>
+        <view class="fx-col gap-20 mt-20">
+            <view @click="handleBatchSelect(item)" v-for="item in batchList"
+                  class="bg-gradient-to-b from-sky-100 to-white px-30 py-50 fx-row fx-bet-cen mx-card">
+                <view class="flex-1 fx-col">
+                    <view class="fx-row items-center">
+                        <text class="text-xl mr-10 batch-name">{{ item.name }}</text>
+                        <uv-tags v-if="item.recommand" text="重点推荐" type="error" shape="circle" size="mini"/>
+                    </view>
+                    <view class="text-2xs mt-20">{{ item.tips }}</view>
+                </view>
+                <view class="fx-col ai-cen">
+                    <uv-button type="primary" shape="circle" class="pointer-events-none"
+                               color="linear-gradient(to right, var(--primary-deep-color),var(--primary-light-color))"
+                               custom-style="height: 32px; border: none;" text="智能填报"/>
+                    <!--                    <text class="f-tips f10 mt5">可填{{ getCollegeLimit(item.batch) }}所院校</text>-->
+                </view>
+            </view>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {watch} from 'vue';
+import {useInjectVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useInjectUserSnapshot} from "@/pagesOther/pages/ie/hooks/useUserSnapshotInjection";
+import {useInjectVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import {useInjectVoluntaryStep} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryStepInjection";
+
+const {currentStep} = useInjectVoluntaryStep()
+const {model, batch, batchList, loadBatchList} = useInjectVoluntaryForm()
+const {userSnapshot} = useInjectUserSnapshot()
+const {handleForward} = useInjectVoluntaryAssistant()
+
+const handleBatchSelect = (item) => {
+    batch.value = item
+    handleForward()
+}
+
+watch(currentStep, step => {
+    if (step == 1) loadBatchList()
+})
+
+//         getCollegeLimit(batch) {
+//             const match = this.voluntaryData?.batches?.find(b => b.batch == batch)
+//             return match?.groups
+//         }
+</script>
+
+<style lang="scss" scoped>
+.batch-name {
+    position: relative;
+    z-index: 10;
+
+    &::before {
+        position: absolute;
+        left: 0;
+        bottom: 0;
+        content: ' ';
+        width: 66rpx;
+        height: 10rpx;
+        border-radius: 5rpx;
+        background: linear-gradient(to right, #FFD423, #FFFFFF);
+        z-index: -1;
+    }
+}
+</style>

+ 226 - 0
src/pagesOther/pages/vhs/index/components/cart-step.vue

@@ -0,0 +1,226 @@
+<template>
+    <z-paging ref="paging" v-model="list" :auto="false" :height="safeScrollHeight" auto-show-system-loading
+              @query="handleQuery">
+        <template #top>
+            <slot name="top"/>
+            <mx-condition-dropdown ref="dropdown" x layout="fx-row items-center gap-20 w-max"/>
+        </template>
+        <voluntary-search @search="handleSearch"/>
+        <view class="fx-col p-20 gap-20">
+            <voluntary-item v-for="item in list" :item="item" @major="openMajorPopup(item)" @notify="showNotify"/>
+            <vip-guide-more v-if="isNotVip"/>
+        </view>
+        <template #bottom>
+            <voluntary-bottom @modify="$refs.modifyPopup.open()" @cart="$refs.cartPopup.open()"/>
+        </template>
+    </z-paging>
+    <!--  这里渲染的弹窗不会被back-to-top遮挡  -->
+    <score-batch-popup ref="modifyPopup"/>
+    <voluntary-cart-popup ref="cartPopup"/>
+    <major-popup ref="majorPopup" @notify="showNotify"/>
+    <uv-notify ref="notifier"/>
+</template>
+
+<script setup>
+import {computed, ref, watch} from 'vue';
+import {toValue} from "@vueuse/core";
+import {createPropDefine} from "@/utils";
+import {sleep} from "@/uni_modules/uv-ui-tools/libs/function";
+import {getRecommendVoluntary, getVoluntaryMarjors} from "@/api/webApi/volunteer";
+import {useUserStore} from "@/hooks/useUserStore";
+import {useInjectVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useInjectVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import {useInjectVoluntaryStep} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryStepInjection";
+import {useInjectVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+import {useVoluntaryMajorGroupIdentifier} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorGroupIdentifier";
+import {useInjectVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import VoluntaryItem from "@/pagesOther/pages/voluntary/index/components/voluntary-item.vue";
+import VoluntaryBottom from "@/pagesOther/pages/voluntary/index/components/voluntary-bottom.vue";
+import MajorPopup from "@/pagesOther/pages/voluntary/index/components/major-popup.vue";
+import ScoreBatchPopup from "@/pagesOther/pages/voluntary/index/components/score-batch-popup.vue";
+import VoluntaryCartPopup from "@/pagesOther/pages/voluntary/index/components/voluntary-cart-popup.vue";
+import VoluntarySearch from "@/pagesOther/pages/voluntary/index/components/voluntary-search.vue";
+import {useProvideVoluntarySearch} from "@/pagesOther/pages/voluntary/hooks/useVoluntarySearchInjection";
+
+const props = defineProps({
+    editMode: createPropDefine(false, Boolean)
+})
+
+const paging = ref(null)
+const notifier = ref(null)
+const majorPopup = ref(null)
+const cartPopup = ref(null)
+const dropdown = ref(null)
+const {currentUser, GetInfo, isBind} = useUserStore()
+const {model, batch, mode} = useInjectVoluntaryForm()
+const {currentStep} = useInjectVoluntaryStep()
+const {
+    resetCart, total, list, selectedList,
+    syncMajorGroupToSelected, syncMajorsToSelectedGroup
+} = useInjectVoluntaryCart()
+const {scrollHeight, onBeforeBack, save} = useInjectVoluntaryAssistant() // 填报页下面有原生tabs,必须手动设置高度
+const {isMock, ensureHistoryYears} = useInjectVoluntaryHeader()
+
+const {formatQueryParams, onSearch, reset: resetCondition} = useProvideVoluntarySearch()
+
+const safeScrollHeight = computed(() => scrollHeight ? toValue(scrollHeight) + 'px' : undefined)
+const isNotVip = computed(() => {
+    return !toValue(isBind) && toValue(total) > 1
+})
+
+const showNotify = (message) => {
+    const msg = message || '未发布详细的征集信息'
+    notifier.value.show({
+        message: msg,
+        type: 'warning',
+        top: 1
+    })
+}
+
+const openMajorPopup = (item) => {
+    if (item.isExpand) {
+        return majorPopup.value.open(item)
+    } else {
+        item.isExpand = true
+        loadMajorDetails(item)
+    }
+}
+
+const loadMajorDetails = (item) => {
+    uni.showLoading()
+    getVoluntaryMarjors({
+        batchName: toValue(batch).name,
+        collegeCode: item.recruitPlan.collegeCode,
+        jCode: item.jCode,
+        mode: toValue(mode),
+        universityId: item.recruitPlan.universityId,
+        year: item.recruitPlan.year,
+        score: toValue(model).score
+    }).then(res => {
+        syncMajorsToSelectedGroup(res.data, item)
+        item.majors = res.data
+        majorPopup.value.open(item)
+    }).finally(() => uni.hideLoading())
+}
+
+
+const syncUserScoreByNeed = (query) => {
+    const {score, seatInput/*, mode*/} = toValue(currentUser)
+    // if query score mode seatInput changed, re-call getInfo.
+    if (query.score != score ||
+        // query.mode != mode ||
+        query.seatInput != seatInput) {
+        GetInfo()
+    }
+}
+
+const handleQuery = async (pageNum, pageSize) => {
+    const batchVal = toValue(batch)
+    const modelVal = toValue(model)
+    const batchData = {
+        batchName: batchVal.name,
+        batch: batchVal.batch,
+        batchMinScore: batchVal.score2 || batchVal.score1
+    }
+    const modelData = {
+        mode: toValue(mode),
+        score: modelVal.score,
+        seatInput: modelVal.seatInput
+    }
+    const data = {
+        ...batchData,
+        ...modelData,
+        ...formatQueryParams()
+    }
+    const res = await getRecommendVoluntary(data, {pageNum, pageSize})
+
+    if (pageNum == 1) syncUserScoreByNeed(data)
+    total.value = res.total
+    // make reactive properties
+    let rows = {}
+    rows = res.rows.map(item => {
+        item.isExpand = false
+        item.majors = []
+        return item
+    })
+    rows.forEach(useVoluntaryMajorGroupIdentifier)
+    // 回显
+    syncMajorGroupToSelected(rows)
+    paging.value.completeByTotal(rows, total.value)
+}
+
+const checkPopupBlock = async () => {
+    // major popup
+    if (majorPopup.value.show) {
+        majorPopup.value.close()
+        return Promise.reject('popup close')
+    }
+    if (dropdown.value.getShow()) {
+        dropdown.value.terminate()
+        return Promise.reject('popup close')
+    }
+}
+
+const confirmSave = async () => {
+    return new Promise((resolve, reject) => {
+        uni.showModal({
+            content: '是否要保存当前志愿表',
+            showCancel: true,
+            cancelText: '放弃保存',
+            confirmText: '保存志愿表',
+            success: res => {
+                if (res.confirm) {
+                    reject() // to prevent back operation.
+                    save(isMock.value)
+                } else {
+                    resolve() // to continue back operation.
+                }
+            }
+        })
+    })
+}
+
+const handleSearch = () => paging.value.reload() // 输入框触发
+onSearch(() => paging.value.reload()) // 条件选择器触发
+onBeforeBack(async () => {
+    if (currentStep.value == 2) {
+        // 先控制弹层元素 专业详情/条件筛选
+        await checkPopupBlock()
+        // 再判定志愿表
+        if (selectedList.value.length) {
+            await confirmSave()
+        }
+    }
+})
+
+const reloadWatches = [
+    currentStep,
+    () => model.value.score * 1,
+    () => model.value.seatInput * 1,
+    () => batch.value.batch * 1
+]
+watch(reloadWatches, async ([step, score, input, batch], arg2) => {
+    if (step == 2) {
+        if (!props.editMode) resetCart() // 编辑模式下保留cart数据
+        cartPopup.value.close()
+        majorPopup.value.close()
+        dropdown.value.terminate()
+        await sleep(400) // wait for animation complete
+        resetCondition()
+    }
+})
+watch(currentStep, async (step) => {
+    if (step == 2) {
+        const payload = {mode: toValue(mode), year: toValue(batch).year, isMock: toValue(isMock)}
+        await ensureHistoryYears(payload)
+    }
+})
+
+defineExpose({checkPopupBlock, confirmSave})
+</script>
+
+<style scoped lang="scss">
+::v-deep .zp-page-bottom-container {
+    z-index: 10;
+}
+</style>

+ 20 - 0
src/pagesOther/pages/vhs/index/components/course-selector.vue

@@ -0,0 +1,20 @@
+<template>
+    <view class="fx-col">
+        <view class="pl-20">
+            专业类别
+        </view>
+        <view class="py-15 fx-row gap-20">
+            <uv-tags size="large" type="primary" shape="circle" icon="lock" plain plain-fill
+                     :text="currentUser.examMajorName"/>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {useUserStore} from "@/hooks/useUserStore";
+
+const {currentUser} = useUserStore()
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 129 - 0
src/pagesOther/pages/vhs/index/components/major-popup.vue

@@ -0,0 +1,129 @@
+<template>
+    <uv-popup ref="popup" mode="bottom" closeable round="16" @change="handleChange">
+        <view class="h-[50px] fx-row fx-cen-cen text-lg text-main font-bold">
+            {{ group.university.name }}
+        </view>
+        <scroll-view scroll-y class="bg-bg" style="height: 50vh" lower-threshold="100"
+                     @scrolltolower="handleMajorScroll">
+            <view class="p-30">
+                <view v-for="item in pagedMajors" class="fx-col">
+                    <view class="fx-row fx-bet-cen">
+                        <view class="flex-1 fx-row items-center">
+                            <text :class="{'highlight-major': isSearchingMajorFired(item)}">
+                                {{ item.marjorName }}
+                            </text>
+                            ({{ item.marjorBelongs }})
+                            <text v-if="item.level" class="text-error">({{ item.level }})</text>
+                            <uv-tags v-if="item.enrollFluctuate" text="大小年" size="tiny" type="error"
+                                     class="ml-5" @click="$emit('notify', '近两年录取分差较大')"/>
+                        </view>
+                        <uv-tags v-if="!readonly" :plain="!item.selected" :text="item.selected?'已填':'填报'"
+                                 shape="circle" @click="handleApply(item)"/>
+                    </view>
+                    <view v-if="item.professionType||item.typeNames" class="mb-10 text-xs text-primary font-light">
+                        {{ item.professionType }}
+                        {{ item.typeNames }}
+                    </view>
+                    <view class="fx-row fx-sta-cen text-2xs mb-10">
+                        <text class="text-content mr-10">录取概率</text>
+                        <text class="text-sm font-bold">{{ item.enrollRatio || '-' }}</text>
+                        %
+                        <text class="text-content ml-10" :class="[getPickTypeColor(item.pickType)]">
+                            {{ item.enrollRatioText }}
+                        </text>
+                    </view>
+                    <view class="text-2xs">
+                        <view class="mb-5 text-main font-bold">
+                            {{
+                                `代码 ${item.marjorBelongs} ${headerPlanYear}计划 ${item.planCount}人 ${formatXueZhi(item.xuezhi)} ¥${item.xuefei || '-'}`
+                            }}
+                        </view>
+                        <view class="mb-5 text-content">
+                            {{ item.marjorDirection }}
+                        </view>
+                        <voluntary-history-list :container="item" @notify="$emit('notify', $event)"/>
+                    </view>
+                    <uv-divider/>
+                </view>
+            </view>
+        </scroll-view>
+    </uv-popup>
+</template>
+
+<script setup>
+import {ref, computed} from 'vue';
+import MxConst from "@/common/MxConst";
+import {createPropDefine} from "@/utils";
+import {toValue} from "@vueuse/core";
+import VoluntaryHistoryList from "@/pagesOther/pages/voluntary/index/components/voluntary-history-list.vue";
+import {useInjectVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+import {useInjectVoluntaryMajorHighlight} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorHighlightInjection";
+import {useInjectVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import {useInjectVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+
+const props = defineProps({
+    readonly: createPropDefine(false, Boolean)
+})
+const emits = defineEmits(['notify'])
+const popup = ref(null)
+const show = ref(false)
+const group = ref({})
+const localPageNum = ref(1)
+const localPageSize = ref(4)
+
+const {headerPlanYear} = useInjectVoluntaryHeader()
+const {toggleMajorSelected} = useInjectVoluntaryCart()
+const {isSearchingMajorFired, snapshotSearchingMajorWhenApply} = useInjectVoluntaryMajorHighlight()
+const {voluntaryDataCalculate} = useInjectVoluntaryAssistant(props.readonly)
+
+// 已填
+// const selectedCount = computed(() => {
+//     if (empty(group.value.majors)) return 0
+//     return _.countBy(group.value.majors, m => m.selected)[true]
+// })
+const pagedMajors = computed(() => {
+    return group.value.majors?.slice(0, toValue(localPageNum) * toValue(localPageSize)) || []
+})
+
+const handleMajorScroll = () => {
+    if (toValue(pagedMajors).length >= toValue(group).majors.length) return
+    localPageNum.value += 1
+}
+
+const formatXueZhi = (val) => {
+    if (!val) return val
+    return val.endsWith('年') ? val : val + '年'
+}
+
+const getPickTypeColor = (pickType) => {
+    return MxConst.enum.simulatePickTypes.find(t => t.value == pickType)?.color || ''
+}
+
+const handleApply = async (major) => {
+    const majorGroup = group.value
+    const voluntaryOptions = toValue(voluntaryDataCalculate)
+    await toggleMajorSelected(major, majorGroup, voluntaryOptions)
+    snapshotSearchingMajorWhenApply(major) // snapshot when toggle succeed.
+}
+
+const open = (item) => {
+    localPageNum.value = 1
+    group.value = item
+    popup.value.open()
+}
+const close = () => {
+    popup.value.close()
+}
+
+const handleChange = (e) => show.value = e.show
+
+defineExpose({open, close, show})
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-tags) {
+    .uv-tags__text--medium {
+        font-size: 12px !important;
+    }
+}
+</style>

+ 73 - 0
src/pagesOther/pages/vhs/index/components/recommend-filter-college.vue

@@ -0,0 +1,73 @@
+<template>
+    <view class="recommend-filter-college fx-column gap15">
+        <view v-for="op in optionKeys" :key="op.prop" class="recommend-filter-college-group">
+            <view class="f13 f-666 mb5">{{ op.title }}</view>
+            <u-checkbox-group v-model="localFilter[op.writeKey]">
+                <u-checkbox v-for="i in filterOptions[op.prop]" :key="i" :label="i" :name="i"/>
+            </u-checkbox-group>
+        </view>
+    </view>
+</template>
+
+<script>
+export default {
+    name: "recommend-filter-college",
+    inject: ['fetchFilterOptions', 'fetchOptionKeys'],
+    props: {
+        filter: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            localFilter: {}
+        }
+    },
+    computed: {
+        filterOptions() {
+            return this.fetchFilterOptions()
+        },
+        optionKeys() {
+            return this.fetchOptionKeys()
+        }
+    },
+    mounted() {
+        this.makeSnapshot()
+    },
+    methods: {
+        makeSnapshot() {
+            const snapshot = {}
+            this.optionKeys.forEach(k => snapshot[k.writeKey] = [...this.filter[k.writeKey]])
+            this.localFilter = snapshot
+        },
+        handleReset() {
+            this.optionKeys.forEach(k => this.localFilter[k.writeKey] = [])
+        },
+        handleConfirm() {
+            // condition changed judgement
+            const changed = this.optionKeys.some(k => this.localFilter[k.writeKey].toString() != this.filter[k.writeKey].toString())
+            // apply local-filter to real-filter
+            this.optionKeys.forEach(k => this.filter[k.writeKey] = [...this.localFilter[k.writeKey]])
+            return changed
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.recommend-filter-college {
+  padding: 12px 15px;
+
+  // max-height: 40vh;
+  // overflow-y: scroll;
+
+  &-group {
+    .u-checkbox-group--row::v-deep {
+      flex-wrap: wrap;
+      column-gap: 8px;
+      row-gap: 5px;
+    }
+  }
+}
+</style>

+ 161 - 0
src/pagesOther/pages/vhs/index/components/recommend-filter-extra.vue

@@ -0,0 +1,161 @@
+<template>
+    <view class="recommend-filter-extra">
+        <!--        <view class="f-666 f13 mb5">中外合作</view>-->
+        <!--        <u-subsection :current="foreign" :list="foreignOptions" mode="subsection" key-name="label"-->
+        <!--                      :active-color="$u.color.primary" @change="handleForeignChange"/>-->
+        <view class="f-666 f13 mb5">历年征集</view>
+        <u-subsection :current="collect" :list="collectOptions" mode="subsection" key-name="label"
+                      :active-color="$u.color.primary" @change="handleCollectChange"/>
+        <!--        <view class="f-666 f13 mb5 mt15">国家专项</view>-->
+        <!--        <u-subsection :current="nation" :list="nationOptions" mode="subsection" key-name="label"-->
+        <!--                      :active-color="$u.color.primary" @change="handleNationChange"/>-->
+        <!--        <view class="f-666 f13 mb5 mt15">地方专项</view>-->
+        <!--        <u-subsection :current="local" :list="localOptions" mode="subsection" key-name="label"-->
+        <!--                      :active-color="$u.color.primary" @change="handleLocalChange"/>-->
+        <view class="f-666 f13 mb5 mt15">专项计划</view>
+        <u-checkbox-group v-model="specialProjects" placement="column" icon-placement="right" class="gap10"
+                          @change="handleSpecialProjectsMutex">
+            <u-checkbox v-for="item in specialProjectOptions" shape="circle" :key="item" :label="item" :name="item"
+                        :label-color="$u.color.mainColor" :active-color="$u.color.primary"/>
+        </u-checkbox-group>
+        <u-text size="10" prefix-icon="info-circle" type="content" :text="specialProjectsMutexTips" margin="5px 0 0 0"
+                :icon-style="{'margin-right': '5px', color: $u.color.warning}"/>
+    </view>
+</template>
+
+<script>
+import MxConst from '@/common/MxConst'
+import {arrayLast, arrayRemove} from "@/common/tools/ext";
+
+export default {
+    name: 'recommend-filter-extra',
+    props: {
+        filter: {
+            type: Object,
+            default: () => ({})
+        },
+        batch: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            foreignOptions: MxConst.enum.sinoforeignOptions,
+            collectOptions: MxConst.enum.collectOptions,
+            nationOptions: MxConst.enum.specialProjectNationOptions,
+            localOptions: MxConst.enum.specialProjectLocalOptions,
+            foreign: 0,
+            collect: 0,
+            nation: 0,
+            local: 0,
+            // special project options // dynamic feature
+            specialProjectOptions: [],
+            specialProjects: []
+        }
+    },
+    computed: {
+        currentForeign() {
+            return this.foreignOptions[this.foreign]
+        },
+        currentCollect() {
+            return this.collectOptions[this.collect]
+        },
+        currentNation() {
+            return this.nationOptions[this.nation]
+        },
+        currentLocal() {
+            return this.localOptions[this.local]
+        },
+        specialProjectsMutexTips() {
+            return `不看${this.specialProjectOptions.slice(0, -1).join('、')}`
+        }
+    },
+    mounted() {
+        const foreignMatch = this.foreignOptions.findIndex(f => f.value === this.filter.sinoforeign)
+        const collectMatch = this.collectOptions.findIndex(c => c.value === this.filter.collect)
+        const nationMatch = this.nationOptions.findIndex(n => n.value === this.filter.specialProjectNation)
+        const localMatch = this.localOptions.findIndex(l => l.value === this.filter.specialProjectLocal)
+        if (foreignMatch > -1) this.foreign = foreignMatch
+        if (collectMatch > -1) this.collect = collectMatch
+        if (nationMatch > -1) this.nation = nationMatch
+        if (localMatch > -1) this.local = localMatch
+        this.loadSpecialProjectFilter()
+    },
+    methods: {
+        async loadSpecialProjectFilter() {
+            // keep snapshot
+            this.specialProjects = [...this.filter.specialProjects]
+            // load special project filer
+            const params = {year: this.batch.year}
+            this.specialProjectOptions = await this.$store.cache.dispatch('cachedData/getSpecialProjectFilter', params)
+        },
+        handleForeignChange(idx) {
+            this.foreign = idx
+        },
+        handleCollectChange(idx) {
+            this.collect = idx
+        },
+        handleNationChange(idx) {
+            // nation & local are exclusive
+            this.nation = idx
+            // this.local = 0 // close the exclusive connection
+        },
+        handleLocalChange(idx) {
+            // nation & local are exclusive
+            this.local = idx
+            // this.nation = 0 // close the exclusive connection
+        },
+        async handleSpecialProjectsMutex(e) {
+            // 注意:此处组件的值表现形式与 PC 的组件并不一致。PC 的 v-model 在此事件中已经更改,而u-view并没有
+            const snapshot = [...this.specialProjects]
+            // 互斥关系 定义最后一个选项与其它选项互斥
+            // 1 如果新选中最后一项,则清空其他选项
+            // 2 反之如果新选择了非最后一项,已选中存在最后一项,则清空最后一项
+            // 3 保存有效选项到snapshot以进行下一次对比
+            await this.$nextTick()
+            const mutexItem = arrayLast(this.specialProjectOptions)
+            if (e.includes(mutexItem)) {
+                if (!snapshot.includes(mutexItem)) this.specialProjects = [mutexItem]
+                else if (e.length > 1) arrayRemove(e, mutexItem), this.specialProjects = e
+            }
+        },
+        handleReset() {
+            this.foreign = 0
+            this.collect = 0
+            this.nation = 0
+            this.local = 0
+            this.specialProjects = []
+        },
+        handleConfirm() {
+            const foreignChanged = this.filter.sinoforeign !== this.currentForeign.value
+            const collectChanged = this.filter.collect !== this.currentCollect.value
+            const nationChanged = this.filter.specialProjectNation !== this.currentNation.value
+            const localChanged = this.filter.specialProjectLocal !== this.currentLocal.value
+            const spChanged = this.filter.specialProjects.toString() != this.specialProjects.toString()
+            this.filter.sinoforeign = this.currentForeign.value
+            this.filter.collect = this.currentCollect.value
+            this.filter.specialProjectNation = this.currentNation.value
+            this.filter.specialProjectLocal = this.currentLocal.value
+            this.filter.specialProjects = this.specialProjects
+            return foreignChanged || collectChanged || nationChanged || localChanged || spChanged
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.recommend-filter-extra {
+  padding: 12px 15px;
+
+  // max-height: 40vh;
+  // overflow-y: scroll;
+
+  .u-checkbox-group--column::v-deep {
+    .u-checkbox-label--right + .u-checkbox-label--right {
+      padding-top: 10px;
+      border-top: 0.5px solid #EEEEEE;
+    }
+  }
+}
+</style>

+ 249 - 0
src/pagesOther/pages/vhs/index/components/recommend-filter-group.vue

@@ -0,0 +1,249 @@
+<template>
+    <u-row class="bg-white fx-wrap">
+        <u-col v-for="tab in list" :key="tab.name" :span="4">
+            <view class="fx-row fx-cen-cen f13" style="height: 32px;" @click="current=tab,popShow=true">
+                <view class="rel">
+                    {{ tab.name }}
+                    <u-badge :show="tab.isActived" is-dot :custom-style="dotStyle"></u-badge>
+                </view>
+                <mx-icons type="arrowdown" class="ml5"></mx-icons>
+            </view>
+        </u-col>
+        <u-col v-if="activedTabs.length" :span="12" class="pl12 pr12">
+            <view class="fx-row fx-bet-cen pl12 pr12 pb10">
+                <view class="fx-row fx-wrap">
+                    <u-tag v-for="item in activedItems" :key="item.text" :text="item.text" type="success" size="mini"
+                           plain closable @close="handleSingleRemove(item)"/>
+                </view>
+                <u-icon name="trash" @click="handleResetAll"></u-icon>
+            </view>
+        </u-col>
+        <u-popup v-if="current" :show="popShow" mode="top" @close="popShow=false">
+            <scroll-view class="popup-scroll-view" scroll-y>
+                <component :is="current.popType" ref="popFilter" :filter="filter" :batch="batch"/>
+            </scroll-view>
+            <view class="text-right pd12">
+                <view class="fx-inline">
+                    <u-button size="small" text="重置" class="mr10" @click="handleReset"/>
+                    <u-button size="small" type="primary" text="确认" @click="handleConfirm"/>
+                </view>
+            </view>
+        </u-popup>
+    </u-row>
+</template>
+
+<script>
+import RecommendFilterPickType from './recommend-filter-pick-type.vue'
+import RecommendFilterExtra from './recommend-filter-extra.vue'
+import RecommendFilterMajor from './recommend-filter-major.vue'
+import RecommendFilterCollege from "./recommend-filter-college.vue"
+import MxConst from '@/common/MxConst'
+import * as Ext from '@/common/tools/ext'
+import SearchMajorInjectionMixin from "@/pagesOther/pages/voluntary/components/SearchMajorInjectionMixin";
+
+export default {
+    name: 'recommend-filter-group',
+    components: {
+        RecommendFilterPickType,
+        RecommendFilterExtra,
+        RecommendFilterMajor,
+        RecommendFilterCollege
+    },
+    mixins: [SearchMajorInjectionMixin],
+    inject: ['fetchOptionKeys'],
+    props: {
+        filter: {
+            type: Object,
+            default: () => ({})
+        },
+        batch: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            dotStyle: {
+                position: 'absolute',
+                right: '-4px',
+                top: 0
+            },
+            list: [
+                {
+                    name: '概率筛选',
+                    isActived: false,
+                    popType: 'recommend-filter-pick-type',
+                    refreshAction: this.pickTypeActived,
+                    getActiveItems: this.getActivePickTypes
+                },
+                {
+                    name: '院校筛选',
+                    isActived: false,
+                    popType: 'recommend-filter-college',
+                    refreshAction: this.collegeActived,
+                    getActiveItems: this.getActiveCollegeFilters
+                },
+                // {
+                //     name: '专业筛选',
+                //     isActived: false,
+                //     popType: 'recommend-filter-major',
+                //     refreshAction: this.majorActived,
+                //     getActiveItems: this.getActiveMajors
+                // }
+            ],
+            current: null,
+            popShow: false
+        }
+    },
+    computed: {
+        activedTabs() {
+            return this.list.filter(tab => tab.isActived)
+        },
+        activedItems() {
+            return this.activedTabs.reduce((total, cur) => {
+                total.push(...cur.getActiveItems())
+                return total
+            }, [])
+        },
+        collegeOptionKeys() {
+            return this.fetchOptionKeys()
+        }
+    },
+    watch: {
+        filter: {
+            immediate: true,
+            deep: true,
+            handler: function () {
+                this.list.forEach(tab => {
+                    tab.isActived = !!tab.refreshAction() // force to boolean
+                })
+            }
+        }
+    },
+    methods: {
+        closePopup() {
+            this.popShow = false
+        },
+        pickTypeActived() {
+            return this.filter.pickType
+        },
+        extraActived() {
+            return this.filter.collect !== '' || this.filter.sinoforeign !== '' ||
+                this.filter.specialProjectNation !== '' || this.filter.specialProjectLocal !== '' ||
+                this.filter.specialProjects.length
+        },
+        majorActived() {
+            return this.filter.majors?.length
+        },
+        collegeActived() {
+            return this.collegeOptionKeys.some(k => this.filter[k.writeKey]?.length)
+        },
+        handleReset() {
+            this.$refs.popFilter.handleReset()
+        },
+        handleConfirm() {
+            const changed = this.$refs.popFilter.handleConfirm()
+            if (changed === undefined) return // special situation
+            this.popShow = false
+            if (changed) this.$emit('search')
+        },
+        // shortcut
+        getActivePickTypes() {
+            const match = MxConst.enum.simulatePickTypes.find(t => t.value == this.filter.pickType)
+            return [{
+                text: match.text,
+                handler: () => this.filter.pickType = ''
+            }]
+        },
+        getActiveExtras() {
+            const results = []
+            if (this.filter.sinoforeign !== '') {
+                const foreignMatch = MxConst.enum.sinoforeignOptions.find(f => f.value === this.filter.sinoforeign)
+                if (foreignMatch) {
+                    results.push({
+                        text: foreignMatch.label,
+                        handler: () => this.filter.sinoforeign = ''
+                    })
+                }
+            }
+            if (this.filter.collect !== '') {
+                const collectMatch = MxConst.enum.collectOptions.find(c => c.value === this.filter.collect)
+                if (collectMatch) {
+                    results.push({
+                        text: collectMatch.label,
+                        handler: () => this.filter.collect = ''
+                    })
+                }
+            }
+            if (this.filter.specialProjectNation !== '') {
+                const nationMatch = MxConst.enum.specialProjectNationOptions.find(n => n.value === this.filter
+                    .specialProjectNation)
+                results.push({
+                    text: nationMatch.label,
+                    handler: () => this.filter.specialProjectNation = ''
+                })
+            }
+            if (this.filter.specialProjectLocal !== '') {
+                const localMatch = MxConst.enum.specialProjectLocalOptions.find(l => l.value === this.filter
+                    .specialProjectLocal)
+                results.push({
+                    text: localMatch.label,
+                    handler: () => this.filter.specialProjectLocal = ''
+                })
+            }
+            this.filter.specialProjects.forEach(spFilter => {
+                results.push({
+                    text: spFilter,
+                    handler: () => Ext.arrayRemove(this.filter.specialProjects, spFilter)
+                })
+            })
+            return results
+        },
+        getActiveMajors() {
+            return this.filter.majors.map(major => ({
+                text: major,
+                handler: () => Ext.arrayRemove(this.filter.majors, major)
+            }))
+        },
+        getActiveCollegeFilters() {
+            const results = []
+            this.collegeOptionKeys.forEach(k => {
+                results.push(...this.filter[k.writeKey].map(v => ({
+                    text: v,
+                    handler: () => Ext.arrayRemove(this.filter[k.writeKey], v)
+                })))
+            })
+            return results
+        },
+        handleSingleRemove(item) {
+            item.handler()
+            this.$nextTick(() => this.$emit('search'))
+        },
+        clearMajorFilters() {
+            this.filter.majors = []
+            this.updateCheckedList(this.filter.majors)
+        },
+        clearCollegeFilters() {
+            this.collegeOptionKeys.forEach(k => this.filter[k.writeKey] = [])
+        },
+        handleResetAll() {
+            this.filter.pickType = ''
+            this.filter.sinoforeign = ''
+            this.filter.collect = ''
+            this.filter.specialProjectNation = ''
+            this.filter.specialProjectLocal = ''
+            this.filter.specialProjects = []
+            this.clearMajorFilters()
+            this.clearCollegeFilters()
+            this.$nextTick(() => this.$emit('search'))
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.popup-scroll-view {
+  max-height: 40vh;
+  height: fit-content;
+}
+</style>

+ 137 - 0
src/pagesOther/pages/vhs/index/components/recommend-filter-major.vue

@@ -0,0 +1,137 @@
+<template>
+    <view class="recommend-filter-major">
+        <!-- <u-sticky offset-top="-44px">{{0}}/{{checkedMax}}</u-sticky> -->
+        <u-loading-icon v-if="loading || !init"/>
+        <u-collapse v-if="init" :value="expands" @open="markOpenMajors">
+            <u-collapse-item v-for="top in majorTree" :key="top.code" :name="top.name">
+                <view slot="title" class="fx-1 fx-row fx-bet-cen pr15">
+                    <text>{{ top.name }}</text>
+                    <text
+                            v-if="checkedMajors[top.name]">
+                        {{ checkedMajors[top.name].length }}/{{ top.children.length }}
+                    </text>
+                </view>
+                <view v-if="top.children.length>3" class="fx-inline self-end mb12">
+                    <u-button type="primary" shape="circle" size="mini" class="ml10" @click="handleCheckAll(top)">全选
+                    </u-button>
+                    <u-button shape="circle" size="mini" class="ml5" @click="handleReverse(top)">反选</u-button>
+                </view>
+                <u-checkbox-group v-if="openedMajors[top.name]" v-model="checkedMajors[top.name]"
+                                  :active-color="$u.color.primary" placement="column" icon-placement="right">
+                    <u-checkbox v-for="major in top.children" :key="major.code" :label="major.name" :name="major.name">
+                    </u-checkbox>
+                </u-checkbox-group>
+            </u-collapse-item>
+        </u-collapse>
+    </view>
+</template>
+
+<script>
+import SearchMajorInjectionMixin from "@/pagesOther/pages/voluntary/components/SearchMajorInjectionMixin";
+
+export default {
+    name: 'recommend-filter-major',
+    mixins: [SearchMajorInjectionMixin],
+    props: {
+        filter: {
+            type: Object,
+            default: () => ({})
+        },
+        batch: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            loading: false,
+            init: false,
+            expands: [],
+            checkedMajors: {},
+            checkedMax: 10,
+            openedMajors: {} // 用于标记2级专业打开情况,全部打开太卡了
+        }
+    },
+    async mounted() {
+        this.loading = true
+        await this.ensureMajorFullTree(this.batch.batch)
+        this.loading = false
+        this.init = false
+        this.initCheckedMajors()
+        this.init = true
+    },
+    methods: {
+        initCheckedMajors() {
+            let exists = [...this.filter.majors]
+            let expands = []
+            this.majorTree.forEach(top => {
+                const subMajors = top.children.map(c => c.name)
+                const checked = exists.filter(item => subMajors.includes(item))
+                exists = exists.filter(item => !checked.includes(item))
+                this.$set(this.checkedMajors, top.name, checked)
+                this.$set(this.openedMajors, top.name, checked)
+                if (checked.length) {
+                    expands.push(top.name)
+                }
+            })
+            this.expands = expands
+        },
+        markOpenMajors() {
+            const opened = Object.keys(this.openedMajors).filter(key => this.openedMajors[key])
+            const delta = this.expands.filter(key => !opened.includes(key))
+            delta.forEach(key => this.openedMajors[key] = true)
+        },
+        handleCheckAll(top) {
+            const all = top.children.map(c => c.name)
+            this.checkedMajors[top.name] = all
+        },
+        handleReverse(top) {
+            const all = top.children.map(c => c.name)
+            const current = this.checkedMajors[top.name]
+            const reverse = all.filter(i => !current.includes(i))
+            this.checkedMajors[top.name] = reverse
+        },
+        handleReset() {
+            Object.keys(this.checkedMajors).forEach(key => this.checkedMajors[key] = [])
+        },
+        handleConfirm() {
+            if (this.loading) return
+
+            const checked = []
+            Object.keys(this.checkedMajors).forEach(key => checked.push(...this.checkedMajors[key]))
+            if (checked.length > this.checkedMax) {
+                this.msgError(`最多选择${this.checkedMax}个专业`)
+                return
+            }
+
+            const checkedCopy = [...checked]
+            const exists = [...this.filter.majors]
+            checkedCopy.sort()
+            exists.sort()
+            const changed = checkedCopy.toString() != exists.toString()
+            if (changed) {
+                this.filter.majors = checked // 保留本身顺序在复显时能更快还原
+                this.updateCheckedList(checked)
+            }
+            return changed
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.recommend-filter-major {
+  padding: 12px 15px;
+
+  // max-height: 40vh;
+  // overflow-y: scroll;
+
+  /deep/ .u-checkbox-group {
+    padding-left: 30px;
+
+    .u-checkbox + .u-checkbox {
+      margin-top: 10px;
+    }
+  }
+}
+</style>

+ 55 - 0
src/pagesOther/pages/vhs/index/components/recommend-filter-pick-type.vue

@@ -0,0 +1,55 @@
+<template>
+	<view class="recommend-filter-pick-type">
+		<u-radio-group v-model="type" :active-color="$u.color.primary" border-bottom placement="column"
+			icon-placement="right">
+			<u-radio label="所有"></u-radio>
+			<u-radio v-for="opt in typeOptions" :key="opt.value" :name="opt.value" :label="opt.text"></u-radio>
+		</u-radio-group>
+	</view>
+</template>
+
+<script>
+	import MxConst from '@/common/MxConst'
+	export default {
+		name: 'recommend-filter-pick-type',
+		props: {
+			filter: {
+				type: Object,
+				default: () => ({})
+			}
+		},
+		data() {
+			return {
+				typeOptions: MxConst.enum.simulatePickTypes,
+				type: ''
+			}
+		},
+		mounted() {
+			this.type = this.filter.pickType
+		},
+		methods: {
+			handleReset() {
+				this.type = ''
+			},
+			handleConfirm() {
+				const changed = this.type !== this.filter.pickType
+				this.filter.pickType = this.type
+				return changed
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.recommend-filter-pick-type {
+		padding: 12px 15px;
+
+		.u-radio+.u-radio {
+			margin-top: 10px;
+		}
+
+		.u-button+.u-button {
+			margin-left: 10px;
+		}
+	}
+</style>

+ 109 - 0
src/pagesOther/pages/vhs/index/components/recommend-score-range.vue

@@ -0,0 +1,109 @@
+<template>
+    <slider-range v-if="sliderAvailable" :min="scoreLimit.availableRange[0]" :max="scoreLimit.availableRange[1]"
+                  :value="scoreRange" :activeColor="$u.color.primary" @change="confirmScoreRange"></slider-range>
+    <view v-else class="fx-1">
+        <u-divider text="无最佳推荐分数范围" :text-size="13" :custom-style="{margin: '0'}"/>
+    </view>
+</template>
+
+<script>
+import SliderRange from '@/components/primewind-sliderrange/components/primewind-sliderrange/index.vue'
+
+export default {
+    name: 'RecommendScoreRange',
+    components: {
+        SliderRange
+    },
+    inject: ['fetchScoreBatchRange', 'fetchVoluntaryData'],
+    props: {
+        formSubject: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            scoreRange: [0, 100],
+        }
+    },
+    computed: {
+        voluntaryData() {
+            // noinspection JSUnresolvedFunction
+            return this.fetchVoluntaryData()
+        },
+        scoreBatchRange() {
+            // noinspection JSUnresolvedFunction
+            return this.fetchScoreBatchRange()
+        },
+        scoreLimit() {
+            const emptyDefault = {
+                defaultRange: [],
+                availableRange: []
+            }
+            if (!this.scoreBatchRange[0] || !this.scoreBatchRange[1]) return emptyDefault
+            const min = this.scoreBatchRange[0] * 1
+            const max = this.voluntaryData.maxScore * 1 // requirements change: no need to limit top score
+            const current = this.formSubject.score * 1
+            const expectMax = current + 20
+            const expectMin = current - 30
+            const defaultRange = [Math.max(expectMin, min), Math.min(expectMax, max)]
+            const availableMax = expectMax + 10
+            const availableMin = expectMin - 20
+            const availableRange = [Math.max(availableMin, min), Math.min(availableMax, max)]
+            return {
+                defaultRange,
+                availableRange
+            }
+        },
+        sliderAvailable() {
+            return this.scoreLimit.defaultRange[1] > this.scoreLimit.defaultRange[0]
+        }
+    },
+    watch: {
+        scoreLimit: {
+            handler() {
+                console.log('calculate score limit', this.scoreLimit)
+                this.resetScoreRange()
+            },
+            immediate: true
+        },
+    },
+    methods: {
+        resetScoreRange() {
+            if (this.scoreLimit.defaultRange.length != 2) return
+            this.scoreRange = this.scoreLimit.defaultRange
+        },
+        confirmScoreRange(e) {
+            // NOTE: ext.debounce do not support arguments in uni-app, but it works fine in pc.
+            this.$u.debounce(() => {
+                if (this.scoreRange.toString() === e.toString()) return
+                this.scoreRange = e
+                this.$emit('change', e)
+            }, 1000)
+        }
+    }
+}
+</script>
+
+<style scoped lang="scss">
+.slider-range ::v-deep {
+  padding-top: 0;
+
+  .slider-range-inner {
+    height: 24px !important;
+  }
+
+  .slider-handle-block {
+    width: 32px !important;
+    border-radius: 10px;
+    background-color: $uni-color-primary !important;
+  }
+
+  .range-tip {
+    color: #FFFFFF;
+    top: 20px;
+    z-index: 99;
+    pointer-events: none;
+  }
+}
+</style>

+ 169 - 0
src/pagesOther/pages/vhs/index/components/score-batch-popup.vue

@@ -0,0 +1,169 @@
+<template>
+    <uv-popup ref="popup" mode="bottom" round="16" closeable @change="handleChange">
+        <view class="fx-row fx-cen-cen h-[50px]">
+            <view class="text-lg font-bold text-main">修改分数/批次</view>
+        </view>
+        <score-form ref="form" :model="modelCopy"/>
+        <view class="px-20">
+            <uv-line margin="5px 0 0 0"/>
+            <uv-cell title="选择批次:" is-link title-style="font-size: 14px; color: #333333;" @click="openBatchList">
+                <template #value>
+                    <view v-if="batchCopy.batch" class="fx-row items-center text-lg">
+                        {{ batchCopy.name }}
+                        <uv-tags v-if="batchCopy.recommand" text="重点推荐" size="tiny" type="error" shape="circle"
+                                 class="ml-5 pointer-events-none"/>
+                    </view>
+                </template>
+            </uv-cell>
+        </view>
+        <view class="px-40 h-[60px] fx-row fx-cen-cen">
+            <uv-button class="!flex-1" shape="circle" type="primary" text="确认修改" @click="handleConfirm"/>
+        </view>
+    </uv-popup>
+    <uv-action-sheet ref="actionSheet" :actions="formatList" @select="handleBatchSelection"/>
+</template>
+
+<script setup>
+import {ref, computed, watch} from 'vue';
+import _ from 'lodash';
+import ScoreForm from "@/pagesOther/pages/voluntary/index/components/score-form.vue";
+import {useInjectVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useInjectVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import {confirmAsync} from "@/utils/uni-helper";
+
+const emits = defineEmits(['change'])
+
+const popup = ref(null)
+const form = ref(null)
+const show = ref(false)
+const actionSheet = ref(null)
+const modelCopy = ref({})
+const batchCopy = ref({})
+const listCopy = ref([])
+const {model, batch, batchList, getBatchList} = useInjectVoluntaryForm()
+const {selectedList} = useInjectVoluntaryCart()
+
+const formatList = computed(() => {
+    return listCopy.value.map(i => ({...i, subname: i.recommand ? '重点推荐' : ''}))
+})
+
+const openBatchList = () => {
+    actionSheet.value.open()
+}
+const handleBatchSelection = (item) => {
+    batchCopy.value = item
+}
+
+const reloadBatchListDebounce = _.debounce(async (score) => {
+    // score for debounce validation
+    if (score != modelCopy.value.score) return
+    const list = await getBatchList(modelCopy.value)
+    if (score != modelCopy.value.score) return
+    listCopy.value = list
+    // validate current batch
+    const fired = list.find(b => b.batch == batchCopy.value.batch) || {}
+    batchCopy.value = fired
+}, 800)
+
+const handleConfirm = async () => {
+    await form.value.validate()
+    if (!batchCopy.value?.batch) return toast('请选择批次')
+    // wait user confirm
+    const change = batch.value.batch != batchCopy.value.batch ||
+        model.value.score != modelCopy.value.score ||
+        model.value.seatInput != modelCopy.value.seatInput
+    if (change && selectedList.value.length) await confirmAsync('修改将清空当前志愿表')
+    // apply to real models
+    _.assign(model.value, modelCopy.value)
+    batch.value = batchCopy.value
+    batchList.value = listCopy.value
+    // popup hidden
+    close()
+}
+
+const open = () => {
+    // make copy from real models
+    modelCopy.value = _.clone(model.value)
+    batchCopy.value = batch.value
+    listCopy.value = [...batchList.value]
+    popup.value.open()
+}
+
+const close = () => {
+    popup.value.close()
+}
+
+const handleChange = (e) => show.value = e.show
+
+watch(() => modelCopy.value.score, (score) => {
+    if (!score) return
+    reloadBatchListDebounce(score)
+})
+
+defineExpose({open, close, show})
+// export default {
+//     name: "score-batch-popup",
+//     components: {ScoreStep},
+//     props: {
+//         show: {
+//             type: Boolean,
+//             default: false
+//         },
+//         form: {
+//             type: Object,
+//             default: () => {
+//             }
+//         },
+//         batch: {
+//             type: Number | String,
+//             default: ''
+//         },
+//         batchList: {
+//             type: Array,
+//             default: () => []
+//         }
+//     },
+//     data() {
+//         return {
+//             openBatchList: false
+//         }
+//     },
+//     computed: {
+//         firedBatch() {
+//             return this.batchList.find(b => this.batch == b.batch)
+//         },
+//         formatBatchList() {
+//             return this.batchList.map(b => ({
+//                 ...b,
+//                 subname: b.recommand ? '重点推荐' : ''
+//             }))
+//         }
+//     },
+//     methods: {
+//         handleBatchSelection(item) {
+//             this.$emit('update:batch', item.batch)
+//         },
+//         async validate() {
+//             await this.$refs.score.validate()
+//             if (!this.firedBatch) {
+//                 const error = '请选择批次'
+//                 this.$message.error(error)
+//                 return Promise.reject(error)
+//             }
+//         },
+//         async handleConfirm() {
+//             await this.validate()
+//             this.$emit('confirm')
+//         }
+//     }
+// }
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-cell) {
+    .uv-cell__body {
+        padding: 10px 5px;
+    }
+}
+</style>

+ 113 - 0
src/pagesOther/pages/vhs/index/components/score-form.vue

@@ -0,0 +1,113 @@
+<template>
+    <view class="p-20 text-sm text-main">
+        <uv-text v-if="isScoreLocked&&false" type="error" :text="MxConfig.scoreLockedTips"/>
+        <uv-text v-if="isScoreUnlocked&&false" type="error" :text="MxConfig.scoreRuleTips"/>
+        <course-selector/>
+        <uv-line margin="15px 0 0 0"/>
+        <uv-input v-model="model.score" :disabled="isScoreLocked" placeholder="请输入您的分数"
+                  v-bind="inputCommon">
+            <template #prefix>您的分数:</template>
+        </uv-input>
+        <uv-input v-if="false" v-model="model.rank.lowestRank" disabled v-bind="inputCommon">
+            <template #prefix>匹配位次:</template>
+        </uv-input>
+        <uv-input v-model="model.seatInput" placeholder="输入成绩单位次" v-bind="inputCommon">
+            <template #prefix>填写位次:</template>
+        </uv-input>
+        <view class="font-[PingFang] text-content text-2xs mt-20">
+            <uv-icon name="info-circle" class="mr3" style="display: inline-block"/>
+            已根据最新位次表获取分数的最低位次,您也可以修改为成绩单上的位次
+            <template v-if="model.rank&&isScoreLocked">,位次区间[{{
+                    model.rank.highestRank
+                }}~{{ model.rank.lowestRank }}]
+            </template>
+            。
+            <text class="underline text-primary" @click="goQuerySegment">查看位次</text>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {watch} from 'vue';
+import MxConfig from "@/common/mxConfig";
+import {createPropDefine} from "@/utils";
+import {useUserStore} from "@/hooks/useUserStore";
+import {useInjectTransfer} from "@/hooks/useTransfer";
+import {useInjectVoluntaryData} from "@/hooks/useVoluntaryDataInjection";
+import CourseSelector from "@/pagesOther/pages/voluntary/index/components/course-selector.vue";
+import debounce from "@/uni_modules/uv-ui-tools/libs/function/debounce";
+import {useCacheStore} from "@/hooks/useCacheStore";
+import {getRankByScore} from "@/api/webApi/volunteer";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+
+const props = defineProps({
+    model: createPropDefine({}, Object)
+})
+const inputCommon = {
+    type: 'number',
+    border: 'bottom',
+    fontSize: '18px',
+    customStyle: {height: '30px'}
+}
+
+const {isScoreLocked, isScoreUnlocked} = useUserStore()
+const {transferTo} = useInjectTransfer()
+const {validate, voluntaryData} = useInjectVoluntaryData()
+const {dispatchCache} = useCacheStore()
+
+const goQuerySegment = () => {
+    transferTo('/pages/career/query-segment/query-segment')
+}
+
+const setRankByScore = async () => {
+    const {firstSubject, score} = props.model
+    if (!score) {
+        props.model.seatInput = ''
+        props.model.rank = {}
+        return
+    }
+
+    const payload = {mode: firstSubject, scoreRank: score}
+    const res = await dispatchCache(getRankByScore, payload)
+    // 2次校验,防止串分
+    if (payload.scoreRank != props.model.score) return
+    props.model.rank = res.data
+    const {lowestRank, highestRank} = res.data
+    const {seatInput} = props.model
+    if (seatInput >= highestRank && seatInput <= lowestRank) return
+    props.model.seatInput = lowestRank
+}
+
+const validateForm = async function () {
+    const {score, seatInput, rank} = props.model
+    // score validate
+    await validate(score)
+    // seat validate
+    let error = ''
+    if (!seatInput) error = '请输入位次'
+    else if (seatInput && !/^[1-9]\d*$/.test(seatInput)) error = '请输入合法的位次'
+    else if (!isScoreUnlocked.value && (seatInput < rank.highestRank || seatInput > rank.lowestRank)) error = `位次必须在[${rank.highestRank}~${rank.lowestRank}]之间`
+    if (error) {
+        toast(error)
+        return Promise.reject(error)
+    }
+}
+
+watch([() => props.model.firstSubject, () => props.model.score * 1], async ([firstSubject, score]) => {
+    // console.log('watch firstSubject and score', firstSubject, score)
+    if (/*!firstSubject || */!score) return // 单招没有firstSubject
+    // error input fix, when props.model.score assigned, watch will be triggered again.
+    if (score > voluntaryData.value.maxScore) return props.model.score = voluntaryData.value.maxScore
+    else if (score < 0) return props.model.score = 0
+    // silence validation
+    await validate(score, true)
+
+    debounce(setRankByScore)
+})
+
+defineExpose({validate: validateForm})
+</script>
+
+<style scoped>
+
+</style>

+ 70 - 0
src/pagesOther/pages/vhs/index/components/score-step.vue

@@ -0,0 +1,70 @@
+<template>
+    <view class="tabs-swiper-content">
+        <uv-image :src="banner" width="100vw" height="auto" mode="widthFix" class="-mt-[44px]"/>
+        <view class="mx-30 -mt-60 bg-white rounded-lg relative z-20">
+            <view class="fx-col items-center py-50">
+                <view class="fx-row items-center gap-5 mb-10" @click="$refs.popup.open()">
+                    <uv-icon name="question-circle" color="primary"/>
+                    <view class="text-sm text-primary">填报须知</view>
+                </view>
+                (一)输入考试成绩
+            </view>
+            <score-form ref="scoreForm" :model="model"/>
+        </view>
+        <view class="px-30 my-40 fx-row fx-bet-cen gap-30">
+            <view class="flex-2">
+                <uv-button type="primary" plain shape="circle" :icon="iconList"
+                           :custom-style="{height: '44px'}" @click="goVoluntaryList">
+                    <view class="text-primary keep-all">我的志愿表</view>
+                </uv-button>
+            </view>
+            <view class="flex-3">
+                <uv-button type="primary" shape="circle" text="下一步"
+                           color="linear-gradient(to right, var(--primary-deep-color), var(--primary-light-color))"
+                           :custom-style="{height: '44px', border: 'none'}" @click="handleForward"/>
+            </view>
+        </view>
+        <mx-popup-template ref="popup" title="填报须知" left="" right="我知道了" @right="$refs.popup.close()">
+            <view class="text-main text-xs fx-col gap-30">
+                <view v-for="line in noticeTips" class="indent-50">{{ line }}</view>
+            </view>
+        </mx-popup-template>
+    </view>
+</template>
+
+<script setup>
+import {ref} from 'vue';
+import {combineOssFile} from "@/utils";
+import ScoreForm from "@/pagesOther/pages/voluntary/index/components/score-form.vue";
+import {useInjectTransfer} from "@/hooks/useTransfer";
+import {useInjectVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useInjectVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import {useInjectVoluntaryStep} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryStepInjection";
+import {useInjectVoluntaryData} from "@/hooks/useVoluntaryDataInjection";
+
+const banner = combineOssFile('/static/voluntary/voluntary_banner.png')
+const iconList = combineOssFile('/static/voluntary/voluntary_list_icon.png')
+const noticeTips = [
+    '本系统提供高考志愿填报智能模拟功能,不等同于实际的网上填报志愿,正式填报请登录省考试院指定填报网站。',
+    '各地的高考政策不同,模拟志愿填报会根据当前注册用户所属地自动匹配。',
+    '本系统数据均来自省考试院公布的当年招生计划和历年录取数据,推荐结果仅供您模拟参考使用。正式填报时请务必参阅省考试院发布的相关招生计划书籍,如遇到数据错漏请以考试院公布信息为准。',
+]
+
+const {transferTo} = useInjectTransfer()
+const {currentStep} = useInjectVoluntaryStep()
+const {model} = useInjectVoluntaryForm()
+const {validate} = useInjectVoluntaryData()
+const {handleForward, onBeforeForward} = useInjectVoluntaryAssistant()
+const scoreForm = ref(null)
+
+const goVoluntaryList = () => {
+    transferTo('/pages/voluntary/list/list')
+}
+
+onBeforeForward(async () => {
+    if (currentStep.value == 0) await scoreForm.value.validate()
+})
+</script>
+
+<style scoped lang="scss">
+</style>

+ 48 - 0
src/pagesOther/pages/vhs/index/components/voluntary-bottom.vue

@@ -0,0 +1,48 @@
+<template>
+    <view class="h-[50px] bg-white px-20 fx-row fx-bet-cen mx-shadow-up">
+        <view class="flex-1 fx-row fx-sta-cen text-content text-2xs" @click="openModify">
+            {{ `${model.score}分 ${model.seatInput || model.rank.lowestRank}位` }}
+            {{ batch.name }} {{ userSnapshot.examMajorName }}
+            <uv-icon v-if="!id" name="edit-pen-fill" color="primary" class="ml-10"/>
+        </view>
+        <uv-tags shape="circle" :text="previewText" @click="openVolunteer"/>
+    </view>
+</template>
+
+<script setup>
+import {computed, ref} from 'vue';
+import {useInjectVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {useInjectVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+import {useInjectUserSnapshot} from "@/pagesOther/pages/ie/hooks/useUserSnapshotInjection";
+
+const emits = defineEmits(['cart', 'modify'])
+
+const {model, batch, simpleMode} = useInjectVoluntaryForm()
+const {id, selectedList} = useInjectVoluntaryCart()
+const {userSnapshot} = useInjectUserSnapshot()
+
+const previewText = computed(() => {
+    const len = selectedList.value.length
+    const lenStr = len ? `(${len})` : ''
+    return '预览志愿表' + lenStr
+})
+
+const openModify = () => {
+    if (id.value) return
+    emits('modify')
+}
+
+const openVolunteer = () => {
+    if (!selectedList.value.length) return toast('至少选择1个专业组')
+    emits('cart')
+}
+</script>
+
+<style scoped lang="scss">
+::v-deep(.uv-tags) {
+    .uv-tags--medium {
+        padding: 3px 10px;
+    }
+}
+</style>

+ 373 - 0
src/pagesOther/pages/vhs/index/components/voluntary-cart-popup.vue

@@ -0,0 +1,373 @@
+<template>
+    <uv-popup ref="popup" mode="bottom" round="16" closeable>
+        <view class="h-[50px] px-40 fx-row fx-bet-cen text-main text-lg font-bold">
+            <template v-if="!id">
+                志愿表预览
+            </template>
+            <view v-else class="fx-row flex-1 pr-30">
+                <text v-if="!nameEditing">{{ name }}</text>
+                <uv-input v-else v-model="name" placeholder="志愿表名称"/>
+                <uv-icon name="edit-pen" size="18" class="ml-10" @click="nameEditing=!nameEditing"/>
+            </view>
+            <uv-tags icon="list-dot" text="快速排序" plain class="mr-50" @click="openSortList"/>
+        </view>
+        <scroll-view :scroll-y="!anyDragging" class="bg-bg" style="height: 50vh" lower-threshold="100"
+                     @scrolltolower="handleGroupScroll">
+            <uv-sticky v-if="firedSorts.length" :offsetTop="-44">
+                <view class="px-20 pb-20 fx-row bg-white">
+                    <uv-tags v-for="s in firedSorts" :key="s.name" :text="s.short" :icon="s.icon" size="mini"
+                             type="success" closable plain-fill @close="handleSortRemove(s)"/>
+                </view>
+            </uv-sticky>
+            <view class="p-20 fx-col gap-20">
+                <view v-for="(college,index) in pagedSelectedList" class="fx-row fx-bet-sta bg-white mx-card">
+                    <view class="ml-10 mb-10 text-sm">
+                        <text class="fx-row rounded-b-full px-15 pt-5 pb-10 text-white bg-primary">
+                            {{ generateSeq(index) }}
+                        </text>
+                    </view>
+                    <view class="flex-1 p-20 fx-col">
+                        <view class="font-bold">
+                            {{ college.university.name }}
+                            <!-- TODO:志愿表还未保存group信息,所以这里显示会在编辑时表现不一致 -->
+                            <template v-if="false">({{ college.recruitPlan.group }})</template>
+                            ({{ college.recruitPlan.collegeCode }})
+                        </view>
+                        <view class="fx-row fx-bet-cen mt-10 text-content text-2xs gap-20">
+                            <view>录取概率:
+                                <view class="font-bold text-main">
+                                    {{ college.enrollRatio || '-' }}%
+                                </view>
+                            </view>
+                            <view>最低位次:
+                                <view class="font-bold text-main">
+                                    {{ college.history && college.history.seat || '-' }}
+                                </view>
+                            </view>
+                            <view>院校排名:
+                                <view class="font-bold text-main">
+                                    {{ college.university.ranking || '-' }}
+                                </view>
+                            </view>
+                        </view>
+                        <view class="fx-row gap-20 my-20">
+                            <uv-button :disabled="selectedList.length<2" type="primary" size="mini" plain
+                                       shape="circle" icon="arrow-down-fill" :text="generateSeq(index)"
+                                       icon-color="primary" @click="openSeqSelect(index)"/>
+                            <uv-button :disabled="index==0" type="primary" size="mini" plain shape="circle"
+                                       icon="arrow-up" icon-color="primary" @click="handleMoveUp(college)"/>
+                            <uv-button :disabled="index==selectedList.length-1" type="primary" size="mini" plain
+                                       shape="circle" icon="arrow-down" icon-color="primary"
+                                       @click="handleMoveDown(college)"/>
+                            <uv-button type="primary" size="mini" plain shape="circle" icon="trash"
+                                       icon-color="primary" @click="handleRemoveAll(college)"/>
+                        </view>
+                        <m-drag ref="drag" :list="getSelectedSortedMajors(college)" :item-height="44"
+                                @change="handleDragComplete">
+                            <template #default="{item:major,index:majorIndex}">
+                                <view class="fx-row items-center text-xs text-content h-[44px] box-border mx-border-b">
+                                    <view class="flex-1 truncate">
+                                        <text v-if="majorIndex>-1" class="mr-20">{{ majorIndex + 1 }}</text>
+                                        <text :class="{'highlight-major': isFormedMajorFired(major)}">
+                                            {{ major.marjorName }}[{{ major.marjorBelongs }}]
+                                        </text>
+                                    </view>
+                                    <!-- 因为手机上区域比较小,只保留了拖拽排序 -->
+                                    <uv-icon v-if="false" name="arrow-up" size="20px" class="mr10"
+                                             @click="handleMajorUp(major, college)"></uv-icon>
+                                    <uv-icon v-if="false" name="arrow-down" size="20px" class="mr10"
+                                             @click="handleMajorDown(major, college)"></uv-icon>
+                                    <uv-icon name="trash" size="20px" class="mr10"
+                                             @click="handleMajorDelete(major, college)"></uv-icon>
+                                </view>
+                            </template>
+                        </m-drag>
+                    </view>
+                </view>
+            </view>
+        </scroll-view>
+        <view class="h-[50px] px-40 fx-row items-center mx-border-t">
+            <uv-button type="primary" text="保存志愿表" shape="circle" :loading="locking"
+                       @click="handleSave"></uv-button>
+        </view>
+        <uv-action-sheet ref="actionSheet" :actions="sortList" :cancel-text="cancelSortText" @select="handleSort"
+                         @cancel="handleSortReset"/>
+        <!--  We don't use `u-action-sheet` because there maybe a great number of seq options  -->
+        <uv-picker ref="picker" title="指定志愿顺序" :columns="[sequenceOptions]" :default-index="[seqIndex]"
+                   @confirm="handleSeq"/>
+    </uv-popup>
+</template>
+
+<script setup>
+import {ref, computed, watch} from 'vue';
+import {useInjectVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {toValue} from "@vueuse/core";
+import {useVoluntarySortService} from "@/pagesOther/pages/voluntary/hooks/useVoluntarySortService";
+import {useInjectVoluntaryMajorHighlight} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorHighlightInjection";
+import {confirmAsync} from "@/utils/uni-helper";
+import {useInjectVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import {useInjectVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+
+const popup = ref(null)
+const drag = ref(null)
+const picker = ref(null)
+const actionSheet = ref(null)
+const seqIndex = ref(0)
+const nameEditing = ref(false) // 暂时不允许修改名称
+const {id, name, locking, selectedList, defaultSort, recalculatePureSelectedList} = useInjectVoluntaryCart()
+const {isFormedMajorFired, snapshotSearchingMajorWhenApply} = useInjectVoluntaryMajorHighlight()
+const {
+    firedSorts, sortList, cancelSortText, generateSeq, sequenceOptions,
+    handleSort, handleSortReset, handleSortRemove,
+    getSelectedSortedMajors, sortInterrupt,
+    handleMoveDown, handleMoveUp
+} = useVoluntarySortService(popup, defaultSort)
+const {isMock} = useInjectVoluntaryHeader()
+const {save} = useInjectVoluntaryAssistant()
+const anyDragging = computed(() => drag.value?.some(d => d.dragging))
+
+// optimization for faster rendering
+const localPageNum = ref(1)
+const localPageSize = ref(4)
+const pagedSelectedList = computed(() => {
+    return selectedList.value.slice(0, toValue(localPageNum) * toValue(localPageSize))
+})
+const handleGroupScroll = () => {
+    if (toValue(pagedSelectedList).length >= toValue(selectedList).length) return
+    localPageNum.value += 1
+}
+
+const openSortList = () => {
+    actionSheet.value.open()
+}
+
+const handleDragComplete = (newList, oldList) => {
+    // make localPriority exchange when drag complete.
+    const copyPriorities = oldList.map(i => i.localPriority)
+    copyPriorities.forEach((p, i) => newList[i].localPriority = p)
+}
+
+const handleRemoveAll = async (college) => {
+    await confirmAsync('确认删除该专业组下的全部专业?')
+    college.majors.forEach(m => {
+        m.selected = false
+        snapshotSearchingMajorWhenApply(m)
+    })
+    recalculatePureSelectedList()
+    sortInterrupt()
+}
+
+// sort for major item
+const handleMajorUp = (major, majorGroup) => {
+    // TODO: HM-DragSorts 已经没有再使用了,所以不存在深拷贝的问题,但这个方法目前没有使用,先不优化
+    const source = getSelectedSortedMajors(majorGroup)
+    // HM-DragSorts clone element will cause index error
+    const index = source.findIndex(s => s.marjorBelongs == major.marjorBelongs)
+    const targetIndex = index - 1
+    // exchange two majors' localPriority
+    if (targetIndex >= 0) {
+        major = source[index] // this is the original major
+        const targetMajor = source[targetIndex]
+        const temp = major.localPriority
+        major.localPriority = targetMajor.localPriority
+        targetMajor.localPriority = temp
+    }
+}
+const handleMajorDown = (major, majorGroup) => {
+    // TODO: HM-DragSorts 已经没有再使用了,所以不存在深拷贝的问题,但这个方法目前没有使用,先不优化
+    const source = getSelectedSortedMajors(majorGroup)
+    // HM-DragSorts clone element will cause index error
+    const index = source.findIndex(s => s.marjorBelongs == major.marjorBelongs)
+    const targetIndex = index + 1
+    // exchange two majors' localPriority
+    if (targetIndex < source.length) {
+        major = source[index] // this is the original major
+        const targetMajor = source[targetIndex]
+        const temp = major.localPriority
+        major.localPriority = targetMajor.localPriority
+        targetMajor.localPriority = temp
+    }
+}
+const handleMajorDelete = (major) => {
+    major.selected = false
+    snapshotSearchingMajorWhenApply(major)
+    if (recalculatePureSelectedList()) sortInterrupt()
+}
+
+const openSeqSelect = (groupIndex) => {
+    seqIndex.value = groupIndex
+    picker.value.open()
+}
+const handleSeq = ({indexs}) => {
+    const oldIndex = seqIndex.value
+    const newIndex = indexs[0]
+    if (oldIndex != newIndex) {
+        const seqGroup = selectedList.value[oldIndex]
+        selectedList.value.splice(oldIndex, 1)
+        selectedList.value.splice(newIndex, 0, seqGroup)
+        sortInterrupt()
+    }
+}
+
+const handleSave = async () => {
+    await save(isMock.value)
+}
+
+const open = () => {
+    localPageNum.value = 1
+    popup.value.open()
+}
+
+const close = () => {
+    popup.value.close()
+}
+
+defineExpose({open, close})
+// export default {
+//     props: {
+//         id: {
+//             type: String | Number,
+//             default: 0
+//         },
+//         name: {
+//             type: String,
+//             default: ''
+//         },
+//         show: {
+//             type: Boolean,
+//             default: false
+//         },
+//         selectedList: {
+//             type: Array,
+//             default: () => []
+//         },
+//         defaultSort: {
+//             type: Array,
+//             default: () => []
+//         },
+//         locking: {
+//             type: Boolean,
+//             default: false
+//         }
+//     },
+//     data() {
+//         return {
+//             nameEditing: false,
+//             confirmGroup: null,
+//             showConfirm: false,
+//             showSort: false,
+//             showSeq: false,
+//             seqIndex: 0,
+//         }
+//     },
+//     watch: {
+//         show: function (val) {
+//             // u-popup will release dom elements when not showing
+//             // so reset the current page number here
+//             if (val) this.localPageNum = 1
+//         }
+//     },
+//     methods: {
+//         getSelectedSortedMajors(group) {
+//             return group.majors.filter(m => m.selected).sort(MxConst.recommendMajorSortFn)
+//         },
+//         getGroupHeight(group) {
+//             const majors = this.getSelectedSortedMajors(group)
+//             return majors.length * 45
+//         },
+//         openSeqSelect(groupIndex) {
+//             this.seqIndex = groupIndex
+//             this.showSeq = true
+//         },
+//         handleSeq({indexs}) {
+//             const oldIndex = this.seqIndex
+//             const newIndex = indexs[0]
+//             if (oldIndex != newIndex) {
+//                 const seqGroup = this.selectedList[oldIndex]
+//                 this.selectedList.splice(oldIndex, 1)
+//                 this.selectedList.splice(newIndex, 0, seqGroup)
+//                 this.sortInterrupt()
+//             }
+//             this.showSeq = false
+//         },
+//         close() {
+//             this.$emit('closeVolunteer')
+//         },
+//         open() {
+//
+//         },
+//         save() {
+//             this.$emit('save')
+//         },
+//         handleRemoveAll() {
+//             this.confirmGroup.majors.forEach(m => {
+//                 m.selected = false
+//                 this.snapshotSearchingMajorWhenApply(m)
+//             })
+//             this.$emit('change')
+//             this.showConfirm = false
+//         },
+//         // sort for major item
+//         handleMajorUp(major, majorGroup) {
+//             const source = this.getSelectedSortedMajors(majorGroup)
+//             // HM-DragSorts clone element will cause index error
+//             const index = source.findIndex(s => s.marjorBelongs == major.marjorBelongs)
+//             const targetIndex = index - 1
+//             // exchange two majors' localPriority
+//             if (targetIndex >= 0) {
+//                 major = source[index] // this is the original major
+//                 const targetMajor = source[targetIndex]
+//                 const temp = major.localPriority
+//                 major.localPriority = targetMajor.localPriority
+//                 targetMajor.localPriority = temp
+//             }
+//         },
+//         handleMajorDown(major, majorGroup) {
+//             const source = this.getSelectedSortedMajors(majorGroup)
+//             // HM-DragSorts clone element will cause index error
+//             const index = source.findIndex(s => s.marjorBelongs == major.marjorBelongs)
+//             const targetIndex = index + 1
+//             // exchange two majors' localPriority
+//             if (targetIndex < source.length) {
+//                 major = source[index] // this is the original major
+//                 const targetMajor = source[targetIndex]
+//                 const temp = major.localPriority
+//                 major.localPriority = targetMajor.localPriority
+//                 targetMajor.localPriority = temp
+//             }
+//         },
+//         deleteMajor(major, majorGroup) {
+//             const source = this.getSelectedSortedMajors(majorGroup)
+//             // HM-DragSorts clone element will cause index error
+//             const index = source.findIndex(s => s.marjorBelongs == major.marjorBelongs)
+//             major = source[index] // this is the original major
+//             major.selected = false
+//             this.snapshotSearchingMajorWhenApply(major)
+//             this.$emit('change')
+//         },
+//         handleMajorDrag(group, event) {
+//             const source = this.getSelectedSortedMajors(group)
+//             const oldIndex = event.index * 1
+//             const newIndex = event.moveTo * 1
+//             if (oldIndex == newIndex) return
+//             // get localPriority one by one
+//             const oldSeq = source.map(m => m.localPriority)
+//             const oldMajor = source[oldIndex]
+//             const newMajor = source[newIndex]
+//             const newSource = [...source]
+//             newSource.splice(oldIndex, 1)
+//             newSource.splice(newIndex, 0, oldMajor)
+//             // assign oldSeq to newSource one by one
+//             newSource.forEach((m, idx) => {
+//                 m.localPriority = oldSeq[idx]
+//             })
+//         }
+//     }
+// }
+</script>
+
+<style scoped lang="scss">
+.uv-button-wrapper {
+    flex: 1 !important;
+}
+</style>

+ 82 - 0
src/pagesOther/pages/vhs/index/components/voluntary-history-list.vue

@@ -0,0 +1,82 @@
+<template>
+    <view v-if="list.length" class="mt-20">
+        <uv-row class="text-2xs text-content">
+            <uv-col :span="3">年份</uv-col>
+            <uv-col :span="3">录取</uv-col>
+            <uv-col :span="3">最低分</uv-col>
+            <uv-col :span="3">最低位次</uv-col>
+        </uv-row>
+        <uv-row v-for="(history, idx) in list.filter(h => h)" class="mt-10 text-xs text-content">
+            <uv-col :span="3">
+                <view v-if="history">{{ history.year }}</view>
+                <view v-else>{{ historyYears[idx] }}</view>
+            </uv-col>
+            <template v-if="history">
+                <uv-col :span="3">
+                    <view>
+                        {{ history.numReal || '-' }}
+                        <text>人</text>
+                    </view>
+                </uv-col>
+                <uv-col :span="3">
+                    <view class="fx-row">
+                        <text v-if="!history.inheritScore">{{ history.score || '-' }}</text>
+                        <view v-else class="fx-row" @click="$emit('notify', inheritScoreTips)">
+                            {{ history.score || '-' }}
+                            <uv-icon name="question-circle"/>
+                        </view>
+                        <uv-badge v-if="history.collect" value="征集" shape="horn" class="mr3"
+                                  @click.native="$emit('notify', history.collectDesc)"/>
+                    </view>
+                </uv-col>
+                <uv-col :span="3">
+                    <view>{{ history.seat || '-' }}</view>
+                </uv-col>
+            </template>
+            <template v-else>
+                <uv-col :span="9">
+                    <view class="fx-row" @click="$emit('notify', unmatchedTips)">
+                        未招生
+                        <uv-icon name="question-circle"/>
+                    </view>
+                </uv-col>
+            </template>
+        </uv-row>
+    </view>
+</template>
+
+<script>
+import {useInjectVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+
+export default {
+    name: "voluntary-history-list",
+    emits: ['notify'],
+    props: {
+        container: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            unmatchedTips: '未招生,或者专业名称/备注发生变化',
+            inheritScoreTips: '院校或专业组分数线,专业分数线未更新或者官网未公布'
+        }
+    },
+    setup() {
+        const {historyYears} = useInjectVoluntaryHeader()
+        return {
+            historyYears
+        }
+    },
+    computed: {
+        list() {
+            return this.container?.histories || []
+        }
+    }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 96 - 0
src/pagesOther/pages/vhs/index/components/voluntary-item.vue

@@ -0,0 +1,96 @@
+<template>
+    <view class="fx-row fx-bet-sta bg-white mx-card">
+        <view class="fx-col fx-sta-cen ml-10 mb-10">
+            <view class="fx-row rounded-b-full px-15 pt-5 pb-10 text-white" :class="pickType.bg">
+                <text>{{ pickType.text }}</text>
+            </view>
+            <view class="text-main fx-row fx-cen-base gap-5">
+                <view class="font-bold text-xl">{{ item.enrollRatio || '-' }}</view>
+                %
+            </view>
+            <view class="text-content text-xs keep-all">
+                {{ item.enrollRatioText }}
+            </view>
+        </view>
+        <view class="flex-1 fx-col p-20 gap-15 text-main">
+            <view class="fx-row fx-bet-sta gap-15">
+                <view class="flex-1" @click="goCollegePage">
+                    <view class="font-[PingFang] font-bold">
+                        {{ item.university.name }}
+                        <template v-if="item.recruitPlan.group">({{ item.recruitPlan.group }})</template>
+                        ({{ item.recruitPlan.collegeCode }})
+                        <text v-if="item.specialProject" class="text-primary flex-nowrap">({{
+                                item.specialProject
+                            }})
+                        </text>
+                    </view>
+                    <view class="mt-10 text-tips text-2xs">{{ item.university.features.split(',').join(' / ') }}</view>
+                    <view class="mt-5 text-tips text-2xs">
+                        {{
+                            `${item.university.location} ${item.university.cityName} / ${item.university.type} / ${item.university.natureTypeCN} / 排名${item.university.rankingOfEdu || '-'}`
+                        }}
+                    </view>
+                </view>
+                <view class="fx-col fx-sta-cen relative">
+                    <uv-tags :plain="!item.majors.filter(major => {return major.selected}).length" shape="circle"
+                             type="primary" :text="`专业 ${item.recruitPlan.majorCount}`"
+                             @click="$emit('major')"/>
+                    <view class="text-2xs text-main absolute -bottom-35"
+                          v-if="item.majors.filter(major => {return major.selected}).length">
+                        已填 {{ item.majors.filter(major => major.selected).length }}
+                    </view>
+                </view>
+            </view>
+            <view class="text-2xs">
+                <view class="text-xs">
+                    {{ `${headerPlanYear}计划 ${item.recruitPlan.planCount}人 ${userSnapshot.examMajorName}` }}
+                </view>
+                <voluntary-history-list :container="item" @notify="$emit('notify', $event)"/>
+            </view>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {computed} from 'vue';
+import {createPropDefine} from "@/utils";
+import {useInjectTransfer} from "@/hooks/useTransfer";
+import {useInjectVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+import {useInjectUserSnapshot} from "@/pagesOther/pages/ie/hooks/useUserSnapshotInjection";
+import VoluntaryHistoryList from "@/pagesOther/pages/voluntary/index/components/voluntary-history-list.vue";
+
+const props = defineProps({
+    item: createPropDefine({}, Object)
+})
+defineEmits(['major', 'notify'])
+
+const {transferTo} = useInjectTransfer()
+const {headerPlanYear} = useInjectVoluntaryHeader()
+const {userSnapshot} = useInjectUserSnapshot()
+
+const pickType = computed(() => {
+    switch (props.item.pickType) {
+        case 0:
+            return {bg: 'bg-error', text: '冲'}
+        case 1:
+            return {bg: 'bg-warning', text: '稳'}
+        case 2:
+            return {bg: 'bg-success', text: '保'}
+    }
+    return {bg: '', text: ''}
+})
+
+const goCollegePage = () => {
+    const path = '/pagesOther/pages/college-library/detail/detail'
+    const {university: {code}} = props.item
+    transferTo(path, {code})
+}
+</script>
+
+<style lang="scss" scoped>
+::v-deep(.uv-tags) {
+    .uv-tags__text--medium {
+        font-size: 12px !important;
+    }
+}
+</style>

+ 19 - 0
src/pagesOther/pages/vhs/index/components/voluntary-search.vue

@@ -0,0 +1,19 @@
+<template>
+    <view class="px-20 relative pt-20">
+        <mx-search v-model="queryParams.name" placeholder="请输入院校名称" @search="handleSearch"
+                   @clear="handleSearch"/>
+    </view>
+</template>
+
+<script setup>
+import {useInjectVoluntarySearch} from "@/pagesOther/pages/voluntary/hooks/useVoluntarySearchInjection";
+
+const emits = defineEmits(['search'])
+const {queryParams} = useInjectVoluntarySearch()
+
+const handleSearch = () => emits('search')
+</script>
+
+<style scoped>
+
+</style>

+ 60 - 0
src/pagesOther/pages/vhs/index/index.vue

@@ -0,0 +1,60 @@
+<template>
+    <view class="page-content">
+        <mx-nav-bar v-bind="navBinding"/>
+        <view ref="container" class="flex-1 min-h-1">
+            <swiper :current="currentStep" disable-touch :style="{height: height+'px'}">
+                <swiper-item>
+                    <score-step/>
+                </swiper-item>
+                <swiper-item>
+                    <batch-step/>
+                </swiper-item>
+                <swiper-item>
+                    <cart-step/>
+                </swiper-item>
+            </swiper>
+        </view>
+    </view>
+</template>
+
+<script setup>
+import {ref} from 'vue';
+import {useProvideTransfer} from "@/hooks/useTransfer";
+import {useProvideVoluntaryStep} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryStepInjection";
+import {useProvideVoluntaryData} from "@/hooks/useVoluntaryDataInjection";
+import {useProvideVoluntaryForm} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryFormInjection";
+import {useProvideVoluntaryCart} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryCartInjection";
+import {useProvideVoluntaryAssistant} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryAssistantInjection";
+import ScoreStep from "@/pagesOther/pages/voluntary/index/components/score-step.vue";
+import {useElementSize} from "@vueuse/core";
+import {useProvideUserSnapshot} from "@/pagesOther/pages/ie/hooks/useUserSnapshotInjection";
+import BatchStep from "@/pagesOther/pages/voluntary/index/components/batch-step.vue";
+import CartStep from "@/pagesOther/pages/voluntary/index/components/cart-step.vue";
+import {useProvideVoluntaryMajorHighlight} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryMajorHighlightInjection";
+import {useProvideVoluntaryHeader} from "@/pagesOther/pages/voluntary/hooks/useVoluntaryHeaderInjection";
+
+useProvideUserSnapshot()
+const {transferTo} = useProvideTransfer()
+const stepSvc = useProvideVoluntaryStep()
+const dataSvc = useProvideVoluntaryData()
+const formSvc = useProvideVoluntaryForm()
+const cartSvc = useProvideVoluntaryCart()
+const highlightSvc = useProvideVoluntaryMajorHighlight()
+useProvideVoluntaryHeader()
+
+const {currentStep} = stepSvc
+const container = ref(null) // swiper必须指定明确的高度,所以多包了一层
+const {height} = useElementSize(container)
+
+const assistantSvc = useProvideVoluntaryAssistant(stepSvc, dataSvc, formSvc, cartSvc, highlightSvc, height)
+const {navBinding, onComplete, resetAll} = assistantSvc
+
+onComplete((id) => {
+    transferTo('/pages/voluntary/detail/detail', {id})
+    currentStep.value = 0
+    resetAll()
+})
+</script>
+
+<style scoped lang="scss">
+</style>

+ 68 - 0
src/pagesOther/pages/vhs/list/list.vue

@@ -0,0 +1,68 @@
+<template>
+    <z-paging ref="paging" v-model="list" @query="handleQuery">
+        <template #top>
+            <mx-nav-bar title="我的志愿表"/>
+        </template>
+        <view class="p-30 fx-col gap-30">
+            <view v-for="item in list" class="bg-white mx-card p-30 fx-row fx-bet-cen" @click="goDetails(item)">
+                <view class="fx-col gap-10">
+                    <text class="font-bold text-main"> {{ item.name }}</text>
+                    <text class="text-tips text-sm">
+                        {{ `${item.score}分 ${item.batchName || ''} ${item.userSnapshot.examMajorName}` }}
+                    </text>
+                </view>
+                <view class="fx-row">
+                    <view class="w-80 h-80 fx-row fx-cen-cen" @click.stop="handleDelete(item)">
+                        <uv-icon name="trash" size="20"/>
+                    </view>
+                    <uv-icon name="arrow-right"></uv-icon>
+                </view>
+            </view>
+        </view>
+    </z-paging>
+</template>
+
+<script>
+import {delZytbRecord, selectZytbRecord} from '@/api/webApi/volunteer'
+import {useProvideTransfer} from "@/hooks/useTransfer";
+import {confirmAsync} from "@/utils/uni-helper";
+import {toast} from "@/uni_modules/uv-ui-tools/libs/function";
+
+export default {
+    data() {
+        return {
+            list: []
+        }
+    },
+    setup() {
+        const {transferTo} = useProvideTransfer()
+        return {
+            transferTo
+        }
+    },
+    methods: {
+        goDetails(data) {
+            this.transferTo('/pages/voluntary/detail/detail', data, null, true)
+        },
+        handleQuery(pageNum, pageSize) {
+            selectZytbRecord({pageNum, pageSize}).then(res => {
+                res.rows.forEach(r => {
+                    if (typeof r.userSnapshot === 'string')
+                        r.userSnapshot = JSON.parse(r.userSnapshot)
+                    if (!r.userSnapshot) r.userSnapshot = {examMajorName: ''}
+                })
+                this.$refs.paging.completeByTotal(res.rows, res.total)
+            }).catch(e => this.$refs.paging.complete(false))
+        },
+        async handleDelete(item) {
+            await confirmAsync(`确认删除'${item.name}'`)
+            await delZytbRecord({id: item.id})
+            toast('删除成功')
+            this.$refs.paging.reload()
+        }
+    }
+}
+</script>
+
+<style scoped>
+</style>

+ 3 - 0
src/store/userStore.ts

@@ -128,6 +128,9 @@ export const useUserStore = defineStore('ie-user', {
     isVHS(): boolean {
       return this.getExamType === EnumExamType.VHS;
     },
+    examMajorName(): string {
+      return this.userInfo.examMajorName || '';
+    },
     isBindWechat(): boolean {
       return !!this.userInfo.wxOpenId;
     }

+ 2 - 0
src/types/user.ts

@@ -163,6 +163,8 @@ export interface UserInfo {
   campusName?: string;
   classSelect: number; // 0: 不可修改班级 1: 可修改班级
   wxOpenId?: string;
+
+  examMajorName?: string; // 职高对口的专业类别
 }
 
 export interface VipCardInfo {