first commit

This commit is contained in:
2026-04-14 15:06:26 +08:00
commit 9ee0c6c597
582 changed files with 61051 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
分类名
</view>
<wd-input
v-model="formData.name"
placeholder="请输入分类名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
分类标志
</view>
<wd-input
v-model="formData.code"
placeholder="请输入分类标志"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
分类状态
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
code: undefined as string | undefined,
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`分类名:${formData.name}`)
}
if (formData.code) {
conditions.push(`分类标志:${formData.code}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程分类'
})
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.code = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程分类详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="分类编号" :value="formData?.id" />
<wd-cell title="分类名" :value="formData?.name" />
<wd-cell title="分类标志" :value="formData?.code" />
<wd-cell title="分类描述" :value="formData?.description || '-'" />
<wd-cell title="分类状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="分类排序" :value="formData?.sort" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:category:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:category:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteCategory, getCategory } from '@/api/bpm/category'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<Category>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/category/index')
}
/** 加载流程分类详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getCategory(props.id)
} finally {
toast.close()
}
}
/** 编辑流程分类 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/category/form/index?id=${props.id}`,
})
}
/** 删除流程分类 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该流程分类吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteCategory(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,159 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="分类名"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入分类名"
/>
<wd-input
v-model="formData.code"
label="分类标志"
label-width="180rpx"
prop="code"
clearable
placeholder="请输入分类标志"
/>
<wd-textarea
v-model="formData.description"
label="分类描述"
label-width="180rpx"
prop="description"
clearable
placeholder="请输入分类描述"
/>
<wd-cell title="分类状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model.number="formData.sort"
label="分类排序"
label-width="180rpx"
prop="sort"
type="number"
clearable
placeholder="请输入分类排序"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Category } from '@/api/bpm/category'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createCategory, getCategory, updateCategory } from '@/api/bpm/category'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑流程分类' : '新增流程分类')
const formLoading = ref(false)
const formData = ref<Category>({
id: undefined,
name: '',
code: '',
status: CommonStatusEnum.ENABLE,
description: '',
sort: 0,
})
const formRules = {
name: [{ required: true, message: '分类名不能为空' }],
code: [{ required: true, message: '分类标志不能为空' }],
status: [{ required: true, message: '分类状态不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/category/index')
}
/** 加载流程分类详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getCategory(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateCategory(formData.value)
toast.success('修改成功')
} else {
await createCategory(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,167 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程分类管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程分类列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">分类标志</text>
<text>{{ item.code }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">分类描述</text>
<text class="min-w-0 flex-1 truncate">{{ item.description || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">分类排序</text>
<text>{{ item.sort }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程分类数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:category:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getCategoryPage } from '@/api/bpm/category'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<Category[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程分类列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getCategoryPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增流程分类 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/category/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Category) {
uni.navigateTo({
url: `/pages-bpm/category/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,168 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
请假类型
</view>
<wd-radio-group v-model="formData.type" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)" :key="dict.value" :value="dict.value">
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
审批结果
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" :key="dict.value" :value="dict.value">
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
请假原因
</view>
<wd-input
v-model="formData.reason"
placeholder="请输入请假原因"
clearable
/>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
type: -1, // -1 表示全部
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
reason: undefined as string | undefined,
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.type !== -1) {
conditions.push(`类型:${getDictLabel(DICT_TYPE.BPM_OA_LEAVE_TYPE, formData.type)}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
if (formData.reason) {
conditions.push(`原因:${formData.reason}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索请假记录'
})
// 时间选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
type: formData.type === -1 ? undefined : formData.type,
status: formData.status === -1 ? undefined : formData.status,
reason: formData.reason || undefined,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.type = -1
formData.status = -1
formData.createTime = [undefined, undefined]
formData.reason = undefined
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,285 @@
<template>
<view class="yd-page-container pb-[76rpx]">
<!-- 顶部导航栏 -->
<wd-navbar
title="发起请假"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
<!-- 表单内容 -->
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border title="请假信息">
<wd-picker
v-model="formData.type"
:columns="getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
label="请假类型"
label-width="200rpx"
prop="type"
:rules="[{ required: true, message: '请选择请假类型' }]"
placeholder="请选择请假类型"
/>
<wd-datetime-picker
v-model="formData.startTime"
label="开始时间"
label-width="200rpx"
prop="startTime"
:rules="[{ required: true, message: '请选择开始时间' }]"
placeholder="请选择开始时间"
/>
<wd-datetime-picker
v-model="formData.endTime"
label="结束时间"
label-width="200rpx"
prop="endTime"
:rules="[{ required: true, message: '请选择结束时间' }]"
placeholder="请选择结束时间"
/>
<wd-textarea
v-model="formData.reason"
label="请假原因"
label-width="200rpx"
prop="reason"
:rules="[{ required: true, message: '请输入请假原因' }]"
placeholder="请输入请假原因"
:maxlength="200"
show-word-limit
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 流程预览卡片 -->
<view class="mx-24rpx mb-120rpx mt-24rpx rounded-16rpx bg-white">
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<text class="text-28rpx text-[#333] font-bold">流程预览</text>
<wd-loading v-if="processTimeLineLoading" size="32rpx" />
</view>
<!-- 流程时间线 -->
<ProcessInstanceTimeline
v-if="activityNodes.length > 0"
:activity-nodes="activityNodes"
:show-status-icon="false"
@select-user-confirm="selectUserConfirm"
/>
<!-- 无流程数据提示 -->
<view v-else-if="!processTimeLineLoading" class="py-40rpx text-center">
<text class="text-24rpx text-[#999]">暂无流程预览数据</text>
</view>
</view>
</view>
<!-- 底部提交按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button type="primary" class="flex-1" :loading="formLoading" @click="handleSubmit">
提交
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Leave } from '@/api/bpm/oa/leave'
import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import { computed, onMounted, ref, watch } from 'vue'
import { useMessage, useToast } from 'wot-design-uni'
import { getProcessDefinition } from '@/api/bpm/definition'
import { createLeave } from '@/api/bpm/oa/leave'
import { getApprovalDetail } from '@/api/bpm/processInstance'
import { getIntDictOptions } from '@/hooks/useDict'
import ProcessInstanceTimeline from '@/pages-bpm/processInstance/detail/components/time-line.vue'
import { navigateBackPlus } from '@/utils'
import { BpmCandidateStrategyEnum, BpmNodeIdEnum, DICT_TYPE } from '@/utils/constants'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const message = useMessage()
const formLoading = ref(false)
const processTimeLineLoading = ref(false) // 流程预览加载状态
// 流程相关数据
const processDefineKey = 'oa_leave' // 流程定义 Key
const processDefinitionId = ref('')
const activityNodes = ref<ApprovalNodeInfo[]>([]) // 审批节点信息
const startUserSelectTasks = ref<any[]>([]) // 发起人需要选择审批人的用户任务列表
const startUserSelectAssignees = ref<any>({}) // 发起人选择审批人的数据
const tempStartUserSelectAssignees = ref<any>({}) // 临时保存的审批人数据
const formData = ref<Partial<Leave>>({
type: undefined,
startTime: undefined,
endTime: undefined,
reason: undefined,
})
const formRules = {
type: [{ required: true, message: '请选择请假类型' }],
startTime: [{ required: true, message: '请选择开始时间' }],
endTime: [{ required: true, message: '请选择结束时间' }],
reason: [{ required: true, message: '请输入请假原因' }],
}
const formRef = ref<FormInstance>()
// 计算请假天数
const leaveDays = computed(() => {
if (!formData.value.startTime || !formData.value.endTime) {
return 0
}
const start = new Date(formData.value.startTime)
const end = new Date(formData.value.endTime)
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
})
/** 返回上一页 */
function handleBack() {
message.confirm({
title: '提示',
msg: '确定要返回吗?请先保存您填写的信息!',
}).then(({ action }) => {
if (action === 'confirm') {
navigateBackPlus('/pages-bpm/oa/leave/index')
}
})
}
/** 获取流程审批详情 */
async function getProcessApprovalDetail() {
if (!processDefinitionId.value) {
return
}
processTimeLineLoading.value = true
try {
const data = await getApprovalDetail({
processDefinitionId: processDefinitionId.value,
activityId: BpmNodeIdEnum.START_USER_NODE_ID,
processVariablesStr: JSON.stringify({
day: leaveDays.value,
}),
})
if (!data) {
toast.show('查询不到审批详情信息!')
return
}
// 获取审批节点,显示 Timeline 的数据
activityNodes.value = data.activityNodes || []
// 获取发起人自选的任务
startUserSelectTasks.value = data.activityNodes?.filter(
(node: ApprovalNodeInfo) =>
BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
) || []
// 恢复之前的选择审批人
if (startUserSelectTasks.value.length > 0) {
for (const node of startUserSelectTasks.value) {
startUserSelectAssignees.value[node.id]
= tempStartUserSelectAssignees.value[node.id]
&& tempStartUserSelectAssignees.value[node.id].length > 0
? tempStartUserSelectAssignees.value[node.id]
: []
}
}
} catch (error) {
console.error('获取流程审批详情失败:', error)
} finally {
processTimeLineLoading.value = false
}
}
/** 选择审批人确认 */
function selectUserConfirm(id: string, userList: any[]) {
startUserSelectAssignees.value[id] = userList?.map((item: any) => item.id) || []
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
if (formData.value.startTime! >= formData.value.endTime!) {
toast.show('结束时间必须大于开始时间')
return
}
// 校验指定审批人
if (startUserSelectTasks.value.length > 0) {
for (const userTask of startUserSelectTasks.value) {
if (
Array.isArray(startUserSelectAssignees.value[userTask.id])
&& startUserSelectAssignees.value[userTask.id].length === 0
) {
toast.show(`请选择${userTask.name}的审批人`)
return
}
}
}
formLoading.value = true
try {
const submitData = { ...formData.value }
// 设置指定审批人
if (startUserSelectTasks.value.length > 0) {
submitData.startUserSelectAssignees = startUserSelectAssignees.value
}
await createLeave(submitData)
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => {
navigateBackPlus('/pages-bpm/oa/leave/index')
}, 1500)
} finally {
formLoading.value = false
}
}
// 监听表单数据变化,重新预测流程节点
watch(
() => [formData.value.startTime, formData.value.endTime, formData.value.type],
(newValue, oldValue) => {
if (!oldValue || !oldValue.some(v => v !== undefined)) {
return
}
if (newValue && newValue.some(v => v !== undefined)) {
// 记录之前的节点审批人
tempStartUserSelectAssignees.value = { ...startUserSelectAssignees.value }
startUserSelectAssignees.value = {}
// 加载最新的审批详情,主要用于节点预测
getProcessApprovalDetail()
}
},
{ deep: true },
)
// 组件初始化
onMounted(async () => {
try {
// 获取流程定义详情
const processDefinitionDetail = await getProcessDefinition(undefined, processDefineKey)
if (!processDefinitionDetail) {
toast.show('OA 请假的流程模型未配置,请检查!')
return
}
processDefinitionId.value = processDefinitionDetail.id
// 获取流程审批详情
await getProcessApprovalDetail()
} catch (error) {
console.error('初始化流程失败:', error)
toast.show('初始化流程失败,请稍后重试')
}
})
</script>

View File

@@ -0,0 +1,76 @@
<template>
<view :class="embedded ? '' : 'yd-page-container'">
<!-- 顶部导航栏仅路由访问时显示 -->
<wd-navbar
v-if="!embedded"
title="请假详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group :border="!embedded">
<wd-cell title="请假类型">
<dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="formData.type" />
</wd-cell>
<wd-cell title="开始时间" :value="formatDateTime(formData.startTime) || '-'" />
<wd-cell title="结束时间" :value="formatDateTime(formData.endTime) || '-'" />
<wd-cell title="请假原因" :value="formData.reason || '-'" />
<wd-cell title="审批状态">
<dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="formData.status" />
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData.createTime) || '-'" />
</wd-cell-group>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Leave } from '@/api/bpm/oa/leave'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getLeave } from '@/api/bpm/oa/leave'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | string
embedded?: boolean // 是否作为嵌入组件使用(非路由访问)
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<Partial<Leave>>({})
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/oa/leave/index')
}
/** 获取详情数据 */
async function getDetail() {
if (!props.id) {
toast.show('参数错误')
return
}
try {
toast.loading('加载中...')
formData.value = await getLeave(Number(props.id))
} finally {
toast.close()
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>

View File

@@ -0,0 +1,218 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="请假列表"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<LeaveSearchForm @search="handleSearch" @reset="handleReset" />
<view class="bpm-list">
<!-- 请假列表 -->
<view
v-for="item in list"
:key="item.id"
class="bpm-card"
@click="handleDetail(item)"
>
<view class="bpm-card-content">
<view class="bpm-card-header">
<view class="bpm-card-title">
<dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="item.type" />
</view>
<dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="item.status" />
</view>
<view class="bpm-summary">
<view class="bpm-summary-item">
<text class="text-[#999]">开始时间</text>
<text>{{ formatDateTime(item.startTime) }}</text>
</view>
<view class="bpm-summary-item">
<text class="text-[#999]">结束时间</text>
<text>{{ formatDateTime(item.endTime) }}</text>
</view>
<view class="bpm-summary-item">
<text class="text-[#999]">请假原因</text>
<text>{{ item.reason }}</text>
</view>
</view>
<view class="bpm-card-info">
<view class="bpm-user">
<view class="bpm-avatar">
{{ userNickname?.[0] }}
</view>
<text class="bpm-nickname">{{ userNickname }}</text>
</view>
<text class="bpm-time">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
<view class="bpm-actions">
<view class="bpm-action-btn" @click.stop="handleDetail(item)">
<wd-icon name="view" size="32rpx" />
<text class="ml-8rpx">详情</text>
</view>
<view class="bpm-action-btn" @click.stop="handleProgress(item)">
<wd-icon name="queue" size="32rpx" />
<text class="ml-8rpx">审批进度</text>
</view>
<view
v-if="item.status === BpmProcessInstanceStatus.RUNNING"
class="bpm-action-btn text-[#ff4d4f]!"
@click.stop="handleCancel(item)"
>
<wd-icon name="close" size="32rpx" color="#ff4d4f" />
<text class="ml-8rpx">取消</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="bpm-empty">
<wd-status-tip image="content" tip="暂无请假记录" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
<!-- 新增按钮 -->
<wd-fab
position="right-bottom"
type="primary"
:expandable="false"
@click="handleCreate"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Leave } from '@/api/bpm/oa/leave'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { computed, onMounted, ref } from 'vue'
import { getLeavePage } from '@/api/bpm/oa/leave'
import { cancelProcessInstanceByStartUser } from '@/api/bpm/processInstance'
import { useUserStore } from '@/store'
import { navigateBackPlus } from '@/utils'
import { BpmProcessInstanceStatus, DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import LeaveSearchForm from './components/search-form.vue'
import '@/pages/bpm/styles/index.scss'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const userStore = useUserStore()
const userNickname = computed(() => userStore.userInfo?.nickname || '')
const total = ref(0)
const list = ref<Leave[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getLeavePage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 搜索 */
function handleSearch(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置 */
function handleReset() {
handleSearch()
}
/** 查看详情 */
function handleDetail(item: Leave) {
uni.navigateTo({ url: `/pages-bpm/oa/leave/detail/index?id=${item.id}` })
}
/** 审批进度 */
function handleProgress(item: Leave) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstanceId}` })
}
/** 取消请假 */
function handleCancel(item: Leave) {
uni.showModal({
title: '取消流程',
editable: true,
placeholderText: '请输入取消原因',
success: async (res) => {
if (!res.confirm) {
return
}
const reason = res.content?.trim()
if (!reason) {
uni.showToast({ title: '请输入取消原因', icon: 'none' })
return
}
await cancelProcessInstanceByStartUser(String(item.processInstanceId), reason)
// 更新状态
uni.showToast({ title: '取消成功', icon: 'success' })
item.status = BpmProcessInstanceStatus.CANCEL
item.endTime = new Date()
},
})
}
/** 发起请假 */
function handleCreate() {
uni.navigateTo({ url: '/pages-bpm/oa/leave/create/index' })
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,153 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
表达式名字
</view>
<wd-input
v-model="formData.name"
placeholder="请输入表达式名字"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
表达式状态
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名字:${formData.name}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程表达式'
})
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,131 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程表达式详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id" />
<wd-cell title="表达式名字" :value="formData?.name" />
<wd-cell title="表达式状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="表达式">
<view class="break-all">
{{ formData?.expression }}
</view>
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:process-expression:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:process-expression:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ProcessExpression } from '@/api/bpm/process-expression'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteProcessExpression, getProcessExpression } from '@/api/bpm/process-expression'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<ProcessExpression>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-expression/index')
}
/** 加载流程表达式详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getProcessExpression(props.id)
} finally {
toast.close()
}
}
/** 编辑流程表达式 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/process-expression/form/index?id=${props.id}`,
})
}
/** 删除流程表达式 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该流程表达式吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteProcessExpression(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,140 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="表达式名字"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入表达式名字"
/>
<wd-cell title="表达式状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-textarea
v-model="formData.expression"
label="表达式"
label-width="180rpx"
prop="expression"
clearable
placeholder="请输入表达式"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ProcessExpression } from '@/api/bpm/process-expression'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createProcessExpression, getProcessExpression, updateProcessExpression } from '@/api/bpm/process-expression'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑流程表达式' : '新增流程表达式')
const formLoading = ref(false)
const formData = ref<ProcessExpression>({
id: undefined,
name: '',
status: CommonStatusEnum.ENABLE,
expression: '',
})
const formRules = {
name: [{ required: true, message: '表达式名字不能为空' }],
status: [{ required: true, message: '表达式状态不能为空' }],
expression: [{ required: true, message: '表达式不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-expression/index')
}
/** 加载流程表达式详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getProcessExpression(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateProcessExpression(formData.value)
toast.success('修改成功')
} else {
await createProcessExpression(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,161 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程表达式管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程表达式列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between gap-16rpx">
<view class="min-w-0 flex-1 truncate text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="shrink-0">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
</view>
<view class="mb-12rpx text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">表达式</text>
<text class="break-all">{{ item.expression }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程表达式数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:process-expression:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { ProcessExpression } from '@/api/bpm/process-expression'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getProcessExpressionPage } from '@/api/bpm/process-expression'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<ProcessExpression[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程表达式列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getProcessExpressionPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增流程表达式 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/process-expression/form/index',
})
}
/** 查看详情 */
function handleDetail(item: ProcessExpression) {
uni.navigateTo({
url: `/pages-bpm/process-expression/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,94 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
监听器名字
</view>
<wd-input
v-model="formData.name"
placeholder="请输入监听器名字"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
监听器类型
</view>
<wd-radio-group v-model="formData.type" shape="button">
<wd-radio value="">
全部
</wd-radio>
<wd-radio
v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getStrDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
type: '', // 空字符串表示全部
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名字:${formData.name}`)
}
if (formData.type) {
conditions.push(`类型:${getDictLabel(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE, formData.type)}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程监听器'
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
type: formData.type || undefined,
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.type = ''
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程监听器详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id" />
<wd-cell title="监听器名字" :value="formData?.name" />
<wd-cell title="监听器类型">
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE" :value="formData?.type" />
</wd-cell>
<wd-cell title="监听器状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="监听事件" :value="formData?.event" />
<wd-cell title="值类型">
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE" :value="formData?.valueType" />
</wd-cell>
<wd-cell title="值">
<view class="break-all">
{{ formData?.value }}
</view>
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:process-listener:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:process-listener:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ProcessListener } from '@/api/bpm/process-listener'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteProcessListener, getProcessListener } from '@/api/bpm/process-listener'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<ProcessListener>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-listener/index')
}
/** 加载流程监听器详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getProcessListener(props.id)
} finally {
toast.close()
}
}
/** 编辑流程监听器 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/process-listener/form/index?id=${props.id}`,
})
}
/** 删除流程监听器 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该流程监听器吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteProcessListener(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,220 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="监听器名字"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入监听器名字"
/>
<wd-cell title="监听器状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="监听器类型" title-width="180rpx" prop="type" center>
<wd-radio-group v-model="formData.type" shape="button" @change="handleTypeChange">
<wd-radio
v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="监听事件" title-width="180rpx" prop="event" center>
<wd-radio-group v-model="formData.event" shape="button">
<wd-radio
v-for="option in eventOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="值类型" title-width="180rpx" prop="valueType" center>
<wd-radio-group v-model="formData.valueType" shape="button" @change="handleValueTypeChange">
<wd-radio
v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model="formData.value"
:label="valueLabel"
label-width="180rpx"
prop="value"
clearable
:placeholder="valuePlaceholder"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ProcessListener } from '@/api/bpm/process-listener'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createProcessListener, getProcessListener, updateProcessListener } from '@/api/bpm/process-listener'
import { getIntDictOptions, getStrDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
/** 执行监听器事件选项 */
const EVENT_EXECUTION_OPTIONS = [
{ label: '开始', value: 'start' },
{ label: '结束', value: 'end' },
]
/** 任务监听器事件选项 */
const EVENT_OPTIONS = [
{ label: '创建', value: 'create' },
{ label: '指派', value: 'assignment' },
{ label: '完成', value: 'complete' },
{ label: '删除', value: 'delete' },
{ label: '更新', value: 'update' },
{ label: '超时', value: 'timeout' },
]
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑流程监听器' : '新增流程监听器')
const formLoading = ref(false)
const formData = ref<ProcessListener>({
id: undefined,
name: '',
type: '',
status: CommonStatusEnum.ENABLE,
event: '',
valueType: '',
value: '',
})
const formRules = {
name: [{ required: true, message: '监听器名字不能为空' }],
type: [{ required: true, message: '监听器类型不能为空' }],
status: [{ required: true, message: '监听器状态不能为空' }],
event: [{ required: true, message: '监听事件不能为空' }],
valueType: [{ required: true, message: '值类型不能为空' }],
value: [{ required: true, message: '值不能为空' }],
}
const formRef = ref<FormInstance>()
/** 根据类型获取事件选项 */
const eventOptions = computed(() => {
return formData.value.type === 'execution' ? EVENT_EXECUTION_OPTIONS : EVENT_OPTIONS
})
/** 值标签 */
const valueLabel = computed(() => {
return formData.value.valueType === 'class' ? '类路径' : '表达式'
})
/** 值占位符 */
const valuePlaceholder = computed(() => {
return formData.value.valueType === 'class' ? '请输入类路径' : '请输入表达式'
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-listener/index')
}
/** 类型变更时清空事件 */
function handleTypeChange() {
formData.value.event = ''
}
/** 值类型变更时清空值 */
function handleValueTypeChange() {
formData.value.value = ''
}
/** 加载流程监听器详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getProcessListener(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateProcessListener(formData.value)
toast.success('修改成功')
} else {
await createProcessListener(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,168 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程监听器管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程监听器列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between gap-16rpx">
<view class="min-w-0 flex-1 truncate text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="shrink-0">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">监听器类型</text>
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE" :value="item.type" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">监听事件</text>
<text>{{ item.event }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">值类型</text>
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE" :value="item.valueType" />
</view>
<view class="mb-12rpx text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]"></text>
<text class="break-all">{{ item.value }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程监听器数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:process-listener:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { ProcessListener } from '@/api/bpm/process-listener'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getProcessListenerPage } from '@/api/bpm/process-listener'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<ProcessListener[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程监听器列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getProcessListenerPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增流程监听器 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/process-listener/form/index',
})
}
/** 查看详情 */
function handleDetail(item: ProcessListener) {
uni.navigateTo({
url: `/pages-bpm/process-listener/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,284 @@
<template>
<!-- TODO vben 对应的地址/Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/processInstance/create/index.vue -->
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="发起申请"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索框 -->
<wd-search
v-model="searchName"
placeholder="请输入流程名称"
placeholder-left
hide-cancel
@search="handleSearch"
@clear="handleSearch"
/>
<!-- 分类标签 -->
<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-320rpx)]"
:scroll-into-view="scrollIntoView"
scroll-with-animation
@scroll="handleScroll"
>
<view
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">
<text class="text-28rpx text-[#333] font-bold">{{ item.name }}</text>
</view>
<!-- 流程列表 -->
<view v-if="groupedDefinitions[item.code]?.length" class="overflow-hidden rounded-16rpx bg-white">
<view
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(definition)"
>
<wd-img
v-if="definition.icon"
:src="definition.icon"
width="64rpx"
height="64rpx"
mode="aspectFit"
radius="24rpx"
class="mr-16rpx"
/>
<view
v-else
class="mr-16rpx h-64rpx w-64rpx flex items-center justify-center rounded-12rpx"
:style="{ backgroundColor: getIconColor(definition.name) }"
>
<text class="text-24rpx text-white font-bold">{{ getIconText(definition.name) }}</text>
</view>
<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="categoryList.length === 0" class="py-100rpx">
<wd-status-tip image="content" tip="暂无可发起的流程" />
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import type { ProcessDefinition } from '@/api/bpm/definition'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getCategorySimpleList } from '@/api/bpm/category'
import { getProcessDefinitionList } from '@/api/bpm/definition'
import { getMobileFormCustomPath } from '@/pages-bpm/utils'
import { navigateBackPlus } from '@/utils'
import { BpmModelFormType } from '@/utils/constants'
// TODO @芋艿:【重新发起流程】支持通过 processInstanceId 参数重新发起已有流程
// 对应 vben 第 44-60 行:从路由获取 processInstanceId查询流程实例后自动选中对应流程定义并填充表单数据
// TODO @芋艿:【流程表单填写】选择流程后跳转到表单填写页面
// 对应 vben form.vue 全部:包含以下子功能:
// - 表单渲染 (form-create)vben form.vue 第 145-152 行
// - 审批流程预览时间线vben form.vue 第 153-162 行
// - 流程图预览 (BPMN/简易)vben form.vue 第 163-178 行
// - 发起人自选审批人vben form.vue 第 30-32, 85-95 行
// - 表单字段权限控制 (读/写/隐藏)vben form.vue 第 119-131 行
// - 业务表单跳转 (formCustomCreatePath)vben form.vue 第 79-85 行
// - 表单值变化重新预测审批节点vben form.vue 第 87-102 行
// - 提交流程实例vben form.vue 第 56-76 行
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const searchName = 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[]>([])
/** 根据流程名称获取图标背景色 */
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(() => {
if (!searchName.value.trim()) {
return definitionList.value
}
return definitionList.value.filter(item =>
item.name.toLowerCase().includes(searchName.value.toLowerCase()),
)
})
/** 按分类分组的流程定义 */
const groupedDefinitions = computed<Record<string, ProcessDefinition[]>>(() => {
const grouped: Record<string, ProcessDefinition[]> = {}
filteredDefinitions.value.forEach((item) => {
if (!item.category)
return
if (!grouped[item.category])
grouped[item.category] = []
grouped[item.category].push(item)
})
return grouped
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages/bpm/index')
}
/** 搜索 */
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)
})
}
/** 滚动事件 - 自动切换 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 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
}
})
}
/** 选择流程定义 */
function handleSelect(item: ProcessDefinition) {
// 情况一:流程表单,提示仅允许 PC 端发起
if (item.formType === BpmModelFormType.NORMAL) {
// TODO @jason业务表单/Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/processInstance/create/modules/form.vue
toast.show('流程表单仅支持 PC 端发起')
return
}
// 情况二:业务表单,跳转到对应的移动端页面
if (item.formType === BpmModelFormType.CUSTOM) {
const mobilePath = getMobileFormCustomPath(item.formCustomCreatePath)
if (mobilePath) {
uni.navigateTo({ url: mobilePath })
} else {
toast.show('该业务表单暂不支持移动端发起')
}
}
}
/** 加载分类列表 */
async function loadCategoryList() {
categoryList.value = await getCategorySimpleList()
}
/** 加载流程定义列表 */
async function loadDefinitionList() {
definitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
}
/** 初始化 */
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>

View File

@@ -0,0 +1,143 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="加签任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 加签处理人 -->
<UserPicker
v-model="formData.userIds"
prop="userIds"
type="checkbox"
label="加签处理人:"
label-width="200rpx"
placeholder="请选择加签处理人"
:rules="formRules.userIds"
/>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="200rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx flex gap-16rpx">
<wd-button
type="primary"
class="flex-1"
plain
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit('before')"
>
向前加签
</wd-button>
<wd-button
type="primary"
class="flex-1"
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit('after')"
>
向后加签
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { signCreateTask } from '@/api/bpm/task'
import UserPicker from '@/components/system-select/user-picker.vue'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const toast = useToast()
const formLoading = ref(false)
const formData = reactive({
userIds: [] as number[],
reason: '',
})
const formRules = {
userIds: [
{ required: true, message: '加签处理人不能为空', validator: (value: number[]) => value.length > 0 },
],
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 提交操作 */
async function handleSubmit(type: 'before' | 'after') {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await signCreateTask({
id: taskId.value as string,
type,
userIds: formData.userIds,
reason: formData.reason,
})
const actionText = type === 'before' ? '向前加签' : '向后加签'
toast.success(`${actionText}成功`)
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
}
})
</script>

View File

@@ -0,0 +1,327 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="isApprove ? '审批同意' : '审批拒绝'"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 审批表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 下一个节点的审批人 -->
<view v-if="isApprove && nextAssigneesActivityNode.length > 0" class="p-24rpx">
<view class="mb-16rpx flex items-center">
<text class="mr-8rpx text-[#f56c6c]">*</text>
<text class="text-28rpx text-[#333]">下一个节点的审批人</text>
</view>
<ProcessInstanceTimeline
:activity-nodes="nextAssigneesActivityNode"
:show-status-icon="false"
:enable-approve-user-select="true"
@select-user-confirm="selectNextAssigneesConfirm"
/>
</view>
<!-- 签名 -->
<view v-if="isApprove && taskInfo?.signEnable" class="border-b border-[#eee] p-24rpx">
<view class="mb-16rpx flex items-center">
<text class="mr-8rpx text-[#f56c6c]">*</text>
<text class="text-28rpx text-[#333]">签名</text>
</view>
<view class="flex items-center gap-16rpx">
<wd-button type="primary" size="small" @click="openSignatureModal">
{{ formData.signPicUrl ? '重新签名' : '点击签名' }}
</wd-button>
<image
v-if="formData.signPicUrl"
:src="formData.signPicUrl"
class="h-80rpx w-192rpx"
mode="aspectFit"
@click="previewSignature"
/>
</view>
</view>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="180rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
</wd-form>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
:type="isApprove ? 'primary' : 'error'"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
{{ isApprove ? '同意' : '拒绝' }}
</wd-button>
</view>
</view>
<!-- 签名弹窗 -->
<wd-popup v-model="showSignatureModal" position="bottom" custom-style="height: 60vh;">
<view class="h-full flex flex-col">
<view class="flex items-center justify-between border-b border-[#eee] p-24rpx">
<text class="text-32rpx text-[#333] font-bold">手写签名</text>
<wd-icon name="close" size="40rpx" @click="showSignatureModal = false" />
</view>
<view class="flex-1 p-24rpx">
<wd-signature
:height="300"
:export-scale="2"
background-color="#ffffff"
@confirm="handleSignatureConfirm"
@clear="handleSignatureClear"
/>
</view>
</view>
</wd-popup>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import type { Task } from '@/api/bpm/task'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getApprovalDetail, getNextApproveNodes } from '@/api/bpm/processInstance'
import { approveTask, rejectTask } from '@/api/bpm/task'
import ProcessInstanceTimeline from '@/pages-bpm/processInstance/detail/components/time-line.vue'
import { getEnvBaseUrl, navigateBackPlus } from '@/utils'
import { BpmCandidateStrategyEnum } from '@/utils/constants'
const props = defineProps<{
processInstanceId?: string
taskId?: string
pass?: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId || '')
const processInstanceId = computed(() => props.processInstanceId)
const isApprove = computed(() => props.pass !== 'false') // true: 同意, false: 拒绝
const toast = useToast()
const formLoading = ref(false)
const taskInfo = ref<Task | null>(null) // 任务信息
const nextAssigneesActivityNode = ref<ApprovalNodeInfo[]>([]) // 下一个节点审批人列表
const approveUserSelectTasks = ref<ApprovalNodeInfo[]>([]) // 需要选择审批人的节点列表
const approveUserSelectAssignees = ref<Record<string, number[]>>({}) // 审批人选择的审批人数据
// 签名相关
const showSignatureModal = ref(false)
const formData = reactive({
reason: '',
signPicUrl: '', // 签名图片 URL
})
const formRules = computed(() => {
let rules = {}
if (taskInfo.value?.reasonRequire) {
rules = {
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
}
return rules
})
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 加载任务信息 */
async function loadTaskInfo() {
const data = await getApprovalDetail({
processInstanceId: processInstanceId.value,
taskId: taskId.value,
})
taskInfo.value = data?.todoTask || null
}
/** 加载下一个节点审批人 */
async function loadNextApproveNodes() {
if (!isApprove.value) {
return
}
const params = {
processInstanceId: processInstanceId.value,
taskId: taskId.value,
}
const data = await getNextApproveNodes(params)
if (data && data.length > 0) {
nextAssigneesActivityNode.value = data
// 获取审批人自选的任务
approveUserSelectTasks.value = data.filter(
(node: ApprovalNodeInfo) =>
BpmCandidateStrategyEnum.APPROVE_USER_SELECT === node.candidateStrategy,
) || []
}
}
/** 选择下一个节点审批人确认 */
function selectNextAssigneesConfirm(activityId: string, userList: any[]) {
approveUserSelectAssignees.value[activityId] = userList.map(user => user.id)
}
/** 打开签名弹窗 */
function openSignatureModal() {
showSignatureModal.value = true
}
/** 签名确认 */
async function handleSignatureConfirm(result: { tempFilePath: string, base64: string }) {
toast.loading('上传中...')
try {
// 上传签名图片
const url = await uploadSignatureFile(result.tempFilePath)
formData.signPicUrl = url
showSignatureModal.value = false
toast.success('签名成功')
} catch (err) {
console.error('上传失败:', err)
toast.show('上传失败')
}
}
/** 上传签名文件 */
function uploadSignatureFile(tempFilePath: string): Promise<string> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${getEnvBaseUrl()}/infra/file/upload`,
filePath: tempFilePath,
name: 'file',
success: (uploadFileRes) => {
try {
const data = JSON.parse(uploadFileRes.data)
if (data.code === 0 && data.data) {
resolve(data.data)
} else {
reject(new Error(data.msg || '上传失败'))
}
} catch (err) {
reject(err)
}
},
fail: (err) => {
console.error('上传失败:', err)
reject(err)
},
})
})
}
/** 签名清除 */
function handleSignatureClear() {
formData.signPicUrl = ''
}
/** 预览签名 */
function previewSignature() {
if (formData.signPicUrl) {
uni.previewImage({
urls: [formData.signPicUrl],
current: formData.signPicUrl,
})
}
}
/** 提交审批 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
// 验证签名
if (isApprove.value && taskInfo.value?.signEnable && !formData.signPicUrl) {
toast.show('请先进行签名')
return
}
// 验证审批人选择
if (isApprove.value && approveUserSelectTasks.value.length > 0) {
for (const task of approveUserSelectTasks.value) {
if (!approveUserSelectAssignees.value[task.id] || approveUserSelectAssignees.value[task.id].length === 0) {
toast.show(`请选择「${task.name}」的审批人`)
return
}
}
}
formLoading.value = true
try {
if (isApprove.value) {
// 审批通过
await approveTask({
id: taskId.value as string,
reason: formData.reason,
signPicUrl: formData.signPicUrl || undefined,
nextAssignees: Object.keys(approveUserSelectAssignees.value).length > 0
? approveUserSelectAssignees.value
: undefined,
})
} else {
// 审批拒绝
await rejectTask({
id: taskId.value as string,
reason: formData.reason,
})
}
toast.success('审批成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 1000)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(async () => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
return
}
try {
toast.loading('加载中...')
// 加载任务信息和下一个节点审批人
await loadTaskInfo()
await loadNextApproveNodes()
} finally {
toast.close()
}
})
</script>

View File

@@ -0,0 +1,42 @@
<!-- 表单详情流程表单/业务表单 -->
<template>
<view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
<!-- 标题 -->
<view class="px-24rpx pt-24rpx text-28rpx text-[#333] font-bold">
审批详情
</view>
<!-- 表单内容业务表单 -->
<template v-if="processDefinition?.formType === BpmModelFormType.CUSTOM">
<!-- OA 请假详情 -->
<LeaveDetail
v-if="processDefinition?.formCustomViewPath === '/bpm/oa/leave/detail'"
:id="processInstance?.businessKey"
embedded
/>
<!-- 未配置的业务表单 -->
<view v-else class="px-24rpx py-32rpx text-26rpx text-[#999]">
暂不支持该业务表单请参考 LeaveDetail 配置
</view>
</template>
<!-- TODO @jason表单内容流程表单 -->
<template v-else-if="processDefinition?.formType === BpmModelFormType.NORMAL">
<view class="px-24rpx py-32rpx text-26rpx text-[#999]">
流程表单仅 PC 端支持预览
</view>
</template>
</view>
</template>
<script lang="ts" setup>
import type { ProcessDefinition, ProcessInstance } from '@/api/bpm/processInstance'
// 特殊业务表单组件uniapp 小程序不支持动态组件,需要静态导入)
import LeaveDetail from '@/pages-bpm/oa/leave/detail/index.vue'
import { BpmModelFormType } from '@/utils/constants'
defineProps<{
/** 流程定义 */
processDefinition?: ProcessDefinition
/** 流程实例 */
processInstance?: ProcessInstance
}>()
</script>

View File

@@ -0,0 +1,311 @@
<!-- 操作按钮 -->
<template>
<!-- 有待审批的任务 -->
<view v-if="runningTask" class="yd-detail-footer">
<view class="w-full flex items-center">
<!-- 左侧操作按钮 -->
<view v-for="(action, idx) in leftOperations" :key="idx" class="mr-32rpx w-60rpx flex flex-col items-center" @click="handleOperation(action.operationType)">
<wd-icon :name="action.iconName" size="40rpx" color="#1890ff" />
<text class="mt-4rpx text-22rpx text-[#333]">{{ action.displayName }}</text>
</view>
<!-- 更多操作按钮 -->
<view v-if="moreOperations.length > 0" class="mr-32rpx w-60rpx flex flex-col items-center" @click="handleShowMore">
<wd-icon name="ellipsis" size="40rpx" color="#1890ff" />
<text class="mt-4rpx text-22rpx text-[#333]">更多</text>
</view>
<!-- 更多操作 ActionSheet -->
<wd-action-sheet v-if="moreOperations.length > 0" v-model="showMoreActions" :actions="moreOperations" title="请选择操作" @select="handleMoreAction" />
<!-- 右侧按钮TODO @jason是否一定要保留两个按钮需要的哈 -->
<view class="flex flex-1 gap-16rpx">
<wd-button
v-for="(action, idx) in rightOperations"
:key="idx"
:plain="action.plain"
:type="action.btnType"
:round="false"
class="flex-1"
custom-style="min-width: 200rpx; width: 200rpx;"
@click="handleOperation(action.operationType)"
>
{{ action.displayName }}
</wd-button>
</view>
</view>
</view>
<!-- 无待审批的任务 仅显示取消按钮TODO @jason看看还需要显示这个微信交流下 -->
<view v-if="!runningTask && isShowProcessStartCancel()" class="yd-detail-footer">
<wd-button
plain
type="primary"
:round="false"
block
@click="handleOperation(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL)"
>
取消
</wd-button>
</view>
</template>
<script lang="ts" setup>
import type { Action } from 'wot-design-uni/components/wd-action-sheet/types'
import type { ButtonType } from 'wot-design-uni/components/wd-button/types'
import type { ProcessInstance } from '@/api/bpm/processInstance'
import type { Task } from '@/api/bpm/task'
import { useUserStore } from '@/store'
import {
BpmProcessInstanceStatus,
BpmTaskOperationButtonTypeEnum,
BpmTaskStatusEnum,
OPERATION_BUTTON_NAME,
} from '@/utils/constants'
const showMoreActions = ref(false)
type MoreOperationType = Action & {
operationType: number
}
interface LeftOperationType {
operationType: number
iconName: string
displayName: string
}
interface RightOperationType {
operationType: number
btnType: ButtonType
displayName: string
plain: boolean
}
const operationIconsMap: Record<number, string> = {
[BpmTaskOperationButtonTypeEnum.TRANSFER]: 'transfer',
[BpmTaskOperationButtonTypeEnum.ADD_SIGN]: 'add',
[BpmTaskOperationButtonTypeEnum.DELEGATE]: 'share',
[BpmTaskOperationButtonTypeEnum.RETURN]: 'arrow-left',
[BpmTaskOperationButtonTypeEnum.COPY]: 'copy',
[BpmTaskOperationButtonTypeEnum.DELETE_SIGN]: 'remove',
[BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL]: 'stop-circle',
}
const userStore = useUserStore()
const leftOperations = ref<LeftOperationType[]>([]) // 左侧操作按钮 【最多两个】{转办, 委派, 退回, 加签, 抄送等}
const rightOperationTypes = [] // 右侧操作按钮【最多两个】{通过,拒绝, 取消}
const rightOperations = ref<RightOperationType[]>([])
const moreOperations = ref<MoreOperationType[]>([]) // 更多操作
const runningTask = ref<Task>()
const processInstance = ref<ProcessInstance>()
const reasonRequire = ref<boolean>(false)
/** 初始化 */
function init(theProcessInstance: ProcessInstance, task: Task) {
processInstance.value = theProcessInstance
runningTask.value = task
if (task) {
reasonRequire.value = task.reasonRequire ?? false
// TODO @jason这里的判断是否可以简化哈就是默认计算出按钮然后根据数量去渲染具体的按钮。
// 右侧按钮
if (isHandleTaskStatus() && isShowButton(BpmTaskOperationButtonTypeEnum.REJECT)) {
rightOperationTypes.push(BpmTaskOperationButtonTypeEnum.REJECT)
rightOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.REJECT,
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.REJECT),
btnType: 'error',
plain: true,
})
}
if (isHandleTaskStatus() && isShowButton(BpmTaskOperationButtonTypeEnum.APPROVE)) {
rightOperationTypes.push(BpmTaskOperationButtonTypeEnum.APPROVE)
rightOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.APPROVE,
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.APPROVE),
btnType: 'primary',
plain: false,
})
}
// 左侧操作,和更多操作
Object.keys(task.buttonsSetting || {}).forEach((key) => {
const operationType = Number(key)
if (task.buttonsSetting[key].enable && isHandleTaskStatus()
&& !rightOperationTypes.includes(operationType)) {
if (leftOperations.value.length >= 2) {
moreOperations.value.push({
name: getButtonDisplayName(operationType),
operationType,
})
} else {
leftOperations.value.push({
operationType,
iconName: operationIconsMap[operationType],
displayName: getButtonDisplayName(operationType),
})
}
}
})
// 减签操作的显示
if (isShowDeleteSign()) {
if (leftOperations.value.length >= 2) {
moreOperations.value.push({
name: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.DELETE_SIGN),
operationType: BpmTaskOperationButtonTypeEnum.DELETE_SIGN,
})
} else {
leftOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.DELETE_SIGN,
iconName: operationIconsMap[BpmTaskOperationButtonTypeEnum.DELETE_SIGN],
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.DELETE_SIGN),
})
}
}
}
// 是否显示流程取消
if (isShowProcessStartCancel()) {
if (rightOperationTypes.length < 2) {
rightOperationTypes.push(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL)
rightOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL,
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL),
btnType: 'primary',
plain: true,
})
} else {
if (leftOperations.value.length >= 2) {
moreOperations.value.push({
name: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL),
operationType: BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL,
})
} else {
leftOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL,
iconName: operationIconsMap[BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL],
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL),
})
}
}
}
}
/** 跳转到相应的操作页面 */
function handleOperation(operationType: number) {
switch (operationType) {
case BpmTaskOperationButtonTypeEnum.APPROVE:
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?processInstanceId=${processInstance.value.id}&taskId=${runningTask.value?.id}&pass=true` })
break
case BpmTaskOperationButtonTypeEnum.REJECT:
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?processInstanceId=${processInstance.value.id}&taskId=${runningTask.value?.id}&pass=false` })
break
case BpmTaskOperationButtonTypeEnum.DELEGATE:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/reassign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}&type=delegate`,
})
break
case BpmTaskOperationButtonTypeEnum.TRANSFER:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/reassign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}&type=transfer`,
})
break
case BpmTaskOperationButtonTypeEnum.ADD_SIGN:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/add-sign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}`,
})
break
case BpmTaskOperationButtonTypeEnum.RETURN:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/return/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}`,
})
break
case BpmTaskOperationButtonTypeEnum.DELETE_SIGN:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/delete-sign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}&children=${encodeURIComponent(JSON.stringify(runningTask.value.children || []))}`,
})
break
case BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/process-cancel/index?processInstanceId=${processInstance.value.id}&taskId=${runningTask.value?.id}`,
})
break
}
}
/** 显示更多操作 */
function handleShowMore() {
showMoreActions.value = true
}
/** 处理更多操作选择 */
function handleMoreAction(action: { item: MoreOperationType }) {
handleOperation(action.item.operationType)
showMoreActions.value = false
}
/** 获取按钮的显示名称 */
function getButtonDisplayName(btnType: BpmTaskOperationButtonTypeEnum) {
let displayName = OPERATION_BUTTON_NAME.get(btnType)
if (
runningTask.value?.buttonsSetting
&& runningTask.value?.buttonsSetting[btnType]
) {
displayName = runningTask.value.buttonsSetting[btnType].displayName
}
return displayName
}
/** 是否显示按钮 */
function isShowButton(btnType: BpmTaskOperationButtonTypeEnum): boolean {
let isShow = true
if (
runningTask.value?.buttonsSetting
&& runningTask.value?.buttonsSetting[btnType]
) {
isShow = runningTask.value.buttonsSetting[btnType].enable
}
return isShow
}
/** 任务是否为处理中状态 */
function isHandleTaskStatus() {
let canHandle = false
if (BpmTaskStatusEnum.RUNNING === runningTask.value?.status) {
canHandle = true
}
return canHandle
}
/** 流程状态是否为结束状态 */
function isEndProcessStatus(status: number) {
let isEndStatus = false
if (
BpmProcessInstanceStatus.APPROVE === status
|| BpmProcessInstanceStatus.REJECT === status
|| BpmProcessInstanceStatus.CANCEL === status
) {
isEndStatus = true
}
return isEndStatus
}
/** 流程发起人是否为当前用户 */
function isProcessStartUser() {
let isStartUser = false
if (userStore.userInfo?.userId === processInstance.value?.startUser?.id) {
isStartUser = true
}
return isStartUser
}
/** 是否显示减签 */
function isShowDeleteSign() {
return runningTask.value?.children?.length > 0
}
/** 是否显示流程发起人取消 */
function isShowProcessStartCancel() {
return isProcessStartUser() && !isEndProcessStatus(processInstance.value?.status)
}
/** 暴露方法 */
defineExpose({ init })
</script>

View File

@@ -0,0 +1,394 @@
<template>
<!-- 遍历每个审批节点 -->
<view
v-for="(activity, index) in activityNodes"
:key="activity.id || index"
class="relative pb-24rpx pl-80rpx"
>
<!-- 时间线圆点 -->
<view
class="absolute left-12rpx top-8rpx h-52rpx w-52rpx flex items-center justify-center rounded-full bg-blue-500"
>
<!-- 节点类型图标 -->
<wd-icon
:name="getApprovalNodeTypeIcon(activity.nodeType)"
size="32rpx"
color="white"
/>
</view>
<!-- 状态小图标 -->
<view
v-if="showStatusIcon"
class="absolute left-48rpx top-44rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
:style="{ backgroundColor: getApprovalNodeColor(activity.status) }"
>
<wd-icon
:name="getApprovalNodeIcon(activity.status, activity.nodeType)"
size="12rpx"
color="white"
/>
</view>
<!-- 连接线 -->
<view
v-if="index < activityNodes.length - 1"
class="absolute bottom-0 left-38rpx top-64rpx w-2rpx bg-[#e5e5e5]"
/>
<!-- 节点内容 -->
<view class="ml-8rpx">
<!-- 第一行节点名称时间 -->
<view class="mb-8rpx flex items-center justify-between">
<view class="flex items-center">
<text class="text-28rpx text-[#333] font-bold">{{ activity.name }}</text>
<text v-if="activity.status === BpmTaskStatusEnum.SKIP" class="ml-8rpx text-24rpx text-[#999]">
跳过
</text>
</view>
<text
v-if="activity.status !== BpmTaskStatusEnum.NOT_START && getApprovalNodeTime(activity)"
class="text-22rpx text-[#999]"
>
{{ getApprovalNodeTime(activity) }}
</text>
</view>
<!-- 子流程节点 -->
<view v-if="activity.nodeType === BpmNodeTypeEnum.CHILD_PROCESS_NODE" class="mb-16rpx">
<wd-button
type="primary"
plain
size="small"
:disabled="!activity.processInstanceId"
@click="handleChildProcess(activity)"
>
查看子流程
</wd-button>
</view>
<!-- 需要自定义选择审批人 -->
<view v-if="shouldShowCustomUserSelect(activity)" class="mb-16rpx">
<view class="flex flex-wrap items-center">
<!-- 添加用户按钮 -->
<UserPicker
:model-value="getSelectedUserIds(activity.id)"
type="checkbox"
use-default-slot
@confirm="(users) => handleCustomUserSelectConfirm(activity.id, users)"
>
<view
class="mb-8rpx mr-16rpx h-48rpx w-48rpx flex items-center justify-center border-indigo-500 rounded-lg border-solid"
>
<wd-icon name="user-add" size="32rpx" color="blue" />
</view>
</UserPicker>
<!-- 已选择的用户 -->
<view
v-for="(user, userIndex) in customApproveUsers[activity.id]"
:key="user.id || userIndex"
class="mb-8rpx mr-16rpx flex items-center rounded-32rpx bg-[#f5f5f5] pr-16rpx"
>
<view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
{{ user.nickname?.[0] || '?' }}
</view>
<text class="text-24rpx text-[#333]">{{ user.nickname }}</text>
</view>
</view>
</view>
<!-- 审批人员列表 -->
<view v-else class="mb-16rpx">
<!-- 情况一遍历每个审批节点下的进行中task 任务 -->
<view v-if="activity.tasks && activity.tasks.length > 0">
<view
v-for="(task, taskIndex) in activity.tasks"
:key="taskIndex"
class="mb-16rpx"
>
<!-- 审批人信息 -->
<view v-if="task.assigneeUser || task.ownerUser" class="mb-8rpx flex items-center">
<!-- TODO @jason 用户头像显示 -->
<view class="relative mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
{{ (task.assigneeUser?.nickname || task.ownerUser?.nickname)?.[0] || '?' }}
<!-- 任务状态小图标 -->
<view
v-if="showStatusIcon && shouldShowTaskStatusIcon(task.status)"
class="absolute right--4rpx top-36rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
:style="{ backgroundColor: getApprovalNodeColor(task.status) }"
>
<wd-icon
:name="getApprovalNodeIcon(task.status, activity.nodeType)"
size="12rpx"
color="white"
/>
</view>
</view>
<view class="flex-1">
<view class="flex items-center justify-between">
<view class="flex items-center">
<text class="text-26rpx text-[#333]">
{{ task.assigneeUser?.nickname || task.ownerUser?.nickname }}
</text>
<text
v-if="task.assigneeUser?.deptName || task.ownerUser?.deptName"
class="ml-8rpx text-22rpx text-[#999]"
>
{{ task.assigneeUser?.deptName || task.ownerUser?.deptName }}
</text>
</view>
</view>
<view class="mt-4rpx flex items-center">
<text :class="getStatusTextClass(task.status)" class="text-24rpx">
{{ getStatusText(task.status) }}
</text>
</view>
</view>
</view>
<!-- 审批意见 -->
<view
v-if="shouldShowApprovalReason(task, activity.nodeType)"
class="mt-8rpx rounded-8rpx bg-[#f5f5f5] p-16rpx"
>
<text class="text-24rpx text-[#666]">审批意见{{ task.reason }}</text>
</view>
<!-- 签名 -->
<view
v-if="task.signPicUrl && activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE"
class="mt-8rpx flex items-center rounded-8rpx bg-[#f5f5f5] p-16rpx"
>
<text class="text-24rpx text-[#666]">签名</text>
<image
:src="task.signPicUrl"
class="ml-8rpx h-96rpx w-288rpx"
mode="aspectFit"
@click="previewImage(task.signPicUrl)"
/>
</view>
</view>
</view>
<!-- 情况二遍历每个审批节点下的候选的task 任务 -->
<view v-if="activity.candidateUsers && activity.candidateUsers.length > 0">
<view
v-for="(user, userIndex) in activity.candidateUsers"
:key="userIndex"
class="mb-8rpx flex items-center"
>
<view class="relative mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
{{ user.nickname?.[0] || '?' }}
<!-- 候选状态图标 -->
<view
v-if="showStatusIcon"
class="absolute right--4rpx top-36rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
:style="{ backgroundColor: getApprovalNodeColor(BpmTaskStatusEnum.NOT_START) }"
>
<wd-icon name="time" size="12rpx" color="white" />
</view>
</view>
<view class="flex-1">
<text class="text-26rpx text-[#333]">{{ user.nickname }}</text>
<text v-if="user.deptName" class="ml-8rpx text-22rpx text-[#999]">
{{ user.deptName }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import { ref } from 'vue'
import UserPicker from '@/components/system-select/user-picker.vue'
import { BpmCandidateStrategyEnum, BpmNodeTypeEnum, BpmTaskStatusEnum } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = withDefaults(
defineProps<{
activityNodes: ApprovalNodeInfo[]
enableApproveUserSelect?: boolean
showStatusIcon?: boolean
}>(),
{
showStatusIcon: true,
enableApproveUserSelect: false,
},
)
const emit = defineEmits<{
selectUserConfirm: [activityId: string, userList: any[]]
}>()
// 状态图标映射
const statusIconMap: Record<string, { color: string, icon: string }> = {
'-2': { color: '#909398', icon: 'skip-forward' }, // 跳过
'-1': { color: '#909398', icon: 'time' }, // 审批未开始
'0': { color: '#f59e0b', icon: 'refresh1' }, // 待审批
'1': { color: '#f59e0b', icon: 'refresh1' }, // 审批中
'2': { color: '#00b32a', icon: 'check' }, // 审批通过
'3': { color: '#f46b6c', icon: 'close' }, // 审批不通过
'4': { color: '#cccccc', icon: 'delete' }, // 已取消
'5': { color: '#f46b6c', icon: 'arrow-left' }, // 退回
'6': { color: '#448ef7', icon: 'time' }, // 委派中
'7': { color: '#00b32a', icon: 'check' }, // 审批通过中
}
// 节点类型图标映射 TODO 图标重新选一下
const nodeTypeSvgMap: Record<number, { color: string, icon: string }> = {
[BpmNodeTypeEnum.END_EVENT_NODE]: { color: '#909398', icon: 'poweroff' },
[BpmNodeTypeEnum.START_USER_NODE]: { color: '#909398', icon: 'user' },
[BpmNodeTypeEnum.USER_TASK_NODE]: { color: '#ff943e', icon: 'user-talk' },
[BpmNodeTypeEnum.TRANSACTOR_NODE]: { color: '#ff943e', icon: 'edit' },
[BpmNodeTypeEnum.COPY_TASK_NODE]: { color: '#3296fb', icon: 'copy' },
[BpmNodeTypeEnum.CONDITION_NODE]: { color: '#14bb83', icon: 'branch' },
[BpmNodeTypeEnum.PARALLEL_BRANCH_NODE]: { color: '#14bb83', icon: 'branch' },
[BpmNodeTypeEnum.CHILD_PROCESS_NODE]: { color: '#14bb83', icon: 'cluster' },
}
const onlyStatusIconShow = [BpmTaskStatusEnum.NOT_START, BpmTaskStatusEnum.RUNNING, BpmTaskStatusEnum.WAIT] // 只有状态是 -1、0、1 才展示头像右小角状态小 icon
// 响应式数据
const customApproveUsers = ref<Record<string, any[]>>({})
const showUserPicker = ref(false)
const selectedUserIds = ref<number[]>([])
const selectedActivityNodeId = ref<string>()
/** 获取审批节点类型图标 */
function getApprovalNodeTypeIcon(nodeType: number) {
return nodeTypeSvgMap[nodeType]?.icon || 'time'
}
/** 获取审批节点图标 */
function getApprovalNodeIcon(taskStatus: number, nodeType: number) {
if (taskStatus === BpmTaskStatusEnum.NOT_START) {
return statusIconMap[taskStatus]?.icon || 'time'
}
return statusIconMap[taskStatus]?.icon || 'time'
}
/** 获取审批节点颜色 */
function getApprovalNodeColor(taskStatus: number) {
return statusIconMap[taskStatus]?.color || '#909398'
}
/** 获取审批节点时间 */
function getApprovalNodeTime(node: ApprovalNodeInfo) {
if (node.nodeType === BpmNodeTypeEnum.START_USER_NODE && node.startTime) {
return formatDateTime(node.startTime)
}
if (node.endTime) {
return formatDateTime(node.endTime)
}
if (node.startTime) {
return formatDateTime(node.startTime)
}
return ''
}
/** 是否显示任务状态图标 */
function shouldShowTaskStatusIcon(status: number) {
return onlyStatusIconShow.includes(status)
}
/** 判断是否需要显示自定义选择审批人 */
function shouldShowCustomUserSelect(activity: ApprovalNodeInfo) {
return (
(!activity.tasks || activity.tasks.length === 0)
&& ((BpmCandidateStrategyEnum.START_USER_SELECT === activity.candidateStrategy
&& (!activity.candidateUsers || activity.candidateUsers.length === 0))
|| (props.enableApproveUserSelect
&& BpmCandidateStrategyEnum.APPROVE_USER_SELECT === activity.candidateStrategy))
)
}
/** 判断是否需要显示审批意见 */
function shouldShowApprovalReason(task: any, nodeType: number) {
return (
task.reason
&& [BpmNodeTypeEnum.END_EVENT_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(nodeType)
)
}
/** 获取状态文本样式类 */
function getStatusTextClass(status: number) {
const colorMap: Record<number, string> = {
[BpmTaskStatusEnum.RUNNING]: 'text-[#ff943e]',
[BpmTaskStatusEnum.APPROVE]: 'text-[#00b32a]',
[BpmTaskStatusEnum.REJECT]: 'text-[#f46b6c]',
[BpmTaskStatusEnum.CANCEL]: 'text-[#cccccc]',
[BpmTaskStatusEnum.RETURN]: 'text-[#f46b6c]',
}
return colorMap[status] || 'text-[#666]'
}
/** 获取状态文本 */
function getStatusText(status: number) {
const textMap: Record<number, string> = {
[BpmTaskStatusEnum.NOT_START]: '未开始',
[BpmTaskStatusEnum.RUNNING]: '待审批',
[BpmTaskStatusEnum.APPROVE]: '已通过',
[BpmTaskStatusEnum.REJECT]: '已拒绝',
[BpmTaskStatusEnum.CANCEL]: '已取消',
[BpmTaskStatusEnum.RETURN]: '已退回',
[BpmTaskStatusEnum.SKIP]: '已跳过',
}
return textMap[status] || '未知'
}
/** 用户选择确认 */
function handleCustomUserSelectConfirm(activityId: string, users: any[]) {
customApproveUsers.value[activityId] = users || []
emit('selectUserConfirm', activityId, users)
}
/** 获取选中的用户ID数组 */
function getSelectedUserIds(activityId: string): number[] {
const users = customApproveUsers.value[activityId] || []
return users.map(user => user.id).filter(id => id !== undefined)
}
/** 跳转子流程 */
function handleChildProcess(activity: ApprovalNodeInfo) {
if (!activity.processInstanceId) {
return
}
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/index?id=${activity.processInstanceId}`,
})
}
/** 预览图片 */
function previewImage(url: string) {
uni.previewImage({
urls: [url],
current: url,
})
}
/** 设置自定义审批人 */
function setCustomApproveUsers(activityId: string, users: any[]) {
customApproveUsers.value[activityId] = users || []
}
/** 批量设置多个节点的自定义审批人 */
function batchSetCustomApproveUsers(data: Record<string, any[]>) {
Object.keys(data).forEach((activityId) => {
customApproveUsers.value[activityId] = data[activityId] || []
})
}
// 暴露方法给父组件
defineExpose({
setCustomApproveUsers,
batchSetCustomApproveUsers,
})
</script>

View File

@@ -0,0 +1,165 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="减签任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 减签人员选择 -->
<wd-picker
v-model="formData.deleteSignTaskId"
:columns="taskOptions"
value-key="id"
label-key="label"
label="减签人员:"
label-width="180rpx"
placeholder="请选择减签人员"
prop="deleteSignTaskId"
/>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="180rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
减签
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { signDeleteTask } from '@/api/bpm/task'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
children?: string // JSON 字符串格式的子任务数据
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const toast = useToast()
const formLoading = ref(false)
const taskOptions = ref<any[]>([])
const formData = reactive({
deleteSignTaskId: '',
reason: '',
})
const formRules = {
deleteSignTaskId: [
{ required: true, message: '减签人员不能为空' },
],
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 获取减签人员标签 */
function getDeleteSignUserLabel(task: any): string {
const deptName = task?.assigneeUser?.deptName || task?.ownerUser?.deptName
const nickname = task?.assigneeUser?.nickname || task?.ownerUser?.nickname
return `${nickname} ( 所属部门:${deptName} )`
}
/** 获取可减签的任务列表 */
async function loadDeleteSignTaskList() {
let childTasks = []
// 从 props 中获取子任务数据
if (props.children) {
try {
childTasks = JSON.parse(decodeURIComponent(props.children))
} catch (parseError) {
console.error('[delete-sign] 解析子任务数据失败:', parseError)
}
}
// 提示没有子任务数据
if (childTasks.length === 0) {
toast.show('没有可减签的任务')
return
}
taskOptions.value = childTasks.map(task => ({
id: task.id,
label: getDeleteSignUserLabel(task),
}))
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await signDeleteTask({
id: formData.deleteSignTaskId,
reason: formData.reason,
})
toast.success('减签成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时,获取可减签任务列表 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
return
}
loadDeleteSignTaskList()
})
</script>

View File

@@ -0,0 +1,147 @@
<template>
<view class="yd-page-container pb-[80rpx]">
<!-- 顶部导航栏 -->
<wd-navbar
title="审批详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 区域流程信息基本信息 -->
<view class="relative mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
<!-- 审批状态图标盖章效果 -->
<image
v-if="processInstance?.status !== undefined"
:src="getStatusIcon(processInstance?.status)"
class="absolute right-20rpx top-20rpx z-10 h-144rpx w-144rpx"
mode="aspectFit"
/>
<view class="p-24rpx">
<!-- 标题 -->
<view class="mb-16rpx pr-160rpx">
<text class="text-32rpx text-[#333] font-bold">{{ processInstance?.name }}</text>
</view>
<!-- 发起人信息 -->
<view class="flex items-center">
<view class="mr-12rpx h-64rpx w-64rpx flex items-center justify-center rounded-full bg-[#1890ff] text-white">
{{ processInstance?.startUser?.nickname?.[0] || '?' }}
</view>
<view>
<text class="text-28rpx text-[#333]">{{ processInstance?.startUser?.nickname }}</text>
<text v-if="processInstance?.startUser?.deptName" class="ml-8rpx text-24rpx text-[#999]">
{{ processInstance?.startUser?.deptName }}
</text>
</view>
</view>
<!-- 提交时间 -->
<view class="mt-16rpx text-24rpx text-[#999]">
提交于 {{ formatDateTime(processInstance?.startTime) }}
</view>
</view>
</view>
<!-- 区域审批详情表单 -->
<FormDetail :process-definition="processDefinition" :process-instance="processInstance" />
<!-- 区域审批进度 -->
<view class="mx-24rpx mt-24rpx rounded-16rpx bg-white">
<view class="p-24rpx">
<view class="mb-16rpx flex">
<text class="text-28rpx text-[#333] font-bold">审批进度</text>
</view>
<!-- 流程时间线 -->
<ProcessInstanceTimeline :activity-nodes="activityNodes" />
</view>
</view>
<!-- TODO 待开发区域流程评论 -->
<!-- 区域底部操作栏 -->
<ProcessInstanceOperationButton ref="operationButtonRef" />
</view>
</template>
<script lang="ts" setup>
import type { ApprovalNodeInfo, ProcessDefinition, ProcessInstance } from '@/api/bpm/processInstance'
import type { Task } from '@/api/bpm/task'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getApprovalDetail } from '@/api/bpm/processInstance'
import { getTaskListByProcessInstanceId } from '@/api/bpm/task'
import { navigateBackPlus } from '@/utils'
import { BpmProcessInstanceStatus } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import FormDetail from './components/form-detail.vue'
import ProcessInstanceOperationButton from './components/operation-button.vue'
import ProcessInstanceTimeline from './components/time-line.vue'
const props = defineProps<{
id: string // 流程实例的编号
taskId?: string // 任务编号
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const processInstance = ref<ProcessInstance>()
const processDefinition = ref<ProcessDefinition>()
const tasks = ref<Task[]>([])
const activityNodes = ref<ApprovalNodeInfo[]>([]) // 审批节点信息
const operationButtonRef = ref() // 操作按钮组件 ref
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages/bpm/index')
}
/** 获取状态图标 */
function getStatusIcon(status?: number): string {
// 状态映射: 1-审批中, 2-审批通过, 3-审批不通过, 4-已取消. -1 未开始不会出现
const iconMap: Record<number, string> = {
[BpmProcessInstanceStatus.RUNNING]: '/static/my-icons/bpm/bpm-running.svg', // 待审批
[BpmProcessInstanceStatus.APPROVE]: '/static/my-icons/bpm/bpm-approve.svg', // 审批通过
[BpmProcessInstanceStatus.REJECT]: '/static/my-icons/bpm/bpm-reject.svg', // 审批不通过
[BpmProcessInstanceStatus.CANCEL]: '/static/my-icons/bpm/bpm-cancel.svg', // 已取消
}
return iconMap[status ?? 1]
}
/** 加载流程实例 */
async function loadProcessInstance() {
const data = await getApprovalDetail({
processInstanceId: props.id,
taskId: props.taskId,
})
if (!data || !data.processInstance) {
toast.show('查询不到审批详情信息')
return
}
processInstance.value = data.processInstance
processDefinition.value = data.processDefinition
// 获取审批节点,显示 Timeline 的数据
activityNodes.value = data.activityNodes
operationButtonRef.value?.init(data.processInstance, data.todoTask)
}
/** 加载任务列表 */
async function loadTasks() {
tasks.value = await getTaskListByProcessInstanceId(props.id)
}
/** 初始化 */
onMounted(async () => {
if (!props.id) {
toast.show('参数错误')
return
}
await Promise.all([loadProcessInstance(), loadTasks()])
})
</script>

View File

@@ -0,0 +1,126 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="取消流程"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 友情提醒 -->
<view class="mb-24rpx border border-[#ffd591] rounded-16rpx bg-[#fff7e6] p-24rpx">
<view class="mb-12rpx flex items-center">
<wd-icon name="warning" color="#faad14" size="32rpx" />
<text class="ml-12rpx text-28rpx text-[#faad14] font-bold">友情提醒</text>
</view>
<text class="text-26rpx text-[#666]">取消后该审批流程将自动结束</text>
</view>
<!-- 取消理由 -->
<wd-textarea
v-model="formData.cancelReason"
prop="cancelReason"
label="取消理由:"
label-width="180rpx"
placeholder="请输入取消理由"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
确认取消
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { cancelProcessInstanceByStartUser } from '@/api/bpm/processInstance'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId?: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const processInstanceId = computed(() => props.processInstanceId)
const taskId = computed(() => props.taskId)
const toast = useToast()
const formLoading = ref(false)
const formData = reactive({
cancelReason: '',
})
const formRules = {
cancelReason: [
{ required: true, message: '取消理由不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
const backUrl = taskId.value
? `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`
: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}`
navigateBackPlus(backUrl)
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await cancelProcessInstanceByStartUser(
processInstanceId.value,
formData.cancelReason,
)
toast.success('流程取消成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(() => {
/** 初始化校验 */
if (!props.processInstanceId) {
toast.show('参数错误')
}
})
</script>

View File

@@ -0,0 +1,143 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="isDelegate ? '委派任务' : '转办任务'"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 用户选择 -->
<UserPicker
v-model="formData.userId"
prop="userId"
type="radio"
:label="`${isDelegate ? '接收人' : '新审批人'}`"
:placeholder="`请选择${isDelegate ? '接收人' : '新审批人'}`"
/>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="180rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
{{ isDelegate ? '委派' : '转办' }}
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { delegateTask, transferTask } from '@/api/bpm/task'
import UserPicker from '@/components/system-select/user-picker.vue'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
type: string // 'delegate' 或 'transfer'
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const operationType = computed(() => props.type || 'transfer') // 默认转办
const isDelegate = computed(() => operationType.value === 'delegate')
const toast = useToast()
const formLoading = ref(false)
const formData = reactive({
userId: undefined as number | undefined,
reason: '',
})
const formRules = {
userId: [
{ required: true, message: `请选择${isDelegate.value ? '接收人' : '新审批人'}` },
],
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
const data = {
id: taskId.value as string,
reason: formData.reason,
}
if (isDelegate.value) {
await delegateTask({
...data,
delegateUserId: String(formData.userId),
})
} else {
await transferTask({
...data,
assigneeUserId: String(formData.userId),
})
}
toast.success(`${isDelegate.value ? '委派' : '转办'}成功`)
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
}
})
</script>

View File

@@ -0,0 +1,140 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="退回任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 退回节点选择 -->
<wd-picker
v-model="formData.targetActivityId"
label="退回节点:"
prop="targetActivityId"
:columns="activityOptions"
value-key="taskDefinitionKey"
label-key="name"
placeholder="请选择退回节点"
/>
<!-- 退回原因 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="退回原因:"
label-width="180rpx"
placeholder="请输入退回原因"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
退回
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getTaskListByReturn, returnTask } from '@/api/bpm/task'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const toast = useToast()
const formLoading = ref(false)
const activityOptions = ref<any[]>([])
const formData = reactive({
targetActivityId: '',
reason: '',
})
const formRules = {
targetActivityId: [
{ required: true, message: '退回节点不能为空' },
],
reason: [
{ required: true, message: '退回原因不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 获取可退回的节点列表 */
async function loadReturnTaskList() {
const result = await getTaskListByReturn(taskId.value)
activityOptions.value = result
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await returnTask({
id: taskId.value as string,
targetTaskDefinitionKey: formData.targetActivityId,
reason: formData.reason,
})
toast.success('退回成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时获取可退回节点列表 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
return
}
loadReturnTaskList()
})
</script>

View File

@@ -0,0 +1,222 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
发起人
</view>
<UserPicker
v-model="formData.startUserId"
type="radio"
placeholder="请选择发起人"
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
流程名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入流程名称"
clearable
/>
</view>
<view v-if="processDefinitionList.length > 0" class="yd-search-form-item">
<view class="yd-search-form-label">
所属流程
</view>
<wd-picker
v-model="formData.processDefinitionId"
:columns="processDefinitionList"
label-key="name"
value-key="id"
label=""
/>
</view>
<!-- 流程分类 -->
<view v-if="categoryList.length > 0" class="yd-search-form-item">
<view class="yd-search-form-label">
流程分类
</view>
<wd-picker
v-model="formData.category"
:columns="categoryList"
label-key="name"
value-key="code"
label=""
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
流程状态
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" :key="dict.value" :value="dict.value">
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
发起时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import type { ProcessDefinition } from '@/api/bpm/definition'
import { computed, onMounted, reactive, ref } from 'vue'
import { getCategorySimpleList } from '@/api/bpm/category'
import { getProcessDefinitionList } from '@/api/bpm/definition'
import UserPicker from '@/components/system-select/user-picker.vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
startUserId: undefined as number | undefined, // 发起人
name: undefined as string | undefined, // 流程名称
processDefinitionId: undefined as string | undefined, // 所属流程
category: undefined as string | undefined, // 流程分类
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined], // 发起时间
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名称:${formData.name}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程实例'
})
const categoryList = ref<Category[]>([])
const processDefinitionList = ref<ProcessDefinition[]>([])
// 时间选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 获取流程分类列表 */
async function getCategoryList() {
try {
categoryList.value = await getCategorySimpleList()
} catch (error) {
console.error('获取流程分类失败:', error)
}
}
/** 获取流程定义列表 */
async function getProcessDefinitions() {
try {
processDefinitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
} catch (error) {
console.error('获取流程定义失败:', error)
}
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.startUserId = undefined
formData.name = undefined
formData.processDefinitionId = undefined
formData.category = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
/** 初始化 */
onMounted(() => {
getCategoryList()
getProcessDefinitions()
})
</script>

View File

@@ -0,0 +1,231 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程实例列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="mr-16rpx flex-1">
<view class="line-clamp-1 text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="mt-8rpx text-24rpx text-[#999]">
{{ item.categoryName || '-' }}
</view>
</view>
<DictTag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center">
<view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-20rpx text-white">
{{ item.startUser?.nickname?.[0] || '?' }}
</view>
<view class="flex-1">
<view class="text-28rpx text-[#333]">
{{ item.startUser?.nickname || '-' }}
</view>
<view class="text-24rpx text-[#999]">
{{ item.startUser?.deptName || '-' }}
</view>
</view>
</view>
<view class="mb-12rpx rounded-8rpx bg-[#f7f8f9] p-16rpx">
<view class="mb-8rpx flex items-center justify-between text-26rpx">
<text class="text-[#999]">发起时间</text>
<text class="text-[#333]">{{ formatDateTime(item.startTime) }}</text>
</view>
<view v-if="item.endTime" class="flex items-center justify-between text-26rpx">
<text class="text-[#999]">结束时间</text>
<text class="text-[#333]">{{ formatDateTime(item.endTime) }}</text>
</view>
</view>
<view v-if="item.tasks && item.tasks.length > 0" class="mb-12rpx">
<view class="mb-8rpx text-26rpx text-[#999]">
当前审批任务
</view>
<view class="flex flex-wrap gap-8rpx">
<wd-tag
v-for="task in item.tasks"
:key="task.id"
type="primary"
plain
@click.stop="handleTaskDetail(item, task)"
>
{{ task.name }}
</wd-tag>
</view>
</view>
<view
v-if="item.status === BpmProcessInstanceStatus.RUNNING"
class="flex items-center justify-end border-t border-[#f0f0f0] -mt-8"
>
<wd-button size="small" type="error" plain @click.stop="handleCancel(item)">
取消流程
</wd-button>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程实例" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ProcessInstance } from '@/api/bpm/processInstance'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import {
cancelProcessInstanceByAdmin,
getProcessInstanceManagerPage,
} from '@/api/bpm/processInstance'
import DictTag from '@/components/dict-tag/dict-tag.vue'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
// 流程实例状态枚举
const BpmProcessInstanceStatus = {
RUNNING: 1, // 进行中
APPROVE: 2, // 审批通过
REJECT: 3, // 审批不通过
CANCEL: 4, // 已取消
}
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const total = ref(0)
const list = ref<(ProcessInstance & { tasks?: { id: string, name: string }[] })[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程实例列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getProcessInstanceManagerPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情 */
function handleDetail(item: ProcessInstance) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.id}` })
}
/** 查看任务详情 */
function handleTaskDetail(row: ProcessInstance, task: { id: string, name: string }) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${row.id}&taskId=${task.id}` })
}
/** 取消流程实例 */
function handleCancel(item: ProcessInstance) {
uni.showModal({
title: '取消流程',
editable: true,
placeholderText: '请输入取消原因',
success: async (res) => {
if (!res.confirm) {
return
}
const reason = res.content?.trim()
if (!reason) {
toast.error('请输入取消原因')
return
}
try {
await cancelProcessInstanceByAdmin(item.id, reason)
toast.success('取消成功')
// 刷新列表
queryParams.value.pageNo = 1
list.value = []
await getList()
} catch (error) {
console.error('取消流程失败:', error)
}
},
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,128 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
任务名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入任务名称"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getNavbarHeight } from '@/utils'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined, // 任务名称
createTime: [undefined, undefined] as [number | undefined, number | undefined], // 创建时间
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名称:${formData.name}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索任务'
})
// 时间选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="任务管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 任务列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="mr-16rpx flex-1">
<view class="line-clamp-1 text-32rpx text-[#333] font-semibold">
{{ item.processInstance?.name || '-' }}
</view>
<view class="mt-8rpx text-24rpx text-[#999]">
当前任务{{ item.name }}
</view>
</view>
<DictTag :type="DICT_TYPE.BPM_TASK_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center">
<view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-20rpx text-white">
{{ item.processInstance?.startUser?.nickname?.[0] || '?' }}
</view>
<view class="flex-1">
<view class="text-28rpx text-[#333]">
发起人{{ item.processInstance?.startUser?.nickname || '-' }}
</view>
<view class="text-24rpx text-[#999]">
审批人{{ item.assigneeUser?.nickname || '-' }}
</view>
</view>
</view>
<view class="rounded-8rpx bg-[#f7f8f9] p-16rpx">
<view class="mb-8rpx flex items-center justify-between text-26rpx">
<text class="text-[#999]">任务开始时间</text>
<text class="text-[#333]">{{ formatDateTime(item.createTime) }}</text>
</view>
<view v-if="item.endTime" class="mb-8rpx flex items-center justify-between text-26rpx">
<text class="text-[#999]">任务结束时间</text>
<text class="text-[#333]">{{ formatDateTime(item.endTime) }}</text>
</view>
<view v-if="item.reason" class="flex items-center justify-between text-26rpx">
<text class="text-[#999]">审批建议</text>
<text class="line-clamp-1 ml-16rpx flex-1 text-right text-[#333]">{{ item.reason }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无任务" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Task } from '@/api/bpm/task'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getTaskManagerPage } from '@/api/bpm/task'
import DictTag from '@/components/dict-tag/dict-tag.vue'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const total = ref(0)
const list = ref<Task[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询任务列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getTaskManagerPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情(历史) */
function handleDetail(item: Task) {
if (item.processInstance?.id) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstance.id}` })
}
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,153 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
组名
</view>
<wd-input
v-model="formData.name"
placeholder="请输入组名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
状态
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`组名:${formData.name}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索用户分组'
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,155 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="用户分组详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id" />
<wd-cell title="组名" :value="formData?.name" />
<wd-cell title="描述" :value="formData?.description || '-'" />
<wd-cell title="成员">
<view class="flex flex-wrap gap-8rpx justify-end">
<wd-tag
v-for="userId in (formData?.userIds || [])"
:key="userId"
type="primary"
plain
>
{{ getUserNickname(userId) }}
</wd-tag>
<text v-if="!formData?.userIds?.length">-</text>
</view>
</wd-cell>
<wd-cell title="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:user-group:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:user-group:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { UserGroup } from '@/api/bpm/user-group'
import type { SimpleUser } from '@/api/system/user'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteUserGroup, getUserGroup } from '@/api/bpm/user-group'
import { getSimpleUserList } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<UserGroup>()
const deleting = ref(false)
const userList = ref<SimpleUser[]>([])
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/user-group/index')
}
/** 获取用户昵称 */
function getUserNickname(userId: number) {
const user = userList.value.find(u => u.id === userId)
return user?.nickname || userId
}
/** 加载用户列表 */
async function loadUserList() {
userList.value = await getSimpleUserList()
}
/** 加载用户分组详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getUserGroup(props.id)
} finally {
toast.close()
}
}
/** 编辑用户分组 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/user-group/form/index?id=${props.id}`,
})
}
/** 删除用户分组 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该用户分组吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteUserGroup(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
loadUserList()
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,151 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="组名"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入组名"
/>
<wd-textarea
v-model="formData.description"
label="描述"
label-width="180rpx"
prop="description"
clearable
placeholder="请输入描述"
/>
<UserPicker
ref="userPickerRef"
v-model="formData.userIds"
label="成员"
type="checkbox"
placeholder="请选择成员"
/>
<wd-cell title="状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { UserGroup } from '@/api/bpm/user-group'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createUserGroup, getUserGroup, updateUserGroup } from '@/api/bpm/user-group'
import { UserPicker } from '@/components/system-select'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑用户分组' : '新增用户分组')
const formLoading = ref(false)
const formData = ref<UserGroup>({
id: undefined,
name: '',
description: '',
userIds: [],
status: CommonStatusEnum.ENABLE,
remark: '',
})
const formRules = {
name: [{ required: true, message: '组名不能为空' }],
userIds: [{ required: true, message: '成员不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref<FormInstance>()
const userPickerRef = ref()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/user-group/index')
}
/** 加载用户分组详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getUserGroup(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateUserGroup(formData.value)
toast.success('修改成功')
} else {
await createUserGroup(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,190 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="用户分组管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 用户分组列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">描述</text>
<text class="min-w-0 flex-1 truncate">{{ item.description || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">成员</text>
<view class="min-w-0 flex flex-1 flex-wrap gap-8rpx">
<wd-tag
v-for="userId in (item.userIds || []).slice(0, 3)"
:key="userId"
type="primary"
plain
>
{{ getUserNickname(userId) }}
</wd-tag>
<wd-tag v-if="(item.userIds || []).length > 3" type="info" plain>
+{{ (item.userIds || []).length - 3 }}
</wd-tag>
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无用户分组数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:user-group:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { UserGroup } from '@/api/bpm/user-group'
import type { SimpleUser } from '@/api/system/user'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getUserGroupPage } from '@/api/bpm/user-group'
import { getSimpleUserList } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<UserGroup[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
const userList = ref<SimpleUser[]>([])
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 获取用户昵称 */
function getUserNickname(userId: number) {
const user = userList.value.find(u => u.id === userId)
return user?.nickname || userId
}
/** 加载用户列表 */
async function loadUserList() {
userList.value = await getSimpleUserList()
}
/** 查询用户分组列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getUserGroupPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增用户分组 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/user-group/form/index',
})
}
/** 查看详情 */
function handleDetail(item: UserGroup) {
uni.navigateTo({
url: `/pages-bpm/user-group/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
loadUserList()
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,24 @@
/**
* PC 端业务表单路径 -> 移动端路径映射
* - key: PC 端路径 (formCustomCreatePath / formCustomViewPath)
* - value: 移动端路径
* 原因是:目前暂时没有 mobile 端的自定义表单字段,所以暂时需要硬编码映射关系
* 另外:需要在 src/pages-bpm/processInstance/detail/components/form-detail.vue 里,增加使用类似 LeaveDetail 的使用
*/
const PC_TO_MOBILE_PATH_MAP: Record<string, string> = {
// OA 请假
'/bpm/oa/leave/create': '/pages-bpm/oa/leave/create/index',
'/bpm/oa/leave/detail': '/pages-bpm/oa/leave/detail/index',
}
/**
* 根据 PC 端路径获取移动端的跳转路径
* @param pcPath PC 端的表单路径
* @returns 移动端的跳转路径,如果没有映射则返回 undefined
*/
export function getMobileFormCustomPath(pcPath: string | undefined): string | undefined {
if (!pcPath) {
return undefined
}
return PC_TO_MOBILE_PATH_MAP[pcPath]
}