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

View File

@ -19,16 +19,20 @@ interface Props {
multiple?: boolean; multiple?: boolean;
/** 禁选用户ID */ /** 禁选用户ID */
disabledIds?: CommonType.IdType[]; disabledIds?: CommonType.IdType[];
rowKeys?: CommonType.IdType[];
searchUserIds?: string | null;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: '用户选择', title: '用户选择',
multiple: false, multiple: false,
disabledIds: () => [] disabledIds: () => [],
rowKeys: () => [],
searchUserIds: null
}); });
interface Emits { interface Emits {
(e: 'confirm', value: CommonType.IdType[]): void; (e: 'confirm', value: CommonType.IdType[], rows?: Api.System.User[]): void;
} }
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
@ -128,6 +132,23 @@ const {
const { checkedRowKeys } = useTableOperate(data, getData); 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 { loading: treeLoading, startLoading: startTreeLoading, endLoading: endTreeLoading } = useLoading();
const deptPattern = ref<string>(); const deptPattern = ref<string>();
const deptData = ref<Api.Common.CommonTreeRecord>([]); const deptData = ref<Api.Common.CommonTreeRecord>([]);
@ -165,38 +186,64 @@ function handleResetSearch() {
} }
function closeModal() { function closeModal() {
checkedRowKeys.value = [];
allPagesData.value = [];
visible.value = false; visible.value = false;
} }
function handleConfirm() { function handleConfirm() {
emit('confirm', checkedRowKeys.value); //
const selectedUsers = allPagesData.value.filter(item => checkedRowKeys.value.includes(item.userId.toString()));
emit('confirm', checkedRowKeys.value, selectedUsers);
closeModal(); closeModal();
} }
function getRowProps(row: Api.System.User) { function getRowProps(row: Api.System.User) {
return { 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())) { if (props.disabledIds.includes(row.userId.toString())) {
return; return;
} }
// userId
const userId = row.userId.toString();
if (props.multiple) { if (props.multiple) {
const index = checkedRowKeys.value.findIndex(key => key === row.userId); const index = checkedRowKeys.value.findIndex(key => key === userId);
if (index > -1) { if (index > -1) {
checkedRowKeys.value.splice(index, 1); checkedRowKeys.value.splice(index, 1);
} else { } else {
checkedRowKeys.value.push(row.userId); checkedRowKeys.value.push(userId);
} }
} else { } else {
checkedRowKeys.value = [row.userId]; checkedRowKeys.value = [userId];
} }
} }
}; };
} }
//
watch(
data,
() => {
updateAllPagesData();
},
{ deep: true }
);
watch(visible, () => { watch(visible, () => {
if (visible.value) { if (visible.value) {
getTreeData(); getTreeData();
getData(); if (props.searchUserIds) {
searchParams.userIds = props.searchUserIds;
}
allPagesData.value = [];
getDataByPage();
checkedRowKeys.value = [...props.rowKeys];
} }
}); });
</script> </script>
@ -273,7 +320,7 @@ watch(visible, () => {
:loading="loading" :loading="loading"
:row-props="getRowProps" :row-props="getRowProps"
remote remote
:row-key="row => row.userId" :row-key="row => row.userId.toString()"
:pagination="mobilePagination" :pagination="mobilePagination"
class="h-full lt-sm:max-h-300px" class="h-full lt-sm:max-h-300px"
/> />

View File

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

View File

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from 'vue'; import { reactive, ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui'; import type { UploadFileInfo } from 'naive-ui';
import { useBoolean, useLoading } from '@sa/hooks';
import { messageTypeOptions } from '@/constants/workflow'; 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'; import FileUpload from '@/components/custom/file-upload.vue';
defineOptions({ defineOptions({
@ -27,6 +28,10 @@ const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { const visible = defineModel<boolean>('visible', {
default: false 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', { const title = defineModel<string>('title', {
default: '流程发起' default: '流程发起'
}); });
@ -45,65 +50,263 @@ function createDefaultModel(): Model {
fileId: null, fileId: null,
flowCopyList: [], flowCopyList: [],
messageType: ['1'], messageType: ['1'],
taskVariables: {}, taskVariables: null,
variables: {}, variables: null,
assigneeMap: {} 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() { function initDefault() {
const { error, data } = await fetchGetTask(props.taskId); selectCopyUserList.value = [];
if (error) return; selectCopyUserIds.value = [];
task.value = data; 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() { 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) { if (fileList.value?.length) {
const fileIds = fileList.value.map(item => item.id); const fileIds = fileList.value.map(item => item.id);
model.fileId = fileIds.join(','); model.fileId = fileIds.join(',');
} }
model.taskId = props.taskId; model.taskId = props.taskId;
model.taskVariables = props.taskVariables; model.variables = props.taskVariables;
const { error } = await fetchCompleteTask(model); startBtnLoading();
if (error) return; startBaseFormLoading();
window.$message?.success('提交成功'); try {
visible.value = false; const { error } = await fetchCompleteTask(model);
emit('finished'); 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, () => { watch(visible, () => {
if (visible.value) { if (visible.value) {
initDefault();
getTask(); getTask();
Object.assign(model, createDefaultModel());
} }
}); });
</script> </script>
<template> <template>
<NModal v-model:show="visible" preset="card" class="w-700px" :title="title" :native-scrollbar="false" closable> <NModal v-model:show="visible" preset="card" class="w-800px" :title="title" :native-scrollbar="false" closable>
<NForm :model="model"> <NSpin :show="baseFormLoading">
<NFormItem label="通知方式" path="messageType"> <NForm :model="model" label-placement="left" :label-width="100">
<NCheckboxGroup v-model:value="model.messageType"> <NFormItem label="通知方式" path="messageType">
<NSpace item-style="display: flex;"> <NCheckboxGroup v-model:value="model.messageType">
<NCheckbox <NSpace item-style="display: flex;">
v-for="item in messageTypeOptions" <NCheckbox
:key="item.value" v-for="item in messageTypeOptions"
:disabled="item.value === '1'" :key="item.value"
:value="item.value" :disabled="item.value === '1'"
:label="item.label" :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> </NSpace>
</NCheckboxGroup> </NFormItem>
</NFormItem> <NFormItem
<NFormItem label="附件" path="fileId"> v-if="buttonPerm.pop && nestNodeList && nestNodeList.length > 0"
<FileUpload v-model:file-list="fileList" :file-size="20" :max="20" upload-type="file" :accept="accept" /> label="下一步审批人"
</NFormItem> path="assigneeMap"
</NForm> >
<div class="flex justify-end gap-12px"> <NSpace>
<NButton @click="visible = false">取消</NButton> <div v-for="(item, index) in nestNodeList" :key="index">
<NButton type="primary" @click="handleSubmit">提交</NButton> <span>{{ item.nodeName }}</span>
</div> <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> </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> </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) { export function fetchCompleteTask(data: Api.Workflow.CompleteTaskOperateParams) {
return request<Api.Workflow.Task>({ return request<Api.Workflow.Task>({

View File

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

View File

@ -428,6 +428,10 @@ declare namespace Api {
createByIds: CommonType.IdType[]; createByIds: CommonType.IdType[];
} }
>; >;
type TaskNextNodeSearchParams = CommonType.RecordNullable<{
taskId: CommonType.IdType;
taskVariables: { [key: string]: any };
}>;
/** 消息类型 */ /** 消息类型 */
type MessageType = '1' | '2' | '3'; type MessageType = '1' | '2' | '3';
@ -455,5 +459,48 @@ declare namespace Api {
/** 扩展字段 */ /** 扩展字段 */
ext: string; 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[];
} }
} }