Jelajahi Sumber

志愿模块添加vip权限

shmily1213 1 bulan lalu
induk
melakukan
5b7a30a477

+ 15 - 27
src/components/ie-page/components/vip-popup.vue

@@ -1,32 +1,20 @@
 <template>
-  <!-- #ifdef H5 -->
-  <teleport to="body">
-    <!-- #endif -->
-    <!-- #ifdef MP-WEIXIN -->
-    <root-portal externalClass="theme-ie">
-      <!-- #endif -->
-      <uv-popup ref="popupRef" mode="center" :close-on-click-overlay="true" :closeable="false" :round="16">
-        <view class="theme-ie w-[88vw] box-border px-54 pt-60 pb-80 bg-white text-center relative overflow-hidden">
-          <view class="relative z-1">
-            <view class="text-40 text-fore-title font-bold">
-              <text>当前无</text>
-              <text class="text-primary">VIP</text>
-              <text>权限</text>
-            </view>
-            <view class="mt-10 text-32 text-fore-light">开通会员,立即畅享专属权益与服务</view>
-            <!-- <ie-button custom-class="mt-60" @click="handleBuy">升级VIP权限</ie-button> -->
-            <ie-button type="info" custom-class="mt-40" @click="handleActivate">已线下购买,去激活</ie-button>
-          </view>
-          <ie-image :is-oss="true" src="/study-bg14.png" custom-class="absolute bottom-0 left-0 w-full h-full z-0"
-            mode="aspectFill" />
+  <ie-popup ref="popupRef" mode="center">
+    <view class="w-[88vw] box-border px-54 pt-60 pb-80 bg-white text-center relative overflow-hidden">
+      <view class="relative z-1">
+        <view class="text-40 text-fore-title font-bold">
+          <text>当前无</text>
+          <text class="text-primary">VIP</text>
+          <text>权限</text>
         </view>
-      </uv-popup>
-      <!-- #ifdef MP-WEIXIN -->
-    </root-portal>
-    <!-- #endif -->
-    <!-- #ifdef H5 -->
-  </teleport>
-  <!-- #endif -->
+        <view class="mt-10 text-32 text-fore-light">开通会员,立即畅享专属权益与服务</view>
+        <!-- <ie-button custom-class="mt-60" @click="handleBuy">升级VIP权限</ie-button> -->
+        <ie-button type="info" custom-class="mt-40" @click="handleActivate">已线下购买,去激活</ie-button>
+      </view>
+      <ie-image :is-oss="true" src="/study-bg14.png" custom-class="absolute bottom-0 left-0 w-full h-full z-0"
+        mode="aspectFill" />
+    </view>
+  </ie-popup>
 </template>
 <script lang="ts" setup>
 import { useTransferPage } from '@/hooks/useTransferPage';

+ 1 - 5
src/components/ie-page/ie-page.vue

@@ -81,13 +81,9 @@ const addListener = () => {
 const removeListener = () => {
   uni.$off(EnumEvent.OPEN_VIP_POPUP);
 }
-onMounted(() => {
+onShow(() => {
   addListener();
 });
-onBeforeUnmount(() => {
-  removeListener();
-});
-
 onHide(() => {
   removeListener();
 });

+ 1 - 1
src/components/ie-popup/ie-popup.vue

@@ -7,7 +7,7 @@
       <!-- #endif -->
       <uv-popup ref="popupRef" :mode="mode" :round="round" popup-class="theme-ie"
         :close-on-click-overlay="closeOnClickOverlay" :safeAreaInsetBottom="mode === 'bottom'" @close="handleClose">
-        <template v-if="showToolbar">
+        <template v-if="mode === 'bottom' && showToolbar">
           <ie-popup-toolbar :title="title" :cancelText="cancelText" :confirmText="confirmText" :showCancel="showCancel"
             :showConfirm="showConfirm" @cancel="handleCancel" @confirm="handleConfirm" />
         </template>

+ 85 - 80
src/pagesOther/pages/skill/index/index.vue

@@ -1,99 +1,106 @@
 <template>
-    <ie-page bg-color="#F6F8FA">
-        <ie-navbar title="职业技能分测算" transparent bg-color="#FFFFFF" title-color="black" keep-title-color/>
-        <!-- #ifdef MP-WEIXIN -->
-        <uv-gap height="44"/>
-        <!-- #endif -->
-        <ie-image is-oss src="/volunteer/skill/index/bg.png" custom-class="w-full h-600"/>
-        <view class="-mt-420 z-1">
-            <view class="px-36 flex justify-between items-center">
-                <view class="text-3xl text-primary keep-all">
-                    <view class="mb-10 font-bold">测职业技能分</view>
-                    <view class="text-base">输入分数,测算所需职业技能分</view>
-                </view>
-                <ie-image is-oss src="/volunteer/skill/index/banner.png" custom-class="w-208 h-208"/>
-            </view>
-            <view class="mt-60 mx-30 bg-white rounded-xl p-35">
-                <view class="flex justify-between items-center">
-                    <view class="text-lg text-fore-title">报考院校专业</view>
-                    <view class="text-base text-fore-placeholder flex items-center" @click="handleSelect">
-                        <text>请选择</text>
-                        <uv-icon name="arrow-right" color="info"/>
-                    </view>
-                </view>
-                <ie-empty v-if="!rules.length" :image="emptyImg" text="请选择你的报考院校专业~"/>
-                <voluntary-form v-else ref="form" disable-simulate/>
-            </view>
+  <ie-page bg-color="#F6F8FA">
+    <ie-navbar title="职业技能分测算" transparent bg-color="#FFFFFF" title-color="black" keep-title-color />
+    <!-- #ifdef MP-WEIXIN -->
+    <uv-gap height="44" />
+    <!-- #endif -->
+    <ie-image is-oss src="/volunteer/skill/index/bg.png" custom-class="w-full h-600" />
+    <view class="-mt-420 z-1">
+      <view class="px-36 flex justify-between items-center">
+        <view class="text-3xl text-primary keep-all">
+          <view class="mb-10 font-bold">测职业技能分</view>
+          <view class="text-base">输入分数,测算所需职业技能分</view>
         </view>
-        <ie-safe-toolbar :height="84" :shadow="false">
-            <view class="px-30 py-16">
-                <ie-button @click="handleSubmit">开始计算</ie-button>
-            </view>
-        </ie-safe-toolbar>
-    </ie-page>
+        <ie-image is-oss src="/volunteer/skill/index/banner.png" custom-class="w-208 h-208" />
+      </view>
+      <view class="mt-60 mx-30 bg-white rounded-xl p-35">
+        <view class="flex justify-between items-center">
+          <view class="text-lg text-fore-title">报考院校专业</view>
+          <view class="text-base text-fore-placeholder flex items-center" @click="handleSelect">
+            <text>请选择</text>
+            <uv-icon name="arrow-right" color="info" />
+          </view>
+        </view>
+        <ie-empty v-if="!rules.length" :image="emptyImg" text="请选择你的报考院校专业~" />
+        <voluntary-form v-else ref="form" disable-simulate />
+      </view>
+    </view>
+    <ie-safe-toolbar :height="84" :shadow="false">
+      <view class="px-30 py-16">
+        <ie-button @click="handleSubmit">开始计算</ie-button>
+      </view>
+    </ie-safe-toolbar>
+  </ie-page>
 </template>
 
 <script setup lang="ts">
 import VoluntaryForm from "@/pagesOther/pages/voluntary/index/components/voluntary-form.vue";
-import {useTransferPage} from "@/hooks/useTransferPage";
-import {routes} from "@/common/routes";
-import {VOLUNTARY_TARGET, VOLUNTARY_RULES, VOLUNTARY_MODEL} from "@/types/injectionSymbols";
-import {EnrollRule, SelectedUniversityMajor, VoluntaryDto, VoluntaryModel, VoluntaryResult} from "@/types/voluntary";
+import { useTransferPage } from "@/hooks/useTransferPage";
+import { routes } from "@/common/routes";
+import { VOLUNTARY_TARGET, VOLUNTARY_RULES, VOLUNTARY_MODEL } from "@/types/injectionSymbols";
+import { EnrollRule, SelectedUniversityMajor, VoluntaryDto, VoluntaryModel, VoluntaryResult } from "@/types/voluntary";
 import config from "@/config";
-import {UniversityPickerPageOptions} from "@/types/transfer";
-import {getSkillRules, postSkillRules} from "@/api/modules/voluntary";
+import { UniversityPickerPageOptions } from "@/types/transfer";
+import { getSkillRules, postSkillRules } from "@/api/modules/voluntary";
+import { useAuth } from "@/hooks/useAuth";
+import { EnumUserRole } from "@/common/enum";
 
+const { hasPermission } = useAuth();
 const emptyImg = computed(() => config.ossUrl + '/volunteer/voluntary/index/empty_data.png')
 
 const form = ref<InstanceType<typeof VoluntaryForm>>()
 const target = ref<SelectedUniversityMajor>({} as SelectedUniversityMajor)
 const rules = ref<EnrollRule[]>([])
 const model = ref<VoluntaryModel>({})
-const {transferTo} = useTransferPage()
+const { transferTo } = useTransferPage()
 
 const handleSelect = async () => {
-    const option: UniversityPickerPageOptions = {
-        title: '选择你的报考院校专业',
-        fromVoluntary: true,
-        selectedUniversityId: target.value?.universityId,
-        selectedMajorId: target.value?.majorId
-    }
-    const picked = await transferTo(routes.targetPicker, {data: option})
-    if (!picked) return
-    target.value = picked as SelectedUniversityMajor
-    uni.$ie.showLoading()
-    try {
-        // reset
-        model.value = {}
-        rules.value = []
-        // request render rules
-        const res = await getSkillRules(target.value)
-        rules.value = res.data
-        // init model
-        rules.value.forEach((r) => {
-            r.details?.forEach((d) => {
-                if (d.options?.length) {
-                    model.value[d.fieldName] = d.defaultValue ? d.defaultValue : ''
-                    model.value[d.fieldName + 'Total'] = d.options ? d.options[0] : null
-                }
-            })
-        })
-    } catch (e) {
-        console.log('getRenderRules ex', e, target.value)
-        target.value = {} as SelectedUniversityMajor // clear for re-pick
-    } finally {
-        uni.$ie.hideLoading()
-    }
+  const option: UniversityPickerPageOptions = {
+    title: '选择你的报考院校专业',
+    fromVoluntary: true,
+    selectedUniversityId: target.value?.universityId,
+    selectedMajorId: target.value?.majorId
+  }
+  const picked = await transferTo(routes.targetPicker, { data: option })
+  if (!picked) return
+  target.value = picked as SelectedUniversityMajor
+  uni.$ie.showLoading()
+  try {
+    // reset
+    model.value = {}
+    rules.value = []
+    // request render rules
+    const res = await getSkillRules(target.value)
+    rules.value = res.data
+    // init model
+    rules.value.forEach((r) => {
+      r.details?.forEach((d) => {
+        if (d.options?.length) {
+          model.value[d.fieldName] = d.defaultValue ? d.defaultValue : ''
+          model.value[d.fieldName + 'Total'] = d.options ? d.options[0] : null
+        }
+      })
+    })
+  } catch (e) {
+    console.log('getRenderRules ex', e, target.value)
+    target.value = {} as SelectedUniversityMajor // clear for re-pick
+  } finally {
+    uni.$ie.hideLoading()
+  }
 }
 
 const handleSubmit = async () => {
-    if (!target.value.universityId||!target.value.majorId) return uni.$ie.showToast('请选择院校专业')
-    if (!rules.value.length) return uni.$ie.showToast('由于官方未公布历年录取分数或计划变更,暂时无法计算技能分')
-    await form.value?.validate()
-    // make request
-    const {data: result} = await postSkillRules(target.value, model.value)
-    const bigData: VoluntaryDto = {target: target.value, rules: rules.value, model: model.value, result}
-    transferTo(routes.skillResult, {bigData})
+  const hasAuth = hasPermission([EnumUserRole.VIP]);
+  if (!hasAuth) {
+    return;
+  }
+  if (!target.value.universityId || !target.value.majorId) return uni.$ie.showToast('请选择院校专业')
+  if (!rules.value.length) return uni.$ie.showToast('由于官方未公布历年录取分数或计划变更,暂时无法计算技能分')
+  await form.value?.validate()
+  // make request
+  const { data: result } = await postSkillRules(target.value, model.value)
+  const bigData: VoluntaryDto = { target: target.value, rules: rules.value, model: model.value, result }
+  transferTo(routes.skillResult, { bigData })
 }
 
 provide(VOLUNTARY_TARGET, target)
@@ -105,6 +112,4 @@ onPageScroll(() => {
 })
 </script>
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 25 - 27
src/pagesOther/pages/skill/result/result.vue

@@ -1,43 +1,43 @@
 <template>
-    <ie-page bg-color="#F6F8FA">
-        <ie-navbar title="测职业技能分"/>
-        <ie-image is-oss src="/volunteer/skill/index/bg.png" custom-class="w-full h-600 absolute"/>
-        <view class="p-28 z-2">
-            <view class="rounded-xl overflow-hidden">
-                <college-item :item="target.info" hidden-star reverse/>
-                <uv-line/>
-                <college-summary :college="target.info"/>
-            </view>
-            <skill-result />
-        </view>
-        <ie-safe-toolbar :height="84" :shadow="false">
-            <view class="px-30 py-16">
-                <ie-button @click="handleSubmit">加入志愿表</ie-button>
-            </view>
-        </ie-safe-toolbar>
-    </ie-page>
+  <ie-page bg-color="#F6F8FA">
+    <ie-navbar title="测职业技能分" />
+    <ie-image is-oss src="/volunteer/skill/index/bg.png" custom-class="w-full h-600 absolute" />
+    <view class="p-28 z-2">
+      <view v-if="target.info" class="rounded-xl overflow-hidden">
+        <college-item :item="target.info" hidden-star reverse />
+        <uv-line />
+        <college-summary :college="target.info" />
+      </view>
+      <skill-result />
+    </view>
+    <ie-safe-toolbar :height="84" :shadow="false">
+      <view class="px-30 py-16">
+        <ie-button @click="handleSubmit">加入志愿表</ie-button>
+      </view>
+    </ie-safe-toolbar>
+  </ie-page>
 </template>
 
 <script setup lang="ts">
 
-import {VOLUNTARY_TARGET, VOLUNTARY_RULES, VOLUNTARY_MODEL, VOLUNTARY_RESULT} from "@/types/injectionSymbols";
-import {useTransferPage} from "@/hooks/useTransferPage";
-import {VoluntaryDto} from "@/types/voluntary";
+import { VOLUNTARY_TARGET, VOLUNTARY_RULES, VOLUNTARY_MODEL, VOLUNTARY_RESULT } from "@/types/injectionSymbols";
+import { useTransferPage } from "@/hooks/useTransferPage";
+import { VoluntaryDto } from "@/types/voluntary";
 import VoluntaryFormMajor from "@/pagesOther/pages/voluntary/index/components/voluntary-form-major.vue";
 import CollegeItem from "@/pagesOther/pages/university/index/components/plus/college-item.vue";
 import CollegeSummary from "@/pagesOther/pages/university/index/components/plus/college-summary.vue";
 import SkillResult from "@/pagesOther/pages/skill/result/components/skill-result.vue";
-import {addVoluntary} from "@/api/modules/voluntary";
+import { addVoluntary } from "@/api/modules/voluntary";
 
-const {prevData} = useTransferPage<VoluntaryDto, any>()
+const { prevData } = useTransferPage<VoluntaryDto, any>()
 const target = computed(() => prevData.value.target || {})
 const rules = computed(() => prevData.value.rules || [])
 const model = computed(() => prevData.value.model || {})
 const result = computed(() => prevData.value.result || {})
 
 const handleSubmit = async () => {
-    await addVoluntary(target.value)
-    uni.$ie.showSuccess('保存成功')
+  await addVoluntary(target.value)
+  uni.$ie.showSuccess('保存成功')
 }
 
 provide(VOLUNTARY_TARGET, target)
@@ -49,6 +49,4 @@ onPageScroll(() => {
 })
 </script>
 
-<style lang="scss">
-
-</style>
+<style lang="scss"></style>

+ 11 - 0
src/pagesOther/pages/university/detail/components/plan-enroll-list.vue

@@ -67,7 +67,10 @@ 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";
 
+const { hasPermission } = useAuth();
 
 const props = withDefaults(defineProps<{
   mode: HistoryMode;
@@ -142,12 +145,20 @@ 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 handleRateVoluntary = (item: IPlanEnrollHistory) => {
+  const hasAuth = hasPermission([EnumUserRole.VIP]);
+  if (!hasAuth) {
+    return;
+  }
   const selected: SelectedUniversityMajor = {
     universityId: baseInfo.value.code,
     universityLogo: baseInfo.value.logo,

+ 49 - 51
src/pagesOther/pages/university/index/components/plus/college-item.vue

@@ -1,39 +1,39 @@
 <template>
-    <view class="p-20 bg-white flex justify-between items-start gap-20" @click="emits('click')">
-        <ie-image v-if="!hiddenLogo&&!reverse" :src="item.logo" mode="aspectFit" custom-class="w-120 h-120"/>
-        <view class="flex-1 flex flex-col gap-10">
-            <view v-if="showName||showStar" class="flex justify-between items-center gap-20">
-                <uv-text v-if="showName" type="main" bold :text="item.name"/>
-                <uv-tags v-if="showStar" v-bind="starBinding" :text="item.star"/>
-            </view>
-            <view v-if="bxTags.length" class="flex flex-wrap gap-8">
-                <uv-tags v-for="t in bxTags" :text="t" v-bind="getHighlightBindings(t)" @click="handleTagClick(t)"/>
-            </view>
-            <slot v-if="!hiddenAddress" name="address">
-                <uv-text type="tips" prefix-icon="empty-address" :lines="addressLines" :icon-style="{color: '#999999'}"
-                         size="12" :text="item.address"/>
-            </slot>
-        </view>
-        <ie-image v-if="!hiddenLogo&&reverse" :src="item.logo" mode="aspectFit" custom-class="w-120 h-120"/>
-        <slot name="right"/>
+  <view class="p-20 bg-white flex justify-between items-start gap-20" @click="emits('click')">
+    <ie-image v-if="!hiddenLogo && !reverse" :src="item.logo" mode="aspectFit" custom-class="w-120 h-120" />
+    <view class="flex-1 flex flex-col gap-10">
+      <view v-if="showName || showStar" class="flex justify-between items-center gap-20">
+        <uv-text v-if="showName" type="main" bold :text="item.name" />
+        <uv-tags v-if="showStar" v-bind="starBinding" :text="item.star" />
+      </view>
+      <view v-if="bxTags.length" class="flex flex-wrap gap-8">
+        <uv-tags v-for="t in bxTags" :key="t" :text="t" v-bind="getHighlightBindings(t)" @click="handleTagClick(t)" />
+      </view>
+      <slot v-if="!hiddenAddress" name="address">
+        <uv-text type="tips" prefix-icon="empty-address" :lines="addressLines" :icon-style="{ color: '#999999' }"
+          size="12" :text="item.address" />
+      </slot>
     </view>
+    <ie-image v-if="!hiddenLogo && reverse" :src="item.logo" mode="aspectFit" custom-class="w-120 h-120" />
+    <slot name="right" />
+  </view>
 </template>
 
 <script setup lang="ts">
 import _ from 'lodash';
-import {University} from "@/types/university";
+import { University } from "@/types/university";
 
 const props = withDefaults(defineProps<{
-    item: University;
-    hiddenLogo?: boolean;
-    hiddenName?: boolean;
-    hiddenStar?: boolean;
-    hiddenAddress?: boolean;
-    addressLines?: number;
-    reverse?: boolean;
+  item: University;
+  hiddenLogo?: boolean;
+  hiddenName?: boolean;
+  hiddenStar?: boolean;
+  hiddenAddress?: boolean;
+  addressLines?: number;
+  reverse?: boolean;
 }>(), {
-    item: () => ({} as University),
-    addressLines: 1
+  item: () => ({} as University),
+  addressLines: 1
 })
 const emits = defineEmits(['tag', 'click'])
 
@@ -42,50 +42,48 @@ const showName = computed(() => !props.hiddenName)
 const showStar = computed(() => !props.hiddenStar && props.item.star)
 
 const tagAttrs = {
-    type: 'info',
-    plain: true,
-    size: 'tiny',
-    'class': 'pointer-events-none'
+  type: 'info',
+  plain: true,
+  size: 'tiny',
+  'class': 'pointer-events-none'
 }
 const highlights = ['双高']
 const starBinding = {
-    ...tagAttrs,
-    type: 'warning'
+  ...tagAttrs,
+  type: 'warning'
 }
 const tagHighlight = {
-    ...tagAttrs,
-    type: 'primary',
-    plainFill: true
+  ...tagAttrs,
+  type: 'primary',
+  plainFill: true
 }
 
 const bxTags = computed(() => {
-    const {bxLevel, bxType} = props.item
-    const tags = bxLevel ? bxLevel.split(',') : []
-    if (bxType) {
-        _.pull(tags, '双高')
-        tags.push(bxType)
-    }
-    return tags
+  const { bxLevel, bxType } = props.item
+  const tags = bxLevel ? bxLevel.split(',') : []
+  if (bxType) {
+    _.pull(tags, '双高')
+    tags.push(bxType)
+  }
+  return tags
 })
 
 const isSpecialTag = (tag: string) => {
-    return !isCultural.value && tag == props.item.bxType
+  return !isCultural.value && tag == props.item.bxType
 }
 
 const isHighlight = (tag: string) => {
-    return highlights.includes(tag) || isSpecialTag(tag)
+  return highlights.includes(tag) || isSpecialTag(tag)
 }
 
 const getHighlightBindings = (tag: string) => {
-    const attrs = isHighlight(tag) ? tagHighlight : tagAttrs
-    return isSpecialTag(tag) ? {...attrs, icon: 'question-circle', reverse: true, 'class': ''} : attrs
+  const attrs = isHighlight(tag) ? tagHighlight : tagAttrs
+  return isSpecialTag(tag) ? { ...attrs, icon: 'question-circle', reverse: true, 'class': '' } : attrs
 }
 
 const handleTagClick = (tag: string) => {
-    if (isSpecialTag(tag)) emits('tag')
+  if (isSpecialTag(tag)) emits('tag')
 }
 </script>
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 7 - 0
src/pagesOther/pages/voluntary/index/index.vue

@@ -34,7 +34,10 @@ import {useTransferPage} from "@/hooks/useTransferPage";
 import {routes} from "@/common/routes";
 import {UniversityPickerPageOptions} from "@/types/transfer";
 import {getRenderRules, postRenderRules} from "@/api/modules/voluntary";
+import { useAuth } from "@/hooks/useAuth";
+import { EnumUserRole } from "@/common/enum";
 
+const { hasPermission } = useAuth();
 const emptyImg = computed(() => config.ossUrl + '/volunteer/voluntary/index/empty_data.png')
 
 const form = ref<InstanceType<typeof VoluntaryForm>>()
@@ -83,6 +86,10 @@ const processSelected = async (picked: SelectedUniversityMajor) => {
 }
 
 const handleSubmit = async () => {
+    const hasAuth = hasPermission([EnumUserRole.VIP]);
+    if (!hasAuth) {
+        return;
+    }
     if (!target.value.universityId || !target.value.majorId) return uni.$ie.showToast('请选择院校专业')
     if (!rules.value.length) return uni.$ie.showToast('由于官方未公布历年录取分数或计划变更,暂时无法计算录取概率')
     await form.value?.validate()

+ 29 - 28
src/pagesOther/pages/voluntary/list/components/voluntary-item.vue

@@ -1,53 +1,54 @@
 <template>
-    <view class="bg-white rounded-xl p-28 flex justify-between items-center gap-20 relative">
-        <view v-if="showIndex" class="absolute left-0 top-10 px-20 py-12 rounded-r-full text-24 font-bold"
-              :class="showIndex.clazz">
-            {{ showIndex.text }}
-        </view>
-        <view class="flex-1" :class="{'mt-50': showIndex}">
-            <view class="flex justify-between items-center gap-20">
-                <ie-image :src="data.universityLogo" mode="aspectFit" custom-class="w-48 h-48"/>
-                <view class="text-30 font-bold text-fore-title flex-1">{{ data.universityName }}</view>
-                <uv-icon name="more-dot-fill" size="20" @click="$emit('more')"/>
-            </view>
-            <voluntary-majors-draggable v-model:majors="data.majors" @change="handleSortUpdate" @delete="handleDelete" />
-        </view>
+  <view class="bg-white rounded-xl p-28 flex justify-between items-center gap-20 relative">
+    <view v-if="showIndex" class="absolute left-0 top-10 px-20 py-12 rounded-r-full text-24 font-bold"
+      :class="showIndex.clazz">
+      {{ showIndex.text }}
     </view>
+    <view class="flex-1" :class="{ 'mt-50': showIndex }">
+      <view class="flex justify-between items-center gap-20">
+        <ie-image :src="data.universityLogo" mode="aspectFit" custom-class="w-48 h-48" />
+        <view class="text-30 font-bold text-fore-title flex-1">{{ data.universityName }}</view>
+        <uv-icon name="more-dot-fill" size="20" @click="$emit('more')" />
+      </view>
+      <voluntary-majors-draggable-list v-model:majors="data.majors" @change="handleSortUpdate" @delete="handleDelete" />
+    </view>
+  </view>
 </template>
 
 <script lang="ts" setup>
-import {VoluntaryMajorItem, VoluntaryRecord} from "@/types/voluntary";
-import VoluntaryMajorsDraggable from "@/pagesOther/pages/voluntary/list/components/voluntary-majors-draggable.vue";
-import {removeVoluntaryByMajor, sortVoluntaryByMajor} from "@/api/modules/voluntary";
+import { VoluntaryMajorItem, VoluntaryRecord } from "@/types/voluntary";
+import VoluntaryMajorsDraggableList from "./voluntary-majors-draggable-list.vue";
+import { removeVoluntaryByMajor, sortVoluntaryByMajor } from "@/api/modules/voluntary";
 
 const props = defineProps<{
-    index: number;
-    data: VoluntaryRecord;
+  index: number;
+  data: VoluntaryRecord;
 }>()
 const emits = defineEmits(['more', 'error', 'deleted'])
 
 const indexOptions = [{
-    text: '第一志愿',
-    clazz: 'bg-warning-light text-warning-dark'
+  text: '第一志愿',
+  clazz: 'bg-warning-light text-warning-dark'
 }, {
-    text: '第二志愿',
-    clazz: 'bg-primary-100 text-primary'
+  text: '第二志愿',
+  clazz: 'bg-primary-100 text-primary'
 }]
 const showIndex = computed(() => indexOptions[props.index])
 
 const handleSortUpdate = () => {
-    sortVoluntaryByMajor(props.data.universityId, props.data.majors.map(m => m.majorId))
-        .catch((e: any) => emits('error', e))
+  sortVoluntaryByMajor(props.data.universityId, props.data.majors.map(m => m.majorId))
+    .catch((e: any) => emits('error', e))
 }
 
 const handleDelete = async (m: VoluntaryMajorItem) => {
-    await uni.$ie.showConfirm({title: '删除提醒', content: `确定删除该专业?`})
+  // 改为 showModal,避免 reject 错误上报
+  const confirm = await uni.$ie.showModal({ title: '删除提醒', content: `确定删除该专业?` })
+  if (confirm) {
     await removeVoluntaryByMajor(m.majorId)
     uni.$ie.showSuccess('删除成功')
     emits('deleted')
+  }
 }
 </script>
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 58 - 0
src/pagesOther/pages/voluntary/list/components/voluntary-majors-draggable-list.vue

@@ -0,0 +1,58 @@
+<template>
+  <view class="mt-20">
+    <l-drag ref="dragRef" :list="majors" :column="1" gridHeight="56px" :touchHandle="touchHandle" ghost handle
+      @change="changeSort">
+      <!-- // 每一项的插槽 grid 的 content 您传入的数据 -->
+      <template #grid="{ active, content, index }">
+        <!-- // grid.active 是否为当前拖拽项目 根据自己需要写样式 -->
+        <view
+          class="bg-back rounded-6 h-[44px] pl-30 pr-20 relative flex items-center gap-x-16 mb-[10px] border border-solid"
+          :class="[active ? 'border-primary' : 'border-transparent']">
+          <view class="text-32 text-fore-placeholder font-bold">{{ toFixedLen(index) }}</view>
+          <view class="flex-1 w-1 h-full leading-[44px] text-28 text-fore-title font-bold ellipsis-1">{{ content.majorName }}</view>
+          <uv-icon name="trash" size="18" color="error" @click="handleDelete" />
+          <view slot="handle" @touchstart="handleDragStart" @touchend="handleDragEnd">
+            <uv-icon name="list-dot" size="18" color="primary" />
+          </view>
+        </view>
+      </template>
+      <template #ghots></template>
+    </l-drag>
+  </view>
+</template>
+<script lang="ts" setup>
+import type { Voluntary } from '@/types';
+import { VOLUNTARY_REFRESHER_ENABLED } from "@/types/injectionSymbols";
+
+const props = defineProps<{
+  majors: Voluntary.VoluntaryMajorItem[];
+}>();
+const refresherEnabled = inject(VOLUNTARY_REFRESHER_ENABLED) || ref(false)
+const emits = defineEmits<{
+  (e: "delete", major: Voluntary.VoluntaryMajorItem): void;
+  (e: "update:majors", majors: Voluntary.VoluntaryMajorItem[]): void;
+  (e: "change", majors: Voluntary.VoluntaryMajorItem[]): void;
+}>();
+const touchHandle = ref(false)
+
+const handleDragStart = () => {
+  touchHandle.value = true;
+  refresherEnabled.value = false;
+}
+const handleDragEnd = () => {
+  touchHandle.value = false;
+  refresherEnabled.value = true;
+}
+const changeSort = (e: any) => {
+  const list = e.map((item: any) => item.content);
+  emits('update:majors', list);
+  emits('change', list);
+}
+const handleDelete = (major: Voluntary.VoluntaryMajorItem) => {
+  emits('delete', major);
+}
+const toFixedLen = (i: number, len: number = 2) => {
+  return String(i + 1).padStart(len, "0");
+}
+</script>
+<style lang="scss" scoped></style>

+ 0 - 214
src/pagesOther/pages/voluntary/list/components/voluntary-majors-draggable.vue

@@ -1,214 +0,0 @@
-<template>
-    <!-- H5:SortableJS -->
-    <!-- #ifdef H5 -->
-    <view ref="h5ListRef" class="mt-30 flex flex-col">
-        <view
-            v-for="(m, i) in innerMajors"
-            :key="getKey(m, i)"
-            class="bg-back-light rounded-lg p-20 flex justify-between items-center gap-20 mb-20 min-w-0"
-            :data-index="i"
-        >
-            <view class="text-32 text-fore-placeholder font-bold">{{ toFixedLen(i) }}</view>
-            <view class="flex-1 w-0 text-28 text-fore-title font-bold truncate">{{ m.majorName }}</view>
-
-            <uv-icon name="trash" size="18" color="error" @click="$emit('delete', m)"/>
-
-            <!-- 拖拽手柄:Sortable handle -->
-            <view class="drag-handle">
-                <uv-icon name="list-dot" size="18" color="primary"/>
-            </view>
-        </view>
-    </view>
-    <!-- #endif -->
-
-    <!-- #ifndef H5 -->
-    <movable-area
-        class="mt-30 w-full"
-        :style="{ height: areaHeight + 'px' }"
-        :catchtouchmove="dragging"
-        @touchmove.stop.prevent="noop"
-    >
-        <movable-view
-            v-for="(m, i) in innerMajors"
-            :key="getKey(m, i)"
-            class="w-full"
-            direction="vertical"
-            :y="getY(i)"
-            :disabled="activeIndex !== i"
-            :inertia="false"
-            :damping="80"
-            :animation="true"
-            @change="(e) => onDragChange(e, i)"
-            @touchend="onDragEnd"
-        >
-            <view class="bg-back-light rounded-lg p-20 flex justify-between items-center gap-20">
-                <!-- 主内容:阻断触摸,确保只能手柄拖 -->
-                <view class="flex-1 flex items-center gap-20" @touchmove.stop>
-                    <view class="text-32 text-fore-placeholder font-bold">{{ toFixedLen(i) }}</view>
-                    <view class="flex-1 w-0 text-28 text-fore-title font-bold truncate">{{ m.majorName }}</view>
-                    <uv-icon name="trash" size="18" color="error" @tap.stop="$emit('delete', m)" />
-                </view>
-
-                <!-- 手柄:不 stop / prevent -->
-                <view class="ml-10" @touchstart="onHandleDown(i)" @touchend="onHandleUp">
-                    <uv-icon name="list-dot" size="18" color="primary" />
-                </view>
-            </view>
-        </movable-view>
-    </movable-area>
-    <!-- #endif -->
-</template>
-
-<script setup lang="ts">
-import {VoluntaryMajorItem} from "@/types/voluntary";
-import {VOLUNTARY_SORTING} from "@/types/injectionSymbols";
-// #ifdef H5
-import Sortable from "sortablejs";
-// #endif
-
-const props = defineProps<{
-    majors: VoluntaryMajorItem[];
-}>();
-
-const emits = defineEmits<{
-    (e: "delete", major: VoluntaryMajorItem): void;
-    (e: "update:majors", majors: VoluntaryMajorItem[]): void;
-    (e: "change", majors: VoluntaryMajorItem[]): void;
-}>();
-
-const isSorting = inject(VOLUNTARY_SORTING) || ref(false)
-const innerMajors = ref<VoluntaryMajorItem[]>([]);
-watch(
-    () => props.majors,
-    (v) => (innerMajors.value = v ? [...v] : []),
-    {immediate: true, deep: true}
-);
-
-function toFixedLen(i: number, len: number = 2) {
-    return String(i + 1).padStart(len, "0");
-}
-
-function getKey(m: any, i: number) {
-    // 你的示例 majorId 可能重复,所以拼 i
-    return m.majorId
-}
-
-// ========== H5:SortableJS ==========
-const h5ListRef = ref<any>(null);
-// #ifdef H5
-let sortableIns: Sortable | null = null;
-
-onMounted(async () => {
-    await nextTick();
-    const el = h5ListRef.value?.$el || h5ListRef.value; // 兼容 view ref
-    if (!el) return;
-
-    sortableIns = Sortable.create(el, {
-        animation: 150,
-        handle: ".drag-handle", // 只允许手柄拖
-        forceFallback: true,
-        onStart: () => {
-            isSorting.value = true
-            console.log('sortableIns onStart', true)
-        },
-        onEnd: (evt) => {
-            isSorting.value = false;
-            console.log('sortableIns onEnd', false)
-            // sort logic
-            if (evt.oldIndex == null || evt.newIndex == null) return;
-            const arr = [...innerMajors.value];
-            const [moved] = arr.splice(evt.oldIndex, 1);
-            arr.splice(evt.newIndex, 0, moved);
-            innerMajors.value = arr;
-            emits("update:majors", arr);
-            emits('change', arr)
-        },
-    });
-});
-
-onBeforeUnmount(() => {
-    sortableIns?.destroy();
-    sortableIns = null;
-});
-// #endif
-
-// ========== 小程序:movable-view(重写版) ==========
-const dragging = ref(false);
-const activeIndex = ref(-1);
-const activeY = ref(0);
-
-const rowGapPx = uni.upx2px(20);
-const rowHeightPx = uni.upx2px(92) + rowGapPx;
-const areaHeight = computed(() => innerMajors.value.length * rowHeightPx);
-
-const noop = () => {}; // 关键:给 @touchmove 一个“函数”,不要给 boolean
-
-function getY(i: number) {
-    return i === activeIndex.value ? activeY.value : i * rowHeightPx;
-}
-
-function clampY(y: number) {
-    const maxY = Math.max(0, (innerMajors.value.length - 1) * rowHeightPx);
-    return Math.min(Math.max(y, 0), maxY);
-}
-
-function swap(arr: any[], a: number, b: number) {
-    const t = arr[a];
-    arr[a] = arr[b];
-    arr[b] = t;
-}
-
-function onHandleDown(i: number) {
-    console.log("onHandleDown", i);
-    dragging.value = true;
-    activeIndex.value = i;
-    activeY.value = i * rowHeightPx;
-}
-
-function onHandleUp() {
-    console.log("onHandleUp");
-    // 不要在这里结束;结束交给 movable-view 的 touchend
-}
-
-function onDragChange(e: any, i: number) {
-    // 有了上面的报错修复后,这里应该能打出来
-    console.log("onDragChange", e?.detail, i);
-
-    if (!dragging.value) return;
-    if (i !== activeIndex.value) return;
-    if (e?.detail?.source !== "touch") return;
-
-    const y = clampY(Number(e.detail.y || 0));
-    activeY.value = y;
-
-    const target = Math.round(y / rowHeightPx);
-    if (target === i) return;
-
-    swap(innerMajors.value as any[], i, target);
-    activeIndex.value = target;
-}
-
-function onDragEnd() {
-    console.log("onDragEnd");
-    if (!dragging.value) return;
-
-    dragging.value = false;
-
-    if (activeIndex.value === -1) return;
-    activeY.value = activeIndex.value * rowHeightPx;
-
-    const arr = [...innerMajors.value];
-    activeIndex.value = -1;
-    console.log('emits update:majors', arr)
-    emits("update:majors", arr);
-    emits('change', arr);
-}
-</script>
-
-<style scoped>
-.drag-handle {
-    touch-action: none;
-    -webkit-user-select: none;
-    user-select: none;
-}
-</style>

+ 145 - 134
src/pagesOther/pages/voluntary/list/list.vue

@@ -1,177 +1,188 @@
 <template>
-    <ie-page>
-        <z-paging ref="paging" v-model="list" bg-color="#F6F8FA" safe-area-inset-bottom :scrollable="!isSorting"
-                  :refresher-enabled="!isSorting" @query="handleQuery">
-            <template #top>
-                <ie-navbar title="志愿表"/>
-            </template>
-            <view class="mt-20 bg-warning-light p-28 text-23 leading-38 text-fore-title">
-                <text class="font-bold">说明:</text>
-                目前志愿计划为2025年,排序前两个为第一、二志愿,可通过修改排序重新选择第一、二志愿
-            </view>
-            <view class="p-28 flex flex-col gap-28">
-                <voluntary-item v-for="(item, i) in list" :key="i" :data="item" :index="i" @more="showActions(item)"
-                                @error="handleError" @deleted="handleDeleted"/>
-            </view>
-        </z-paging>
-        <ie-safe-toolbar :height="84" :shadow="false">
-            <view class="px-30 py-16">
-                <ie-button @click="handleAdd">添加志愿</ie-button>
-            </view>
-        </ie-safe-toolbar>
-        <uv-action-sheet ref="actionSheet" :actions="moreActions" safe-area-inset-bottom close-on-click-overlay
-                         cancel-text="取消" @select="handleActionSelect"/>
-    </ie-page>
+  <ie-page>
+    <z-paging ref="paging" v-model="list" bg-color="#F6F8FA" safe-area-inset-bottom :scrollable="!refresherEnabled"
+      :refresher-enabled="refresherEnabled" @query="handleQuery">
+      <template #top>
+        <ie-navbar title="志愿表" />
+      </template>
+      <view class="mt-20 bg-warning-light p-28 text-23 leading-38 text-fore-title">
+        <text class="font-bold">说明:</text>
+        目前志愿计划为2025年,排序前两个为第一、二志愿,可通过修改排序重新选择第一、二志愿
+      </view>
+      <view class="p-28 flex flex-col gap-28">
+        <voluntary-item v-for="(item, i) in list" :key="i" :data="item" :index="i" @more="showActions(item)"
+          @error="handleError" @deleted="handleDeleted" />
+      </view>
+    </z-paging>
+    <ie-safe-toolbar :height="84" :shadow="false">
+      <view class="px-30 py-16">
+        <ie-button @click="handleAdd">添加志愿</ie-button>
+      </view>
+    </ie-safe-toolbar>
+    <uv-action-sheet ref="actionSheet" :actions="moreActions" safe-area-inset-bottom close-on-click-overlay
+      cancel-text="取消" @select="handleActionSelect" />
+  </ie-page>
 </template>
 
 <script setup lang="ts">
-import {SelectedUniversityMajor, VoluntaryRecord} from "@/types/voluntary";
+import { SelectedUniversityMajor, VoluntaryRecord } from "@/types/voluntary";
 import VoluntaryItem from "@/pagesOther/pages/voluntary/list/components/voluntary-item.vue";
-import {VOLUNTARY_SORTING} from "@/types/injectionSymbols";
+import { VOLUNTARY_REFRESHER_ENABLED } from "@/types/injectionSymbols";
 import {
-    addVoluntary,
-    getVoluntaryList,
-    removeVoluntaryByUniversity,
-    sortVoluntaryByUniversity
+  addVoluntary,
+  getVoluntaryList,
+  removeVoluntaryByUniversity,
+  sortVoluntaryByUniversity
 } from "@/api/modules/voluntary";
 import UvActionSheet from "@/uni_modules/uv-action-sheet/components/uv-action-sheet/uv-action-sheet.vue";
-import {useTransferPage} from "@/hooks/useTransferPage";
-import {UniversityPickerPageOptions} from "@/types/transfer";
-import {routes} from "@/common/routes";
+import { useTransferPage } from "@/hooks/useTransferPage";
+import { UniversityPickerPageOptions } from "@/types/transfer";
+import { routes } from "@/common/routes";
+import { useAuth } from "@/hooks/useAuth";
+import { EnumUserRole } from "@/common/enum";
 
 interface ActionItem {
-    id: string;
-    name: string;
-    icon?: string;
-    color?: string;
-    iconColor?: string;
-    disabled?: boolean;
+  id: string;
+  name: string;
+  icon?: string;
+  color?: string;
+  iconColor?: string;
+  disabled?: boolean;
 }
-
-const {transferTo} = useTransferPage()
+const { hasPermission } = useAuth();
+const { transferTo } = useTransferPage()
 const list = ref<VoluntaryRecord[]>([])
 const paging = ref<ZPagingInstance>()
-const isSorting = ref<boolean>(false)
+const refresherEnabled = ref<boolean>(true)
 const actionSheet = ref<InstanceType<typeof UvActionSheet>>()
 const actionRecord = ref<VoluntaryRecord>()
 const moreActions = computed<ActionItem[]>(() => {
-    const records = list.value
-    const current = actionRecord.value
-    const idx = current ? records.indexOf(current) : -1
-    const enableTop = records.length > 1 && idx > 0
-    const enableUp = records.length > 1 && idx > 0
-    const enableDown = records.length > 1 && idx < records.length - 1
-    return [{
-        id: 'top',
-        name: '置顶',
-        icon: 'pushpin-fill',
-        color: 'var(--primary-color)',
-        iconColor: enableTop ? 'primary' : 'info',
-        disabled: !enableTop
-    }, {
-        id: 'up',
-        name: '上移',
-        icon: 'arrow-upward',
-        iconColor: enableUp ? '' : 'info',
-        disabled: !enableUp
-    }, {
-        id: 'down',
-        name: '下移',
-        icon: 'arrow-downward',
-        iconColor: enableDown ? '' : 'info',
-        disabled: !enableDown
-    }, {
-        id: 'delete',
-        name: '删除',
-        icon: 'trash',
-        color: 'var(--danger)',
-        iconColor: 'error'
-    }]
+  const records = list.value
+  const current = actionRecord.value
+  const idx = current ? records.indexOf(current) : -1
+  const enableTop = records.length > 1 && idx > 0
+  const enableUp = records.length > 1 && idx > 0
+  const enableDown = records.length > 1 && idx < records.length - 1
+  return [{
+    id: 'top',
+    name: '置顶',
+    icon: 'pushpin-fill',
+    color: 'var(--primary-color)',
+    iconColor: enableTop ? 'primary' : 'info',
+    disabled: !enableTop
+  }, {
+    id: 'up',
+    name: '上移',
+    icon: 'arrow-upward',
+    iconColor: enableUp ? '' : 'info',
+    disabled: !enableUp
+  }, {
+    id: 'down',
+    name: '下移',
+    icon: 'arrow-downward',
+    iconColor: enableDown ? '' : 'info',
+    disabled: !enableDown
+  }, {
+    id: 'delete',
+    name: '删除',
+    icon: 'trash',
+    color: 'var(--danger)',
+    iconColor: 'error'
+  }]
 })
 
 const handleQuery = () => {
-    getVoluntaryList().then(res => {
-        paging.value?.completeByNoMore(res.data, true)
-    }).catch(e => paging.value?.completeByError(e))
+  getVoluntaryList().then(res => {
+    paging.value?.completeByNoMore(res.data, true)
+  }).catch(e => paging.value?.completeByError(e))
 }
 
 const showActions = (record: VoluntaryRecord) => {
-    actionRecord.value = record
-    actionSheet.value?.open()
+  actionRecord.value = record
+  actionSheet.value?.open()
 }
 
 const handleActionSelect = async (e: ActionItem) => {
-    const record = actionRecord.value
-    const recordList = list.value
-    if (!record) return
-    const idx = recordList.findIndex(r => r == record)
-    if (['top', 'up', 'down'].includes(e.id)) {
-        try {
-            switch (e.id) {
-                case 'top':
-                    if (idx > 0) {
-                        recordList.splice(idx, 1)
-                        recordList.unshift(record)
-                        await sortVoluntaryByUniversity(recordList.map(r => r.universityId))
-                        uni.$ie.showSuccess('保存成功')
-                    }
-                    break;
-                case 'up':
-                    if (idx > 0) {
-                        recordList.splice(idx, 1)
-                        recordList.splice(idx - 1, 0, record)
-                        await sortVoluntaryByUniversity(recordList.map(r => r.universityId))
-                        uni.$ie.showSuccess('保存成功')
-                    }
-                    break;
-                case 'down':
-                    if (idx < recordList.length - 1) {
-                        recordList.splice(idx, 1)
-                        recordList.splice(idx + 1, 0, record)
-                        await sortVoluntaryByUniversity(recordList.map(r => r.universityId))
-                        uni.$ie.showSuccess('保存成功')
-                    }
-                    break;
-            }
-        } catch (e) {
-            console.log('action ex', e)
-            paging.value?.reload() // 发生异常时,重新加载列表
-        }
-    } else if (e.id === 'delete') {
-        await uni.$ie.showConfirm({
-            title: '志愿删除提醒',
-            content: `删除'${record.universityName}',将同时删除该院校下所有意向专业。\n确认删除?!`
-        })
-        await removeVoluntaryByUniversity(record.universityId)
-        uni.$ie.showSuccess('删除成功')
-        paging.value?.reload()
-    } else {
-        throw new Error('Unsupported action id: ' + e.id)
+  const record = actionRecord.value
+  const recordList = list.value
+  if (!record) return
+  const idx = recordList.findIndex(r => r == record)
+  if (['top', 'up', 'down'].includes(e.id)) {
+    try {
+      switch (e.id) {
+        case 'top':
+          if (idx > 0) {
+            recordList.splice(idx, 1)
+            recordList.unshift(record)
+            await sortVoluntaryByUniversity(recordList.map(r => r.universityId))
+            uni.$ie.showSuccess('保存成功')
+          }
+          break;
+        case 'up':
+          if (idx > 0) {
+            recordList.splice(idx, 1)
+            recordList.splice(idx - 1, 0, record)
+            await sortVoluntaryByUniversity(recordList.map(r => r.universityId))
+            uni.$ie.showSuccess('保存成功')
+          }
+          break;
+        case 'down':
+          if (idx < recordList.length - 1) {
+            recordList.splice(idx, 1)
+            recordList.splice(idx + 1, 0, record)
+            await sortVoluntaryByUniversity(recordList.map(r => r.universityId))
+            uni.$ie.showSuccess('保存成功')
+          }
+          break;
+      }
+    } catch (e) {
+      console.log('action ex', e)
+      paging.value?.reload() // 发生异常时,重新加载列表
+    }
+  } else if (e.id === 'delete') {
+    // 改为 showModal,避免 reject 错误上报
+    const confirm = await uni.$ie.showModal({
+      title: '志愿删除提醒',
+      content: `删除'${record.universityName}',将同时删除该院校下所有意向专业。\n确认删除?`
+    })
+    if (confirm) {
+      await removeVoluntaryByUniversity(record.universityId)
+      uni.$ie.showSuccess('删除成功')
+      paging.value?.reload()
     }
+  } else {
+    throw new Error('Unsupported action id: ' + e.id)
+  }
 }
 
 const handleError = () => {
-    // 内部异常,重新取数
-    paging.value?.reload()
+  // 内部异常,重新取数
+  paging.value?.reload()
 }
 
 const handleDeleted = () => {
-    paging.value?.reload()
+  paging.value?.reload()
 }
 
 const handleAdd = async () => {
-    const option: UniversityPickerPageOptions = {
-        title: '选择你的意向院校专业',
-        fromVoluntary: true
-    }
-    const picked = await transferTo(routes.targetPicker, {data: option})
-    if (!picked) return
+  const hasAuth = hasPermission([EnumUserRole.VIP]);
+  if (!hasAuth) {
+    return;
+  }
+  const option: UniversityPickerPageOptions = {
+    title: '选择你的意向院校专业',
+    fromVoluntary: true
+  }
+  const picked = await transferTo(routes.targetPicker, { data: option })
+  if (!picked) return
+  setTimeout(async () => {
     await addVoluntary(picked)
     uni.$ie.showSuccess('保存成功')
     paging.value?.reload()
+  }, 300)
 }
 
-provide(VOLUNTARY_SORTING, isSorting)
+provide(VOLUNTARY_REFRESHER_ENABLED, refresherEnabled)
 </script>
 
 <style lang="scss"></style>

+ 1 - 1
src/types/injectionSymbols.ts

@@ -51,7 +51,7 @@ export const VOLUNTARY_MODEL = Symbol('VOLUNTARY_MODEL') as InjectionKey<Ref<Vol
 export const VOLUNTARY_RESULT = Symbol('VOLUNTARY_RESULT') as InjectionKey<Ref<Voluntary.VoluntaryResult>>
 
 export const UNIVERSITY_FILTER = Symbol('UNIVERSITY_FILTER') as InjectionKey<Ref<University.UniversityQueryDto>>
-export const VOLUNTARY_SORTING = Symbol('VOLUNTARY_SORTING') as InjectionKey<Ref<boolean>>
+export const VOLUNTARY_REFRESHER_ENABLED = Symbol('VOLUNTARY_REFRESHER_ENABLED') as InjectionKey<Ref<boolean>>
 
 export const UNIVERSITY_DETAIL = Symbol('UNIVERSITY_DETAIL') as InjectionKey<Ref<University.UniversityDetail>>
 export const MAJOR_TREE = Symbol('MAJOR_TREE') as InjectionKey<Ref<Major.MajorItem[]>>

+ 7 - 0
src/types/transfer.ts

@@ -44,4 +44,11 @@ export interface ExamAnalysisPageOptions {
 export interface SimulationAnalysisPageOptions {
   examineeId: number;
   paperType: EnumPaperType;
+}
+
+export interface UniversityPickerPageOptions {
+  title: string;
+  fromVoluntary: boolean;
+  selectedUniversityId?: string;
+  selectedMajorId?: string;
 }

+ 6 - 0
src/uni_modules/uv-icon/components/uv-icon/uv-icon.vue

@@ -120,6 +120,9 @@
 			},
 			// 判断传入的name属性,是否图片路径,只要带有"/"均认为是图片形式
 			isImg() {
+        if (!this.name) {
+          return false;
+        }
 				const isBase64 = this.name.indexOf('data:') > -1 && this.name.indexOf('base64') > -1;
 				return this.name.indexOf('/') !== -1 || isBase64;
 			},
@@ -132,6 +135,9 @@
 			},
 			// 通过图标名,查找对应的图标
 			icon() {
+        if (!this.name) {
+          return '';
+        }
 				// 如果内置的图标中找不到对应的图标,就直接返回name值,因为用户可能传入的是unicode代码
 				const code = icons['uvicon-' + this.name];
 				// #ifdef APP-NVUE

+ 32 - 4
src/utils/uni-tool.ts

@@ -24,7 +24,7 @@ export interface IModalOptions {
 /**
  * 工具函数接口
  */
-export interface IeTool {
+export interface IeTool extends Record<string, any> {
   /** 是否显示加载提示 */
   loading: boolean,
   /** 加载提示开始时间 */
@@ -98,18 +98,39 @@ const defaultModalOptions: IModalOptions = {
 
 const tool: IeTool = {
   loading: false,
+  _hasToast: false,
+  _hideToastTimer: null as NodeJS.Timeout | null,
   loadingStartTime: 0,
   minLoadingTime: 500, // 最小显示时间,单位毫秒
   showToast(title: string = '') {
     // 先立即隐藏,避免上一个 toast存在导致下次的 toast 很快关闭
-    uni.hideToast();
+    if (this._hasToast) {
+      try {
+        if (this._hideToastTimer !== null) {
+          clearTimeout(this._hideToastTimer);
+          this._hideToastTimer = null;
+        }
+        uni.hideToast();
+        this._hideToast();
+      } catch (error) {
+        console.log(error);
+      }
+    }
+
     setTimeout(() => {
+      this._hasToast = true;
       uni.showToast({
         title,
         icon: 'none'
       });
+      this._hideToastTimer = setTimeout(() => {
+        this._hideToast();
+      }, 1500);
     }, 50);
   },
+  _hideToast() {
+    this._hasToast = false;
+  },
   showSuccess(title: string = '') {
     uni.showToast({
       title,
@@ -127,10 +148,15 @@ const tool: IeTool = {
     uni.showLoading({
       title,
       mask: true,
-      success: () => { }
+      success: () => {
+        this.loading = true;
+       }
     });
   },
   hideLoading() {
+    if (!this.loading) {
+      return;
+    }
     const currentTime = Date.now();
     const elapsedTime = currentTime - this.loadingStartTime;
     const remainingTime = Math.max(0, this.minLoadingTime - elapsedTime);
@@ -138,9 +164,11 @@ const tool: IeTool = {
     if (remainingTime > 0) {
       setTimeout(() => {
         uni.hideLoading();
+        this.loading = false;
       }, remainingTime);
     } else {
       uni.hideLoading();
+      this.loading = false;
     }
   },
   showModal(params: IModalOptions) {
@@ -175,7 +203,7 @@ const tool: IeTool = {
         success: (res) => {
           /* 这里不要动,就是这样的。形成await showConfirm的效果 2026.1.13 */
           /* 如果有必须接收true/false的写法,请使用showModal */
-          if(res.confirm)(resolve(true))
+          if (res.confirm) (resolve(true))
           else reject(false)
         },
         fail: reject