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:
Ray.Hao committed 2026-09-08 10:34:15 +08:00
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;
+156
View File
@@ -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>
+284
View File
@@ -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>
+532
View File
@@ -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>
+115
View File
@@ -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>