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

This commit is contained in:
YunaiV
2025-12-24 22:18:14 +08:00
parent e3bb55e8af
commit 625c6258dd
2 changed files with 130 additions and 107 deletions

View File

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

View File

@@ -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 definitionList = ref<ProcessDefinition[]>([])
const expandedCategories = ref<Record<string, boolean>>({})
const categoryPositions = ref<{ code: string, top: number }[]>([]) // 分类区域位置信息(用于滚动时自动切换 tab
const scrollIntoView = ref('')
const isTabClicking = ref(false) // 是否正在通过点击 tab 触发滚动(避免滚动事件反向更新 tab
/** 图标配置 */
// 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' },
]
const definitionList = ref<ProcessDefinition[]>([])
/** 根据流程名称获取图标背景色 */
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()
}
/** Tab 点击 */
function handleTabClick({ name }: { index: number, name: string }) {
isTabClicking.value = true
// 滚动到对应分类
scrollIntoView.value = ''
nextTick(() => {
scrollIntoView.value = `category-${name}`
// 300ms 后恢复滚动监听
setTimeout(() => {
isTabClicking.value = false
}, 300)
})
}
/** 切换分类 */
// 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 = ''
setTimeout(() => {
scrollIntoView.value = `category-${category.code}`
}, 50)
/** 滚动事件 - 自动切换 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 toggleCategory(code: string) {
expandedCategories.value[code] = !expandedCategories.value[code]
}
/** 获取分类名称 */
function getCategoryName(code: string) {
return categoryList.value.find(item => item.code === code)?.name || code
}
/** 获取图标名称 */
function getIconName(index: number) {
return iconConfig[index % iconConfig.length].icon
}
/** 获取图标颜色 */
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>