feat(projects): 新增抄送、下一审批人提交功能,优化组件通用性

This commit is contained in:
AN
2025-07-13 22:07:40 +08:00
parent 3a506df9b9
commit 523aca6b75
7 changed files with 377 additions and 49 deletions

View File

@ -7,19 +7,31 @@ interface Props {
type?: NaiveUI.ThemeColor;
size?: 'small' | 'medium' | 'large';
placeholder?: string;
closable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
size: 'small',
placeholder: '无'
placeholder: '无',
closable: false
});
interface Emits {
(e: 'close', index?: number): void;
}
const emit = defineEmits<Emits>();
// 统一解析 value 成数组
const tags = computed(() => {
if (!props.value) return [];
return Array.isArray(props.value) ? props.value : props.value.split(',');
});
function handleClose(index?: number) {
emit('close', index);
}
</script>
<template>
@ -30,7 +42,7 @@ const tags = computed(() => {
</template>
<template v-else-if="tags.length === 1">
<NTag :type="type" :size="size">
<NTag :type="type" :size="size" :closable="closable" @close="handleClose(0)">
{{ tags[0] }}
</NTag>
</template>
@ -41,7 +53,14 @@ const tags = computed(() => {
<NTag :type="type" :size="size" class="cursor-pointer">{{ tags[0] }}...({{ tags.length }})</NTag>
</template>
<NSpace vertical size="small">
<NTag v-for="tag in tags" :key="tag" :type="type" :size="size">
<NTag
v-for="(tag, index) in tags"
:key="index"
:type="type"
:size="size"
:closable="closable"
@close="handleClose(index)"
>
{{ tag }}
</NTag>
</NSpace>

View File

@ -19,16 +19,20 @@ interface Props {
multiple?: boolean;
/** 禁选用户ID */
disabledIds?: CommonType.IdType[];
rowKeys?: CommonType.IdType[];
searchUserIds?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
title: '用户选择',
multiple: false,
disabledIds: () => []
disabledIds: () => [],
rowKeys: () => [],
searchUserIds: null
});
interface Emits {
(e: 'confirm', value: CommonType.IdType[]): void;
(e: 'confirm', value: CommonType.IdType[], rows?: Api.System.User[]): void;
}
const emit = defineEmits<Emits>();
@ -128,6 +132,23 @@ const {
const { checkedRowKeys } = useTableOperate(data, getData);
// 存储所有页面的用户数据,用于跨页选择
const allPagesData = ref<Api.System.User[]>([]);
// 更新allPagesData保存当前页数据
function updateAllPagesData() {
// 将当前页数据添加到allPagesData中避免重复
data.value.forEach(user => {
const existIndex = allPagesData.value.findIndex(item => item.userId === user.userId);
if (existIndex === -1) {
allPagesData.value.push(user);
} else {
// 更新已存在的数据
allPagesData.value[existIndex] = user;
}
});
}
const { loading: treeLoading, startLoading: startTreeLoading, endLoading: endTreeLoading } = useLoading();
const deptPattern = ref<string>();
const deptData = ref<Api.Common.CommonTreeRecord>([]);
@ -165,38 +186,64 @@ function handleResetSearch() {
}
function closeModal() {
checkedRowKeys.value = [];
allPagesData.value = [];
visible.value = false;
}
function handleConfirm() {
emit('confirm', checkedRowKeys.value);
// 获取选中行对应的用户对象(从所有页面数据中筛选)
const selectedUsers = allPagesData.value.filter(item => checkedRowKeys.value.includes(item.userId.toString()));
emit('confirm', checkedRowKeys.value, selectedUsers);
closeModal();
}
function getRowProps(row: Api.System.User) {
return {
onClick: () => {
onClick: (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.n-data-table-td--selection')) {
return;
}
if (props.disabledIds.includes(row.userId.toString())) {
return;
}
// 将userId转为字符串
const userId = row.userId.toString();
if (props.multiple) {
const index = checkedRowKeys.value.findIndex(key => key === row.userId);
const index = checkedRowKeys.value.findIndex(key => key === userId);
if (index > -1) {
checkedRowKeys.value.splice(index, 1);
} else {
checkedRowKeys.value.push(row.userId);
checkedRowKeys.value.push(userId);
}
} else {
checkedRowKeys.value = [row.userId];
checkedRowKeys.value = [userId];
}
}
};
}
// 监听数据变化(页面切换时)
watch(
data,
() => {
updateAllPagesData();
},
{ deep: true }
);
watch(visible, () => {
if (visible.value) {
getTreeData();
getData();
if (props.searchUserIds) {
searchParams.userIds = props.searchUserIds;
}
allPagesData.value = [];
getDataByPage();
checkedRowKeys.value = [...props.rowKeys];
}
});
</script>
@ -273,7 +320,7 @@ watch(visible, () => {
:loading="loading"
:row-props="getRowProps"
remote
:row-key="row => row.userId"
:row-key="row => row.userId.toString()"
:pagination="mobilePagination"
class="h-full lt-sm:max-h-300px"
/>

View File

@ -64,6 +64,7 @@ function handleTransferConfirm(ids: CommonType.IdType[]) {
return;
}
model.userId = ids[0];
model.taskId = props.taskId;
window.$dialog?.warning({
title: '提示',
content: '是否确认转办?',
@ -107,6 +108,7 @@ function handleAddSignatureConfirm(ids: CommonType.IdType[]) {
}
function handleTerminate() {
terminateModel.taskId = props.taskId;
window.$dialog?.warning({
title: '提示',
content: '是否确认中止?',

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { useBoolean, useLoading } from '@sa/hooks';
import { messageTypeOptions } from '@/constants/workflow';
import { fetchCompleteTask, fetchGetTask } from '@/service/api/workflow';
import { fetchCompleteTask, fetchGetTask, fetchGetkNextNode } from '@/service/api/workflow';
import FileUpload from '@/components/custom/file-upload.vue';
defineOptions({
@ -27,6 +28,10 @@ const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { loading: baseFormLoading, startLoading: startBaseFormLoading, endLoading: endBaseFormLoading } = useLoading();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
const { bool: copyVisible, setTrue: openCopyModal } = useBoolean();
const { bool: assigneeVisible, setTrue: openAssigneeModal } = useBoolean();
const title = defineModel<string>('title', {
default: '流程发起'
});
@ -45,65 +50,263 @@ function createDefaultModel(): Model {
fileId: null,
flowCopyList: [],
messageType: ['1'],
taskVariables: {},
variables: {},
assigneeMap: {}
taskVariables: null,
variables: null,
assigneeMap: null
};
}
const fileList = ref<UploadFileInfo[]>([]);
// 抄送人
const selectCopyUserList = ref<Api.System.User[]>([]);
// 抄送人id
const selectCopyUserIds = ref<CommonType.IdType[]>([]);
// 下一节点列表
const nestNodeList = ref<Api.Workflow.FlowNode[]>([]);
const nickNameMap = ref<{ [key: string]: string }>({});
const assigneeSearchUserIds = ref<string | null>(null);
const selectAssigneeIds = ref<string[]>([]);
// 节点编码
const nodeCode = ref<string>('');
// 按钮权限
interface ButtonPerm {
pop: boolean;
trust: boolean;
transfer: boolean;
addSign: boolean;
subSign: boolean;
termination: boolean;
back: boolean;
copy: boolean;
}
const buttonPerm = reactive<ButtonPerm>(createDefaultButtonPerm());
function createDefaultButtonPerm(): ButtonPerm {
return {
pop: false,
trust: false,
transfer: false,
addSign: false,
subSign: false,
termination: false,
back: false,
copy: false
};
}
async function getTask() {
const { error, data } = await fetchGetTask(props.taskId);
if (error) return;
task.value = data;
function initDefault() {
selectCopyUserList.value = [];
selectCopyUserIds.value = [];
nickNameMap.value = {};
assigneeSearchUserIds.value = null;
selectAssigneeIds.value = [];
nodeCode.value = '';
Object.assign(model, createDefaultModel());
Object.assign(buttonPerm, createDefaultButtonPerm());
}
const fileList = ref<UploadFileInfo[]>([]);
async function getTask() {
startBtnLoading();
startBaseFormLoading();
const { error, data } = await fetchGetTask(props.taskId);
if (error) {
endBtnLoading();
endBaseFormLoading();
return;
}
task.value = data;
task.value.buttonList.forEach(item => {
buttonPerm[item.code as keyof ButtonPerm] = !item.show;
});
endBtnLoading();
const { error: nextNodeError, data: nextNodeData } = await fetchGetkNextNode({
taskId: props.taskId,
taskVariables: props.taskVariables
});
if (nextNodeError) {
endBaseFormLoading();
return;
}
nestNodeList.value = nextNodeData;
endBaseFormLoading();
}
async function handleSubmit() {
if (buttonPerm.pop && nestNodeList.value?.length) {
const hasEmptyAssignee = nestNodeList.value.some(e => !model.assigneeMap || !model.assigneeMap[e.nodeCode]);
if (hasEmptyAssignee) {
window.$message?.error('请选择审批人!');
return;
}
} else {
model.assigneeMap = {};
}
if (selectCopyUserList.value?.length) {
model.flowCopyList = selectCopyUserList.value.map(e => ({
userId: e.userId,
userName: e.nickName
}));
}
if (fileList.value?.length) {
const fileIds = fileList.value.map(item => item.id);
model.fileId = fileIds.join(',');
}
model.taskId = props.taskId;
model.taskVariables = props.taskVariables;
const { error } = await fetchCompleteTask(model);
if (error) return;
window.$message?.success('提交成功');
visible.value = false;
emit('finished');
model.variables = props.taskVariables;
startBtnLoading();
startBaseFormLoading();
try {
const { error } = await fetchCompleteTask(model);
if (error) return;
window.$message?.success('提交成功');
visible.value = false;
emit('finished');
} catch (error) {
window.$message?.error(`提交失败,请稍后重试,${error}`);
} finally {
endBtnLoading();
endBaseFormLoading();
}
}
function handleCopyConfirm(userIds: CommonType.IdType[], users?: Api.System.User[]) {
selectCopyUserList.value = users || [];
selectCopyUserIds.value = userIds;
}
function handleAssigneeOpen(item: Api.Workflow.FlowNode) {
if (!item.permissionFlag) {
window.$message?.error('没有可选人员,请联系管理员!');
return;
}
assigneeSearchUserIds.value = item.permissionFlag;
nodeCode.value = item.nodeCode;
selectAssigneeIds.value = model.assigneeMap?.[item.nodeCode]?.split(',') || [];
openAssigneeModal();
}
function handleAssigneeConfirm(userIds: CommonType.IdType[], users?: Api.System.User[]) {
// 更新当前节点的审批人
if (!model.assigneeMap) model.assigneeMap = {};
model.assigneeMap[nodeCode.value] = userIds.join(',');
nickNameMap.value[nodeCode.value] = users?.map(item => item.nickName).join(',') || '';
}
function handleCopyTagClose(index?: number) {
if (index !== undefined) {
// 删除指定索引的用户
selectCopyUserIds.value = selectCopyUserIds.value.filter((_, i) => i !== index);
selectCopyUserList.value = selectCopyUserList.value.filter((_, i) => i !== index);
} else {
// 清空所有用户
selectCopyUserList.value = [];
selectCopyUserIds.value = [];
model.flowCopyList = [];
}
}
function handleAssigneeTagClose(code: string, index?: number) {
if (!model.assigneeMap?.[code]) return;
// 获取当前节点的用户ID列表和名称列表
const userIds = model.assigneeMap[code].split(',');
const nickNames = nickNameMap.value[code]?.split(',') || [];
if (index !== undefined) {
// 删除指定索引的用户
// 使用filter方式移除指定索引的元素
const newUserIds = userIds.filter((_, i) => i !== index);
const newNickNames = nickNames.filter((_, i) => i !== index);
// 更新数据
model.assigneeMap[code] = newUserIds.join(',');
nickNameMap.value[code] = newNickNames.join(',');
} else {
// 清空所有用户
model.assigneeMap[code] = '';
nickNameMap.value[code] = '';
}
}
watch(visible, () => {
if (visible.value) {
initDefault();
getTask();
Object.assign(model, createDefaultModel());
}
});
</script>
<template>
<NModal v-model:show="visible" preset="card" class="w-700px" :title="title" :native-scrollbar="false" closable>
<NForm :model="model">
<NFormItem label="通知方式" path="messageType">
<NCheckboxGroup v-model:value="model.messageType">
<NSpace item-style="display: flex;">
<NCheckbox
v-for="item in messageTypeOptions"
:key="item.value"
:disabled="item.value === '1'"
:value="item.value"
:label="item.label"
<NModal v-model:show="visible" preset="card" class="w-800px" :title="title" :native-scrollbar="false" closable>
<NSpin :show="baseFormLoading">
<NForm :model="model" label-placement="left" :label-width="100">
<NFormItem label="通知方式" path="messageType">
<NCheckboxGroup v-model:value="model.messageType">
<NSpace item-style="display: flex;">
<NCheckbox
v-for="item in messageTypeOptions"
:key="item.value"
:disabled="item.value === '1'"
:value="item.value"
:label="item.label"
/>
</NSpace>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="附件" path="fileId">
<FileUpload v-model:file-list="fileList" :file-size="20" :max="20" upload-type="file" :accept="accept" />
</NFormItem>
<NFormItem v-if="buttonPerm.copy" label="抄送人员">
<NSpace>
<NButton ghost type="primary" @click="openCopyModal">选择抄送人员</NButton>
<GroupTag
size="large"
:value="selectCopyUserList.map(item => item.nickName)"
:closable="true"
@close="handleCopyTagClose"
/>
</NSpace>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="附件" path="fileId">
<FileUpload v-model:file-list="fileList" :file-size="20" :max="20" upload-type="file" :accept="accept" />
</NFormItem>
</NForm>
<div class="flex justify-end gap-12px">
<NButton @click="visible = false">取消</NButton>
<NButton type="primary" @click="handleSubmit">提交</NButton>
</div>
</NFormItem>
<NFormItem
v-if="buttonPerm.pop && nestNodeList && nestNodeList.length > 0"
label="下一步审批人"
path="assigneeMap"
>
<NSpace>
<div v-for="(item, index) in nestNodeList" :key="index">
<span>{{ item.nodeName }}</span>
<NSpace>
<NButton ghost type="primary" @click="handleAssigneeOpen(item)">选择审批人员</NButton>
<NInput v-if="false" v-model:value="model.assigneeMap![item.nodeCode]" />
<GroupTag
size="large"
:value="nickNameMap[item.nodeCode]"
:closable="true"
@close="index => handleAssigneeTagClose(item.nodeCode, index)"
/>
</NSpace>
</div>
</NSpace>
</NFormItem>
<NFormItem v-if="task?.flowStatus === 'waiting'" label="审批意见" path="message">
<NInput v-model:value="model.message" type="textarea" />
</NFormItem>
</NForm>
<div class="flex justify-end gap-12px">
<NButton @click="visible = false">取消</NButton>
<NButton :loading="btnLoading" type="primary" @click="handleSubmit">提交</NButton>
</div>
</NSpin>
</NModal>
<UserSelectModal v-model:visible="copyVisible" :row-keys="selectCopyUserIds" multiple @confirm="handleCopyConfirm" />
<UserSelectModal
v-model:visible="assigneeVisible"
:row-keys="selectAssigneeIds"
:search-user-ids="assigneeSearchUserIds"
multiple
@confirm="handleAssigneeConfirm"
/>
</template>

View File

@ -17,6 +17,15 @@ export function fetchGetTask(taskId: CommonType.IdType) {
});
}
/** 获取任务下一个节点 */
export function fetchGetkNextNode(data: Api.Workflow.TaskNextNodeSearchParams) {
return request<Api.Workflow.FlowNodeList>({
url: '/workflow/task/getNextNodeList',
method: 'post',
data
});
}
/** 完成任务 */
export function fetchCompleteTask(data: Api.Workflow.CompleteTaskOperateParams) {
return request<Api.Workflow.Task>({

View File

@ -128,6 +128,7 @@ declare namespace Api {
type UserSearchParams = CommonType.RecordNullable<
Pick<User, 'deptId' | 'userName' | 'nickName' | 'phonenumber' | 'status'> & {
roleId: CommonType.IdType;
userIds: string;
} & Common.CommonSearchParams
>;

View File

@ -428,6 +428,10 @@ declare namespace Api {
createByIds: CommonType.IdType[];
}
>;
type TaskNextNodeSearchParams = CommonType.RecordNullable<{
taskId: CommonType.IdType;
taskVariables: { [key: string]: any };
}>;
/** 消息类型 */
type MessageType = '1' | '2' | '3';
@ -455,5 +459,48 @@ declare namespace Api {
/** 扩展字段 */
ext: string;
}>;
/** 工作流节点 */
type FlowNode = Common.CommonRecord<{
/** 节点ID */
id: CommonType.IdType;
/** 删除标志 */
delFlag: string;
/** 节点类型0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关 */
nodeType: WorkflowNodeType;
/** 流程定义ID */
definitionId: CommonType.IdType;
/** 节点编码 */
nodeCode: string;
/** 节点名称 */
nodeName: string;
/** 权限标识 */
permissionFlag: string;
/** 流程签署比例值 */
nodeRatio: string;
/** 节点坐标 */
coordinate: string;
/** 流程版本号 */
version: string;
/** 是否允许任意节点跳转 */
anyNodeSkip: string;
/** 监听器类型 */
listenerType: string;
/** 监听器路径 */
listenerPath: string;
/** 处理器类型 */
handlerType: string;
/** 处理器路径 */
handlerPath: string;
/** 审批表单是否自定义Y是 N否 */
formCustom: Api.Common.YesOrNoStatus;
/** 审批表单路径 */
formPath: string;
/** 扩展字段 */
ext: string;
}>;
/** 工作流节点列表 */
type FlowNodeList = FlowNode[];
}
}