feat: 新增工作流模块与动态表单发布
- src/api/workflow: 工作流模型、实例、任务接口与类型定义 - src/views/workflow: 流程设计、发起、审批中心(待办/已办/我发起)页面与 BPMN 设计器、预览器 - src/views/dynamic-form/components/publish: 发布弹窗拆分为多步(入口配置/方式/结果),原 PublishDialog 大幅精简 - src/api/form: 新增 getWorkflowOptions,表单定义增加 category 区分普通/工作流表单 - src/api/system/role: 新增 getCodeOptions 供流程候选组选择 - src/stores/permission: 路由重载先摘旧路由再注册新路由,避免空窗触发 404 - vite.config: 预构建 bpmn-js,避免设计器首进重优化刷新 - src/views/login、.env.development、LayoutToolbar: 本地开发联调调整(昵称展示、本地接口) - src/api/system/user: 重置密码改用 data 传参 - src/views/codegen: 支持 FormTypeEnum 日期字段范围查询 - package.json: 版本号 4.9.0;删除误存的 dev-output.txt
This commit is contained in:
1 parent
3503a399fb
commit
2ab0b31cfc
37 files changed
+5760
-1326
No files matched your search
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<el-timeline v-if="stages.length > 0" class="approval-flow">
|
||||
<template v-for="(stage, index) in stages" :key="stage.nodeId">
|
||||
<!-- 环节的实际办理记录:驳回重办同一节点产生多条,全部按时间展示 -->
|
||||
<el-timeline-item
|
||||
v-for="record in recordsOf(stage.nodeId)"
|
||||
:key="`${stage.nodeId}-${record.startTime ?? index}`"
|
||||
:type="record.endTime ? 'success' : 'primary'"
|
||||
:hollow="!record.endTime"
|
||||
>
|
||||
<div class="approval-flow__title">
|
||||
<div class="approval-flow__name">
|
||||
<span>{{ stage.nodeName }}</span>
|
||||
<el-tag v-if="record.assignee" size="small" effect="plain">
|
||||
{{ record.assignee }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-tag v-if="record.endTime" size="small" type="success" effect="light">已办理</el-tag>
|
||||
<el-tag v-else size="small" type="warning" effect="light">进行中</el-tag>
|
||||
</div>
|
||||
<div class="approval-flow__time">
|
||||
<template v-if="record.endTime">办理于 {{ record.endTime }}</template>
|
||||
<template v-else>
|
||||
{{
|
||||
record.assignee ? `等待 ${record.assignee} 办理` : `待 ${describe(stage)} 认领办理`
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="record.comment" class="approval-flow__comment">{{ record.comment }}</div>
|
||||
</el-timeline-item>
|
||||
|
||||
<!-- 无办理记录:运行中按位置区分"未到"与"未经过"(网关分支跳过),已结束均为未经过;
|
||||
两种情况均展示配置的办理人,便于了解该环节本应由谁处理 -->
|
||||
<el-timeline-item v-if="!hasRecord(stage.nodeId)" type="info" hollow>
|
||||
<div class="approval-flow__title" :class="{ 'is-skipped': !isUpcoming(index) }">
|
||||
<span>{{ stage.nodeName }}</span>
|
||||
<el-tag size="small" type="info" effect="plain">{{ pendingLabel(index) }}</el-tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="describe(stage)"
|
||||
class="approval-flow__time"
|
||||
:class="{ 'is-skipped': !isUpcoming(index) }"
|
||||
>
|
||||
{{ describe(stage) }}
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</template>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无审批流程" :image-size="64" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ApprovalHistoryItem, InstanceStatus, ProcessStageItem } from "@/api/workflow";
|
||||
|
||||
defineOptions({
|
||||
name: "ApprovalFlowTimeline",
|
||||
});
|
||||
|
||||
/**
|
||||
* 审批流程时间线(流程走向 + 审批记录融合)
|
||||
*
|
||||
* @description 以设计环节为骨架按编排顺序展示,实际办理记录按 activityId 归位到环节:
|
||||
* 已办(办理人/意见/时间)、进行中(待认领或等待办理)、未到(运行中位于当前环节之后)、
|
||||
* 未经过(网关分支跳过或流程结束仍未到达),驳回重办的多轮记录全部保留
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 审批环节(按 BPMN 编排顺序,含分支未走节点) */
|
||||
stages: ProcessStageItem[];
|
||||
/** 审批记录(按时间正序) */
|
||||
history: ApprovalHistoryItem[];
|
||||
/** 当前进行中环节下标(运行中有效) */
|
||||
active: number;
|
||||
/** 实例状态:非运行中视为已结束,无记录环节均为"未经过" */
|
||||
status: InstanceStatus;
|
||||
}>();
|
||||
|
||||
/** 节点ID -> 办理记录(驳回重办为多条) */
|
||||
const recordMap = computed(() => {
|
||||
const map = new Map<string, ApprovalHistoryItem[]>();
|
||||
props.history.forEach((item) => {
|
||||
if (!item.activityId) return;
|
||||
const list = map.get(item.activityId) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.activityId, list);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function recordsOf(nodeId: string): ApprovalHistoryItem[] {
|
||||
return recordMap.value.get(nodeId) ?? [];
|
||||
}
|
||||
|
||||
function hasRecord(nodeId: string): boolean {
|
||||
return recordsOf(nodeId).length > 0;
|
||||
}
|
||||
|
||||
/** 是否"未到":流程运行中且位于当前环节之后 */
|
||||
function isUpcoming(index: number): boolean {
|
||||
return props.status === "running" && index >= props.active;
|
||||
}
|
||||
|
||||
/** 无记录环节状态标签:未到(后续环节)/ 未经过(分支跳过或已结束) */
|
||||
function pendingLabel(index: number): string {
|
||||
return isUpcoming(index) ? "未到" : "未经过";
|
||||
}
|
||||
|
||||
/** 环节办理人描述:角色(成员账号);发起人办理环节标注"发起人" */
|
||||
function describe(stage: ProcessStageItem): string {
|
||||
if (stage.initiator) {
|
||||
return "发起人";
|
||||
}
|
||||
const roles = stage.roleNames.join("、");
|
||||
const users = stage.userNames.join("、");
|
||||
if (roles && users) {
|
||||
return `${roles}(${users})`;
|
||||
}
|
||||
return roles || users || "未配置办理人";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.approval-flow {
|
||||
padding-left: 4px;
|
||||
|
||||
/* Element Plus 默认 wrapper 上移 3px(为单行纯文本设计),多行内容下
|
||||
标签顶部与节点连线贴边,视觉上边框被裁切;归零后按内容自然排版 */
|
||||
:deep(.el-timeline-item__wrapper) {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
/* 未经过环节整体弱化,与已办/进行中形成层级差 */
|
||||
&.is-skipped {
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__time {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 审批意见引用块:左侧色条 + 浅底,与时间/标题形成视觉分层 */
|
||||
&__comment {
|
||||
padding: 6px 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
background: var(--el-fill-color-light);
|
||||
border-left: 3px solid var(--el-color-primary-light-5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<el-timeline v-if="history.length > 0" class="approval-timeline">
|
||||
<el-timeline-item
|
||||
v-for="(item, index) in history"
|
||||
:key="index"
|
||||
:timestamp="item.startTime"
|
||||
:type="item.endTime ? 'success' : 'primary'"
|
||||
placement="top"
|
||||
>
|
||||
<div class="approval-timeline__title">
|
||||
<span>{{ item.activityName }}</span>
|
||||
<el-tag v-if="item.assignee" size="small" effect="plain">{{ item.assignee }}</el-tag>
|
||||
<el-tag v-else size="small" type="info" effect="plain">未办理</el-tag>
|
||||
<el-tag v-if="!item.endTime" size="small" type="warning" effect="plain">进行中</el-tag>
|
||||
</div>
|
||||
<div v-if="item.comment" class="approval-timeline__comment">{{ item.comment }}</div>
|
||||
<div v-if="item.endTime" class="approval-timeline__time">办理于 {{ item.endTime }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无审批记录" :image-size="64" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ApprovalHistoryItem } from "@/api/workflow";
|
||||
|
||||
defineOptions({
|
||||
name: "ApprovalTimeline",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 审批记录时间线
|
||||
*
|
||||
* @description 按时间正序展示各节点办理人与审批意见,
|
||||
* 未办理节点(当前待办)以 primary 色区分
|
||||
*/
|
||||
defineProps<{
|
||||
/** 审批记录(按时间正序) */
|
||||
history: ApprovalHistoryItem[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.approval-timeline {
|
||||
padding-left: 4px;
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__comment {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
&__time {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="bpmn-designer">
|
||||
<div class="bpmn-designer__toolbar">
|
||||
<span class="bpmn-designer__title">{{ title }}</span>
|
||||
<div class="bpmn-designer__actions">
|
||||
<el-tooltip content="缩小" placement="bottom">
|
||||
<el-button class="page-icon-btn" @click="handleZoom(-0.1)">
|
||||
<el-icon><ZoomOut /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="放大" placement="bottom">
|
||||
<el-button class="page-icon-btn" @click="handleZoom(0.1)">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="适应画布" placement="bottom">
|
||||
<el-button class="page-icon-btn" @click="handleFitViewport">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<slot name="toolbar-extra" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bpmn-designer__body">
|
||||
<div ref="canvasRef" class="bpmn-designer__canvas" />
|
||||
|
||||
<aside class="bpmn-designer__panel">
|
||||
<template v-if="selectedElement">
|
||||
<el-form label-width="80px" label-position="top" class="bpmn-designer__form">
|
||||
<el-form-item label="节点ID">
|
||||
<el-input :model-value="selectedElement.id" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点名称">
|
||||
<el-input
|
||||
:model-value="selectedName"
|
||||
placeholder="如:部门主管审批"
|
||||
@update:model-value="updateProperty('name', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="isFormBindable">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex-y-center">
|
||||
发起表单
|
||||
<el-tooltip
|
||||
content="发起人启动流程时填写的表单,审批环节展示的也是这份表单数据"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-icon class="ml-1 cursor-pointer"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-select
|
||||
:model-value="selectedFormKey"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择已发布的工作流表单"
|
||||
class="w-full"
|
||||
@update:model-value="updateProperty('formKey', String($event ?? ''))"
|
||||
>
|
||||
<el-option
|
||||
v-for="form in workflowFormOptions"
|
||||
:key="form.value"
|
||||
:label="form.label"
|
||||
:value="String(form.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="isUserTask">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex-y-center">
|
||||
办理人
|
||||
<el-tooltip
|
||||
content="单个用户名(登录账号)或 ${initiator}(发起人本人);任务直接进入其待办"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-icon class="ml-1 cursor-pointer"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input
|
||||
:model-value="selectedAssignee"
|
||||
placeholder="如 admin 或 ${initiator}"
|
||||
@update:model-value="updateProperty('assignee', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex-y-center">
|
||||
候选组
|
||||
<el-tooltip
|
||||
content="选择角色,可多选;角色成员均可在待办中认领办理"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-icon class="ml-1 cursor-pointer"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-select
|
||||
:model-value="candidateGroupCodes"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择可办理该节点的角色"
|
||||
class="w-full"
|
||||
@update:model-value="updateCandidateGroups"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roleCodeOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="String(role.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="isSequenceFlow">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex-y-center">
|
||||
条件表达式
|
||||
<el-tooltip
|
||||
content="留空走默认分支;表达式引用表单字段或流程变量,如 ${days > 3}"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-icon class="ml-1 cursor-pointer"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input
|
||||
:model-value="selectedCondition"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="${days > 3}"
|
||||
@update:model-value="updateCondition"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</template>
|
||||
<el-empty v-else description="点击画布节点配置属性" :image-size="64" />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// palette/context-pad 样式与图标字体缺一不可,bpmn-embedded 内嵌字体免字体文件路径问题
|
||||
import "bpmn-js/dist/assets/diagram-js.css";
|
||||
import "bpmn-js/dist/assets/bpmn-js.css";
|
||||
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
|
||||
|
||||
import Modeler from "bpmn-js/lib/Modeler";
|
||||
import { is } from "bpmn-js/lib/util/ModelUtil";
|
||||
import { FullScreen, QuestionFilled, ZoomIn, ZoomOut } from "@element-plus/icons-vue";
|
||||
import type Modeling from "bpmn-js/lib/features/modeling/Modeling";
|
||||
import type { Shape } from "bpmn-js/lib/model/Types";
|
||||
|
||||
import flowableModdleDescriptor from "./bpmn/flowable-moddle";
|
||||
import RoleAPI from "@/api/system/role";
|
||||
import FormAPI from "@/api/form";
|
||||
import type { OptionItem } from "@/api/common";
|
||||
|
||||
/** 画布服务调用子集(diagram-js Canvas,pnpm 严格模式下不可直接引用其类型) */
|
||||
interface CanvasLike {
|
||||
zoom(): number;
|
||||
zoom(scale: number | "fit-viewport"): number;
|
||||
}
|
||||
|
||||
/** moddle 服务调用子集(创建条件表达式等 BPMN 元素) */
|
||||
interface ModdleLike {
|
||||
create(descriptor: string, properties?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "BpmnDesigner",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* BPMN 流程设计器
|
||||
*
|
||||
* @description bpmn-js 建模器 + 轻量属性面板(名称/表单标识/办理人/候选组/条件表达式),
|
||||
* 扩展 flowable 命名空间属性,保存时产出引擎可直接部署的 BPMN 2.0 XML
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 设计器标题(工具栏展示) */
|
||||
title: string;
|
||||
/** BPMN 2.0 XML(导入渲染) */
|
||||
xml: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 保存(抛出当前画布的 XML) */
|
||||
save: [xml: string];
|
||||
}>();
|
||||
|
||||
const canvasRef = ref<HTMLElement | null>(null);
|
||||
const modeler = shallowRef<Modeler>();
|
||||
|
||||
/** 脏标记:画布有未保存的修改(承载页据此拦截离开) */
|
||||
const dirty = ref(false);
|
||||
|
||||
/** 当前选中元素(Shape/Connection 统称) */
|
||||
const selectedElement = shallowRef<Shape | null>(null);
|
||||
|
||||
const isUserTask = computed(
|
||||
() => !!selectedElement.value && is(selectedElement.value, "bpmn:UserTask")
|
||||
);
|
||||
const isSequenceFlow = computed(
|
||||
() => !!selectedElement.value && is(selectedElement.value, "bpmn:SequenceFlow")
|
||||
);
|
||||
/** 仅开始节点可绑定表单(后端只读开始节点 formKey 渲染发起表单,审批展示复用同一份数据) */
|
||||
const isFormBindable = computed(
|
||||
() => !!selectedElement.value && is(selectedElement.value, "bpmn:StartEvent")
|
||||
);
|
||||
|
||||
const selectedName = computed(() => selectedElement.value?.businessObject?.name ?? "");
|
||||
const selectedFormKey = computed(() => selectedElement.value?.businessObject?.get("formKey") ?? "");
|
||||
const selectedAssignee = computed(
|
||||
() => selectedElement.value?.businessObject?.get("assignee") ?? ""
|
||||
);
|
||||
/** 候选组(角色编码,逗号分隔)与办理人二选一配置,均空发布时会被后端拦截 */
|
||||
const selectedCandidateGroups = computed(
|
||||
() => selectedElement.value?.businessObject?.get("candidateGroups") ?? ""
|
||||
);
|
||||
/** 候选组多选值:XML 逗号串 ↔ 选项数组互转,数据结构保持引擎兼容 */
|
||||
const candidateGroupCodes = computed(() =>
|
||||
String(selectedCandidateGroups.value)
|
||||
.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
/** 角色编码选项(label 角色名 / value 角色编码) */
|
||||
const roleCodeOptions = ref<OptionItem[]>([]);
|
||||
/** 发起表单选项(label 表单名 / value formKey,仅已发布 workflow 类型) */
|
||||
const workflowFormOptions = ref<OptionItem[]>([]);
|
||||
const selectedCondition = computed(
|
||||
() => selectedElement.value?.businessObject?.conditionExpression?.body ?? ""
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新元素普通属性(名称/formKey/assignee/candidateGroups)
|
||||
*
|
||||
* @param property 属性名
|
||||
* @param value 属性值(空值传 undefined 移除属性,避免序列化出空串属性干扰引擎解析)
|
||||
*/
|
||||
function updateProperty(property: string, value: string): void {
|
||||
const element = selectedElement.value;
|
||||
if (!element) return;
|
||||
const modeling = modeler.value!.get("modeling") as Modeling;
|
||||
modeling.updateProperties(element, {
|
||||
[property]: value.trim() === "" ? undefined : value,
|
||||
});
|
||||
// bpmn-js 原地修改 businessObject,shallowRef 感知不到深值变更;
|
||||
// 手动触发依赖刷新,否则受控控件(表单下拉/办理人输入等)选中值不回显
|
||||
triggerRef(selectedElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新候选组多选值(数组拼接为逗号串写入 candidateGroups)
|
||||
*
|
||||
* @param codes 选中的角色编码数组
|
||||
*/
|
||||
function updateCandidateGroups(codes: string[]): void {
|
||||
updateProperty("candidateGroups", codes.join(","));
|
||||
}
|
||||
|
||||
/** 加载角色编码选项(候选组下拉数据源) */
|
||||
async function loadRoleCodeOptions(): Promise<void> {
|
||||
roleCodeOptions.value = await RoleAPI.getCodeOptions();
|
||||
}
|
||||
|
||||
/** 加载发起表单选项(formKey 下拉数据源) */
|
||||
async function loadWorkflowFormOptions(): Promise<void> {
|
||||
workflowFormOptions.value = await FormAPI.getWorkflowOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新连线条件表达式(空值移除条件,走默认分支)
|
||||
*
|
||||
* @param value 条件表达式,如 ${days > 3}
|
||||
*/
|
||||
function updateCondition(value: string): void {
|
||||
const element = selectedElement.value;
|
||||
if (!element) return;
|
||||
const moddle = modeler.value!.get("moddle") as ModdleLike;
|
||||
const modeling = modeler.value!.get("modeling") as Modeling;
|
||||
const conditionExpression = value
|
||||
? moddle.create("bpmn:FormalExpression", { body: value })
|
||||
: undefined;
|
||||
modeling.updateProperties(element, { conditionExpression });
|
||||
// 同 updateProperty:手动触发 shallowRef 依赖刷新,保证条件表达式回显
|
||||
triggerRef(selectedElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存:导出当前画布 XML 并抛出
|
||||
*/
|
||||
async function handleSave(): Promise<void> {
|
||||
const { xml } = await modeler.value!.saveXML({ format: true });
|
||||
emit("save", xml ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布缩放
|
||||
*
|
||||
* @param delta 缩放步长(正放大/负缩小)
|
||||
*/
|
||||
function handleZoom(delta: number): void {
|
||||
const canvas = modeler.value!.get("canvas") as CanvasLike;
|
||||
canvas.zoom(canvas.zoom() + delta);
|
||||
}
|
||||
|
||||
/** 适应画布(完整展示流程图) */
|
||||
function handleFitViewport(): void {
|
||||
(modeler.value!.get("canvas") as CanvasLike).zoom("fit-viewport");
|
||||
}
|
||||
|
||||
/** 导入 XML 并选中态复位 */
|
||||
async function importXml(xml: string): Promise<void> {
|
||||
if (!xml || !modeler.value) return;
|
||||
try {
|
||||
await modeler.value.importXML(xml);
|
||||
handleFitViewport();
|
||||
} catch (error) {
|
||||
console.error("BPMN 导入失败:", error);
|
||||
} finally {
|
||||
// importXML 亦走命令栈,回显不算脏
|
||||
dirty.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.xml,
|
||||
(xml) => importXml(xml)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
modeler.value = new Modeler({
|
||||
container: canvasRef.value!,
|
||||
moddleExtensions: { flowable: flowableModdleDescriptor },
|
||||
});
|
||||
// 选中变化时同步属性面板(多选只取首个)
|
||||
modeler.value.on("selection.changed", (event: { newSelection: Shape[] }) => {
|
||||
selectedElement.value = event.newSelection[0] ?? null;
|
||||
});
|
||||
// 任何建模操作(增删节点/连线/改属性)都会触发命令栈变更
|
||||
modeler.value.on("commandStack.changed", () => {
|
||||
dirty.value = true;
|
||||
});
|
||||
importXml(props.xml);
|
||||
loadRoleCodeOptions();
|
||||
loadWorkflowFormOptions();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
modeler.value?.destroy();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
/** 供承载页触发的保存动作(与工具栏保存共用一套导出逻辑) */
|
||||
save: handleSave,
|
||||
/** 画布是否有未保存的修改 */
|
||||
isDirty: () => dirty.value,
|
||||
/** 持久化成功后清除脏标记(由承载页调用) */
|
||||
markSaved: () => {
|
||||
dirty.value = false;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bpmn-designer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 110px);
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__panel {
|
||||
width: 260px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__form {
|
||||
:deep(.el-form-item__label) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="bpmn-viewer" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Viewer from "bpmn-js/lib/NavigatedViewer";
|
||||
|
||||
/** 画布服务调用子集(diagram-js Canvas,pnpm 严格模式下不可直接引用其类型) */
|
||||
interface CanvasLike {
|
||||
zoom(scale: number | "fit-viewport"): number;
|
||||
addMarker(id: string, marker: string): void;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "BpmnViewer",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* BPMN 流程图查看器
|
||||
*
|
||||
* @description 渲染 BPMN XML 并按节点状态高亮:已办节点标记走过路径、
|
||||
* 进行中节点标记当前待办;缩放平移由 NavigatedViewer 自带滚轮/空格拖拽支持
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** BPMN 2.0 XML */
|
||||
xml: string;
|
||||
/** 已办节点ID列表(走过路径高亮) */
|
||||
executedActivityIds?: string[];
|
||||
/** 进行中节点ID列表(当前待办高亮) */
|
||||
activeActivityIds?: string[];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const viewer = shallowRef<Viewer>();
|
||||
|
||||
/**
|
||||
* 导入 XML 并叠加高亮标记
|
||||
*
|
||||
* @param xml BPMN XML
|
||||
* @param executedIds 已办节点ID列表
|
||||
* @param activeIds 进行中节点ID列表
|
||||
*/
|
||||
async function renderDiagram(
|
||||
xml: string,
|
||||
executedIds?: string[],
|
||||
activeIds?: string[]
|
||||
): Promise<void> {
|
||||
if (!xml || !viewer.value) return;
|
||||
const canvas = viewer.value.get("canvas") as CanvasLike;
|
||||
try {
|
||||
await viewer.value.importXML(xml);
|
||||
canvas.zoom("fit-viewport");
|
||||
executedIds?.forEach((id) => canvas.addMarker(id, "bpmn-viewer__executed"));
|
||||
activeIds?.forEach((id) => canvas.addMarker(id, "bpmn-viewer__active"));
|
||||
} catch (error) {
|
||||
console.error("BPMN 流程图渲染失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 后续 props 变化时重新渲染(post:待组件树更新完毕再执行)
|
||||
watch(
|
||||
() => [props.xml, props.executedActivityIds, props.activeActivityIds],
|
||||
([xml, executedIds, activeIds]) =>
|
||||
renderDiagram(
|
||||
xml as string,
|
||||
executedIds as string[] | undefined,
|
||||
activeIds as string[] | undefined
|
||||
),
|
||||
{ flush: "post" }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
viewer.value = new Viewer({
|
||||
container: containerRef.value!,
|
||||
// 高亮样式挂在父级容器上,作用域仅本组件
|
||||
});
|
||||
// 动态挂载场景(v-if):挂载时 xml 已是初始值而非变化,watch 不会触发,
|
||||
// 必须主动渲染一次,否则弹窗流程图空白
|
||||
if (props.xml) {
|
||||
renderDiagram(props.xml, props.executedActivityIds, props.activeActivityIds);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
viewer.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.bpmn-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
// 已办节点:绿色描边,表示已走过的路径
|
||||
.bpmn-viewer__executed:not(.djs-connection) .djs-visual > :nth-child(1) {
|
||||
fill: var(--el-color-success-light-9) !important;
|
||||
stroke: var(--el-color-success) !important;
|
||||
}
|
||||
|
||||
.bpmn-viewer__executed.djs-connection .djs-visual > :nth-child(1) {
|
||||
stroke: var(--el-color-success) !important;
|
||||
}
|
||||
|
||||
// 进行中节点:橙色加粗描边,表示当前待办节点
|
||||
.bpmn-viewer__active:not(.djs-connection) .djs-visual > :nth-child(1) {
|
||||
fill: var(--el-color-warning-light-9) !important;
|
||||
stroke: var(--el-color-warning) !important;
|
||||
stroke-width: 2.5px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<form-create v-model="formData" v-model:api="formApi" :rule="rule" :option="option" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Options, Rule } from "@form-create/element-ui";
|
||||
|
||||
defineOptions({
|
||||
name: "FormDetail",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 表单只读回显
|
||||
*
|
||||
* @description 按提交时快照规则渲染表单并禁用全部控件,
|
||||
* 供审批办理、实例详情等场景回看发起数据
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 表单规则(form-create rule 数组 JSON 字符串) */
|
||||
formJson?: string;
|
||||
/** 表单全局配置 JSON 字符串 */
|
||||
optionsJson?: string;
|
||||
/** 表单数据(field -> value 映射的 JSON 字符串) */
|
||||
dataJson?: string;
|
||||
}>();
|
||||
|
||||
const formApi = ref();
|
||||
const formData = ref<Record<string, unknown>>({});
|
||||
// shallowRef:避免深代理破坏 Rule 内部的 Creator 结构
|
||||
const rule = shallowRef<Rule[]>([]);
|
||||
const option = ref<Options>({ submitBtn: false, resetBtn: false });
|
||||
|
||||
watch(
|
||||
() => [props.formJson, props.optionsJson, props.dataJson],
|
||||
() => {
|
||||
rule.value = props.formJson ? disableRules(JSON.parse(props.formJson)) : [];
|
||||
if (props.optionsJson) {
|
||||
option.value = { ...JSON.parse(props.optionsJson), submitBtn: false, resetBtn: false };
|
||||
}
|
||||
formData.value = parseDataJson(props.dataJson);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 递归禁用规则中的全部控件
|
||||
*
|
||||
* 同时移除校验规则,只读态不展示必填星号
|
||||
*
|
||||
* @param rules form-create 规则(JSON 解析值)
|
||||
*/
|
||||
function disableRules(rules: unknown): Rule[] {
|
||||
const walk = (nodes: unknown[]): void => {
|
||||
nodes.forEach((node) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const item = node as Record<string, unknown>;
|
||||
item.props = { ...(item.props as object), disabled: true };
|
||||
delete item.validate;
|
||||
if (Array.isArray(item.children)) {
|
||||
walk(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
const cloned = structuredClone(rules);
|
||||
walk(Array.isArray(cloned) ? cloned : []);
|
||||
return cloned as Rule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析表单数据(解析失败按空数据兜底)
|
||||
*
|
||||
* @param dataJson 数据 JSON 字符串
|
||||
*/
|
||||
function parseDataJson(dataJson?: string): Record<string, unknown> {
|
||||
try {
|
||||
return dataJson ? (JSON.parse(dataJson) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="流程详情" size="720px">
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="流程名称">
|
||||
{{ detail.processName }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发起人">{{ detail.startUser || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusOptions[detail.status].tag">
|
||||
{{ statusOptions[detail.status].label }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发起时间">{{ detail.startTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">{{ detail.endTime || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="终止原因">
|
||||
{{ detail.deleteReason || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs class="instance-detail__tabs">
|
||||
<el-tab-pane label="表单数据">
|
||||
<FormDetail
|
||||
v-if="detail.formJson"
|
||||
:form-json="detail.formJson"
|
||||
:options-json="detail.optionsJson"
|
||||
:data-json="detail.dataJson"
|
||||
/>
|
||||
<!-- formKey 有值说明流程绑定了表单,数据为空即发起数据已被清理(如重置过演示数据) -->
|
||||
<el-empty
|
||||
v-else-if="detail.formKey"
|
||||
description="表单数据不存在或已被清理"
|
||||
:image-size="64"
|
||||
/>
|
||||
<el-empty v-else description="该流程未绑定表单" :image-size="64" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="审批流程">
|
||||
<ApprovalFlowTimeline
|
||||
:stages="detail.stages"
|
||||
:history="detail.history"
|
||||
:active="detail.activeStageIndex ?? 0"
|
||||
:status="detail.status"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="流程图" lazy>
|
||||
<div v-loading="diagramLoading" class="instance-detail__diagram">
|
||||
<BpmnViewer
|
||||
v-if="diagram.bpmnXml"
|
||||
:xml="diagram.bpmnXml"
|
||||
:executed-activity-ids="diagram.executedActivityIds"
|
||||
:active-activity-ids="diagram.activeActivityIds"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import type { InstanceDetailData, InstanceStatus, ProcessDiagramData } from "@/api/workflow";
|
||||
import ApprovalFlowTimeline from "./ApprovalFlowTimeline.vue";
|
||||
import BpmnViewer from "./BpmnViewer.vue";
|
||||
import FormDetail from "./FormDetail.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "InstanceDetailDrawer",
|
||||
});
|
||||
|
||||
/** 实例状态展示映射(标签文案 + 标签色) */
|
||||
const statusOptions: Record<
|
||||
InstanceStatus,
|
||||
{ label: string; tag: "primary" | "success" | "danger" }
|
||||
> = {
|
||||
running: { label: "运行中", tag: "primary" },
|
||||
finished: { label: "已完成", tag: "success" },
|
||||
terminated: { label: "已终止", tag: "danger" },
|
||||
};
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const detail = ref<InstanceDetailData>();
|
||||
|
||||
/** 流程图数据(详情抽屉"流程图"页签) */
|
||||
const diagram = ref<ProcessDiagramData>({
|
||||
bpmnXml: "",
|
||||
executedActivityIds: [],
|
||||
activeActivityIds: [],
|
||||
});
|
||||
|
||||
const diagramLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 打开抽屉并加载实例详情与流程图
|
||||
*
|
||||
* @param instanceId 流程实例 ID
|
||||
*/
|
||||
async function open(instanceId: string): Promise<void> {
|
||||
visible.value = true;
|
||||
loading.value = true;
|
||||
diagramLoading.value = true;
|
||||
diagram.value = { bpmnXml: "", executedActivityIds: [], activeActivityIds: [] };
|
||||
try {
|
||||
// 详情与流程图互不依赖,并行加载
|
||||
[detail.value, diagram.value] = await Promise.all([
|
||||
WorkflowAPI.instance.getDetail(instanceId),
|
||||
WorkflowAPI.instance.getDiagram(instanceId),
|
||||
]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
diagramLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.instance-detail {
|
||||
&__tabs {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__diagram {
|
||||
height: 420px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<!-- simple 模式紧凑展示审批链;当前及之前环节为完成态,当前环节高亮进行中 -->
|
||||
<el-steps :active="active" simple finish-status="success" class="process-stages">
|
||||
<el-step
|
||||
v-for="stage in stages"
|
||||
:key="stage.nodeId"
|
||||
:title="stage.nodeName"
|
||||
:description="describe(stage)"
|
||||
/>
|
||||
</el-steps>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProcessStageItem } from "@/api/workflow";
|
||||
|
||||
defineOptions({
|
||||
name: "ProcessStages",
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/** 审批环节(按 BPMN 编排顺序) */
|
||||
stages: ProcessStageItem[];
|
||||
/** 高亮位置:当前环节下标(之前为完成、当前为进行中);发起页预览传 -1 表示全部待开始 */
|
||||
active: number;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 环节办理人描述:角色(成员账号);发起人办理环节标注"发起人";未配置办理人的环节兜底提示
|
||||
*/
|
||||
function describe(stage: ProcessStageItem): string {
|
||||
if (stage.initiator) {
|
||||
return "发起人办理";
|
||||
}
|
||||
const roles = stage.roleNames.join("、");
|
||||
const users = stage.userNames.join("、");
|
||||
if (roles && users) {
|
||||
return `${roles}(${users})`;
|
||||
}
|
||||
return roles || users || "未配置办理人";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.process-stages {
|
||||
:deep(.el-step__title) {
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
:deep(.el-step__description) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Flowable 命名空间 moddle 描述符
|
||||
*
|
||||
* 为 bpmn-js 扩展 flowable:formKey / flowable:assignee 等自定义属性,
|
||||
* 设计器属性面板可读写、XML 序列化保留 flowable: 前缀,供引擎解析
|
||||
*/
|
||||
const flowableModdleDescriptor = {
|
||||
name: "Flowable",
|
||||
uri: "http://flowable.org/bpmn",
|
||||
prefix: "flowable",
|
||||
xml: {
|
||||
tagAlias: "lowerCase",
|
||||
},
|
||||
types: [
|
||||
{
|
||||
name: "FlowableAttributes",
|
||||
isAbstract: true,
|
||||
extends: ["bpmn:FlowNode"],
|
||||
properties: [
|
||||
{ name: "formKey", isAttr: true, type: "String" },
|
||||
{ name: "assignee", isAttr: true, type: "String" },
|
||||
{ name: "candidateUsers", isAttr: true, type: "String" },
|
||||
{ name: "candidateGroups", isAttr: true, type: "String" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default flowableModdleDescriptor;
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="designer-container">
|
||||
<div class="designer-toolbar">
|
||||
<div class="designer-toolbar__left">
|
||||
<el-button circle @click="handleBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<span class="designer-toolbar__title">{{ title }}</span>
|
||||
</div>
|
||||
<div class="designer-toolbar__right">
|
||||
<el-button type="primary" :loading="saving" @click="designerRef?.save()">保 存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="designer-wrapper">
|
||||
<BpmnDesigner ref="designerRef" :title="title" :xml="xml" @save="handleSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowLeft } from "@element-plus/icons-vue";
|
||||
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import router from "@/router";
|
||||
import BpmnDesigner from "./components/BpmnDesigner.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowDesigner",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
/** 模型 ID(设计器始终由列表页"设计"按钮带参进入) */
|
||||
const modelId = computed(() => String(route.query.modelId ?? ""));
|
||||
|
||||
/** 页面标题(列表页携带,如【请假审批】流程设计) */
|
||||
const title = computed(() => String(route.query.title ?? "流程设计"));
|
||||
|
||||
const designerRef = ref<InstanceType<typeof BpmnDesigner>>();
|
||||
|
||||
/** 回显的 BPMN XML(新模型为空串,画布空白可自由拖拽) */
|
||||
const xml = ref("");
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
// 无参进入(直接访问隐藏路由/URL 丢参),设计器无法工作,提示后跳回模型列表
|
||||
if (!modelId.value) {
|
||||
ElMessage.error("缺少模型ID参数,请从流程设计列表进入");
|
||||
router.replace({ name: "WorkflowModel" });
|
||||
return;
|
||||
}
|
||||
loadXml();
|
||||
});
|
||||
|
||||
// 菜单 keep_alive=1 缓存页面,换模型再次进入需按 modelId 重载画布;
|
||||
// 离开页面时 query 置空,跳过重载避免缓存页误报"缺少模型ID参数"
|
||||
watch(modelId, (val) => {
|
||||
if (val) loadXml();
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载模型 BPMN XML(首次进入与切换模型共用)
|
||||
*/
|
||||
async function loadXml(): Promise<void> {
|
||||
if (!modelId.value) {
|
||||
ElMessage.error("缺少模型ID参数");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await WorkflowAPI.model.getXml(modelId.value);
|
||||
xml.value = data.xml ?? "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设计器产出的 BPMN XML
|
||||
*
|
||||
* @param xml 设计器导出的 XML
|
||||
*/
|
||||
async function handleSave(xml: string): Promise<void> {
|
||||
if (!modelId.value) {
|
||||
ElMessage.error("缺少模型ID参数");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await WorkflowAPI.model.saveXml(modelId.value, xml);
|
||||
designerRef.value?.markSaved();
|
||||
ElMessage.success("保存成功");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回流程设计列表(有未保存修改时二次确认)
|
||||
*/
|
||||
async function handleBack(): Promise<void> {
|
||||
if (designerRef.value?.isDirty()) {
|
||||
try {
|
||||
await ElMessageBox.confirm("画布有未保存的修改,离开将丢失,确定返回?", "提示", {
|
||||
confirmButtonText: "确定离开",
|
||||
cancelButtonText: "留在本页",
|
||||
type: "warning",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.designer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// 视口高度减去导航栏、标签栏及容器内边距
|
||||
height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.designer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.designer-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="launch-container">
|
||||
<el-card class="launch-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="launch-card__header">
|
||||
<span class="launch-card__title">发起流程</span>
|
||||
<el-select
|
||||
v-model="selectedId"
|
||||
placeholder="请选择要发起的流程"
|
||||
class="launch-card__select"
|
||||
@change="handleProcessChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="process in processList"
|
||||
:key="process.id"
|
||||
:label="`${process.name}(v${process.version})`"
|
||||
:value="process.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 演示引导:告诉体验者用哪些账号走完整个流程,可关闭 -->
|
||||
<el-alert
|
||||
v-if="demoTipVisible"
|
||||
class="launch-card__demo-tip"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="true"
|
||||
title="工作流演示说明"
|
||||
description="用 employee 发起后,依次切换各环节审批人(dept_manager、manager、finance、clerk)在「审批中心」的待办页签中审批,即可走完流程。数据乱了可在「流程设计」页重置。"
|
||||
@close="demoTipVisible = false"
|
||||
/>
|
||||
|
||||
<!-- 流程实例名称:默认可自动拼接流程-姓名-时间,发起人可改以区分同流程的多次发起 -->
|
||||
<el-form v-if="selectedProcess" class="launch-card__name-form" label-position="top">
|
||||
<el-form-item label="流程名称">
|
||||
<el-input
|
||||
v-model="processName"
|
||||
placeholder="例:报销审批-张三-09月07日"
|
||||
clearable
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
<div v-if="autoNameDirty" class="launch-card__name-reset">
|
||||
<el-link type="primary" :underline="false" @click="resetAutoName">恢复自动命名</el-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 审批流程走向预览:发起前即可见各环节的审批角色与办理人(全部待开始) -->
|
||||
<ProcessStages
|
||||
v-if="stages.length"
|
||||
:stages="stages"
|
||||
:active="-1"
|
||||
class="launch-card__stages"
|
||||
/>
|
||||
|
||||
<!-- 绑定表单的流程:FormRenderer 渲染发起表单,提交即发起 -->
|
||||
<FormRenderer
|
||||
v-if="selectedProcess?.formKey"
|
||||
:title="selectedProcess.name"
|
||||
:rule="rule"
|
||||
:option="option"
|
||||
:loading="loading"
|
||||
:submitted="submitted"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #success>
|
||||
<el-result icon="success" title="流程发起成功">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="refill">继续发起</el-button>
|
||||
<el-button @click="goMyTasks">查看流程进度</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</template>
|
||||
</FormRenderer>
|
||||
|
||||
<!-- 未绑定表单的流程:确认即发起 -->
|
||||
<el-empty v-else-if="selectedProcess" description="该流程未绑定表单,确认后直接发起">
|
||||
<el-button type="primary" :loading="starting" @click="handleDirectStart">
|
||||
发起流程
|
||||
</el-button>
|
||||
</el-empty>
|
||||
|
||||
<el-empty v-else description="请选择要发起的流程" :image-size="80" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import type { ProcessStageItem, StartableProcessItem } from "@/api/workflow";
|
||||
import FormAPI from "@/api/form";
|
||||
import router from "@/router";
|
||||
import { useUserStore } from "@/stores";
|
||||
import FormRenderer from "../dynamic-form/components/FormRenderer.vue";
|
||||
import { useFormRenderer } from "../dynamic-form/composables/useFormRenderer";
|
||||
import ProcessStages from "./components/ProcessStages.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowLaunch",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/** 演示引导提示条可见状态(关闭后本次会话不再显示) */
|
||||
const demoTipVisible = ref(true);
|
||||
|
||||
/** 可发起流程列表 */
|
||||
const processList = ref<StartableProcessItem[]>([]);
|
||||
|
||||
const selectedId = ref("");
|
||||
|
||||
const selectedProcess = computed(() =>
|
||||
processList.value.find((process) => process.id === selectedId.value)
|
||||
);
|
||||
|
||||
/** 审批流程走向预览 */
|
||||
const stages = ref<ProcessStageItem[]>([]);
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
/**
|
||||
* 流程实例名称:默认按"流程-姓名-时间"自动拼接,切换流程或手动修改后重置,
|
||||
* 用户可编辑以获得可读标题(同一流程多次发起也能从列表一眼区分)
|
||||
*/
|
||||
const processName = ref("");
|
||||
const autoNameValue = ref("");
|
||||
/** 名称被手动修改过:区别于自动值,展示"恢复自动命名"入口 */
|
||||
const autoNameDirty = ref(false);
|
||||
|
||||
/**
|
||||
* 生成默认流程名称:流程名-昵称-MM月DD日
|
||||
*
|
||||
* <p>精确到日即可:同日多次发起由列表的"发起时间"列区分,名称保持简洁;特殊情况可手动编辑</p>
|
||||
*/
|
||||
function buildAutoName(): string {
|
||||
const nickname = userStore.userInfo?.nickname || userStore.userInfo?.username || "";
|
||||
const now = new Date();
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
return selectedProcess.value
|
||||
? `${selectedProcess.value.name}-${nickname}-${pad(now.getMonth() + 1)}月${pad(now.getDate())}日`
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换流程后重置自动命名,并回填默认拼接值
|
||||
*/
|
||||
function applyAutoName(): void {
|
||||
autoNameValue.value = buildAutoName();
|
||||
processName.value = autoNameValue.value;
|
||||
autoNameDirty.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户手动编辑名称:与自动值不同视为已修改,清除自动状态
|
||||
*/
|
||||
function handleNameInput(value: string): void {
|
||||
autoNameDirty.value = value !== autoNameValue.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复为自动拼接的默认名称
|
||||
*/
|
||||
function resetAutoName(): void {
|
||||
processName.value = autoNameValue.value;
|
||||
autoNameDirty.value = false;
|
||||
}
|
||||
|
||||
/** 发起表单渲染状态(复用动态表单渲染管线,保证与填写页一致) */
|
||||
const { rule, option, loading, submitted, load, refill } = useFormRenderer(() =>
|
||||
FormAPI.getRender(selectedProcess.value?.formKey ?? "")
|
||||
);
|
||||
|
||||
const starting = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
processList.value = await WorkflowAPI.definition.listStartable();
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换流程时按 formKey 加载发起表单规则与审批走向
|
||||
*
|
||||
* 未绑定表单的流程走确认直发,不加载规则
|
||||
*/
|
||||
async function handleProcessChange(): Promise<void> {
|
||||
submitted.value = false;
|
||||
applyAutoName();
|
||||
if (selectedProcess.value?.formKey) {
|
||||
load();
|
||||
}
|
||||
stages.value = selectedId.value ? await WorkflowAPI.definition.listStages(selectedId.value) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发起表单并发起流程(表单数据即流程变量,驱动网关条件)
|
||||
*
|
||||
* @param data 表单数据(field -> value 映射)
|
||||
*/
|
||||
async function handleSubmit(data: Record<string, unknown>): Promise<void> {
|
||||
await WorkflowAPI.instance.start({
|
||||
processDefinitionId: selectedId.value,
|
||||
name: processName.value,
|
||||
formData: data,
|
||||
});
|
||||
submitted.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未绑定表单流程的直接发起
|
||||
*/
|
||||
async function handleDirectStart(): Promise<void> {
|
||||
starting.value = true;
|
||||
try {
|
||||
await WorkflowAPI.instance.start({
|
||||
processDefinitionId: selectedId.value,
|
||||
name: processName.value,
|
||||
});
|
||||
ElMessage.success("流程发起成功");
|
||||
goMyTasks();
|
||||
} finally {
|
||||
starting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转审批中心「我发起的」页签查看进度
|
||||
*/
|
||||
function goMyTasks(): void {
|
||||
router.push({ name: "WorkflowTask", query: { tab: "mine" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.launch-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.launch-card {
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
&__demo-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__name-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-reset {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__stages {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,532 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
<el-form-item label="关键字" prop="keywords">
|
||||
<el-input
|
||||
v-model="params.keywords"
|
||||
placeholder="流程名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<div class="page-toolbar__left">
|
||||
<el-button
|
||||
v-hasPerm="['workflow:model:create']"
|
||||
type="primary"
|
||||
@click="handleCreateClick"
|
||||
>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['workflow:model:delete']"
|
||||
type="danger"
|
||||
:disabled="!hasSelection"
|
||||
@click="handleDelete()"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['workflow:model:reset']"
|
||||
type="warning"
|
||||
plain
|
||||
:loading="resetting"
|
||||
@click="handleResetDemo"
|
||||
>
|
||||
{{ resetting ? "重置中..." : "重置演示" }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="page-toolbar__right">
|
||||
<el-tooltip content="刷新" placement="top">
|
||||
<el-button class="page-icon-btn" @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="全屏" placement="top">
|
||||
<el-button class="page-icon-btn" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-table-wrapper">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
highlight-current-row
|
||||
:data="list"
|
||||
class="page-table"
|
||||
border
|
||||
height="100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="流程名称" min-width="150">
|
||||
<template #default="scope">
|
||||
<el-link
|
||||
type="primary"
|
||||
:underline="false"
|
||||
title="点击修改名称与描述"
|
||||
@click="handleEditClick(scope.row as WorkflowModelItem)"
|
||||
>
|
||||
{{ (scope.row as WorkflowModelItem).name }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流程标识" prop="key" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="描述" prop="description" min-width="150" show-overflow-tooltip />
|
||||
<!-- 开关即语义:开=可发起,关=不可发起;版本号标在开关左侧 -->
|
||||
<el-table-column label="启用" width="110" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
v-if="!(scope.row as WorkflowModelItem).definitionId"
|
||||
type="info"
|
||||
size="small"
|
||||
>
|
||||
未发布
|
||||
</el-tag>
|
||||
<template v-else>
|
||||
<span class="publish-version">
|
||||
v{{ (scope.row as WorkflowModelItem).publishedVersion }}
|
||||
</span>
|
||||
<el-switch
|
||||
v-if="hasStatePerm"
|
||||
:model-value="!(scope.row as WorkflowModelItem).suspended"
|
||||
:loading="switchingId === (scope.row as WorkflowModelItem).id"
|
||||
:disabled="switchingId !== null"
|
||||
inline-prompt
|
||||
@change="handleToggleState(scope.row as WorkflowModelItem)"
|
||||
/>
|
||||
<el-tag
|
||||
v-else
|
||||
:type="(scope.row as WorkflowModelItem).suspended ? 'info' : 'success'"
|
||||
size="small"
|
||||
>
|
||||
{{ (scope.row as WorkflowModelItem).suspended ? "否" : "是" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" prop="updateTime" width="170" align="center" />
|
||||
<el-table-column fixed="right" label="操作" align="center" width="210">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="openDesigner(scope.row as WorkflowModelItem)"
|
||||
>
|
||||
设计
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['workflow:model:deploy']"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="handleDeploy((scope.row as WorkflowModelItem).id)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
:disabled="!(scope.row as WorkflowModelItem).definitionId"
|
||||
@click.stop="openDiagram(scope.row as WorkflowModelItem)"
|
||||
>
|
||||
流程图
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['workflow:model:delete']"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="handleDelete((scope.row as WorkflowModelItem).id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<pagination
|
||||
v-if="total > 0"
|
||||
v-model:total="total"
|
||||
v-model:page="params.pageNum"
|
||||
v-model:limit="params.pageSize"
|
||||
@pagination="fetchData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogState.visible"
|
||||
:title="dialogState.title"
|
||||
width="560px"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<el-form ref="modelFormRef" :model="formData" :rules="rules" label-width="90px">
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入流程名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="key">
|
||||
<template #label>
|
||||
<div class="flex-y-center">
|
||||
流程标识
|
||||
<el-tooltip content="创建后不可修改,建议用有业务含义的英文编码" placement="bottom">
|
||||
<el-icon class="ml-1 cursor-pointer">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<el-input
|
||||
v-model="formData.key"
|
||||
:disabled="!!formData.id"
|
||||
placeholder="如 leave_approval"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="流程描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入流程描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="closeDialog">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 流程图弹窗:渲染已发布版本的 BPMN(只读,滚轮缩放) -->
|
||||
<el-dialog
|
||||
v-model="diagramState.visible"
|
||||
:title="diagramState.title"
|
||||
width="960px"
|
||||
append-to-body
|
||||
>
|
||||
<div v-loading="diagramState.loading" class="workflow-diagram">
|
||||
<BpmnViewer v-if="diagramState.xml" :xml="diagramState.xml" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from "@vueuse/core";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { QuestionFilled, Refresh } from "@element-plus/icons-vue";
|
||||
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import type {
|
||||
WorkflowModelFormData,
|
||||
WorkflowModelItem,
|
||||
WorkflowModelQueryParams,
|
||||
} from "@/api/workflow";
|
||||
import router from "@/router";
|
||||
import { hasPerm } from "@/utils/auth";
|
||||
import { usePageTable, useTableSelection } from "@/composables";
|
||||
import BpmnViewer from "./components/BpmnViewer.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowModel",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null);
|
||||
const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
|
||||
|
||||
const queryFormRef = ref<FormInstance>();
|
||||
const modelFormRef = ref<FormInstance>();
|
||||
|
||||
/** 启用开关权限(el-switch 不支持 v-hasPerm 指令移除,改 v-if 控制) */
|
||||
const hasStatePerm = hasPerm("workflow:definition:update");
|
||||
|
||||
/** 分页表格数据管理 */
|
||||
const { loading, list, total, params, fetchData, handleQuery, handleResetQuery } = usePageTable<
|
||||
WorkflowModelItem,
|
||||
WorkflowModelQueryParams
|
||||
>({
|
||||
initialParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
},
|
||||
request: WorkflowAPI.model.getPage,
|
||||
onBeforeReset: () => queryFormRef.value?.resetFields(),
|
||||
});
|
||||
|
||||
const { selectedIds, hasSelection, handleSelectionChange } = useTableSelection<WorkflowModelItem>();
|
||||
|
||||
const dialogState = reactive({
|
||||
title: "",
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const initialFormData: WorkflowModelFormData & { id?: string } = { name: "", key: "" };
|
||||
|
||||
const formData = reactive<WorkflowModelFormData & { id?: string }>({ ...initialFormData });
|
||||
|
||||
const rules: FormRules<WorkflowModelFormData> = {
|
||||
name: [{ required: true, message: "请输入流程名称", trigger: "blur" }],
|
||||
key: [
|
||||
{ required: true, message: "请输入流程标识", trigger: "blur" },
|
||||
{
|
||||
pattern: /^[a-z][a-z0-9_]*$/,
|
||||
message: "须以小写字母开头,仅含小写字母、数字、下划线",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 流程图弹窗状态 */
|
||||
const diagramState = reactive({
|
||||
title: "",
|
||||
visible: false,
|
||||
loading: false,
|
||||
xml: "",
|
||||
});
|
||||
|
||||
/** 正在切换启用状态的流程 ID(该行开关转圈,其余行禁用防并发) */
|
||||
const switchingId = ref<string | null>(null);
|
||||
|
||||
/**
|
||||
* 重置表单数据和验证状态
|
||||
*/
|
||||
function resetForm(): void {
|
||||
modelFormRef.value?.resetFields();
|
||||
modelFormRef.value?.clearValidate();
|
||||
Object.keys(formData).forEach((key) => {
|
||||
delete (formData as Record<string, unknown>)[key];
|
||||
});
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭模型弹窗并清理临时状态
|
||||
*/
|
||||
function closeDialog(): void {
|
||||
dialogState.visible = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开新增流程弹窗
|
||||
*/
|
||||
function handleCreateClick(): void {
|
||||
resetForm();
|
||||
dialogState.title = "新增流程";
|
||||
dialogState.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开编辑流程弹窗并回填数据(列表名称列点击进入)
|
||||
*
|
||||
* @param row 当前流程行
|
||||
*/
|
||||
function handleEditClick(row: WorkflowModelItem): void {
|
||||
resetForm();
|
||||
dialogState.title = "修改流程";
|
||||
Object.assign(formData, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
key: row.key,
|
||||
description: row.description,
|
||||
});
|
||||
dialogState.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并提交模型
|
||||
*/
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await modelFormRef.value?.validate().then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (formData.id) {
|
||||
await WorkflowAPI.model.update(formData.id, formData);
|
||||
ElMessage.success("修改成功");
|
||||
} else {
|
||||
await WorkflowAPI.model.create(formData);
|
||||
ElMessage.success("新增成功");
|
||||
}
|
||||
closeDialog();
|
||||
handleQuery();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布流程(生成新版本,发布后即可发起)
|
||||
*
|
||||
* @param modelId 模型 ID
|
||||
*/
|
||||
async function handleDeploy(modelId: string): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm("发布后即可发起流程,历史版本不受影响,确定发布?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "info",
|
||||
});
|
||||
} catch {
|
||||
ElMessage.info("已取消发布");
|
||||
return;
|
||||
}
|
||||
await WorkflowAPI.model.deploy(modelId);
|
||||
ElMessage.success("发布成功");
|
||||
fetchData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换流程启用状态(关闭后不可发起新流程,运行中的实例不受影响)
|
||||
*
|
||||
* @param row 当前流程行
|
||||
*/
|
||||
async function handleToggleState(row: WorkflowModelItem): Promise<void> {
|
||||
if (!row.definitionId) return;
|
||||
const suspend = !row.suspended;
|
||||
switchingId.value = row.id;
|
||||
try {
|
||||
await WorkflowAPI.definition.updateState(row.definitionId, suspend);
|
||||
ElMessage.success(suspend ? "已关闭,不可发起" : "已开启,可发起");
|
||||
fetchData();
|
||||
} finally {
|
||||
switchingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开已发布版本的流程图弹窗
|
||||
*
|
||||
* @param row 当前流程行
|
||||
*/
|
||||
async function openDiagram(row: WorkflowModelItem): Promise<void> {
|
||||
if (!row.definitionId) {
|
||||
ElMessage.warning("该流程尚未发布,请先发布");
|
||||
return;
|
||||
}
|
||||
diagramState.title = `【${row.name}】流程图`;
|
||||
diagramState.xml = "";
|
||||
diagramState.visible = true;
|
||||
diagramState.loading = true;
|
||||
try {
|
||||
const data = await WorkflowAPI.definition.getXml(row.definitionId);
|
||||
diagramState.xml = data.xml ?? "";
|
||||
} finally {
|
||||
diagramState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置工作流数据(清空所有流程含自建的模型/定义/实例/历史与关联表单数据,重建初始演示流程)
|
||||
*/
|
||||
async function handleResetDemo(): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"将清空所有流程(含用户自建)的模型、发布版本、实例与历史记录,恢复至 4 个初始演示流程。确定重置?",
|
||||
"重置工作流数据",
|
||||
{ confirmButtonText: "重置", cancelButtonText: "取消", type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
ElMessage.info("已取消重置");
|
||||
return;
|
||||
}
|
||||
resetting.value = true;
|
||||
try {
|
||||
await WorkflowAPI.model.resetDemo();
|
||||
ElMessage.success("已重置为初始演示版本");
|
||||
fetchData();
|
||||
} finally {
|
||||
resetting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否正在重置演示流程(重置请求期间按钮转圈并防重复点击) */
|
||||
const resetting = ref(false);
|
||||
|
||||
/**
|
||||
* 跳转到流程设计器页面
|
||||
*
|
||||
* @param row 当前模型行
|
||||
*/
|
||||
function openDesigner(row: WorkflowModelItem): void {
|
||||
router.push({
|
||||
name: "WorkflowDesigner",
|
||||
query: { modelId: row.id, title: `【${row.name}】流程设计` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个或批量流程(级联删除发布版本、实例与历史,设计草稿一并清除)
|
||||
*
|
||||
* @param id 指定时删除单个流程;不指定时删除表格勾选项
|
||||
*/
|
||||
async function handleDelete(id?: string): Promise<void> {
|
||||
const modelIds = id ?? selectedIds.value.join(",");
|
||||
if (!modelIds) {
|
||||
ElMessage.warning("请勾选删除项");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"将删除该流程的设计、已发布版本及全部流程实例数据,不可恢复。确定删除?",
|
||||
"删除流程",
|
||||
{ confirmButtonText: "删除", cancelButtonText: "取消", type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
ElMessage.info("已取消删除");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await WorkflowAPI.model.deleteByIds(modelIds);
|
||||
ElMessage.success("删除成功");
|
||||
handleResetQuery();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-diagram {
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
/* 版本号与开关同行排布 */
|
||||
.publish-version {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
<el-form-item label="关键字" prop="keywords">
|
||||
<el-input
|
||||
v-model="params.keywords"
|
||||
placeholder="任务名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<div class="page-toolbar__left" />
|
||||
<div class="page-toolbar__right">
|
||||
<el-tooltip content="刷新" placement="top">
|
||||
<el-button class="page-icon-btn" @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="全屏" placement="top">
|
||||
<el-button class="page-icon-btn" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-table-wrapper">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
highlight-current-row
|
||||
:data="list"
|
||||
class="page-table"
|
||||
border
|
||||
height="100%"
|
||||
>
|
||||
<!-- 流程名称为主列:各流程首节点名相近(部门主管审批),流程名称才是区分项 -->
|
||||
<el-table-column
|
||||
label="流程名称"
|
||||
prop="processName"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="任务名称" prop="name" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
label="流程实例ID"
|
||||
prop="processInstanceId"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="办理时间" prop="endTime" width="170" align="center" />
|
||||
<el-table-column label="处理意见" prop="comment" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ (scope.row as DoneTaskItem).comment || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column fixed="right" label="操作" align="center" width="90">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="handleDetail((scope.row as DoneTaskItem).processInstanceId)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<pagination
|
||||
v-if="total > 0"
|
||||
v-model:total="total"
|
||||
v-model:page="params.pageNum"
|
||||
v-model:limit="params.pageSize"
|
||||
@pagination="fetchData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 实例详情抽屉:表单数据 / 审批流程(走向+记录融合)/ 流程图 -->
|
||||
<InstanceDetailDrawer ref="detailDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from "@vueuse/core";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import type { DoneTaskItem, WorkflowTaskQueryParams } from "@/api/workflow";
|
||||
import { usePageTable } from "@/composables";
|
||||
import InstanceDetailDrawer from "../../components/InstanceDetailDrawer.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowDoneTab",
|
||||
});
|
||||
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null);
|
||||
const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
|
||||
|
||||
const queryFormRef = ref<FormInstance>();
|
||||
|
||||
/** 分页表格数据管理 */
|
||||
const { loading, list, total, params, fetchData, handleQuery, handleResetQuery } = usePageTable<
|
||||
DoneTaskItem,
|
||||
WorkflowTaskQueryParams
|
||||
>({
|
||||
initialParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
},
|
||||
request: WorkflowAPI.task.getDonePage,
|
||||
onBeforeReset: () => queryFormRef.value?.resetFields(),
|
||||
});
|
||||
|
||||
/** 暴露刷新能力:审批中心页签切换时父组件调用 */
|
||||
defineExpose({ fetchData });
|
||||
|
||||
/** 实例详情抽屉 */
|
||||
const detailDrawerRef = ref<InstanceType<typeof InstanceDetailDrawer>>();
|
||||
|
||||
/**
|
||||
* 打开实例详情抽屉
|
||||
*
|
||||
* @param instanceId 流程实例 ID
|
||||
*/
|
||||
function handleDetail(instanceId: string): void {
|
||||
detailDrawerRef.value?.open(instanceId);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
<el-form-item label="关键字" prop="keywords">
|
||||
<el-input
|
||||
v-model="params.keywords"
|
||||
placeholder="流程名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<div class="page-toolbar__left" />
|
||||
<div class="page-toolbar__right">
|
||||
<el-tooltip content="刷新" placement="top">
|
||||
<el-button class="page-icon-btn" @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="全屏" placement="top">
|
||||
<el-button class="page-icon-btn" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-table-wrapper">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
highlight-current-row
|
||||
:data="list"
|
||||
class="page-table"
|
||||
border
|
||||
height="100%"
|
||||
>
|
||||
<el-table-column
|
||||
label="流程名称"
|
||||
prop="processName"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="流程标识"
|
||||
prop="processDefinitionKey"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusOptions[(scope.row as WorkflowInstanceItem).status].tag">
|
||||
{{ statusOptions[(scope.row as WorkflowInstanceItem).status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发起时间" prop="startTime" width="170" align="center" />
|
||||
<el-table-column label="结束时间" width="170" align="center">
|
||||
<template #default="scope">
|
||||
{{ (scope.row as WorkflowInstanceItem).endTime || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="终止原因"
|
||||
prop="deleteReason"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column fixed="right" label="操作" align="center" width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="openDetail((scope.row as WorkflowInstanceItem).id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="(scope.row as WorkflowInstanceItem).status === 'running'"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="handleCancel((scope.row as WorkflowInstanceItem).id)"
|
||||
>
|
||||
撤销
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="(scope.row as WorkflowInstanceItem).status === 'running'"
|
||||
v-hasPerm="['workflow:instance:terminate']"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="handleTerminate((scope.row as WorkflowInstanceItem).id)"
|
||||
>
|
||||
终止
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<pagination
|
||||
v-if="total > 0"
|
||||
v-model:total="total"
|
||||
v-model:page="params.pageNum"
|
||||
v-model:limit="params.pageSize"
|
||||
@pagination="fetchData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 实例详情抽屉:表单数据 / 审批流程(走向+记录融合)/ 流程图 -->
|
||||
<InstanceDetailDrawer ref="detailDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from "@vueuse/core";
|
||||
import { ElMessage, ElMessageBox, type FormInstance } from "element-plus";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import type {
|
||||
InstanceStatus,
|
||||
WorkflowInstanceItem,
|
||||
WorkflowInstanceQueryParams,
|
||||
} from "@/api/workflow";
|
||||
import { usePageTable } from "@/composables";
|
||||
import InstanceDetailDrawer from "../../components/InstanceDetailDrawer.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowMineTab",
|
||||
});
|
||||
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null);
|
||||
const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
|
||||
|
||||
const queryFormRef = ref<FormInstance>();
|
||||
|
||||
/** 实例状态展示映射(标签文案 + 标签色) */
|
||||
const statusOptions: Record<
|
||||
InstanceStatus,
|
||||
{ label: string; tag: "primary" | "success" | "danger" }
|
||||
> = {
|
||||
running: { label: "运行中", tag: "primary" },
|
||||
finished: { label: "已完成", tag: "success" },
|
||||
terminated: { label: "已终止", tag: "danger" },
|
||||
};
|
||||
|
||||
/** 分页表格数据管理 */
|
||||
const { loading, list, total, params, fetchData, handleQuery, handleResetQuery } = usePageTable<
|
||||
WorkflowInstanceItem,
|
||||
WorkflowInstanceQueryParams
|
||||
>({
|
||||
initialParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
},
|
||||
request: WorkflowAPI.instance.getPage,
|
||||
onBeforeReset: () => queryFormRef.value?.resetFields(),
|
||||
});
|
||||
|
||||
/** 暴露刷新能力:审批中心页签切换时父组件调用 */
|
||||
defineExpose({ fetchData });
|
||||
|
||||
/** 实例详情抽屉 */
|
||||
const detailDrawerRef = ref<InstanceType<typeof InstanceDetailDrawer>>();
|
||||
|
||||
/**
|
||||
* 打开实例详情抽屉
|
||||
*
|
||||
* @param instanceId 流程实例 ID
|
||||
*/
|
||||
function openDetail(instanceId: string): void {
|
||||
detailDrawerRef.value?.open(instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销运行中的流程(仅发起人)
|
||||
*
|
||||
* @param instanceId 流程实例 ID
|
||||
*/
|
||||
async function handleCancel(instanceId: string): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm("撤销后流程终止且不可恢复,确定撤销?", "警告", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
} catch {
|
||||
ElMessage.info("已取消撤销");
|
||||
return;
|
||||
}
|
||||
await WorkflowAPI.instance.cancel(instanceId);
|
||||
ElMessage.success("撤销成功");
|
||||
fetchData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 终止运行中的流程(管理员)
|
||||
*
|
||||
* @param instanceId 流程实例 ID
|
||||
*/
|
||||
async function handleTerminate(instanceId: string): Promise<void> {
|
||||
let reason: string;
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt("请输入终止原因", "警告", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
inputPlaceholder: "终止原因将记录在流程历史中",
|
||||
inputValidator: (input: string) => !!input?.trim() || "请输入终止原因",
|
||||
type: "warning",
|
||||
});
|
||||
reason = value.trim();
|
||||
} catch {
|
||||
ElMessage.info("已取消终止");
|
||||
return;
|
||||
}
|
||||
await WorkflowAPI.instance.terminate(instanceId, reason);
|
||||
ElMessage.success("终止成功");
|
||||
fetchData();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="page-search" shadow="never">
|
||||
<el-form ref="queryFormRef" :model="params" :inline="true">
|
||||
<el-form-item label="关键字" prop="keywords">
|
||||
<el-input
|
||||
v-model="params.keywords"
|
||||
placeholder="任务名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<div class="page-toolbar__left" />
|
||||
<div class="page-toolbar__right">
|
||||
<el-tooltip content="刷新" placement="top">
|
||||
<el-button class="page-icon-btn" @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="全屏" placement="top">
|
||||
<el-button class="page-icon-btn" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-table-wrapper">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
highlight-current-row
|
||||
:data="list"
|
||||
class="page-table"
|
||||
border
|
||||
height="100%"
|
||||
>
|
||||
<!-- 流程名称为主列:各流程首节点名相近(部门主管审批),流程名称才是区分项 -->
|
||||
<el-table-column
|
||||
label="流程名称"
|
||||
prop="processName"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="任务名称" prop="name" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
label="流程实例ID"
|
||||
prop="processInstanceId"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="创建时间" prop="createTime" width="170" align="center" />
|
||||
<el-table-column fixed="right" label="操作" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="openApprove((scope.row as TodoTaskItem).id)"
|
||||
>
|
||||
办理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<pagination
|
||||
v-if="total > 0"
|
||||
v-model:total="total"
|
||||
v-model:page="params.pageNum"
|
||||
v-model:limit="params.pageSize"
|
||||
@pagination="fetchData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 审批办理弹窗:发起表单只读回显 + 审批记录 + 通过/驳回 -->
|
||||
<el-dialog v-model="approveState.visible" :title="approveState.title" width="760px">
|
||||
<el-skeleton v-if="approveState.loading" :rows="6" animated />
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="流程名称">{{ detail.processName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前任务">{{ detail.taskName }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs class="todo-approve__tabs">
|
||||
<el-tab-pane label="申请信息">
|
||||
<FormDetail
|
||||
v-if="detail.formJson"
|
||||
:form-json="detail.formJson"
|
||||
:options-json="detail.optionsJson"
|
||||
:data-json="detail.dataJson"
|
||||
/>
|
||||
<el-empty v-else description="该流程未绑定表单" :image-size="64" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="流程走向">
|
||||
<ProcessStages
|
||||
v-if="detail.stages?.length"
|
||||
:stages="detail.stages"
|
||||
:active="currentStageIndex"
|
||||
/>
|
||||
<el-empty v-else description="无可展示的流程走向" :image-size="64" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="审批记录">
|
||||
<ApprovalTimeline :history="detail.history" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="审批意见">
|
||||
<el-input v-model="comment" type="textarea" :rows="2" placeholder="请输入审批意见" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 驳回模式:展开目标节点选择 -->
|
||||
<el-form-item v-if="rejectMode" label="驳回节点">
|
||||
<el-select v-model="rejectTargetId" placeholder="请选择驳回到的节点" class="!w-full">
|
||||
<el-option
|
||||
v-for="target in rejectTargets"
|
||||
:key="target.activityId"
|
||||
:label="target.activityName"
|
||||
:value="target.activityId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<template v-if="!rejectMode">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="approveState.loading"
|
||||
@click="handleComplete"
|
||||
>
|
||||
通 过
|
||||
</el-button>
|
||||
<el-button type="warning" :disabled="approveState.loading" @click="handleRejectClick">
|
||||
驳 回
|
||||
</el-button>
|
||||
<el-button @click="approveState.visible = false">取 消</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="warning" :loading="submitting" @click="handleReject">
|
||||
确认驳回
|
||||
</el-button>
|
||||
<el-button @click="closeRejectMode">返 回</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from "@vueuse/core";
|
||||
import { ElMessage, type FormInstance } from "element-plus";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
|
||||
import WorkflowAPI from "@/api/workflow";
|
||||
import type {
|
||||
RejectTargetItem,
|
||||
TaskDetailData,
|
||||
TodoTaskItem,
|
||||
WorkflowTaskQueryParams,
|
||||
} from "@/api/workflow";
|
||||
import { usePageTable } from "@/composables";
|
||||
import ApprovalTimeline from "../../components/ApprovalTimeline.vue";
|
||||
import FormDetail from "../../components/FormDetail.vue";
|
||||
import ProcessStages from "../../components/ProcessStages.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowTodoTab",
|
||||
});
|
||||
|
||||
/** 待办数量回传(驱动审批中心页签角标) */
|
||||
const emit = defineEmits<{
|
||||
totalChange: [total: number];
|
||||
}>();
|
||||
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null);
|
||||
const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
|
||||
|
||||
const queryFormRef = ref<FormInstance>();
|
||||
|
||||
/** 分页表格数据管理 */
|
||||
const { loading, list, total, params, fetchData, handleQuery, handleResetQuery } = usePageTable<
|
||||
TodoTaskItem,
|
||||
WorkflowTaskQueryParams
|
||||
>({
|
||||
initialParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keywords: "",
|
||||
},
|
||||
request: WorkflowAPI.task.getTodoPage,
|
||||
onBeforeReset: () => queryFormRef.value?.resetFields(),
|
||||
});
|
||||
|
||||
watch(total, (value) => emit("totalChange", value), { immediate: true });
|
||||
|
||||
/** 暴露刷新能力:审批中心页签切换时父组件调用 */
|
||||
defineExpose({ fetchData });
|
||||
|
||||
/** 办理弹窗状态 */
|
||||
const approveState = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
title: "审批办理",
|
||||
});
|
||||
|
||||
/** 当前任务详情 */
|
||||
const detail = ref<TaskDetailData>();
|
||||
|
||||
/** 审批意见(通过/驳回共用) */
|
||||
const comment = ref("");
|
||||
|
||||
/** 当前任务在流程走向中的位置(节点ID精确匹配,当前环节高亮进行中,之前为已完成;未匹配时不高亮) */
|
||||
const currentStageIndex = computed(() => {
|
||||
const stages = detail.value?.stages;
|
||||
if (!stages?.length || !detail.value?.taskDefinitionKey) {
|
||||
return 0;
|
||||
}
|
||||
return stages.findIndex((stage) => stage.nodeId === detail.value?.taskDefinitionKey);
|
||||
});
|
||||
|
||||
/** 驳回模式状态 */
|
||||
const rejectMode = ref(false);
|
||||
const rejectTargets = ref<RejectTargetItem[]>([]);
|
||||
const rejectTargetId = ref("");
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
/**
|
||||
* 打开办理弹窗并加载任务详情
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
async function openApprove(taskId: string): Promise<void> {
|
||||
approveState.visible = true;
|
||||
approveState.loading = true;
|
||||
closeRejectMode();
|
||||
comment.value = "";
|
||||
try {
|
||||
detail.value = await WorkflowAPI.task.getDetail(taskId);
|
||||
approveState.title = `【${detail.value.taskName}】审批办理`;
|
||||
} finally {
|
||||
approveState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切入驳回模式并加载可驳回的历史节点
|
||||
*/
|
||||
async function handleRejectClick(): Promise<void> {
|
||||
rejectTargets.value = await WorkflowAPI.task.listRejectTargets(detail.value!.taskId);
|
||||
if (rejectTargets.value.length === 0) {
|
||||
ElMessage.warning("当前任务无可驳回的历史节点");
|
||||
return;
|
||||
}
|
||||
rejectTargetId.value = rejectTargets.value[0].activityId;
|
||||
rejectMode.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出驳回模式(保留已填意见)
|
||||
*/
|
||||
function closeRejectMode(): void {
|
||||
rejectMode.value = false;
|
||||
rejectTargets.value = [];
|
||||
rejectTargetId.value = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批通过
|
||||
*/
|
||||
async function handleComplete(): Promise<void> {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await WorkflowAPI.task.complete(detail.value!.taskId, { comment: comment.value });
|
||||
ElMessage.success("审批通过");
|
||||
approveState.visible = false;
|
||||
fetchData();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回到指定历史节点
|
||||
*/
|
||||
async function handleReject(): Promise<void> {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await WorkflowAPI.task.reject(detail.value!.taskId, {
|
||||
targetActivityId: rejectTargetId.value,
|
||||
comment: comment.value,
|
||||
});
|
||||
ElMessage.success("驳回成功");
|
||||
approveState.visible = false;
|
||||
fetchData();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.todo-approve {
|
||||
&__tabs {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 审批中心:待办/已办/我发起的为个人视角三类数据(钉钉审批中心模式),页签聚合免跳页 -->
|
||||
<!-- 切换页签即时刷新对应列表(办理/发起后切页签即见最新数据) -->
|
||||
<el-tabs v-model="activeTab" class="workflow-task__tabs" @tab-change="handleTabChange">
|
||||
<el-tab-pane name="todo">
|
||||
<template #label>
|
||||
<span class="workflow-task__label">
|
||||
待办
|
||||
<el-badge v-if="todoTotal > 0" :value="todoTotal" :max="99" />
|
||||
</span>
|
||||
</template>
|
||||
<TodoTab ref="todoTabRef" class="workflow-task__panel" @total-change="todoTotal = $event" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="done" label="已办" lazy>
|
||||
<DoneTab ref="doneTabRef" class="workflow-task__panel" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="mine" label="我发起的" lazy>
|
||||
<MineTab ref="mineTabRef" class="workflow-task__panel" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import TodoTab from "./components/TodoTab.vue";
|
||||
import DoneTab from "./components/DoneTab.vue";
|
||||
import MineTab from "./components/MineTab.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowTask",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
/** 当前页签;发起成功跳转携带 ?tab=mine 直达「我发起的」查看进度 */
|
||||
const activeTab = ref((route.query.tab as string) || "todo");
|
||||
|
||||
/** 待办数量(TodoTab 查询后回传,驱动页签角标) */
|
||||
const todoTotal = ref(0);
|
||||
|
||||
/** 各页签组件引用(调用其暴露的刷新能力) */
|
||||
const todoTabRef = ref<InstanceType<typeof TodoTab>>();
|
||||
const doneTabRef = ref<InstanceType<typeof DoneTab>>();
|
||||
const mineTabRef = ref<InstanceType<typeof MineTab>>();
|
||||
|
||||
/**
|
||||
* 页签切换刷新对应列表
|
||||
*
|
||||
* @description lazy 页签首次激活时 ref 尚未就绪,由子组件 onMounted 自行首查,此处跳过不重复请求;
|
||||
* 再次切换时调用子组件刷新,保证办理/发起后切回页签即见最新数据
|
||||
*/
|
||||
function handleTabChange(name: string | number): void {
|
||||
if (name === "todo") {
|
||||
todoTabRef.value?.fetchData();
|
||||
} else if (name === "done") {
|
||||
doneTabRef.value?.fetchData();
|
||||
} else if (name === "mine") {
|
||||
mineTabRef.value?.fetchData();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-task {
|
||||
/* 页签栏卡片化,与页面搜索/内容卡片视觉一致 */
|
||||
&__tabs {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
padding: 0 16px;
|
||||
margin-bottom: var(--page-gap);
|
||||
background: var(--content-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 页签面板:搜索卡片 + 内容卡片的纵向布局(类注入子组件根节点) */
|
||||
&__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--page-gap);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user