新增MES审批流设计功能,包括审批流定义、审批实例管理及审批办理接口,支持可视化设计与业务单据联动,提升审批流程的灵活性与用户体验。

This commit is contained in:
geht
2026-05-29 15:49:10 +08:00
parent 94132ea8da
commit aefa44b8a9
48 changed files with 5603 additions and 261 deletions

View File

@@ -0,0 +1,113 @@
<!--
全局审批流程设计悬浮按钮
拥有 approval:flow:design 权限的用户在任意功能页点击即可
1后端按当前页路由反查绑定的业务表
2解析该表字段识别校对/审核/审批/分发/抄送等阶段字段不存在不报错
3进入可视化设计器可点选识别到的阶段字段按顺序生成审批流程并保存发布
@author GHT
@date 2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮
-->
<template>
<div v-if="show" class="approval-design-float" :style="floatStyle">
<div class="approval-design-btn" :class="{ 'is-loading': loading }" title="审批流程设计" @click="openDesigner">
<Icon :icon="loading ? 'ant-design:loading-outlined' : 'ant-design:partition-outlined'" :size="20" :spin="loading" />
<span class="approval-design-text">流程设计</span>
</div>
<!-- 流程设计器全屏 -->
<FlowDesign @register="registerDesign" @success="onSuccess" />
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { usePermission } from '/@/hooks/web/usePermission';
import { useModal } from '/@/components/Modal';
import { getApprovalDesignContext } from '/@/views/approval/flow/approvalFlow.api';
import FlowDesign from '/@/views/approval/flow/components/FlowDesign.vue';
defineOptions({ name: 'ApprovalDesignFloat' });
const { createMessage } = useMessage();
const { currentRoute } = useRouter();
const { hasPermission } = usePermission();
const [registerDesign, { openModal: openDesign }] = useModal();
const loading = ref(false);
// 悬浮位置(位于「发起审批」按钮上方)
const floatStyle = reactive({ right: '24px', bottom: '190px' });
// 仅拥有设计权限的用户可见
const show = computed(() => hasPermission('approval:flow:design'));
function normalizePath(p?: string) {
return (p || '').trim().replace(/\/+$/, '');
}
async function openDesigner() {
if (loading.value) return;
const path = normalizePath(currentRoute.value?.path);
if (!path) return;
try {
loading.value = true;
const ctx: any = await getApprovalDesignContext(path);
if (!ctx || !ctx.bizTable || !ctx.flow) {
createMessage.info('当前页面未能识别到可绑定的业务单据,无法设计审批流程');
return;
}
openDesign(true, {
record: ctx.flow,
readonly: false,
paletteStages: ctx.stages || [],
});
} catch (e: any) {
createMessage.error(e?.message || '获取设计上下文失败');
} finally {
loading.value = false;
}
}
function onSuccess() {
createMessage.success('审批流程已保存');
}
</script>
<style lang="less" scoped>
.approval-design-float {
position: fixed;
z-index: 999;
.approval-design-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #15bca3, #0e9e88);
color: #fff;
cursor: pointer;
box-shadow: 0 4px 14px rgba(21, 188, 163, 0.45);
transition: transform 0.18s, box-shadow 0.18s;
user-select: none;
.approval-design-text {
font-size: 11px;
line-height: 1;
transform: scale(0.92);
}
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(21, 188, 163, 0.6);
}
&.is-loading {
cursor: progress;
}
}
}
</style>

View File

@@ -0,0 +1,283 @@
<!--
全局发起审批悬浮按钮
仅在设计并发布了审批流且能匹配到对应功能页路由的页面显示
支持两种发起方式
1列表多选联动在列表勾选数据后点击弹窗自动带入选中单据并可批量发起
2手动选择未勾选时在弹窗内搜索选择单条单据发起
@author GHT
@date 2026-05-29 forQH-MES审批流设计发起审批运行时
-->
<template>
<div v-if="show" class="approval-float" :style="floatStyle">
<div class="approval-float-btn" title="发起审批" @click="openModal">
<Icon icon="ant-design:audit-outlined" :size="20" />
<span class="approval-float-text">发起审批</span>
</div>
<a-modal v-model:open="visible" title="发起审批" :width="540" :confirmLoading="loading" okText="发起审批" @ok="handleLaunch">
<a-form layout="vertical" style="margin-top: 8px">
<a-form-item label="单据类型(审批流)" required>
<a-select v-model:value="flowId" placeholder="请选择审批流" :options="flowOptions" @change="onFlowChange" allowClear />
</a-form-item>
<!-- 批量模式直接展示列表勾选的单据 -->
<a-form-item v-if="isBatch" :label="`已选单据(共 ${batchItems.length} 条)`" required>
<div class="approval-float-batch">
<div v-for="it in batchItems" :key="it.bizDataId" class="approval-float-batch-item">
<Icon icon="ant-design:file-text-outlined" :size="14" />
<span class="approval-float-batch-title">{{ it.bizTitle }}</span>
</div>
</div>
</a-form-item>
<!-- 手动模式搜索选择单条单据 -->
<a-form-item v-else label="选择单据" required>
<a-select
v-model:value="bizDataId"
show-search
placeholder="请选择需要发起审批的单据"
:filter-option="false"
:options="recordOptions"
:disabled="!flowId"
@search="onSearch"
@change="onRecordChange"
>
<template #notFoundContent>
<a-spin v-if="recordLoading" size="small" />
<span v-else>无单据数据</span>
</template>
</a-select>
<div v-if="flowId && !recordOptions.length && !recordLoading" class="approval-float-tip">
该单据暂无数据或审批流未配置单据标题字段
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { debounce } from 'lodash-es';
import { useMessage } from '/@/hooks/web/useMessage';
import { getPublishedFlows, getBizRecords, launchApproval, launchApprovalBatch } from '/@/views/approval/flow/launch.api';
import { useApprovalSelection } from './useApprovalSelection';
defineOptions({ name: 'ApprovalLaunchFloat' });
const { createMessage } = useMessage();
const { currentRoute } = useRouter();
const approvalSelection = useApprovalSelection();
const visible = ref(false);
const loading = ref(false);
const recordLoading = ref(false);
const flowId = ref<string>();
const bizDataId = ref<string>();
const bizTitle = ref<string>('');
const flowList = ref<any[]>([]);
const recordList = ref<any[]>([]);
// 打开弹窗时快照的列表勾选行(批量模式数据源)
const batchRows = ref<any[]>([]);
// 悬浮位置(右下角)
const floatStyle = reactive({ right: '24px', bottom: '120px' });
function normalizePath(p?: string) {
return (p || '').trim().replace(/\/+$/, '');
}
// 当前路由匹配到的已发布审批流
const matchedFlows = computed(() => {
const cur = normalizePath(currentRoute.value?.path);
if (!cur) return [];
return flowList.value.filter((f) => f.routePath && normalizePath(f.routePath) === cur);
});
const show = computed(() => matchedFlows.value.length > 0);
const flowOptions = computed(() =>
matchedFlows.value.map((f) => ({
label: `${f.flowName}${f.bizTableName || f.bizTable}`,
value: f.id,
}))
);
// 当前选中的审批流对象
const currentFlow = computed(() => matchedFlows.value.find((f) => f.id === flowId.value));
// 是否批量模式(列表有勾选)
const isBatch = computed(() => batchRows.value.length > 0);
// 批量模式下的单据项(用审批流的标题字段取展示标题)
const batchItems = computed(() => {
const titleField = currentFlow.value?.titleField;
return batchRows.value
.filter((r) => r && r.id != null)
.map((r) => ({
bizDataId: String(r.id),
bizTitle: titleField && r[titleField] != null ? String(r[titleField]) : String(r.id),
}));
});
const recordOptions = computed(() =>
recordList.value.map((r) => ({
label: r.title ?? r.id,
value: r.id,
}))
);
onMounted(loadFlows);
async function loadFlows() {
try {
flowList.value = (await getPublishedFlows()) || [];
} catch {
flowList.value = [];
}
}
function resetSelection() {
flowId.value = undefined;
bizDataId.value = undefined;
bizTitle.value = '';
recordList.value = [];
batchRows.value = [];
}
async function openModal() {
visible.value = true;
resetSelection();
// 读取当前列表页的勾选行
batchRows.value = approvalSelection.getRowsByPath(currentRoute.value?.path || '');
// 当前页只匹配到一个审批流时自动选中
if (matchedFlows.value.length === 1) {
flowId.value = matchedFlows.value[0].id;
}
// 手动模式且已确定审批流时预加载单据列表
if (!isBatch.value && flowId.value) {
await loadRecords();
}
}
async function loadRecords(keyword?: string) {
if (!flowId.value) return;
try {
recordLoading.value = true;
recordList.value = (await getBizRecords({ flowId: flowId.value, keyword })) || [];
} finally {
recordLoading.value = false;
}
}
function onFlowChange() {
bizDataId.value = undefined;
bizTitle.value = '';
recordList.value = [];
if (!isBatch.value && flowId.value) loadRecords();
}
const onSearch = debounce((val: string) => {
loadRecords(val);
}, 350);
function onRecordChange() {
const hit = recordList.value.find((r) => r.id === bizDataId.value);
bizTitle.value = hit ? hit.title ?? hit.id : '';
}
async function handleLaunch() {
if (!flowId.value) {
createMessage.warning('请选择审批流');
return;
}
try {
loading.value = true;
if (isBatch.value) {
await launchApprovalBatch({ flowId: flowId.value, items: batchItems.value });
createMessage.success(`已发起 ${batchItems.value.length} 条审批!`);
} else {
if (!bizDataId.value) {
createMessage.warning('请选择需要发起审批的单据');
return;
}
await launchApproval({ flowId: flowId.value, bizDataId: bizDataId.value, bizTitle: bizTitle.value });
createMessage.success('发起成功!');
}
visible.value = false;
} finally {
loading.value = false;
}
}
</script>
<style lang="less" scoped>
.approval-float {
position: fixed;
z-index: 999;
.approval-float-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #3296fa, #1668dc);
color: #fff;
cursor: pointer;
box-shadow: 0 4px 14px rgba(50, 150, 250, 0.45);
transition: transform 0.18s, box-shadow 0.18s;
user-select: none;
.approval-float-text {
font-size: 11px;
line-height: 1;
transform: scale(0.92);
}
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(50, 150, 250, 0.6);
}
}
}
.approval-float-batch {
max-height: 220px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 6px 8px;
.approval-float-batch-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 2px;
font-size: 13px;
border-bottom: 1px dashed #f0f0f0;
&:last-child {
border-bottom: none;
}
.approval-float-batch-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.approval-float-tip {
margin-top: 6px;
font-size: 12px;
color: #faad14;
}
</style>

View File

@@ -0,0 +1,33 @@
import { ref } from 'vue';
/**
* 审批发起-列表选中上下文(全局单例)
* 由 useListPage 自动把当前列表页的选中行同步进来,
* 全局「发起审批」悬浮按钮发起时直接读取,实现"列表多选 -> 发起弹窗"联动。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批支持列表多选联动
*/
// 当前选中的行记录
const rows = ref<any[]>([]);
// 选中来源页面路由,用于校验与当前页是否一致,避免跨页串数据
const sourcePath = ref<string>('');
export function useApprovalSelection() {
function setSelection(list: any[], path: string) {
rows.value = Array.isArray(list) ? [...list] : [];
sourcePath.value = path || '';
}
function clear() {
rows.value = [];
sourcePath.value = '';
}
/** 获取与指定路由匹配的选中行(不匹配返回空) */
function getRowsByPath(path: string): any[] {
return sourcePath.value === path ? rows.value : [];
}
return { rows, sourcePath, setSelection, clear, getRowsByPath };
}