uni-app 审批流程组件封装:打造企业级工作流可视化方案
摘要:本文详细介绍了一个功能强大的审批流程组件的封装实践。从流程可视化、状态管理、交互设计到多端适配,全面展示了如何在 uni-app 中构建高质量的企业级审批流组件。文章包含完整的代码实现、样式优化和最佳实践,适合需要开发 OA、ERP 等系统的开发者参考。
关键词:uni-app;审批流程;工作流;Vue3;组件封装;流程可视化
一、引言
在企业级应用中,审批流程是一个核心功能模块。无论是请假申请、费用报销,还是项目审批,都需要一个直观、易用的审批流程展示界面。然而,开发一个优秀的审批流程组件面临诸多挑战:
- 🎨 复杂的 UI 设计:时间线、状态图标、连接线、动画效果
- 🔄 多样的状态:待审批、审批中、已通过、已拒绝、已取消
- 📱 多端一致性:H5、小程序、APP 端的渲染差异
- ⚡ 性能要求:长流程的流畅滚动、复杂 DOM 的渲染优化
- 🎯 业务适配:多级审批、并行审批、条件分支、退回委派
本文将分享一个经过生产环境验证的审批流程组件封装方案。
二、组件需求分析
2.1 核心功能清单
typescript
interface ApprovalProcessProps {
processInstanceId: string; // 流程实例 ID
types: string; // 业务类型
// 事件
onApprove: Function; // 审批通过回调
onReject: Function; // 审批拒绝回调
onValidateBeforeApprove: Function; // 审批前校验
}
interface ApprovalNode {
id: string; // 节点 ID
name: string; // 节点名称
nodeType: NodeType; // 节点类型
status: TaskStatus; // 节点状态
tasks: Task[]; // 任务列表
candidateUsers: User[]; // 候选用户
endTime?: string; // 结束时间
}
2.2 支持的节点类型
typescript
enum NodeType {
START_USER_NODE = 'start_user', // 发起人节点
USER_TASK_NODE = 'user_task', // 审批人节点
TRANSACTOR_NODE = 'transactor', // 办理人节点
COPY_TASK_NODE = 'copy_task', // 抄送人节点
CONDITION_NODE = 'condition', // 条件分支节点
PARALLEL_BRANCH_NODE = 'parallel', // 并行分支节点
CHILD_PROCESS_NODE = 'child_process', // 子流程节点
END_EVENT_NODE = 'end_event', // 结束节点
}
2.3 支持的任务状态
typescript
enum TaskStatusEnum {
NOT_STARTED = -1, // 未开始
PENDING = 0, // 待审批
RUNNING = 1, // 审批中
APPROVE = 2, // 审批通过
REJECT = 3, // 审批不通过
CANCEL = 4, // 取消
RETURN = 5, // 退回
DELEGATE = 6, // 委派中
APPROVING = 7, // 审批通过中
}
三、组件架构设计
3.1 模板结构
vue
<template>
<view class="approval-process">
<!-- 审批流程卡片 -->
<view v-if="approvalProcess.length > 0" class="detail-card">
<!-- 卡片头部 -->
<view class="card-header">
<view class="card-title">审批流程</view>
<u-image :src="auditIconsMap[taskStatus]" mode="aspectFill" />
</view>
<!-- 流程时间线 -->
<view class="process-container">
<view v-for="(node, index) in approvalProcess" :key="node.id" :data-status="node.status" class="process-item">
<!-- 连接线 -->
<view v-if="index < approvalProcess.length - 1" class="process-line"></view>
<!-- 节点内容 -->
<view class="process-content">
<!-- 节点标题 -->
<view class="process-title">
<view class="flex items-center">
<!-- 节点图标 -->
<view class="process-icon">
<u-image :src="getApprovalNodeImg(node.nodeType)" />
<!-- 状态徽章 -->
<view :style="{ backgroundColor: statusIconMap[node.status]?.color }" class="process-icon-status">
<u-icon :name="statusIconMap[node.status]?.icon" />
</view>
</view>
<view>{{ node.name }}</view>
</view>
<view class="process-info">
<text v-if="node.endTime" class="process-time">{{ node.endTime }}</text>
</view>
</view>
<!-- 审批人列表 -->
<view class="process-detail">
<view v-for="item in node.tasks || node.candidateUsers" :key="item.id" class="process-assignee">
<view class="assignee-item">
<u-avatar :src="fileCdn(item.assigneeUser.avatar)" mode="circle" />
<text>{{ item.assigneeUser.nickname }}</text>
</view>
</view>
</view>
<!-- 审批意见 -->
<view v-for="item in node.tasks" :key="item.id" class="process-comment-container">
<view v-if="item.reason" class="process-comment"> 审批意见:{{ item.reason }} </view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view v-if="showActionBar" class="bottom-action-bar">
<u-button type="primary" @click="handleApprove"> <u-icon name="checkmark"></u-icon> 通过 </u-button>
<u-button type="error" @click="handleReject"> <u-icon name="close"></u-icon> 拒绝 </u-button>
</view>
<!-- 审批弹窗 -->
<u-modal v-model="showRejectModal" title="拒绝原因" @confirm="handleConfirmReject">
<u-input v-model="rejectReason" type="textarea" placeholder="请输入拒绝原因" />
</u-modal>
<u-modal v-model="showApproveModal" title="审批通过意见" @confirm="handleConfirmApprove">
<u-input v-model="approveComment" type="textarea" placeholder="请输入审批意见" />
</u-modal>
</view>
</template>
3.2 数据流转设计
typescript
// 组件接收的数据流
props: {
process_instance_id: String, // 流程实例 ID
types: String // 业务类型(travel/expense 等)
}
// 内部状态
const approvalProcess = reactive([]); // 审批流程数据
const runningTask = ref(); // 运行中的任务
const showActionBar = computed(() => { // 是否显示操作栏
return props.types === 'travel' &&
runningTask.value &&
isHandleTaskStatus() &&
isShowButton(OperationButtonType.RETURN);
});
// 暴露给父组件的方法
defineExpose({
showActionBar
});
四、核心功能实现
4.1 获取审批详情
typescript
const getApprovalDetail = async (processInstanceId: string) => {
try {
const { data } = await ProcessInstanceApi.getApprovalDetail({
processInstanceId,
});
if (data && data.activityNodes.length > 0) {
Object.assign(approvalProcess, data.activityNodes);
}
runningTask.value = data.todoTask;
} catch (error) {
console.error('获取审批详情失败:', error);
uToast1.value.show({
title: '获取审批详情失败',
type: 'error',
});
}
};
// 监听 props 变化,自动获取详情
watch(
() => props.process_instance_id,
(newVal) => {
if (newVal) {
getApprovalDetail(newVal);
}
},
{ immediate: true }
);
4.2 节点图标映射
typescript
// SVG 图标导入
import starterSvg from '@/static/svgs/bpm/starter.svg';
import auditorSvg from '@/static/svgs/bpm/auditor.svg';
import copySvg from '@/static/svgs/bpm/copy.svg';
import conditionSvg from '@/static/svgs/bpm/condition.svg';
import parallelSvg from '@/static/svgs/bpm/parallel.svg';
import finishSvg from '@/static/svgs/bpm/finish.svg';
import transactorSvg from '@/static/svgs/bpm/transactor.svg';
import childProcessSvg from '@/static/svgs/bpm/child-process.svg';
// 节点类型与图标映射
const nodeTypeSvgMap = {
[NodeType.END_EVENT_NODE]: { color: '#909398', svg: finishSvg },
[NodeType.START_USER_NODE]: { color: '#909398', svg: starterSvg },
[NodeType.USER_TASK_NODE]: { color: '#ff943e', svg: auditorSvg },
[NodeType.TRANSACTOR_NODE]: { color: '#ff943e', svg: transactorSvg },
[NodeType.COPY_TASK_NODE]: { color: '#3296fb', svg: copySvg },
[NodeType.CONDITION_NODE]: { color: '#14bb83', svg: conditionSvg },
[NodeType.PARALLEL_BRANCH_NODE]: { color: '#14bb83', svg: parallelSvg },
[NodeType.CHILD_PROCESS_NODE]: { color: '#14bb83', svg: childProcessSvg },
};
const getApprovalNodeImg = (nodeType: NodeType) => {
return nodeTypeSvgMap[nodeType as keyof typeof nodeTypeSvgMap]?.svg || finishSvg;
};
4.3 状态图标映射
typescript
const statusIconMap: StatusIconMap = {
'-1': { color: '#909398', icon: 'clock' }, // 未开始
'0': { color: '#00b32a', icon: 'reload' }, // 待审批
'1': { color: '#448ef7', icon: 'reload' }, // 审批中
'2': { color: '#00b32a', icon: 'checkmark' }, // 审批通过
'3': { color: '#f46b6c', icon: 'Close' }, // 审批不通过
'4': { color: '#cccccc', icon: 'trash' }, // 取消
'5': { color: '#f46b6c', icon: 'minus' }, // 退回
'6': { color: '#448ef7', icon: 'reload' }, // 委派中
'7': { color: '#00b32a', icon: 'checkmark' }, // 审批通过中
};
4.4 审批通过逻辑
typescript
const handleApprove = async () => {
try {
// 触发父组件的校验事件
await new Promise((resolve, reject) => {
emit('validate-before-approve', { resolve, reject });
});
console.log('校验通过');
// 获取下一个审批节点
const data = await ProcessInstanceApi.getNextApprovalNodes({
processInstanceId: props.process_instance_id,
taskId: runningTask.value.id,
processVariablesStr: {},
});
// 显示审批意见弹窗(可选)
// showApproveModal.value = true;
} catch (error) {
console.log('校验未通过');
return;
}
};
const handleConfirmApprove = async () => {
try {
const data = {
id: runningTask.value.id,
reason: '通过',
variables: {},
nextAssignees: {},
};
await TaskApi.approveTask(data);
uToast1.value.show({
title: '审批已通过',
type: 'success',
duration: 3000,
});
approveComment.value = '';
getApprovalDetail(props.process_instance_id);
emit('approve');
} catch (error) {
console.error('审批通过失败:', error);
}
};
4.5 审批拒绝逻辑
typescript
const handleReject = () => {
showRejectModal.value = true;
};
const handleConfirmReject = async () => {
if (!rejectReason.value.trim()) {
uToast1.value.show({
title: '请输入拒绝原因',
type: 'warning',
});
showRejectModal.value = true;
return;
}
try {
const data = {
id: runningTask.value.id,
reason: rejectReason.value,
};
await TaskApi.rejectTask(data);
uToast1.value.show({
title: '审批已拒绝',
type: 'success',
duration: 3000,
});
showRejectModal.value = false;
rejectReason.value = '';
getApprovalDetail(props.process_instance_id);
emit('reject');
} catch (error) {
console.error('审批拒绝失败:', error);
}
};
4.6 按钮权限控制
typescript
/** 任务是否为处理中状态 */
const isHandleTaskStatus = () => {
let canHandle = false;
if (TaskApi.TaskStatusEnum.RUNNING === runningTask.value?.status) {
canHandle = true;
}
return canHandle;
};
/** 是否显示按钮 */
const isShowButton = (btnType: OperationButtonType): boolean => {
let isShow = true;
if (runningTask.value?.buttonsSetting && runningTask.value?.buttonsSetting[btnType]) {
isShow = runningTask.value.buttonsSetting[btnType].enable;
}
return isShow;
};
/** 计算是否显示操作栏 */
const showActionBar = computed(() => {
return props.types === 'travel' && runningTask.value && isHandleTaskStatus() && isShowButton(OperationButtonType.RETURN);
});
五、样式设计与优化
5.1 卡片容器样式
scss
@import '../../styles/_variables.scss';
.detail-card {
margin: $spacing-xl;
padding: $spacing-2xl;
background: linear-gradient(135deg, $color-white 0%, $color-gray-50 100%);
border-radius: $radius-xl;
box-shadow: $shadow-sm;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-$spacing-2xs);
}
.card-header {
padding-bottom: $spacing-xl;
border-bottom: 1rpx solid $color-gray-200;
position: relative;
.card-title {
font-size: 34rpx;
font-weight: 700;
color: $color-gray-950;
letter-spacing: 0.5rpx;
background: linear-gradient(135deg, $color-gray-950 0%, $color-gray-800 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.preview-image {
position: absolute;
right: 0;
top: 0;
transition: transform $transition-normal ease;
&:hover {
transform: scale(1.05);
}
}
}
}
5.2 流程时间线样式
scss
.process-container {
padding: $spacing-3xl 0;
padding-bottom: 0;
position: relative;
}
.process-item {
display: flex;
margin-bottom: $spacing-4xl;
position: relative;
// 入场动画
opacity: 0;
transform: translateY($spacing-lg);
animation: slideInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
// 延迟动画
@for $i from 1 through 5 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.1}s;
}
}
&:hover {
.process-icon {
transform: scale(1.1);
box-shadow: 0 $spacing-md $spacing-xl rgba($color-primary, 0.4);
}
.process-line {
background: linear-gradient(to bottom, $color-primary, $color-primary-light);
}
}
// 状态样式
&[data-status='approving'] .process-icon {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
box-shadow: 0 $spacing-sm $spacing-lg rgba($color-primary, 0.4);
}
&[data-status='approved'] .process-icon {
background: linear-gradient(135deg, $color-success 0%, $color-success-light 100%);
box-shadow: 0 $spacing-sm $spacing-lg rgba($color-success, 0.4);
}
&[data-status='rejected'] .process-icon {
background: linear-gradient(135deg, $color-danger 0%, $color-danger-light 100%);
box-shadow: 0 $spacing-sm $spacing-lg rgba($color-danger, 0.4);
}
}
.process-line {
width: 3rpx;
background: linear-gradient(to bottom, $color-primary, $color-gray-150);
position: absolute;
top: 90rpx;
bottom: -$spacing-4xl;
left: 40rpx;
z-index: 1;
// 绘制动画
transform: scaleY(0);
animation: drawLine 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards;
animation-delay: 0.3s;
// 发光效果
&::before {
content: '';
position: absolute;
top: 0;
left: -1rpx;
right: -1rpx;
bottom: 0;
background: inherit;
filter: blur(2rpx);
opacity: 0.5;
}
// 最后一个项目不显示连接线
.process-item:last-child & {
display: none;
}
}
5.3 节点图标样式
scss
.process-icon {
width: 80rpx;
height: 80rpx;
border-radius: $radius-full;
margin-right: $spacing-lg;
display: flex;
align-items: center;
justify-content: center;
color: $color-white;
font-size: 28rpx;
font-weight: 600;
position: relative;
transition: all $transition-bounce;
cursor: pointer;
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
box-shadow: 0 $spacing-sm $spacing-lg rgba($color-primary, 0.3);
// 内阴影
&::before {
content: '';
position: absolute;
inset: 2rpx;
border-radius: $radius-full;
background: rgba($color-white, 0.1);
}
// 状态徽章
.process-icon-status {
width: 32rpx;
height: 32rpx;
border: 2rpx solid $color-white;
border-radius: $radius-full;
position: absolute;
bottom: -$spacing-2xs;
right: -$spacing-2xs;
display: flex;
align-items: center;
justify-content: center;
background: $color-white;
box-shadow: 0 $spacing-2xs $spacing-sm rgba(0, 0, 0, 0.15);
font-size: 20rpx;
color: $color-primary;
}
}
5.4 审批人列表样式
scss
.process-detail {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: $spacing-md;
margin-left: 108rpx;
font-size: 26rpx;
color: $color-gray-600;
}
.process-assignee {
.assignee-item {
display: flex;
align-items: center;
margin-bottom: $spacing-sm;
padding: $spacing-sm $spacing-md;
background: linear-gradient(135deg, $color-gray-100 0%, $color-gray-200 100%);
margin-right: $spacing-md;
border-radius: $radius-2xl;
font-size: 24rpx;
color: $color-gray-700;
transition: all $transition-normal ease;
border: 1rpx solid $color-gray-200;
&:hover {
background: linear-gradient(135deg, $color-gray-200 0%, $color-gray-300 100%);
transform: translateY(-$spacing-2xs);
box-shadow: 0 $spacing-2xs $spacing-sm rgba(0, 0, 0, 0.1);
}
text {
margin-left: $spacing-2xs;
font-weight: 500;
}
}
}
5.5 审批意见样式
scss
.process-comment-container {
margin-top: $spacing-md;
margin-left: 108rpx;
.process-comment {
font-size: 28rpx;
color: $color-gray-900;
margin-top: $spacing-sm;
background: linear-gradient(135deg, $color-white 0%, $color-gray-50 100%);
padding: $spacing-lg;
border-radius: $radius-lg;
line-height: 1.7;
position: relative;
border: 1rpx solid $color-gray-200;
box-shadow: 0 $spacing-2xs $spacing-sm rgba(0, 0, 0, 0.08);
transition: all $transition-normal ease;
&:hover {
box-shadow: 0 $spacing-sm $spacing-lg rgba(0, 0, 0, 0.12);
transform: translateY(-$spacing-2xs);
}
// 评论框小三角
&::before {
content: '';
position: absolute;
top: -12rpx;
left: 32rpx;
width: 0;
height: 0;
border: 6rpx solid transparent;
border-bottom-color: $color-white;
filter: drop-shadow(0 -1rpx 1rpx rgba(0, 0, 0, 0.08));
}
}
}
5.6 底部操作栏样式
scss
.bottom-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
padding: $spacing-xl $spacing-2xl;
padding-bottom: calc($spacing-xl + constant(safe-area-inset-bottom));
padding-bottom: calc($spacing-xl + env(safe-area-inset-bottom));
background: linear-gradient(135deg, $color-white 0%, $color-gray-50 100%);
box-shadow: $shadow-sm;
justify-content: space-around;
backdrop-filter: blur(20rpx);
// 顶部渐变分割线
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.1), transparent);
}
}
5.7 动画定义
scss
// 节点入场动画
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY($spacing-lg);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 连接线绘制动画
@keyframes drawLine {
from {
transform: scaleY(0);
opacity: 0;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
六、使用示例
6.1 基础用法
vue
<template>
<approval-process :process_instance_id="processInstanceId" types="travel" @approve="handleApprove" @reject="handleReject" />
</template>
<script setup>
import { ref } from 'vue';
import ApprovalProcess from '@/components/approval-process/approval-process.vue';
const processInstanceId = ref('123456');
const handleApprove = () => {
console.log('审批通过');
// 刷新列表或跳转
};
const handleReject = () => {
console.log('审批拒绝');
// 刷新列表或跳转
};
</script>
6.2 带审批前校验
vue
<template>
<approval-process
:process_instance_id="processInstanceId"
types="expense"
@validate-before-approve="validateBeforeApprove"
@approve="handleApprove"
/>
</template>
<script setup>
const validateBeforeApprove = ({ resolve, reject }) => {
// 表单校验
if (!formData.value.amount) {
uni.showToast({ title: '请填写金额', icon: 'none' });
reject();
return;
}
// 异步校验
api.checkExpenseAmount(formData.value.amount).then((res) => {
if (res.valid) {
resolve();
} else {
uni.showToast({ title: '金额超限', icon: 'none' });
reject();
}
});
};
</script>
七、性能优化实践
7.1 条件渲染优化
vue
<!-- 使用 v-if 避免不必要的渲染 -->
<view v-if="approvalProcess.length > 0" class="detail-card">
<!-- ... -->
</view>
<!-- 使用 v-if 控制连接线显示 -->
<view v-if="index < approvalProcess.length - 1" class="process-line"></view>
7.2 计算属性缓存
typescript
const showActionBar = computed(() => {
return props.types === 'travel' && runningTask.value && isHandleTaskStatus() && isShowButton(OperationButtonType.RETURN);
});
7.3 CSS 动画性能
scss
.process-item {
// 使用 GPU 加速
will-change: transform, opacity;
// 使用 cubic-bezier 缓动函数
animation: slideInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.process-icon {
// 弹性动画
transition: all $transition-bounce;
}
八、常见问题与解决方案
8.1 长流程性能问题
问题:审批节点过多时滚动卡顿
解决:
vue
<!-- 使用 scroll-view 并开启增强模式 -->
<scroll-view :enhanced="true" :bounce="false" scroll-y>
<view v-for="node in approvalProcess" :key="node.id">
<!-- ... -->
</view>
</scroll-view>
8.2 图片加载闪烁
问题:头像加载时出现闪烁
解决:
vue
<u-image :fade="true" duration="450" :src="fileCdn(avatar)" mode="circle"></u-image>
8.3 安全区域适配
问题:iPhone X 等机型底部按钮被遮挡
解决:
scss
.bottom-action-bar {
padding-bottom: calc($spacing-xl + env(safe-area-inset-bottom));
}
九、总结
本文详细介绍了审批流程组件的完整封装过程,包括:
✅ 功能完整 :支持多种节点类型和状态
✅ 交互友好 :平滑动画、即时反馈、操作便捷
✅ 样式精美 :渐变设计、阴影效果、微交互
✅ 性能优化 :条件渲染、计算属性、GPU 加速
✅ 多端兼容:H5、小程序、APP 完美适配
该组件已在多个企业项目中应用,日均处理审批流程上千条。希望能为你在开发类似组件时提供参考!
最后的话
学习编程是一场马拉松,不是百米冲刺。
- 🌟 开始最重要: 不要等"准备好了"再开始,现在就开始
- 🌟 坚持是关键: 每天进步一点点,一年后你会感谢自己
- 🌟 实践出真知: 多做项目,多写代码,多总结经验
参考文献
1\] uni-app 官方文档 https://uniapp.dcloud.net.cn/ \[2\] Vue3 组合式 API https://cn.vuejs.org/ \[3\] uView UI 组件库 https://www.uviewui.com/ \[4\] BPMN 2.0 规范 https://www.bpmn.org/ *** ** * ** *** **版权声明**:本文为 CSDN 博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 > **互动话题**:你在开发审批流程时遇到过哪些坑?欢迎评论区交流讨论!觉得有用请点赞 👍 收藏 ⭐️ 支持!