소스 검색

完善专业收藏

shmily1213 3 주 전
부모
커밋
a8fdafee78

+ 18 - 0
src/api/modules/major.ts

@@ -36,4 +36,22 @@ export function getMajorOverviewByCode(code: string) {
  */
 export function getUniversityByMajorCode(params: Major.UniversityQueryDTO) {
   return flyio.get('/front/major/getUniversityByCode', params) as Promise<ApiResponseList<Major.University>>;
+}
+
+/**
+ * 收藏专业
+ * @param code 专业代码
+ * @returns 
+ */
+export function collectMajor(code: string) {
+  return flyio.get('/front/customer/marjors/add', { code }) as Promise<ApiResponse<any>>;
+}
+
+/**
+ * 取消收藏专业
+ * @param code 专业代码
+ * @returns 
+ */
+export function cancelCollectMajor(code: string) {
+  return flyio.get('/front/customer/marjors/remove', { code }) as Promise<ApiResponse<any>>;
 }

+ 8 - 0
src/common/routes.ts

@@ -23,6 +23,14 @@ export const routes = {
    * 大学详情
    */
   universityDetail: '/pagesOther/pages/university/detail/detail',
+  /**
+   * 职业库
+   */
+  careerIndex: '/pagesOther/pages/career/index/index',
+  /**
+   * 职业详情
+   */
+  careerDetail: '/pagesOther/pages/career/detail/detail',
 } as const;
 
 export type Routes = keyof typeof routes;

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

@@ -1,9 +1,10 @@
 <template>
   <view class="ie-page theme-ie"
-    :class="[safeAreaInsetBottom ? 'safe-area-inset-bottom' : '', { 'is-fixed': fixHeight }]"
+    :class="[{ 'is-fixed': fixHeight }]"
     :style="{ backgroundColor: bgColor }">
     <view class="ie-page-content">
       <slot></slot>
+      <view v-if="safeAreaInsetBottom" class="safe-area-inset-bottom" :style="{ backgroundColor: safeAreaColor }"></view>
     </view>
     <view class="ie-page-popup">
       <vip-popup ref="vipPopupRef" />

+ 14 - 0
src/components/ie-safe-area-bottom/ie-safe-area-bottom.vue

@@ -0,0 +1,14 @@
+<template>
+<view class="ie-safe-area-bottom safe-area-inset-bottom" :style="{ backgroundColor: bgColor }"></view>
+</template>
+<script lang="ts" setup>
+defineOptions({
+  name: 'ie-safe-area-bottom',
+  options: {
+    virtualHost: true
+  }
+});
+const props = defineProps<{
+  bgColor: string;
+}>();
+</script>

+ 6 - 2
src/components/ie-search/ie-search.vue

@@ -1,6 +1,7 @@
 <template>
   <view class="ie-search px-20 py-20 border-0 border-b border-solid border-border-light">
-    <uv-search v-model="modelValue" shape="square" :showAction="false" :placeholder="placeholder" @input="handleInput" @search="handleSearch">
+    <uv-search v-model="modelValue" shape="square" :showAction="false" :placeholder="placeholder" @input="handleInput"
+      @search="handleSearch" @clear="handleClear">
       <!-- <template #suffix>
         <view class="text-24 text-fore-title bg-primary text-white px-24 py-10 rounded-full translate-x-10"
           @click="handleSearch">搜索
@@ -17,12 +18,15 @@ const modelValue = defineModel<string>('modelValue', {
   default: ''
 });
 
-const emit = defineEmits(['search']);
+const emit = defineEmits(['search', 'clear']);
 const handleSearch = (value: string) => {
   emit('search', value);
 };
 const handleInput = (value: string) => {
   modelValue.value = value;
 };
+const handleClear = () => {
+  emit('clear');
+};
 </script>
 <style lang="scss" scoped></style>

+ 1 - 1
src/components/ie-tabs-swiper/ie-tabs-swiper.vue

@@ -1,6 +1,6 @@
 <template>
   <view class="w-full h-full flex flex-col relative">
-    <view :style="{ backgroundColor: bgColor, border: border ? '1px solid #e5e6e9' : 'none' }">
+    <view :style="{ backgroundColor: bgColor, borderBottom: border ? '1px solid #e5e6e9' : 'none' }">
       <uv-tabs :list="list" :scrollable="scrollable" @change="handleChange" :current="modelValue"
         :key-name="keyName"></uv-tabs>
     </view>

+ 293 - 0
src/components/ie-tree/ie-tree-node.vue

@@ -0,0 +1,293 @@
+<template>
+  <view class="ie-tree-node">
+    <!-- 节点内容 -->
+    <view @click.stop="handleClick">
+      <slot :node="nodeData" :parent="parentData">
+        <!-- 默认节点内容 -->
+        <view class="ie-tree-node__default">
+          <uv-icon 
+            v-if="!isLeafNode" 
+            name="arrow-right" 
+            size="14" 
+            color="#888"
+            :custom-class="['ie-tree-node__expand-icon', isExpanded ? 'ie-tree-node__expand-icon--expanded' : '']"
+          />
+          <text class="ie-tree-node__label">{{ nodeLabel }}</text>
+        </view>
+      </slot>
+    </view>
+
+    <!-- 子节点容器 -->
+    <view 
+      v-if="hasChildren" 
+      ref="childrenRef" 
+      class="ie-tree-node__children"
+      :id="`ie-tree-children-${nodeKey}`"
+      :class="['ie-tree-node__children', { 'ie-tree-node__children--measuring': isMeasuringHeight }]"
+      :style="childrenStyle"
+    >
+      <ie-tree-node 
+        v-for="child in childrenData" 
+        :key="getNodeKey(child)"
+        :node-data="child"
+        :parent-data="nodeData"
+        :tree-props="treeProps"
+        @node-click="handleChildNodeClick"
+      >
+        <template #default="slotProps: { node: any; parent: any }">
+          <slot :node="slotProps.node" :parent="slotProps.parent"></slot>
+        </template>
+      </ie-tree-node>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { getCurrentInstance, computed, ref, nextTick } from 'vue';
+import IeTreeNode from './ie-tree-node.vue';
+import type { TreeProps } from '@/types';
+
+defineOptions({
+  name: 'ie-tree-node',
+  options: {
+    virtualHost: true
+  }
+});
+
+const instance = getCurrentInstance();
+
+// Props
+const props = defineProps<{
+  nodeData: any; // 节点数据
+  parentData?: any; // 父节点数据
+  treeProps: TreeProps; // 树配置项
+}>();
+
+// Emits
+const emit = defineEmits<{
+  (e: 'node-click', data: { node: any; parent: any | null }): void;
+}>();
+
+// Slots
+defineSlots<{
+  default(props: { node: any; parent: any }): any;
+}>();
+
+// 默认配置
+const defaultTreeProps: Required<TreeProps> = {
+  children: 'children',
+  label: 'name',
+  nodeKey: 'id',
+  isLeaf: 'isLeaf',
+  disabled: 'disabled',
+  isExpanded: 'isExpanded'
+};
+
+// 合并配置
+const mergedProps = computed(() => ({
+  ...defaultTreeProps,
+  ...props.treeProps
+}));
+
+// 获取节点属性值
+const getNodeProp = (node: any, prop: string, defaultValue?: any) => {
+  return node?.[prop] ?? defaultValue;
+};
+
+// 获取节点唯一标识
+const getNodeKey = (node: any): string | number => {
+  const key = mergedProps.value.nodeKey;
+  return getNodeProp(node, key, node?.value ?? node?.id ?? '');
+};
+
+// 获取节点标签
+const nodeLabel = computed(() => {
+  return getNodeProp(props.nodeData, mergedProps.value.label, '');
+});
+
+// 获取子节点数据
+const childrenData = computed(() => {
+  const childrenProp = mergedProps.value.children;
+  return getNodeProp(props.nodeData, childrenProp, []) || [];
+});
+
+// 是否有子节点
+const hasChildren = computed(() => {
+  return childrenData.value && childrenData.value.length > 0;
+});
+
+// 判断是否为叶子节点
+const isLeafNode = computed(() => {
+  const isLeafProp = mergedProps.value.isLeaf;
+  const isLeaf = getNodeProp(props.nodeData, isLeafProp);
+  if (isLeaf !== undefined) {
+    return isLeaf;
+  }
+  // 如果没有 isLeaf 属性,根据是否有子节点判断
+  return !hasChildren.value;
+});
+
+// 是否展开
+const isExpanded = computed(() => {
+  const expandedProp = mergedProps.value.isExpanded;
+  return getNodeProp(props.nodeData, expandedProp, false);
+});
+
+// 节点唯一标识
+const nodeKey = computed(() => getNodeKey(props.nodeData));
+
+// 高度测量相关
+const isMeasuringHeight = ref(false);
+const childrenRef = ref();
+const measuredHeight = ref<number | string>(0);
+const isAnimating = ref(false);
+
+// 子节点容器样式
+const childrenStyle = computed(() => {
+  if (isMeasuringHeight.value) {
+    return { height: 'auto' };
+  }
+  const heightValue = typeof measuredHeight.value === 'number' ? `${measuredHeight.value}px` : measuredHeight.value;
+  return { height: heightValue };
+});
+
+// 获取元素尺寸
+const getRect = (selector: string) => {
+  return new Promise<{ top: number; height: number }>((resolve) => {
+    const query = uni.createSelectorQuery().in(instance?.proxy);
+    query.select(selector).boundingClientRect((rect) => {
+      resolve(rect as { top: number; height: number });
+    }).exec();
+  });
+};
+
+// 测量子节点高度
+const measureHeight = () => {
+  if (!hasChildren.value) return Promise.resolve(0);
+  
+  return new Promise<number>((resolve) => {
+    // 如果节点已经展开,说明子节点已经显示,可以直接测量,不需要隐藏
+    const needHiding = !isExpanded.value;
+    
+    if (needHiding) {
+      isMeasuringHeight.value = true;
+    }
+    
+    setTimeout(() => {
+      nextTick(() => {
+        getRect(`#ie-tree-children-${nodeKey.value}`).then((res) => {
+          if (needHiding) {
+            isMeasuringHeight.value = false;
+          }
+          const height = res?.height ?? 0;
+          resolve(height);
+        });
+      });
+    }, 50);
+  });
+};
+
+// 处理节点点击
+const handleClick = async () => {
+  // 切换展开状态
+  const expandedProp = mergedProps.value.isExpanded;
+  const newExpandedState = !isExpanded.value;
+  
+  if (newExpandedState) {
+    // 展开流程:
+    // 1. 先设置高度为 0(起始状态)
+    measuredHeight.value = 0;
+    
+    // 2. 更新展开状态,让子节点容器渲染
+    props.nodeData[expandedProp] = newExpandedState;
+    
+    // 3. 等待 DOM 更新,确保子节点容器已经渲染出来
+    await nextTick();
+    
+    // 4. 测量目标高度(此时子节点已经渲染,但高度是 0)
+    const height = await measureHeight();
+    
+    // 5. 再等一帧,确保浏览器已经渲染了高度为 0 的状态
+    await nextTick();
+    
+    // 6. 设置目标高度,触发 CSS 动画(0 → height)
+    setTimeout(() => {
+      measuredHeight.value = height;
+    }, 0)
+    
+    // 7. 动画完成后(350ms)设置为 auto
+    setTimeout(() => {
+      if (isExpanded.value && props.nodeData[expandedProp]) {
+        measuredHeight.value = 'auto';
+      }
+    }, 350);
+    
+  } else {
+    // 收起流程:
+    // 1. 如果当前是 auto,先测量实际高度并设置为固定值
+    if (measuredHeight.value === 'auto') {
+      const height = await measureHeight();
+      measuredHeight.value = height;
+      await nextTick();
+    }
+    
+    // 2. 等待一帧,确保浏览器已经应用了固定高度
+    await nextTick();
+    
+    // 3. 设置为 0,触发收起动画
+    measuredHeight.value = 0;
+    
+    // 4. 更新展开状态
+    props.nodeData[expandedProp] = newExpandedState;
+  }
+  
+  // 触发节点点击事件
+  // emit('node-click', {
+  //   node: props.nodeData,
+  //   parent: props.parentData || null
+  // });
+};
+
+// 处理子节点点击事件
+const handleChildNodeClick = (data: { node: any; parent: any | null }) => {
+  emit('node-click', data);
+};
+</script>
+
+<style lang="scss" scoped>
+.ie-tree-node {
+  &__default {
+    display: flex;
+    align-items: center;
+    padding: 10px 0;
+  }
+
+  &__expand-icon {
+    margin-right: 8px;
+    transition: transform 0.3s;
+    
+    &--expanded {
+      transform: rotate(90deg);
+    }
+  }
+
+  &__label {
+    flex: 1;
+  }
+
+  &__children {
+    padding-left: 20px;
+    overflow: hidden;
+    transition: height 0.3s ease-in-out;
+    will-change: height;
+    
+    &--measuring {
+      opacity: 0;
+      position: fixed;
+      z-index: -1000;
+      transform: translateX(0);
+      height: auto !important;
+    }
+  }
+}
+</style>

+ 154 - 0
src/components/ie-tree/ie-tree.vue

@@ -0,0 +1,154 @@
+<template>
+  <view class="ie-tree">
+    <ie-tree-node 
+      v-for="item in initializedData" 
+      :key="getNodeKey(item)"
+      :node-data="item"
+      :tree-props="mergedProps"
+      @node-click="handleNodeClick"
+    >
+      <template #default="slotProps: { node: any; parent: any }">
+        <slot :node="slotProps.node" :parent="slotProps.parent"></slot>
+      </template>
+    </ie-tree-node>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch, computed } from 'vue';
+import type { TreeProps } from '@/types';
+import IeTreeNode from './ie-tree-node.vue';
+
+defineOptions({
+  name: 'ie-tree',
+  options: {
+    virtualHost: true
+  }
+});
+
+// Props
+const props = defineProps<{
+  /** 树形数据 */
+  data: any[];
+  /** 树配置项(参考 Element UI el-tree 的 props) */
+  props?: Partial<TreeProps>;
+  /** 默认展开所有节点 */
+  defaultExpandAll?: boolean;
+  /** 默认展开的节点 key 数组 */
+  defaultExpandedKeys?: (string | number)[];
+}>();
+
+// Emits
+const emit = defineEmits<{
+  (e: 'node-click', data: { node: any; parent: any | null }): void;
+}>();
+
+// 默认配置
+const defaultTreeProps: Required<TreeProps> = {
+  children: 'children',
+  label: 'name',
+  nodeKey: 'id',
+  isLeaf: 'isLeaf',
+  disabled: 'disabled',
+  isExpanded: 'isExpanded'
+};
+
+// 合并配置
+const mergedProps = computed(() => ({
+  ...defaultTreeProps,
+  ...props.props
+}));
+
+// 获取节点唯一标识
+const getNodeKey = (node: any): string | number => {
+  const key = mergedProps.value.nodeKey;
+  return node?.[key] ?? node?.value ?? node?.id ?? '';
+};
+
+// 获取节点属性值
+const getNodeProp = (node: any, prop: string, defaultValue?: any) => {
+  return node?.[prop] ?? defaultValue;
+};
+
+// 初始化后的数据
+const initializedData = ref<any[]>([]);
+
+// 初始化数据
+const initializeData = (sourceData: any[]): any[] => {
+  if (!sourceData || !Array.isArray(sourceData)) return [];
+  
+  return sourceData.map((item, index) => {
+    const oldItem = initializedData.value[index];
+    const childrenProp = mergedProps.value.children;
+    const expandedProp = mergedProps.value.isExpanded;
+    const isLeafProp = mergedProps.value.isLeaf;
+    
+    // 递归处理子节点
+    const children = getNodeProp(item, childrenProp, []);
+    const processedChildren = children && children.length > 0 
+      ? initializeData(children) 
+      : [];
+    
+    // 判断是否为叶子节点
+    const isLeaf = getNodeProp(item, isLeafProp);
+    const computedIsLeaf = isLeaf !== undefined 
+      ? isLeaf 
+      : (!children || children.length === 0);
+    
+    // 判断是否展开
+    let isExpanded = false;
+    if (props.defaultExpandAll) {
+      isExpanded = !computedIsLeaf;
+    } else if (props.defaultExpandedKeys) {
+      const nodeKey = getNodeKey(item);
+      isExpanded = props.defaultExpandedKeys.includes(nodeKey);
+    } else if (oldItem && getNodeKey(oldItem) === getNodeKey(item)) {
+      // 保持之前的展开状态
+      isExpanded = getNodeProp(oldItem, expandedProp, false);
+    } else {
+      isExpanded = getNodeProp(item, expandedProp, false);
+    }
+    
+    return {
+      ...item,
+      [childrenProp]: processedChildren,
+      [isLeafProp]: computedIsLeaf,
+      [expandedProp]: isExpanded
+    };
+  });
+};
+
+// 处理节点点击事件
+const handleNodeClick = (data: { node: any; parent: any | null }) => {
+  emit('node-click', data);
+};
+
+// 监听 props.data 变化,重新初始化数据
+watch(
+  () => props.data,
+  (newData) => {
+    initializedData.value = initializeData(newData || []);
+  },
+  {
+    immediate: true,
+    deep: true
+  }
+);
+
+// 监听配置项变化,重新初始化数据
+watch(
+  () => props.props,
+  () => {
+    initializedData.value = initializeData(props.data || []);
+  },
+  {
+    deep: true
+  }
+);
+</script>
+
+<style lang="scss" scoped>
+.ie-tree {
+  width: 100%;
+}
+</style>

+ 3 - 1
src/main.ts

@@ -97,7 +97,9 @@ export function createApp() {
       },
       'loading-more-title-custom-style': {
         fontSize: '26rpx'
-      }
+      },
+      // 底部安全区域以placeholder形式实现
+      'use-safe-area-placeholder': true
       // 'empty-view-img-style': {
       //   width: '364rpx',
       //   height: '252rpx'

+ 12 - 0
src/pages.json

@@ -86,6 +86,18 @@
           "style": {
             "navigationBarTitleText": ""
           }
+        },
+        {
+          "path": "pages/career/index/index",
+          "style": {
+            "navigationBarTitleText": ""
+          }
+        },
+        {
+          "path": "pages/career/detail/detail",
+          "style": {
+            "navigationBarTitleText": ""
+          }
         }
       ]
     },

+ 1 - 1
src/pagesMain/pages/index/components/index-banner.vue

@@ -70,7 +70,7 @@ const validMenus = computed(() => {
     {
       name: '看职业',
       icon: '/menu/menu-work.png',
-      pageUrl: '/pagesOther/pages/vocation-library/index/index',
+      pageUrl: '/pagesOther/pages/career/index/index',
       noLogin: true
     },
     {

+ 9 - 0
src/pagesOther/pages/career/detail/detail.vue

@@ -0,0 +1,9 @@
+<template>
+<ie-page>
+  <ie-navbar title="职业详情" />
+</ie-page>
+</template>
+<script lang="ts" setup>
+
+</script>
+<style lang="scss" scoped></style>

+ 222 - 0
src/pagesOther/pages/career/index/index.vue

@@ -0,0 +1,222 @@
+<template>
+  <ie-page title="职业测试">
+    <view class="p-20">
+      <!-- 默认节点样式测试 -->
+      <view class="mb-40">
+        <view class="text-32 font-bold mb-20">默认样式树</view>
+        <ie-tree 
+          :data="treeData1"
+          @node-click="handleNodeClick"
+        />
+      </view>
+
+      <!-- 自定义配置测试 -->
+      <view class="mb-40">
+        <view class="text-32 font-bold mb-20">自定义配置树(不同字段名)</view>
+        <ie-tree 
+          :data="treeData2"
+          :props="customProps"
+          @node-click="handleNodeClick"
+        />
+      </view>
+
+      <!-- 自定义节点样式测试 -->
+      <view class="mb-40">
+        <view class="text-32 font-bold mb-20">自定义节点样式</view>
+        <ie-tree 
+          :data="treeData3"
+          @node-click="handleNodeClick"
+        >
+          <template #default="slotProps: { node: any; parent: any }">
+            <view class="flex items-center justify-between py-20 px-20 border-0 border-b border-solid border-[#E6E6E6]">
+              <view class="flex items-center">
+                <view 
+                  class="w-40 h-40 rounded-full flex items-center justify-center mr-16"
+                  :class="slotProps.node.isLeaf ? 'bg-[#E8F5FF]' : 'bg-[#FFF7E6]'"
+                >
+                  <text class="text-24">{{ slotProps.node.isLeaf ? '📄' : '📁' }}</text>
+                </view>
+                <view>
+                  <view class="text-28 font-bold text-fore-title">{{ slotProps.node.name }}</view>
+                  <view v-if="slotProps.node.desc" class="text-24 text-fore-light mt-4">{{ slotProps.node.desc }}</view>
+                </view>
+              </view>
+              <view v-if="slotProps.node.count !== undefined" class="text-24 text-primary">
+                {{ slotProps.node.count }} 个
+              </view>
+            </view>
+          </template>
+        </ie-tree>
+      </view>
+
+      <!-- 默认展开所有节点 -->
+      <view class="mb-40">
+        <view class="text-32 font-bold mb-20">默认展开所有</view>
+        <ie-tree 
+          :data="treeData4"
+          :default-expand-all="true"
+          @node-click="handleNodeClick"
+        />
+      </view>
+    </view>
+  </ie-page>
+</template>
+
+<script lang="ts" setup>
+defineOptions({
+  name: 'career-index'
+});
+
+// 测试数据1:默认配置(children, name, id)
+const treeData1 = ref([
+  {
+    id: 1,
+    name: '计算机类',
+    children: [
+      {
+        id: 11,
+        name: '软件开发',
+        children: [
+          { id: 111, name: '前端工程师' },
+          { id: 112, name: '后端工程师' },
+          { id: 113, name: '全栈工程师' }
+        ]
+      },
+      {
+        id: 12,
+        name: '人工智能',
+        children: [
+          { id: 121, name: '机器学习工程师' },
+          { id: 122, name: '算法工程师' }
+        ]
+      }
+    ]
+  },
+  {
+    id: 2,
+    name: '医疗类',
+    children: [
+      { id: 21, name: '临床医生' },
+      { id: 22, name: '护士' },
+      { id: 23, name: '药剂师' }
+    ]
+  }
+]);
+
+// 测试数据2:自定义字段名
+const treeData2 = ref([
+  {
+    code: 'A1',
+    title: '互联网行业',
+    subList: [
+      {
+        code: 'A11',
+        title: '产品设计',
+        subList: [
+          { code: 'A111', title: '产品经理' },
+          { code: 'A112', title: 'UI设计师' },
+          { code: 'A113', title: 'UX设计师' }
+        ]
+      },
+      {
+        code: 'A12',
+        title: '运营推广',
+        subList: [
+          { code: 'A121', title: '内容运营' },
+          { code: 'A122', title: '用户运营' }
+        ]
+      }
+    ]
+  }
+]);
+
+// 自定义配置
+const customProps = {
+  children: 'subList',
+  label: 'title',
+  nodeKey: 'code'
+};
+
+// 测试数据3:带描述和计数
+const treeData3 = ref([
+  {
+    id: 1,
+    name: '制造业',
+    desc: '实体经济核心',
+    count: 5,
+    children: [
+      {
+        id: 11,
+        name: '机械制造',
+        desc: '传统优势产业',
+        count: 3,
+        children: [
+          { id: 111, name: '机械工程师', desc: '设计制造机械设备' },
+          { id: 112, name: '质量工程师', desc: '产品质量控制' },
+          { id: 113, name: '工艺工程师', desc: '生产工艺优化' }
+        ]
+      },
+      {
+        id: 12,
+        name: '电子制造',
+        desc: '高新技术产业',
+        count: 2,
+        children: [
+          { id: 121, name: '电子工程师', desc: '电路设计与开发' },
+          { id: 122, name: '测试工程师', desc: '产品测试验证' }
+        ]
+      }
+    ]
+  },
+  {
+    id: 2,
+    name: '金融业',
+    desc: '现代服务业',
+    count: 3,
+    children: [
+      { id: 21, name: '银行职员', desc: '银行业务办理' },
+      { id: 22, name: '证券分析师', desc: '投资分析咨询' },
+      { id: 23, name: '保险代理人', desc: '保险产品销售' }
+    ]
+  }
+]);
+
+// 测试数据4:默认展开测试
+const treeData4 = ref([
+  {
+    id: 1,
+    name: '教育培训',
+    children: [
+      {
+        id: 11,
+        name: '学前教育',
+        children: [
+          { id: 111, name: '幼儿园教师' },
+          { id: 112, name: '早教老师' }
+        ]
+      },
+      {
+        id: 12,
+        name: '基础教育',
+        children: [
+          { id: 121, name: '小学教师' },
+          { id: 122, name: '中学教师' }
+        ]
+      }
+    ]
+  }
+]);
+
+// 处理节点点击
+const handleNodeClick = (data: { node: any; parent: any | null }) => {
+  console.log('节点点击:', data);
+  uni.showToast({
+    title: `点击: ${data.node.name || data.node.title}`,
+    icon: 'none',
+    duration: 1500
+  });
+};
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 30 - 5
src/pagesOther/pages/major/detail/components/major-overview.vue

@@ -6,23 +6,27 @@
         <view>➣ {{ data.middleName }}</view>
         <view>➣➣ {{ data.name }}</view>
       </view>
-      <view class="flex flex-col items-center justify-center">
-        <uv-icon name="heart" size="22" color="var(--primary-color)"></uv-icon>
-        <text class="text-primary text-24">收藏</text>
+      <view class="flex flex-col items-center justify-center" @click="handleCollect">
+        <uv-icon :name="data.isCollect ? 'heart-fill' : 'heart'" size="22" color="var(--primary-color)"></uv-icon>
+        <text class="text-primary text-24">{{ data.isCollect ? '已收藏' : '收藏' }}</text>
       </view>
     </view>
     <view class="mt-20 p-30 bg-white flex flex-col gap-30">
       <view v-for="card in cards" :key="card.prop">
         <view class="title">{{ card.title }}</view>
-        <view class="px-30 py-20 text-28 text-fore-content bg-back rounded-12 mt-10 whitespace-pre-line">
+        <view class="p-30 text-28 text-fore-content bg-back rounded-12 mt-10 whitespace-pre-line">
           {{ data[card.prop as keyof Major.MajorOverview] }}
         </view>
       </view>
     </view>
+    <ie-safe-area-bottom bg-color="#FFFFFF" />
   </view>
 </template>
 <script lang="ts" setup>
+import { useUserStore } from '@/store/userStore';
+import { collectMajor, cancelCollectMajor } from '@/api/modules/major';
 import { Major } from '@/types';
+const userStore = useUserStore();
 
 const props = defineProps<{
   data: Major.MajorOverview;
@@ -49,10 +53,31 @@ const cards = ref([
     prop: 'zhongzhiMajors'
   },
   {
-    title: '接本科专业',
+    title: '接本科专业',
     prop: 'benMajors'
   },
 ]);
+const handleCollect = async () => {
+  const isLogin = await userStore.checkLogin();
+  if (!isLogin) {
+    return;
+  }
+  if (props.data.isCollect) {
+    cancelCollectMajor(props.data.code).then(res => {
+      props.data.isCollect = false;
+      uni.$ie.showToast('取消收藏成功');
+    }).catch(() => {
+      uni.$ie.showToast('取消收藏失败');
+    });
+  } else {
+    collectMajor(props.data.code).then(res => {
+      props.data.isCollect = true;
+      uni.$ie.showToast('收藏成功');
+    }).catch(() => {
+      uni.$ie.showToast('收藏失败');
+    });
+  }
+};
 </script>
 <style lang="scss" scoped>
 .title {

+ 1 - 1
src/pagesOther/pages/major/detail/detail.vue

@@ -1,5 +1,5 @@
 <template>
-  <ie-page :fix-height="true" bg-color="#F6F8FA">
+  <ie-page :fix-height="true" bg-color="#F6F8FA" :safe-area-inset-bottom="false">
     <ie-navbar title="专业详情" />
     <ie-auto-resizer>
       <ie-tabs-swiper v-model="current" :list="tabs" :scrollable="false">

+ 20 - 9
src/pagesOther/pages/major/index/index.vue

@@ -3,15 +3,24 @@
     <z-paging ref="paging" v-model="list" :safe-area-inset-bottom="true" @query="handleQuery">
       <template #top>
         <ie-navbar title="专业库" />
-        <ie-search v-model="keyword" placeholder="输入专业名称" @search="handleSearch" />
+        <ie-search v-model="keyword" placeholder="输入专业名称" @search="handleSearch" @clear="handleSearch" />
       </template>
-      <view v-for="item in list" :key="item.id">
-        <view class="text-30 text-fore-title py-20 px-30 font-bold mt-20">{{ item.name }}</view>
-        <uv-cell-group>
-          <uv-cell v-for="child in item.children" :key="child.id" icon="" :title="child.name" :isLink="true"
-            :value="`${child.children?.length}个专业`" arrow-direction="right" @click="handleClick(child)"></uv-cell>
+      <template v-if="!showLevel2">
+        <view v-for="item in list" :key="item.id">
+          <view class="text-30 text-fore-title py-20 px-30 font-bold mt-20">{{ item.name }}</view>
+          <uv-cell-group>
+            <uv-cell v-for="child in item.children" :key="child.id" icon="" :title="child.name" :isLink="true"
+              :value="`${child.children?.length}个专业`" arrow-direction="right" @click="handleClick(child)"></uv-cell>
+          </uv-cell-group>
+        </view>
+      </template>
+      <template v-else>
+        <uv-cell-group :border="false">
+          <uv-cell v-for="item in list" :key="item.id" icon="" :title="item.name" :isLink="true"
+            :value="item?.children?.length ? `${item?.children?.length}个专业` : ''" arrow-direction="right"
+            @click="handleClick(item)"></uv-cell>
         </uv-cell-group>
-      </view>
+      </template>
     </z-paging>
   </id-page>
 </template>
@@ -24,18 +33,20 @@ const { transferTo, routes } = useTransferPage();
 const keyword = ref('');
 const list = ref<Major.MajorItem[]>([]);
 const paging = ref<ZPagingInstance>();
-
+const showLevel2 = ref(false);
 const handleQuery = (page: number, size: number) => {
   const params: Major.MajorTreeQueryDTO = {};
   uni.$ie.showLoading();
   if (keyword.value.trim()) {
     params.name = keyword.value.trim();
     params.level = 1;
+    showLevel2.value = true;
     getMajorByName(params).then(res => {
       paging.value?.completeByNoMore(res.data, true);
     }).catch(() => paging.value?.complete(false))
       .finally(() => uni.$ie.hideLoading());
   } else {
+    showLevel2.value = false;
     getMajorTree(params).then(res => {
       paging.value?.completeByNoMore(res.data, true);
     }).catch(() => paging.value?.complete(false))
@@ -46,7 +57,7 @@ const handleSearch = () => {
   paging.value?.reload();
 }
 const handleClick = (item: Major.MajorItem) => {
-  if (item.childCount > 0) {
+  if (item.children?.length > 0) {
     transferTo(routes.majorLevelTwo, {
       data: item
     });

+ 5 - 4
src/pagesOther/pages/major/level-two/level-two.vue

@@ -1,12 +1,12 @@
 <template>
-  <ie-page bg-color="#F6F8FA">
+  <ie-page>
     <ie-navbar :title="pageTitle" />
-    <view class="">
+    <view class="bg-back sticky z-2" :style="{ top: baseStickyTop + 'px' }">
       <view class="px-30 py-20 text-28 text-fore-light">
         包含<span class="text-primary mx-10 font-bold">{{ childCount }}</span>个专业
       </view>
     </view>
-    <view class="bg-white">
+    <view class="bg-white z-1 relative">
       <uv-cell-group :border="false">
         <uv-cell v-for="child in majorData.children" :key="child.id" icon="" :title="child.name" :isLink="true"
           arrow-direction="right" @click="handleClick(child)"></uv-cell>
@@ -17,10 +17,11 @@
 <script lang="ts" setup>
 import { useTransferPage } from '@/hooks/useTransferPage';
 import { Major } from '@/types';
+import { useNavbar } from '@/hooks/useNavbar';
 
 const { prevData, transferTo, routes } = useTransferPage();
 const majorData = ref<Major.MajorItem>({} as Major.MajorItem);
-
+const { baseStickyTop } = useNavbar();
 const pageTitle = computed(() => {
   return majorData.value?.name || '';
 });

+ 19 - 0
src/types/index.ts

@@ -46,6 +46,25 @@ export interface TreeData {
   children?: TreeData[];
   isExpanded?: boolean;
   isLeaf?: boolean;
+  [key: string]: any; // 允许其他字段
+}
+
+/**
+ * 树组件配置项(参考 Element UI el-tree 的 props)
+ */
+export interface TreeProps {
+  /** 指定子树为节点对象的某个属性值,默认 'children' */
+  children?: string;
+  /** 指定节点标签为节点对象的某个属性值,默认 'name' */
+  label?: string;
+  /** 指定节点标识为节点对象的某个属性值,默认 'id' */
+  nodeKey?: string;
+  /** 指定节点是否为叶子节点,默认 'isLeaf' */
+  isLeaf?: string;
+  /** 指定节点是否禁用,默认 'disabled' */
+  disabled?: string;
+  /** 指定节点是否展开,默认 'isExpanded' */
+  isExpanded?: string;
 }
 
 export interface TableConfig {