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,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>