Pārlūkot izejas kodu

feat:【bpm】流程发起界面,优化分类跳转

YunaiV 4 mēneši atpakaļ
vecāks
revīzija
625c6258dd

+ 1 - 0
src/api/bpm/definition/index.ts

@@ -6,6 +6,7 @@ export interface ProcessDefinition {
   key: string
   name: string
   description?: string
+  icon?: string
   category: string
   formType?: number
   formId?: number

+ 128 - 106
src/pages-bpm/processInstance/create/index.vue

@@ -9,78 +9,75 @@
     />
 
     <!-- 搜索框 -->
-    <view class="bg-white p-24rpx">
-      <wd-search
-        v-model="searchName"
-        placeholder="请输入流程名称"
-        placeholder-left
-        hide-cancel
-        @search="handleSearch"
-        @clear="handleSearch"
-      />
-    </view>
+    <wd-search
+      v-model="searchName"
+      placeholder="请输入流程名称"
+      placeholder-left
+      hide-cancel
+      @search="handleSearch"
+      @clear="handleSearch"
+    />
 
     <!-- 分类标签 -->
-    <!-- TODO @AI:可以使用 https://wot-ui.cn/component/index-bar.html 组件么? -->
-    <view class="flex overflow-x-auto bg-white px-16rpx">
-      <view
-        v-for="(item, index) in categoryList"
-        :key="item.id"
-        class="relative whitespace-nowrap px-24rpx py-20rpx text-28rpx"
-        :class="activeIndex === index ? 'font-bold text-[#1890ff]' : 'text-[#666]'"
-        @click="switchCategory(index)"
-      >
-        {{ item.name }}
-        <view
-          v-if="activeIndex === index"
-          class="absolute bottom-0 left-24rpx right-24rpx h-4rpx bg-[#1890ff]"
-        />
-      </view>
-    </view>
+    <wd-tabs
+      v-model="activeCategory"
+      slidable="always"
+      sticky
+      @click="handleTabClick"
+    >
+      <wd-tab v-for="item in categoryList" :key="item.code" :title="item.name" :name="item.code" />
+    </wd-tabs>
 
     <!-- 流程定义列表 -->
     <scroll-view
       scroll-y
-      class="h-[calc(100vh-280rpx)]"
+      class="h-[calc(100vh-320rpx)]"
       :scroll-into-view="scrollIntoView"
       scroll-with-animation
+      @scroll="handleScroll"
     >
       <view
-        v-for="(definitions, category) in groupedDefinitions"
-        :id="`category-${category}`"
-        :key="category"
-        class="mx-24rpx mt-24rpx"
+        v-for="item in categoryList"
+        :id="`category-${item.code}`"
+        :key="item.code"
+        class="category-section mx-24rpx mt-24rpx"
+        :data-category="item.code"
       >
         <!-- 分类标题 -->
-        <view class="mb-16rpx flex items-center justify-between">
-          <text class="text-28rpx text-[#333] font-bold">{{ getCategoryName(category as string) }}</text>
-          <wd-icon
-            :name="expandedCategories[category as string] ? 'arrow-up' : 'arrow-down'"
-            size="32rpx"
-            @click="toggleCategory(category as string)"
-          />
+        <view class="mb-16rpx flex items-center">
+          <text class="text-28rpx text-[#333] font-bold">{{ item.name }}</text>
         </view>
         <!-- 流程列表 -->
-        <view v-if="expandedCategories[category as string]" class="overflow-hidden rounded-16rpx bg-white">
+        <view v-if="groupedDefinitions[item.code]?.length" class="overflow-hidden rounded-16rpx bg-white">
           <view
-            v-for="(item, index) in definitions"
-            :key="item.id"
+            v-for="definition in groupedDefinitions[item.code]"
+            :key="definition.id"
             class="flex items-center border-b border-[#f5f5f5] p-24rpx last:border-b-0"
-            @click="handleSelect(item)"
+            @click="handleSelect(definition)"
           >
+            <image
+              v-if="definition.icon"
+              :src="definition.icon"
+              class="mr-16rpx h-64rpx w-64rpx rounded-12rpx object-contain"
+              mode="aspectFit"
+            />
             <view
+              v-else
               class="mr-16rpx h-64rpx w-64rpx flex items-center justify-center rounded-12rpx"
-              :style="{ backgroundColor: getIconColor(index) }"
+              :style="{ backgroundColor: getIconColor(definition.name) }"
             >
-              <wd-icon :name="getIconName(index)" size="40rpx" color="#fff" />
+              <text class="text-24rpx text-white font-bold">{{ getIconText(definition.name) }}</text>
             </view>
-            <text class="text-28rpx text-[#333]">{{ item.name }}</text>
+            <text class="text-28rpx text-[#333]">{{ definition.name }}</text>
           </view>
         </view>
+        <view v-else class="overflow-hidden rounded-16rpx bg-white p-24rpx text-center">
+          <text class="text-26rpx text-[#999]">该分类下暂无流程</text>
+        </view>
       </view>
 
       <!-- 空状态 -->
-      <view v-if="Object.keys(groupedDefinitions).length === 0" class="py-100rpx">
+      <view v-if="categoryList.length === 0" class="py-100rpx">
         <wd-status-tip image="content" tip="暂无可发起的流程" />
       </view>
     </scroll-view>
@@ -91,7 +88,7 @@
 import type { Category } from '@/api/bpm/category'
 import type { ProcessDefinition } from '@/api/bpm/definition'
 import { onLoad } from '@dcloudio/uni-app'
-import { computed, ref } from 'vue'
+import { computed, nextTick, ref } from 'vue'
 import { useToast } from 'wot-design-uni'
 import { getCategorySimpleList } from '@/api/bpm/category'
 import { getProcessDefinitionList } from '@/api/bpm/definition'
@@ -121,23 +118,29 @@ definePage({
 const toast = useToast()
 
 const searchName = ref('')
-const activeIndex = ref(0)
-const scrollIntoView = ref('')
+const activeCategory = ref('')
 const categoryList = ref<Category[]>([])
+const categoryPositions = ref<{ code: string, top: number }[]>([]) // 分类区域位置信息(用于滚动时自动切换 tab)
+const scrollIntoView = ref('')
+const isTabClicking = ref(false) // 是否正在通过点击 tab 触发滚动(避免滚动事件反向更新 tab)
+
 const definitionList = ref<ProcessDefinition[]>([])
-const expandedCategories = ref<Record<string, boolean>>({})
-
-/** 图标配置 */
-// TODO @芋艿:【流程定义图标】支持显示流程定义的自定义图标 definition.icon
-// 对应 vben 第 175-189 行:优先显示 definition.icon,无图标时显示流程名称前两个字
-// TODO @AI:优化下,图标使用 vben 对应的逻辑;
-const iconConfig = [
-  { icon: 'warning', color: '#D98469' },
-  { icon: 'heart', color: '#7BC67C' },
-  { icon: 'cart', color: '#4A7FEB' },
-  { icon: 'home', color: '#4A7FEB' },
-  { icon: 'location', color: '#4A9DEB' },
-]
+
+/** 根据流程名称获取图标背景色 */
+function getIconColor(name: string): string {
+  const iconColors = ['#D98469', '#7BC67C', '#4A7FEB', '#9B7FEB', '#4A9DEB']
+  // 根据名称 hashcode 取模选择颜色
+  let hash = 0
+  for (let i = 0; i < name.length; i++) {
+    hash = (hash * 31 + name.charCodeAt(i)) | 0
+  }
+  return iconColors[Math.abs(hash) % iconColors.length]
+}
+
+/** 获取流程名称的前两个字符作为图标文字 */
+function getIconText(name: string): string {
+  return name?.slice(0, 2) || ''
+}
 
 /** 过滤后的流程定义 */
 const filteredDefinitions = computed(() => {
@@ -159,13 +162,7 @@ const groupedDefinitions = computed<Record<string, ProcessDefinition[]>>(() => {
       grouped[item.category] = []
     grouped[item.category].push(item)
   })
-  // 按 categoryList 顺序排序
-  const ordered: Record<string, ProcessDefinition[]> = {}
-  categoryList.value.forEach((cat) => {
-    if (grouped[cat.code])
-      ordered[cat.code] = grouped[cat.code]
-  })
-  return ordered
+  return grouped
 })
 
 /** 返回上一页 */
@@ -174,46 +171,63 @@ function handleBack() {
 }
 
 /** 搜索 */
-function handleSearch() {
-  // 搜索时展开所有分类
-  categoryList.value.forEach((cat) => {
-    expandedCategories.value[cat.code] = true
-  })
+async function handleSearch() {
+  // 搜索后重新计算分类位置
+  await nextTick()
+  updateCategoryPositions()
 }
 
-/** 切换分类 */
-// TODO @AI:目前有个 bug;滚动到为止后,选中的 category 不会变;
-function switchCategory(index: number) {
-  activeIndex.value = index
-  const category = categoryList.value[index]
-  if (category) {
-    expandedCategories.value[category.code] = true
-    // 滚动到对应分类
-    scrollIntoView.value = ''
+/** Tab 点击 */
+function handleTabClick({ name }: { index: number, name: string }) {
+  isTabClicking.value = true
+  // 滚动到对应分类
+  scrollIntoView.value = ''
+  nextTick(() => {
+    scrollIntoView.value = `category-${name}`
+    // 300ms 后恢复滚动监听
     setTimeout(() => {
-      scrollIntoView.value = `category-${category.code}`
-    }, 50)
-  }
-}
-
-/** 切换分类展开/收起 */
-function toggleCategory(code: string) {
-  expandedCategories.value[code] = !expandedCategories.value[code]
-}
-
-/** 获取分类名称 */
-function getCategoryName(code: string) {
-  return categoryList.value.find(item => item.code === code)?.name || code
+      isTabClicking.value = false
+    }, 300)
+  })
 }
 
-/** 获取图标名称 */
-function getIconName(index: number) {
-  return iconConfig[index % iconConfig.length].icon
+/** 滚动事件 - 自动切换 tab */
+function handleScroll(e: { detail: { scrollTop: number } }) {
+  if (isTabClicking.value || categoryPositions.value.length === 0) {
+    return
+  }
+  // 找到当前滚动位置对应的分类
+  const scrollTop = e.detail.scrollTop
+  for (let i = categoryPositions.value.length - 1; i >= 0; i--) {
+    if (scrollTop >= categoryPositions.value[i].top - 20) {
+      if (activeCategory.value !== categoryPositions.value[i].code) {
+        activeCategory.value = categoryPositions.value[i].code
+      }
+      break
+    }
+  }
 }
 
-/** 获取图标颜色 */
-function getIconColor(index: number) {
-  return iconConfig[index % iconConfig.length].color
+/** 更新分类区域位置信息 */
+function updateCategoryPositions() {
+  const query = uni.createSelectorQuery()
+  query.selectAll('.category-section').boundingClientRect()
+  query.exec((res) => {
+    if (res && res[0]) {
+      const positions: { code: string, top: number }[] = []
+      const firstTop = res[0][0]?.top || 0
+      res[0].forEach((item: { top: number, dataset?: { category?: string } }, index: number) => {
+        const cat = categoryList.value[index]
+        if (cat) {
+          positions.push({
+            code: cat.code,
+            top: item.top - firstTop,
+          })
+        }
+      })
+      categoryPositions.value = positions
+    }
+  })
 }
 
 /** 选择流程定义 */
@@ -231,10 +245,6 @@ function handleSelect(item: ProcessDefinition) {
 /** 加载分类列表 */
 async function loadCategoryList() {
   categoryList.value = await getCategorySimpleList()
-  // 默认展开所有分类
-  categoryList.value.forEach((cat) => {
-    expandedCategories.value[cat.code] = true
-  })
 }
 
 /** 加载流程定义列表 */
@@ -245,5 +255,17 @@ async function loadDefinitionList() {
 /** 初始化 */
 onLoad(async () => {
   await Promise.all([loadCategoryList(), loadDefinitionList()])
+  // 默认选中第一个分类
+  if (categoryList.value.length > 0) {
+    activeCategory.value = categoryList.value[0].code
+  }
+  // 等待 DOM 渲染后计算分类位置
+  await nextTick()
+  setTimeout(() => {
+    updateCategoryPositions()
+  }, 100)
 })
 </script>
+
+<style lang="scss" scoped>
+</style>