refactor: 升级框架补充

This commit is contained in:
caiyuchao
2025-07-09 11:37:50 +08:00
parent 258c0e2934
commit cb726c6172
88 changed files with 31053 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const data = `
{
"code": 0,
"message": "success",
"data": [
{
"id": 123456789012345678901234567890123456789012345678901234567890,
"name": "John Doe",
"age": 30,
"email": "john-doe@demo.com"
},
{
"id": 987654321098765432109876543210987654321098765432109876543210,
"name": "Jane Smith",
"age": 25,
"email": "jane@demo.com"
}
]
}
`;
setHeader(event, 'Content-Type', 'application/json');
return data;
});

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { Item } from './ui/typing';
import { onMounted, onUnmounted, ref } from 'vue';
import { Tinyflow as TinyflowNative } from './ui/index';
import './ui/index.css';
const props = defineProps<{
className?: string;
data?: Record<string, any>;
provider?: {
internal?: () => Item[] | Promise<Item[]>;
knowledge?: () => Item[] | Promise<Item[]>;
llm?: () => Item[] | Promise<Item[]>;
};
style?: Record<string, string>;
}>();
const divRef = ref<HTMLDivElement | null>(null);
let tinyflow: null | TinyflowNative = null;
// 定义默认的 provider 方法
const defaultProvider = {
llm: () => [] as Item[],
knowledge: () => [] as Item[],
internal: () => [] as Item[],
};
onMounted(() => {
if (divRef.value) {
// 合并默认 provider 和传入的 props.provider
const mergedProvider = {
...defaultProvider,
...props.provider,
};
tinyflow = new TinyflowNative({
element: divRef.value as Element,
data: props.data || {},
provider: mergedProvider,
});
}
});
onUnmounted(() => {
if (tinyflow) {
tinyflow.destroy();
tinyflow = null;
}
});
const getData = () => {
if (tinyflow) {
return tinyflow.getData();
}
console.warn('Tinyflow instance is not initialized');
return null;
};
defineExpose({
getData,
});
</script>
<template>
<div
ref="divRef"
class="tinyflow"
:class="[className]"
:style="style"
style="height: 100%"
></div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as Tinyflow } from './tinyflow.vue';
export * from './ui/typing';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,68 @@
export interface Item {
children?: Item[];
label: string;
value: number | string;
}
export interface Position {
x: number;
y: number;
}
export interface Viewport {
x: number;
y: number;
zoom: number;
}
export interface Node {
data?: Record<string, any>;
draggable?: boolean;
height?: number;
id: string;
position: Position;
selected?: boolean;
type?: string;
width?: number;
}
export interface Edge {
animated?: boolean;
id: string;
label?: string;
source: string;
target: string;
type?: string;
}
export type TinyflowData = Partial<{
edges: Edge[];
nodes: Node[];
viewport: Viewport;
}>;
export interface TinyflowOptions {
data?: TinyflowData;
element: Element | string;
provider?: {
internal?: () => Item[] | Promise<Item[]>;
knowledge?: () => Item[] | Promise<Item[]>;
llm?: () => Item[] | Promise<Item[]>;
};
}
export declare class Tinyflow {
private _init;
private _setOptions;
private options;
private rootEl;
private svelteFlowInstance;
constructor(options: TinyflowOptions);
destroy(): void;
getData(): {
edges: Edge[];
nodes: Node[];
viewport: Viewport;
};
getOptions(): TinyflowOptions;
setData(data: TinyflowData): void;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as CronTab } from './cron-tab.vue';

View File

@@ -0,0 +1,3 @@
export { default as MarkdownView } from './markdown-view.vue';
export * from './typing';

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import type { MarkdownViewProps } from './typing';
import { computed, onMounted, ref } from 'vue';
import { MarkdownIt } from '@vben/plugins/markmap';
import { useClipboard } from '@vueuse/core';
import { message } from 'ant-design-vue';
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.min.css';
// 定义组件属性
const props = defineProps<MarkdownViewProps>();
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
const contentRef = ref<HTMLElement | null>(null);
const md = new MarkdownIt({
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`;
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>`;
} catch {}
}
return ``;
},
});
/** 渲染 markdown */
const renderedMarkdown = computed(() => {
return md.render(props.content);
});
/** 初始化 */
onMounted(async () => {
// 添加 copy 监听
contentRef.value?.addEventListener('click', (e: any) => {
if (e.target.id === 'copy') {
copy(e.target?.dataset?.copy);
message.success('复制成功!');
}
});
});
</script>
<template>
<div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
</template>
<style lang="scss">
.markdown-view {
max-width: 100%;
font-family: 'PingFang SC';
font-size: 0.95rem;
font-weight: 400;
line-height: 1.6rem;
color: #3b3e55;
text-align: left;
letter-spacing: 0;
pre {
position: relative;
}
pre code.hljs {
width: auto;
}
code.hljs {
width: auto;
padding-top: 20px;
border-radius: 6px;
@media screen and (min-width: 1536px) {
width: 960px;
}
@media screen and (max-width: 1536px) and (min-width: 1024px) {
width: calc(100vw - 400px - 64px - 32px * 2);
}
@media screen and (max-width: 1024px) and (min-width: 768px) {
width: calc(100vw - 32px * 2);
}
@media screen and (max-width: 768px) {
width: calc(100vw - 16px * 2);
}
}
p,
code.hljs {
margin-bottom: 16px;
}
p {
//margin-bottom: 1rem !important;
margin: 0;
margin-bottom: 3px;
}
/* 标题通用格式 */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 24px 0 8px;
font-weight: 600;
color: #3b3e55;
}
h1 {
font-size: 22px;
line-height: 32px;
}
h2 {
font-size: 20px;
line-height: 30px;
}
h3 {
font-size: 18px;
line-height: 28px;
}
h4 {
font-size: 16px;
line-height: 26px;
}
h5 {
font-size: 16px;
line-height: 24px;
}
h6 {
font-size: 16px;
line-height: 24px;
}
/* 列表(有序,无序) */
ul,
ol {
padding: 0;
margin: 0 0 8px;
font-size: 16px;
line-height: 24px;
color: #3b3e55; // var(--color-CG600);
}
li {
margin: 4px 0 0 20px;
margin-bottom: 1rem;
}
ol > li {
margin-bottom: 1rem;
list-style-type: decimal;
// 表达式,修复有序列表序号展示不全的问题
// &:nth-child(n + 10) {
// margin-left: 30px;
// }
// &:nth-child(n + 100) {
// margin-left: 30px;
// }
}
ul > li {
margin-right: 11px;
margin-bottom: 1rem;
font-size: 16px;
line-height: 24px;
color: #3b3e55; // var(--color-G900);
list-style-type: disc;
}
ol ul,
ol ul > li,
ul ul,
ul ul li {
margin-bottom: 1rem;
margin-left: 6px;
// list-style: circle;
font-size: 16px;
list-style: none;
}
ul ul ul,
ul ul ul li,
ol ol,
ol ol > li,
ol ul ul,
ol ul ul > li,
ul ol,
ul ol > li {
list-style: square;
}
}
</style>

View File

@@ -0,0 +1,3 @@
export type MarkdownViewProps = {
content: string;
};

View File

@@ -0,0 +1,9 @@
import { defineAsyncComponent } from 'vue';
export const AsyncOperateLog = defineAsyncComponent(
() => import('./operate-log.vue'),
);
export { default as OperateLog } from './operate-log.vue';
export type { OperateLogProps } from './typing';

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { OperateLogProps } from './typing';
import { formatDateTime } from '@vben/utils';
import { Tag, Timeline } from 'ant-design-vue';
import { DICT_TYPE, getDictLabel, getDictObj } from '#/utils';
defineOptions({ name: 'OperateLogV2' });
withDefaults(defineProps<OperateLogProps>(), {
logList: () => [],
});
function getUserTypeColor(userType: number) {
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
switch (dict?.colorType) {
case 'danger': {
return '#F56C6C';
}
case 'info': {
return '#909399';
}
case 'success': {
return '#67C23A';
}
case 'warning': {
return '#E6A23C';
}
}
return '#409EFF';
}
</script>
<template>
<div>
<Timeline>
<Timeline.Item
v-for="log in logList"
:key="log.id"
:color="getUserTypeColor(log.userType)"
>
<template #dot>
<p
:style="{ backgroundColor: getUserTypeColor(log.userType) }"
class="absolute left--1 flex h-5 w-5 items-center justify-center rounded-full text-xs text-white"
>
{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
</p>
</template>
<p>{{ formatDateTime(log.createTime) }}</p>
<p>
<Tag :color="getUserTypeColor(log.userType)">
{{ log.userName }}
</Tag>
{{ log.action }}
</p>
</Timeline.Item>
</Timeline>
</div>
</template>

View File

@@ -0,0 +1,5 @@
import type { SystemOperateLogApi } from '#/api/system/operate-log';
export interface OperateLogProps {
logList: SystemOperateLogApi.OperateLog[]; // 操作日志列表
}

View File

@@ -0,0 +1,143 @@
// TODO @芋艿:是否有更好的组织形式?!
<script lang="ts" setup>
import type { DataNode } from 'ant-design-vue/es/tree';
import type { SystemDeptApi } from '#/api/system/dept';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { Card, Col, Row, Tree } from 'ant-design-vue';
import { getSimpleDeptList } from '#/api/system/dept';
defineOptions({ name: 'DeptSelectModal' });
const props = withDefaults(
defineProps<{
// 取消按钮文本
cancelText?: string;
// checkable 状态下节点选择完全受控
checkStrictly?: boolean;
// 确认按钮文本
confirmText?: string;
// 是否支持多选
multiple?: boolean;
// 标题
title?: string;
}>(),
{
cancelText: '取消',
checkStrictly: false,
confirmText: '确认',
multiple: true,
title: '部门选择',
},
);
const emit = defineEmits<{
confirm: [deptList: SystemDeptApi.Dept[]];
}>();
type checkedKeys = number[] | { checked: number[]; halfChecked: number[] };
// 部门树形结构
const deptTree = ref<DataNode[]>([]);
// 选中的部门 ID 列表
const selectedDeptIds = ref<checkedKeys>([]);
// 部门数据
const deptData = ref<SystemDeptApi.Dept[]>([]);
// 对话框配置
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
// 获取选中的部门ID
const selectedIds: number[] = Array.isArray(selectedDeptIds.value)
? selectedDeptIds.value
: selectedDeptIds.value.checked || [];
const deptArray = deptData.value.filter((dept) =>
selectedIds.includes(dept.id!),
);
emit('confirm', deptArray);
// 关闭并提示
await modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
deptTree.value = [];
selectedDeptIds.value = [];
return;
}
// 加载数据
const data = modalApi.getData();
if (!data) {
return;
}
modalApi.lock();
try {
deptData.value = await getSimpleDeptList();
deptTree.value = handleTree(deptData.value) as DataNode[];
// // 设置已选择的部门
if (data.selectedList?.length) {
const selectedIds = data.selectedList
.map((dept: SystemDeptApi.Dept) => dept.id)
.filter((id: number) => id !== undefined);
selectedDeptIds.value = props.checkStrictly
? {
checked: selectedIds,
halfChecked: [],
}
: selectedIds;
}
} finally {
modalApi.unlock();
}
},
destroyOnClose: true,
});
/** 处理选中状态变化 */
function handleCheck() {
if (!props.multiple) {
// 单选模式下,只保留最后选择的节点
if (Array.isArray(selectedDeptIds.value)) {
const lastSelectedId =
selectedDeptIds.value[selectedDeptIds.value.length - 1];
if (lastSelectedId) {
selectedDeptIds.value = [lastSelectedId];
}
} else {
// checkStrictly 为 true 时selectedDeptIds 是一个对象
const checked = selectedDeptIds.value.checked || [];
if (checked.length > 0) {
const lastSelectedId = checked[checked.length - 1];
selectedDeptIds.value = {
checked: [lastSelectedId!],
halfChecked: [],
};
}
}
}
}
</script>
<template>
<Modal :title="title" key="dept-select-modal" class="w-2/5">
<Row class="h-full">
<Col :span="24">
<Card class="h-full">
<Tree
:tree-data="deptTree"
v-if="deptTree.length > 0"
v-model:checked-keys="selectedDeptIds"
:checkable="true"
:check-strictly="checkStrictly"
:field-names="{ title: 'name', key: 'id' }"
:default-expand-all="true"
@check="handleCheck"
/>
</Card>
</Col>
</Row>
</Modal>
</template>

View File

@@ -0,0 +1,2 @@
export { default as DeptSelectModal } from './dept-select-modal.vue';
export { default as UserSelectModal } from './user-select-modal.vue';

View File

@@ -0,0 +1,548 @@
<script lang="ts" setup>
// TODO @芋艿:是否有更好的组织形式?!
// TODO @xingyu你感觉这个放到每个 system、infra 模块下,然后新建一个 components表示每个模块有一些共享的组件然后全局只放通用的无业务含义的可以哇
import type { Key } from 'ant-design-vue/es/table/interface';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
Button,
Col,
Input,
message,
Pagination,
Row,
Transfer,
Tree,
} from 'ant-design-vue';
import { getSimpleDeptList } from '#/api/system/dept';
import { getUserPage } from '#/api/system/user';
// 部门树节点接口
interface DeptTreeNode {
key: string;
title: string;
children?: DeptTreeNode[];
name: string;
}
defineOptions({ name: 'UserSelectModal' });
withDefaults(
defineProps<{
cancelText?: string;
confirmText?: string;
multiple?: boolean;
title?: string;
value?: number[];
}>(),
{
title: '选择用户',
multiple: true,
value: () => [],
confirmText: '确定',
cancelText: '取消',
},
);
const emit = defineEmits<{
cancel: [];
closed: [];
confirm: [value: SystemUserApi.User[]];
'update:value': [value: number[]];
}>();
// 部门树数据
const deptTree = ref<any[]>([]);
const deptList = ref<SystemDeptApi.Dept[]>([]);
const expandedKeys = ref<Key[]>([]);
const selectedDeptId = ref<number>();
const deptSearchKeys = ref('');
// 用户数据管理
const userList = ref<SystemUserApi.User[]>([]); // 存储所有已知用户
const selectedUserIds = ref<string[]>([]);
// 弹窗配置
const [Modal, modalApi] = useVbenModal({
onCancel: handleCancel,
onClosed: handleClosed,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetData();
return;
}
// 加载数据
const data = modalApi.getData();
if (!data) {
return;
}
modalApi.lock();
try {
// 加载部门数据
const deptData = await getSimpleDeptList();
deptList.value = deptData;
const treeData = handleTree(deptData);
deptTree.value = treeData.map((node) => processDeptNode(node));
expandedKeys.value = deptTree.value.map((node) => node.key);
// 加载初始用户数据
await loadUserData(1, leftListState.value.pagination.pageSize);
// 设置已选用户
if (data.userIds?.length) {
selectedUserIds.value = data.userIds.map(String);
// 加载已选用户的完整信息 TODO 目前接口暂不支持 多个用户ID 查询, 需要后端支持
const { list } = await getUserPage({
pageNo: 1,
pageSize: 100, // 临时使用固定值确保能加载所有已选用户
userIds: data.userIds,
});
// 使用 Map 来去重,以用户 ID 为 key
const userMap = new Map(userList.value.map((user) => [user.id, user]));
list.forEach((user) => {
if (!userMap.has(user.id)) {
userMap.set(user.id, user);
}
});
userList.value = [...userMap.values()];
updateRightListData();
}
modalApi.open();
} finally {
modalApi.unlock();
}
},
destroyOnClose: true,
});
// 左侧列表状态
const leftListState = ref({
searchValue: '',
dataSource: [] as SystemUserApi.User[],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
});
// 右侧列表状态
const rightListState = ref({
searchValue: '',
dataSource: [] as SystemUserApi.User[],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
});
// 计算属性Transfer 数据源
const transferDataSource = computed(() => {
return [
...leftListState.value.dataSource,
...rightListState.value.dataSource,
];
});
// 过滤部门树数据
const filteredDeptTree = computed(() => {
if (!deptSearchKeys.value) return deptTree.value;
const filterNode = (node: any, depth = 0): any => {
// 添加深度限制,防止过深的递归导致爆栈
if (depth > 100) return null;
// 按部门名称搜索
const name = node?.name?.toLowerCase();
const search = deptSearchKeys.value.toLowerCase();
// 如果当前节点匹配,直接返回节点,不处理子节点
if (name?.includes(search)) {
return {
...node,
children: node.children,
};
}
// 如果当前节点不匹配,检查子节点
if (node.children) {
const filteredChildren = node.children
.map((child: any) => filterNode(child, depth + 1))
.filter(Boolean);
if (filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
};
}
}
return null;
};
return deptTree.value.map((node: any) => filterNode(node)).filter(Boolean);
});
// 加载用户数据
async function loadUserData(pageNo: number, pageSize: number) {
try {
const { list, total } = await getUserPage({
pageNo,
pageSize,
deptId: selectedDeptId.value,
username: leftListState.value.searchValue || undefined,
});
leftListState.value.dataSource = list;
leftListState.value.pagination.total = total;
leftListState.value.pagination.current = pageNo;
leftListState.value.pagination.pageSize = pageSize;
// 更新用户列表缓存
const newUsers = list.filter(
(user) => !userList.value.some((u) => u.id === user.id),
);
if (newUsers.length > 0) {
userList.value.push(...newUsers);
}
} finally {
//
}
}
// 更新右侧列表数据
function updateRightListData() {
// 使用 Set 来去重选中的用户ID
const uniqueSelectedIds = new Set(selectedUserIds.value);
// 获取选中的用户,确保不重复
const selectedUsers = userList.value.filter((user) =>
uniqueSelectedIds.has(String(user.id)),
);
// 应用搜索过滤
const filteredUsers = rightListState.value.searchValue
? selectedUsers.filter((user) =>
user.nickname
.toLowerCase()
.includes(rightListState.value.searchValue.toLowerCase()),
)
: selectedUsers;
// 更新总数(使用 Set 确保唯一性)
rightListState.value.pagination.total = new Set(
filteredUsers.map((user) => user.id),
).size;
// 应用分页
const { current, pageSize } = rightListState.value.pagination;
const startIndex = (current - 1) * pageSize;
const endIndex = startIndex + pageSize;
rightListState.value.dataSource = filteredUsers.slice(startIndex, endIndex);
}
// 处理左侧分页变化
async function handleLeftPaginationChange(page: number, pageSize: number) {
await loadUserData(page, pageSize);
}
// 处理右侧分页变化
function handleRightPaginationChange(page: number, pageSize: number) {
rightListState.value.pagination.current = page;
rightListState.value.pagination.pageSize = pageSize;
updateRightListData();
}
// 处理用户搜索
async function handleUserSearch(direction: string, value: string) {
if (direction === 'left') {
leftListState.value.searchValue = value;
leftListState.value.pagination.current = 1;
await loadUserData(1, leftListState.value.pagination.pageSize);
} else {
rightListState.value.searchValue = value;
rightListState.value.pagination.current = 1;
updateRightListData();
}
}
// 处理用户选择变化
function handleUserChange(targetKeys: string[]) {
// 使用 Set 来去重选中的用户ID
selectedUserIds.value = [...new Set(targetKeys)];
emit('update:value', selectedUserIds.value.map(Number));
updateRightListData();
}
// 重置数据
function resetData() {
userList.value = [];
selectedUserIds.value = [];
// 取消部门选中
selectedDeptId.value = undefined;
// 取消选中的用户
selectedUserIds.value = [];
leftListState.value = {
searchValue: '',
dataSource: [],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
};
rightListState.value = {
searchValue: '',
dataSource: [],
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
};
}
// TODO 后端接口目前仅支持 username 检索, 筛选条件需要跟后端请求参数保持一致。
function filterOption(inputValue: string, option: any) {
return option.username.toLowerCase().includes(inputValue.toLowerCase());
}
// 处理部门树展开/折叠
function handleExpand(keys: Key[]) {
expandedKeys.value = keys;
}
// 处理部门搜索
function handleDeptSearch(value: string) {
deptSearchKeys.value = value;
// 如果有搜索结果,自动展开所有节点
if (value) {
const getAllKeys = (nodes: any[]): string[] => {
const keys: string[] = [];
for (const node of nodes) {
keys.push(node.key);
if (node.children) {
keys.push(...getAllKeys(node.children));
}
}
return keys;
};
expandedKeys.value = getAllKeys(deptTree.value);
} else {
// 清空搜索时,只展开第一级节点
expandedKeys.value = deptTree.value.map((node) => node.key);
}
}
// 处理部门选择
async function handleDeptSelect(selectedKeys: Key[], _info: any) {
// 更新选中的部门ID
const newDeptId =
selectedKeys.length > 0 ? Number(selectedKeys[0]) : undefined;
selectedDeptId.value =
newDeptId === selectedDeptId.value ? undefined : newDeptId;
// 重置分页并加载数据
const { pageSize } = leftListState.value.pagination;
leftListState.value.pagination.current = 1;
await loadUserData(1, pageSize);
}
// 确认选择
function handleConfirm() {
if (selectedUserIds.value.length === 0) {
message.warning('请选择用户');
return;
}
emit(
'confirm',
userList.value.filter((user) =>
selectedUserIds.value.includes(String(user.id)),
),
);
modalApi.close();
}
// 取消选择
function handleCancel() {
emit('cancel');
modalApi.close();
// 确保在动画结束后再重置数据
setTimeout(() => {
resetData();
}, 300);
}
// 关闭弹窗
function handleClosed() {
emit('closed');
resetData();
}
// 递归处理部门树节点
function processDeptNode(node: any): DeptTreeNode {
return {
key: String(node.id),
title: `${node.name} (${node.id})`,
name: node.name,
children: node.children?.map((child: any) => processDeptNode(child)),
};
}
</script>
<template>
<Modal class="w-2/5" key="user-select-modal" :title="title">
<Row :gutter="[16, 16]">
<Col :span="6">
<div class="h-[500px] overflow-auto rounded border">
<div class="border-b p-2">
<Input
v-model:value="deptSearchKeys"
placeholder="搜索部门"
allow-clear
@input="(e) => handleDeptSearch(e.target?.value ?? '')"
/>
</div>
<Tree
:tree-data="filteredDeptTree"
:expanded-keys="expandedKeys"
:selected-keys="selectedDeptId ? [String(selectedDeptId)] : []"
@select="handleDeptSelect"
@expand="handleExpand"
/>
</div>
</Col>
<Col :span="18">
<Transfer
:row-key="(record) => String(record.id)"
:data-source="transferDataSource"
v-model:target-keys="selectedUserIds"
:titles="['未选', '已选']"
:show-search="true"
:show-select-all="true"
:filter-option="filterOption"
@change="handleUserChange"
@search="handleUserSearch"
>
<template #render="item">
<span>{{ item?.nickname }} ({{ item?.username }})</span>
</template>
<template #footer="{ direction }">
<div v-if="direction === 'left'">
<Pagination
v-model:current="leftListState.pagination.current"
v-model:page-size="leftListState.pagination.pageSize"
:total="leftListState.pagination.total"
:show-size-changer="true"
:show-total="(total) => `${total}`"
size="small"
@change="handleLeftPaginationChange"
/>
</div>
<div v-if="direction === 'right'">
<Pagination
v-model:current="rightListState.pagination.current"
v-model:page-size="rightListState.pagination.pageSize"
:total="rightListState.pagination.total"
:show-size-changer="true"
:show-total="(total) => `${total}`"
size="small"
@change="handleRightPaginationChange"
/>
</div>
</template>
</Transfer>
</Col>
</Row>
<template #footer>
<Button
type="primary"
:disabled="selectedUserIds.length === 0"
@click="handleConfirm"
>
{{ confirmText }}
</Button>
<Button @click="handleCancel">{{ cancelText }}</Button>
</template>
</Modal>
</template>
<style lang="scss" scoped>
:deep(.ant-transfer) {
display: flex;
align-items: center;
justify-content: space-between;
height: 500px;
}
:deep(.ant-transfer-list) {
display: flex;
flex: 1;
flex-direction: column;
width: 300px !important;
height: 100%;
}
:deep(.ant-transfer-list-header) {
flex-shrink: 0;
}
:deep(.ant-transfer-list-search) {
flex-shrink: 0;
padding: 8px;
}
:deep(.ant-transfer-list-body) {
flex: 1;
overflow: auto;
}
:deep(.ant-transfer-list-content) {
height: auto !important;
}
:deep(.ant-transfer-list-content-item) {
padding: 6px 12px;
}
:deep(.ant-transfer-operation) {
padding: 0 8px;
}
:deep(.ant-transfer-list-footer) {
flex-shrink: 0;
}
:deep(.ant-pagination) {
margin: 8px;
font-size: 12px;
text-align: right;
}
:deep(.ant-pagination-options) {
margin-left: 8px;
}
:deep(.ant-pagination-options-size-changer) {
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { nextTick, ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import { Input } from 'ant-design-vue';
import { ConditionType } from '../../consts';
import {
getConditionShowText,
getDefaultConditionNodeName,
useFormFieldsAndStartUser,
} from '../../helpers';
import Condition from './modules/condition.vue';
defineOptions({
name: 'ConditionNodeConfig',
});
const props = defineProps({
conditionNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
nodeIndex: {
type: Number,
required: true,
},
});
const currentNode = ref<SimpleFlowNode>(props.conditionNode);
const condition = ref<any>({
conditionType: ConditionType.RULE, // 设置默认值
conditionExpression: '',
conditionGroups: {
and: true,
conditions: [
{
and: true,
rules: [
{
opCode: '==',
leftSide: '',
rightSide: '',
},
],
},
],
},
});
const conditionRef = ref();
const fieldOptions = useFormFieldsAndStartUser(); // 流程表单字段和发起人字段
/** 保存配置 */
async function saveConfig() {
if (!currentNode.value.conditionSetting?.defaultFlow) {
// 校验表单
const valid = await conditionRef.value.validate();
if (!valid) return false;
const showText = getConditionShowText(
condition.value?.conditionType,
condition.value?.conditionExpression,
condition.value.conditionGroups,
fieldOptions,
);
if (!showText) {
return false;
}
currentNode.value.showText = showText;
// 使用 cloneDeep 进行深拷贝
currentNode.value.conditionSetting = cloneDeep({
...currentNode.value.conditionSetting,
conditionType: condition.value?.conditionType,
conditionExpression:
condition.value?.conditionType === ConditionType.EXPRESSION
? condition.value?.conditionExpression
: undefined,
conditionGroups:
condition.value?.conditionType === ConditionType.RULE
? condition.value?.conditionGroups
: undefined,
});
}
drawerApi.close();
return true;
}
const [Drawer, drawerApi] = useVbenDrawer({
title: currentNode.value.name,
onConfirm: saveConfig,
});
function open() {
// 使用三元表达式代替 if-else解决 linter 警告
condition.value = currentNode.value.conditionSetting
? cloneDeep(currentNode.value.conditionSetting)
: {
conditionType: ConditionType.RULE,
conditionExpression: '',
conditionGroups: {
and: true,
conditions: [
{
and: true,
rules: [
{
opCode: '==',
leftSide: '',
rightSide: '',
},
],
},
],
},
};
drawerApi.open();
}
watch(
() => props.conditionNode,
(newValue) => {
currentNode.value = newValue;
},
);
// 显示名称输入框
const showInput = ref(false);
// 输入框的引用
const inputRef = ref<HTMLInputElement | null>(null);
// 监听 showInput 的变化,当变为 true 时自动聚焦
watch(showInput, (value) => {
if (value) {
nextTick(() => {
inputRef.value?.focus();
});
}
});
function clickIcon() {
showInput.value = true;
}
// 修改节点名称
function changeNodeName() {
showInput.value = false;
currentNode.value.name =
currentNode.value.name ||
getDefaultConditionNodeName(
props.nodeIndex,
currentNode.value?.conditionSetting?.defaultFlow,
);
}
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</script>
<template>
<Drawer class="w-1/3">
<template #title>
<div class="flex items-center">
<Input
ref="inputRef"
v-if="showInput"
type="text"
class="mr-2 w-48"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div
v-else
class="flex cursor-pointer items-center"
@click="clickIcon()"
>
{{ currentNode.name }}
<IconifyIcon class="ml-1" icon="lucide:edit-3" />
</div>
</div>
</template>
<div>
<div
class="mb-3 text-base"
v-if="currentNode.conditionSetting?.defaultFlow"
>
未满足其它条件时将进入此分支该分支不可编辑和删除
</div>
<div v-else>
<Condition ref="conditionRef" v-model:model-value="condition" />
</div>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,524 @@
<script setup lang="ts">
import type { Rule } from 'ant-design-vue/es/form';
import type { Ref } from 'vue';
import type { SimpleFlowNode } from '../../consts';
import type { CopyTaskFormType } from '../../helpers';
import { computed, onMounted, reactive, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Col,
Form,
FormItem,
Input,
Radio,
RadioGroup,
Row,
Select,
SelectOption,
TabPane,
Tabs,
Textarea,
TreeSelect,
} from 'ant-design-vue';
import { BpmModelFormType, BpmNodeTypeEnum } from '#/utils';
import {
CANDIDATE_STRATEGY,
CandidateStrategy,
FieldPermissionType,
MULTI_LEVEL_DEPT,
} from '../../consts';
import {
useFormFieldsPermission,
useNodeForm,
useNodeName,
useWatchNode,
} from '../../helpers';
defineOptions({ name: 'CopyTaskNodeConfig' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
const deptLevelLabel = computed(() => {
let label = '部门负责人来源';
label =
configForm.value.candidateStrategy ===
CandidateStrategy.MULTI_LEVEL_DEPT_LEADER
? `${label}(指定部门向上)`
: `${label}(发起人部门向上)`;
return label;
});
// 抽屉配置
const [Drawer, drawerApi] = useVbenDrawer({
header: true,
closable: true,
title: '',
onConfirm() {
saveConfig();
},
});
// 当前节点
const currentNode = useWatchNode(props);
// 节点名称
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
useNodeName(BpmNodeTypeEnum.COPY_TASK_NODE);
// 激活的 Tab 标签页
const activeTabName = ref('user');
// 表单字段权限配置
const {
formType,
fieldsPermissionConfig,
formFieldOptions,
getNodeConfigFormFields,
} = useFormFieldsPermission(FieldPermissionType.READ);
// 表单内用户字段选项, 必须是必填和用户选择器
const userFieldOnFormOptions = computed(() => {
return formFieldOptions.filter((item) => item.type === 'UserSelect');
});
// 表单内部门字段选项, 必须是必填和部门选择器
const deptFieldOnFormOptions = computed(() => {
return formFieldOptions.filter((item) => item.type === 'DeptSelect');
});
// 抄送人表单配置
const formRef = ref(); // 表单 Ref
// 表单校验规则
const formRules: Record<string, Rule[]> = reactive({
candidateStrategy: [
{ required: true, message: '抄送人设置不能为空', trigger: 'change' },
],
userIds: [{ required: true, message: '用户不能为空', trigger: 'change' }],
roleIds: [{ required: true, message: '角色不能为空', trigger: 'change' }],
deptIds: [{ required: true, message: '部门不能为空', trigger: 'change' }],
userGroups: [
{ required: true, message: '用户组不能为空', trigger: 'change' },
],
postIds: [{ required: true, message: '岗位不能为空', trigger: 'change' }],
formUser: [
{ required: true, message: '表单内用户字段不能为空', trigger: 'change' },
],
formDept: [
{ required: true, message: '表单内部门字段不能为空', trigger: 'change' },
],
expression: [
{ required: true, message: '流程表达式不能为空', trigger: 'blur' },
],
});
const {
configForm: tempConfigForm,
roleOptions,
postOptions,
userOptions,
userGroupOptions,
deptTreeOptions,
getShowText,
handleCandidateParam,
parseCandidateParam,
} = useNodeForm(BpmNodeTypeEnum.COPY_TASK_NODE);
const configForm = tempConfigForm as Ref<CopyTaskFormType>;
// 抄送人策略, 去掉发起人自选 和 发起人自己
const copyUserStrategies = computed(() => {
return CANDIDATE_STRATEGY.filter(
(item) => item.value !== CandidateStrategy.START_USER,
);
});
// 改变抄送人设置策略
function changeCandidateStrategy() {
configForm.value.userIds = [];
configForm.value.deptIds = [];
configForm.value.roleIds = [];
configForm.value.postIds = [];
configForm.value.userGroups = [];
configForm.value.deptLevel = 1;
configForm.value.formUser = '';
configForm.value.formDept = '';
}
// 保存配置
async function saveConfig() {
activeTabName.value = 'user';
if (!formRef.value) return false;
const valid = await formRef.value.validate();
if (!valid) return false;
const showText = getShowText();
if (!showText) return false;
currentNode.value.name = nodeName.value!;
currentNode.value.candidateParam = handleCandidateParam();
currentNode.value.candidateStrategy = configForm.value.candidateStrategy;
currentNode.value.showText = showText;
currentNode.value.fieldsPermission = fieldsPermissionConfig.value;
drawerApi.close();
return true;
}
// 显示抄送节点配置, 由父组件传过来
function showCopyTaskNodeConfig(node: SimpleFlowNode) {
nodeName.value = node.name;
// 抄送人设置
configForm.value.candidateStrategy = node.candidateStrategy!;
parseCandidateParam(node.candidateStrategy!, node?.candidateParam);
// 表单字段权限
getNodeConfigFormFields(node.fieldsPermission);
drawerApi.open();
}
/** 批量更新权限 */
function updatePermission(type: string) {
fieldsPermissionConfig.value.forEach((field) => {
if (type === 'READ') {
field.permission = FieldPermissionType.READ;
} else if (type === 'WRITE') {
field.permission = FieldPermissionType.WRITE;
} else {
field.permission = FieldPermissionType.NONE;
}
});
}
// 在组件初始化时对表单字段进行处理
onMounted(() => {
// 可以在这里进行初始化操作
});
defineExpose({ showCopyTaskNodeConfig }); // 暴露方法给父组件
</script>
<template>
<Drawer class="w-1/3">
<template #title>
<div class="config-header">
<Input
v-if="showInput"
ref="inputRef"
type="text"
class="focus:border-blue-500 focus:shadow-[0_0_0_2px_rgba(24,144,255,0.2)] focus:outline-none"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div v-else class="node-name">
{{ nodeName }}
<IconifyIcon class="ml-1" icon="lucide:edit-3" @click="clickIcon()" />
</div>
</div>
</template>
<Tabs v-model:active-key="activeTabName">
<TabPane tab="抄送人" key="user">
<div>
<Form
ref="formRef"
:model="configForm"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
:rules="formRules"
>
<FormItem label="抄送人设置" name="candidateStrategy">
<RadioGroup
v-model:value="configForm.candidateStrategy"
@change="changeCandidateStrategy"
>
<Row :gutter="[0, 8]">
<Col
v-for="(dict, index) in copyUserStrategies"
:key="index"
:span="8"
>
<Radio :value="dict.value" :label="dict.value">
{{ dict.label }}
</Radio>
</Col>
</Row>
</RadioGroup>
</FormItem>
<FormItem
v-if="configForm.candidateStrategy === CandidateStrategy.ROLE"
label="指定角色"
name="roleIds"
>
<Select
v-model:value="configForm.roleIds"
clearable
mode="multiple"
>
<SelectOption
v-for="item in roleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
{{ item.name }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
configForm.candidateStrategy ===
CandidateStrategy.DEPT_MEMBER ||
configForm.candidateStrategy ===
CandidateStrategy.DEPT_LEADER ||
configForm.candidateStrategy ===
CandidateStrategy.MULTI_LEVEL_DEPT_LEADER
"
label="指定部门"
name="deptIds"
>
<TreeSelect
v-model:value="configForm.deptIds"
:tree-data="deptTreeOptions"
:field-names="{
label: 'name',
value: 'id',
children: 'children',
}"
empty-text="加载中,请稍后"
multiple
:check-strictly="true"
allow-clear
tree-checkable
/>
</FormItem>
<FormItem
v-if="configForm.candidateStrategy === CandidateStrategy.POST"
label="指定岗位"
name="postIds"
>
<Select
v-model:value="configForm.postIds"
clearable
mode="multiple"
>
<SelectOption
v-for="item in postOptions"
:key="item.id"
:label="item.name"
:value="item.id!"
>
{{ item.name }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="configForm.candidateStrategy === CandidateStrategy.USER"
label="指定用户"
name="userIds"
>
<Select
v-model:value="configForm.userIds"
clearable
mode="multiple"
>
<SelectOption
v-for="item in userOptions"
:key="item.id"
:label="item.nickname"
:value="item.id"
>
{{ item.nickname }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
configForm.candidateStrategy === CandidateStrategy.USER_GROUP
"
label="指定用户组"
name="userGroups"
>
<Select
v-model:value="configForm.userGroups"
clearable
mode="multiple"
>
<SelectOption
v-for="item in userGroupOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
{{ item.name }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
configForm.candidateStrategy === CandidateStrategy.FORM_USER
"
label="表单内用户字段"
name="formUser"
>
<Select v-model:value="configForm.formUser" clearable>
<SelectOption
v-for="(item, idx) in userFieldOnFormOptions"
:key="idx"
:label="item.title"
:value="item.field"
:disabled="!item.required"
>
{{ item.title }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
configForm.candidateStrategy ===
CandidateStrategy.FORM_DEPT_LEADER
"
label="表单内部门字段"
name="formDept"
>
<Select v-model:value="configForm.formDept" clearable>
<SelectOption
v-for="(item, idx) in deptFieldOnFormOptions"
:key="idx"
:label="item.title"
:value="item.field"
:disabled="!item.required"
>
{{ item.title }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
configForm.candidateStrategy ===
CandidateStrategy.MULTI_LEVEL_DEPT_LEADER ||
configForm.candidateStrategy ===
CandidateStrategy.START_USER_DEPT_LEADER ||
configForm.candidateStrategy ===
CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER ||
configForm.candidateStrategy ===
CandidateStrategy.FORM_DEPT_LEADER
"
:label="deptLevelLabel!"
name="deptLevel"
>
<Select v-model:value="configForm.deptLevel" clearable>
<SelectOption
v-for="(item, index) in MULTI_LEVEL_DEPT"
:key="index"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
configForm.candidateStrategy === CandidateStrategy.EXPRESSION
"
label="流程表达式"
name="expression"
>
<Textarea v-model:value="configForm.expression" clearable />
</FormItem>
</Form>
</div>
</TabPane>
<TabPane
tab="表单字段权限"
key="fields"
v-if="formType === BpmModelFormType.NORMAL"
>
<div class="p-1">
<div class="mb-4 text-base font-bold">字段权限</div>
<!-- 表头 -->
<Row class="border border-gray-200 px-4 py-3">
<Col :span="8" class="font-bold">字段名称</Col>
<Col :span="16">
<Row>
<Col :span="8" class="flex items-center justify-center">
<span
class="cursor-pointer font-bold"
@click="updatePermission('READ')"
>
只读
</span>
</Col>
<Col :span="8" class="flex items-center justify-center">
<span
class="cursor-pointer font-bold"
@click="updatePermission('WRITE')"
>
可编辑
</span>
</Col>
<Col :span="8" class="flex items-center justify-center">
<span
class="cursor-pointer font-bold"
@click="updatePermission('NONE')"
>
隐藏
</span>
</Col>
</Row>
</Col>
</Row>
<!-- 表格内容 -->
<div v-for="(item, index) in fieldsPermissionConfig" :key="index">
<Row class="border border-t-0 border-gray-200 px-4 py-2">
<Col :span="8" class="flex items-center truncate">
{{ item.title }}
</Col>
<Col :span="16">
<RadioGroup v-model:value="item.permission" class="w-full">
<Row>
<Col :span="8" class="flex items-center justify-center">
<Radio
:value="FieldPermissionType.READ"
size="large"
:label="FieldPermissionType.READ"
/>
</Col>
<Col :span="8" class="flex items-center justify-center">
<Radio
:value="FieldPermissionType.WRITE"
size="large"
:label="FieldPermissionType.WRITE"
disabled
/>
</Col>
<Col :span="8" class="flex items-center justify-center">
<Radio
:value="FieldPermissionType.NONE"
size="large"
:label="FieldPermissionType.NONE"
/>
</Col>
</Row>
</RadioGroup>
</Col>
</Row>
</div>
</div>
</TabPane>
</Tabs>
</Drawer>
</template>

View File

@@ -0,0 +1,249 @@
<script setup lang="ts">
import type { Rule } from 'ant-design-vue/es/form';
import type { SimpleFlowNode } from '../../consts';
import { reactive, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Col,
DatePicker,
Form,
FormItem,
Input,
InputNumber,
Radio,
RadioGroup,
Row,
Select,
SelectOption,
} from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import {
DELAY_TYPE,
DelayTypeEnum,
TIME_UNIT_TYPES,
TimeUnitType,
} from '../../consts';
import { useNodeName, useWatchNode } from '../../helpers';
import { convertTimeUnit } from './utils';
defineOptions({ name: 'DelayTimerNodeConfig' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 当前节点
const currentNode = useWatchNode(props);
// 节点名称
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
useNodeName(BpmNodeTypeEnum.DELAY_TIMER_NODE);
// 抄送人表单配置
const formRef = ref(); // 表单 Ref
// 表单校验规则
const formRules: Record<string, Rule[]> = reactive({
delayType: [
{ required: true, message: '延迟时间不能为空', trigger: 'change' },
],
timeDuration: [
{ required: true, message: '延迟时间不能为空', trigger: 'change' },
],
dateTime: [
{ required: true, message: '延迟时间不能为空', trigger: 'change' },
],
});
// 配置表单数据
const configForm = ref({
delayType: DelayTypeEnum.FIXED_TIME_DURATION,
timeDuration: 1,
timeUnit: TimeUnitType.HOUR,
dateTime: '',
});
// 获取显示文本
function getShowText(): string {
let showText = '';
if (configForm.value.delayType === DelayTypeEnum.FIXED_TIME_DURATION) {
showText = `延迟${configForm.value.timeDuration}${TIME_UNIT_TYPES?.find((item) => item.value === configForm.value.timeUnit)?.label}`;
}
if (configForm.value.delayType === DelayTypeEnum.FIXED_DATE_TIME) {
showText = `延迟至${configForm.value.dateTime.replace('T', ' ')}`;
}
return showText;
}
// 获取ISO时间格式
function getIsoTimeDuration() {
let strTimeDuration = 'PT';
if (configForm.value.timeUnit === TimeUnitType.MINUTE) {
strTimeDuration += `${configForm.value.timeDuration}M`;
}
if (configForm.value.timeUnit === TimeUnitType.HOUR) {
strTimeDuration += `${configForm.value.timeDuration}H`;
}
if (configForm.value.timeUnit === TimeUnitType.DAY) {
strTimeDuration += `${configForm.value.timeDuration}D`;
}
return strTimeDuration;
}
// 保存配置
async function saveConfig() {
if (!formRef.value) return false;
const valid = await formRef.value.validate();
if (!valid) return false;
const showText = getShowText();
if (!showText) return false;
currentNode.value.name = nodeName.value!;
currentNode.value.showText = showText;
if (configForm.value.delayType === DelayTypeEnum.FIXED_TIME_DURATION) {
currentNode.value.delaySetting = {
delayType: configForm.value.delayType,
delayTime: getIsoTimeDuration(),
};
}
if (configForm.value.delayType === DelayTypeEnum.FIXED_DATE_TIME) {
currentNode.value.delaySetting = {
delayType: configForm.value.delayType,
delayTime: configForm.value.dateTime,
};
}
drawerApi.close();
return true;
}
const [Drawer, drawerApi] = useVbenDrawer({
title: nodeName.value,
onConfirm: saveConfig,
});
// 显示延迟器节点配置,由父组件调用
function openDrawer(node: SimpleFlowNode) {
nodeName.value = node.name;
if (node.delaySetting) {
configForm.value.delayType = node.delaySetting.delayType;
// 固定时长
if (configForm.value.delayType === DelayTypeEnum.FIXED_TIME_DURATION) {
const strTimeDuration = node.delaySetting.delayTime;
const parseTime = strTimeDuration.slice(2, -1);
const parseTimeUnit = strTimeDuration.slice(-1);
configForm.value.timeDuration = Number.parseInt(parseTime);
configForm.value.timeUnit = convertTimeUnit(parseTimeUnit);
}
// 固定日期时间
if (configForm.value.delayType === DelayTypeEnum.FIXED_DATE_TIME) {
configForm.value.dateTime = node.delaySetting.delayTime;
}
}
drawerApi.open();
}
defineExpose({ openDrawer }); // 暴露方法给父组件
</script>
<template>
<Drawer class="w-1/3">
<template #title>
<div class="flex items-center">
<Input
v-if="showInput"
ref="inputRef"
type="text"
class="mr-2 w-48"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div
v-else
class="flex cursor-pointer items-center"
@click="clickIcon()"
>
{{ nodeName }}
<IconifyIcon class="ml-1" icon="lucide:edit-3" :size="16" />
</div>
</div>
</template>
<div>
<Form
ref="formRef"
:model="configForm"
:rules="formRules"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
>
<FormItem label="延迟时间" name="delayType">
<RadioGroup v-model:value="configForm.delayType">
<Radio
v-for="item in DELAY_TYPE"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Radio>
</RadioGroup>
</FormItem>
<FormItem
v-if="configForm.delayType === DelayTypeEnum.FIXED_TIME_DURATION"
>
<Row :gutter="8">
<Col>
<FormItem name="timeDuration">
<InputNumber
class="w-28"
v-model:value="configForm.timeDuration"
:min="1"
/>
</FormItem>
</Col>
<Col>
<Select v-model:value="configForm.timeUnit" class="w-28">
<SelectOption
v-for="item in TIME_UNIT_TYPES"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectOption>
</Select>
</Col>
<Col>
<span class="inline-flex h-8 items-center">后进入下一节点</span>
</Col>
</Row>
</FormItem>
<FormItem
v-if="configForm.delayType === DelayTypeEnum.FIXED_DATE_TIME"
name="dateTime"
>
<Row :gutter="8">
<Col>
<DatePicker
class="mr-2"
v-model:value="configForm.dateTime"
show-time
placeholder="请选择日期和时间"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
</Col>
<Col>
<span class="inline-flex h-8 items-center">后进入下一节点</span>
</Col>
</Row>
</FormItem>
</Form>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { ConditionGroup } from '../../../consts';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ConditionType, DEFAULT_CONDITION_GROUP_VALUE } from '../../../consts';
import Condition from './condition.vue';
defineOptions({ name: 'ConditionDialog' });
const emit = defineEmits<{
updateCondition: [condition: object];
}>();
const conditionData = ref<{
conditionExpression?: string;
conditionGroups?: ConditionGroup;
conditionType: ConditionType;
}>({
conditionType: ConditionType.RULE,
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
});
// 条件组件的引用
const conditionRef = ref();
const [Modal, modalApi] = useVbenModal({
title: '条件配置',
destroyOnClose: true,
draggable: true,
onOpenChange(isOpen) {
if (isOpen) {
// 获取传递的数据
const conditionObj = modalApi.getData();
if (conditionObj) {
conditionData.value.conditionType = conditionObj.conditionType;
conditionData.value.conditionExpression =
conditionObj.conditionExpression;
conditionData.value.conditionGroups = conditionObj.conditionGroups;
}
}
},
async onConfirm() {
// 校验表单
if (!conditionRef.value) return;
const valid = await conditionRef.value.validate().catch(() => false);
if (!valid) {
message.warning('请完善条件规则');
return;
}
// 设置完的条件传递给父组件
emit('updateCondition', conditionData.value);
modalApi.close();
},
onCancel() {
modalApi.close();
},
});
// TODO xingyu 暴露 modalApi 给父组件是否合适? trigger-node-config.vue 会有多个 conditionDialog 实例
// 不用暴露啊,用 useVbenModal 就可以了
defineExpose({ modalApi });
</script>
<template>
<Modal class="w-1/2">
<Condition ref="conditionRef" v-model="conditionData" />
</Modal>
</template>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import type { Rule } from 'ant-design-vue/es/form';
import type { Ref } from 'vue';
import { computed, inject, reactive, ref } from 'vue';
import { IconifyIcon, Plus, Trash2 } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import {
Card,
Col,
Form,
FormItem,
Input,
Radio,
RadioGroup,
Row,
Select,
SelectOption,
Space,
Switch,
Textarea,
Tooltip,
} from 'ant-design-vue';
import { BpmModelFormType } from '#/utils';
import {
COMPARISON_OPERATORS,
CONDITION_CONFIG_TYPES,
ConditionType,
DEFAULT_CONDITION_GROUP_VALUE,
} from '../../../consts';
import { useFormFieldsAndStartUser } from '../../../helpers';
defineOptions({
name: 'Condition',
});
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const condition = computed({
get() {
return props.modelValue;
},
set(newValue) {
emit('update:modelValue', newValue);
},
});
const formType = inject<Ref<number>>('formType'); // 表单类型
const conditionConfigTypes = computed(() => {
return CONDITION_CONFIG_TYPES.filter((item) => {
// 业务表单暂时去掉条件规则选项
return !(
formType?.value === BpmModelFormType.CUSTOM &&
item.value === ConditionType.RULE
);
});
});
/** 条件规则可选择的表单字段 */
const fieldOptions = useFormFieldsAndStartUser();
// 表单校验规则
const formRules: Record<string, Rule[]> = reactive({
conditionType: [
{ required: true, message: '配置方式不能为空', trigger: 'change' },
],
conditionExpression: [
{
required: true,
message: '条件表达式不能为空',
trigger: ['blur', 'change'],
},
],
});
const formRef = ref(); // 表单 Ref
/** 切换条件配置方式 */
function changeConditionType() {
if (
condition.value.conditionType === ConditionType.RULE &&
!condition.value.conditionGroups
) {
condition.value.conditionGroups = cloneDeep(DEFAULT_CONDITION_GROUP_VALUE);
}
}
function deleteConditionGroup(conditions: any, index: number) {
conditions.splice(index, 1);
}
function deleteConditionRule(condition: any, index: number) {
condition.rules.splice(index, 1);
}
function addConditionRule(condition: any, index: number) {
const rule = {
opCode: '==',
leftSide: undefined,
rightSide: '',
};
condition.rules.splice(index + 1, 0, rule);
}
function addConditionGroup(conditions: any) {
const condition = {
and: true,
rules: [
{
opCode: '==',
leftSide: undefined,
rightSide: '',
},
],
};
conditions.push(condition);
}
async function validate() {
if (!formRef.value) return false;
return await formRef.value.validate();
}
defineExpose({ validate });
</script>
<template>
<Form
ref="formRef"
:model="condition"
:rules="formRules"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
>
<FormItem label="配置方式" name="conditionType">
<RadioGroup
v-model:value="condition.conditionType"
@change="changeConditionType"
>
<Radio
v-for="(dict, indexConditionType) in conditionConfigTypes"
:key="indexConditionType"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</FormItem>
<FormItem
v-if="
condition.conditionType === ConditionType.RULE &&
condition.conditionGroups
"
>
<div class="mb-5 flex w-full justify-between">
<div class="flex items-center">
<div class="mr-4">条件组关系</div>
<Switch
v-model:checked="condition.conditionGroups.and"
checked-children="且"
un-checked-children="或"
/>
</div>
</div>
<Space direction="vertical" size="small" class="w-11/12 pl-1">
<template #split>
{{ condition.conditionGroups.and ? '且' : '或' }}
</template>
<Card
class="group relative w-full hover:border-blue-500"
v-for="(equation, cIdx) in condition.conditionGroups.conditions"
:key="cIdx"
>
<div
class="absolute left-0 top-0 z-[1] flex cursor-pointer opacity-0 group-hover:opacity-100"
v-if="condition.conditionGroups.conditions.length > 1"
>
<IconifyIcon
color="blue"
icon="lucide:circle-x"
class="size-4"
@click="
deleteConditionGroup(condition.conditionGroups.conditions, cIdx)
"
/>
</div>
<template #extra>
<div class="flex items-center justify-between">
<div>条件组</div>
<div class="flex">
<div class="mr-4">规则关系</div>
<Switch
v-model:checked="equation.and"
checked-children="且"
un-checked-children="或"
/>
</div>
</div>
</template>
<Row
:gutter="8"
class="mb-2"
v-for="(rule, rIdx) in equation.rules"
:key="rIdx"
>
<Col :span="8">
<FormItem
:name="[
'conditionGroups',
'conditions',
cIdx,
'rules',
rIdx,
'leftSide',
]"
:rules="{
required: true,
message: '左值不能为空',
trigger: 'change',
}"
>
<Select
v-model:value="rule.leftSide"
allow-clear
placeholder="请选择表单字段"
>
<SelectOption
v-for="(field, fIdx) in fieldOptions"
:key="fIdx"
:label="field.title"
:value="field.field"
:disabled="!field.required"
>
<Tooltip
title="表单字段非必填时不能作为流程分支条件"
placement="right"
v-if="!field.required"
>
<span>{{ field.title }}</span>
</Tooltip>
<template v-else>{{ field.title }}</template>
</SelectOption>
</Select>
</FormItem>
</Col>
<Col :span="6">
<Select v-model:value="rule.opCode" placeholder="请选择操作符">
<SelectOption
v-for="operator in COMPARISON_OPERATORS"
:key="operator.value"
:label="operator.label"
:value="operator.value"
>
{{ operator.label }}
</SelectOption>
</Select>
</Col>
<Col :span="7">
<FormItem
:name="[
'conditionGroups',
'conditions',
cIdx,
'rules',
rIdx,
'rightSide',
]"
:rules="{
required: true,
message: '右值不能为空',
trigger: ['blur', 'change'],
}"
>
<Input
v-model:value="rule.rightSide"
placeholder="请输入右值"
/>
</FormItem>
</Col>
<Col :span="3">
<div class="flex h-8 items-center">
<Trash2
v-if="equation.rules.length > 1"
class="mr-2 size-4 cursor-pointer text-red-500"
@click="deleteConditionRule(equation, rIdx)"
/>
<Plus
class="size-4 cursor-pointer text-blue-500"
@click="addConditionRule(equation, rIdx)"
/>
</div>
</Col>
</Row>
</Card>
</Space>
<div title="添加条件组" class="mt-4 cursor-pointer">
<Plus
class="size-6 text-blue-500"
@click="addConditionGroup(condition.conditionGroups?.conditions)"
/>
</div>
</FormItem>
<FormItem
v-if="condition.conditionType === ConditionType.EXPRESSION"
label="条件表达式"
name="conditionExpression"
>
<Textarea
v-model:value="condition.conditionExpression"
placeholder="请输入条件表达式"
allow-clear
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</FormItem>
</Form>
</template>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import type { HttpRequestParam } from '../../../consts';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Col,
FormItem,
Input,
Row,
Select,
SelectOption,
} from 'ant-design-vue';
import {
BPM_HTTP_REQUEST_PARAM_TYPES,
BpmHttpRequestParamTypeEnum,
} from '../../../consts';
import { useFormFieldsAndStartUser } from '../../../helpers';
defineOptions({ name: 'HttpRequestParamSetting' });
const props = defineProps({
header: {
type: Array as () => HttpRequestParam[],
required: false,
default: () => [],
},
body: {
type: Array as () => HttpRequestParam[],
required: false,
default: () => [],
},
bind: {
type: String,
required: true,
},
});
// 流程表单字段,发起人字段
const formFieldOptions = useFormFieldsAndStartUser();
/** 添加请求配置项 */
function addHttpRequestParam(arr: HttpRequestParam[]) {
arr.push({
key: '',
type: BpmHttpRequestParamTypeEnum.FIXED_VALUE,
value: '',
});
}
/** 删除请求配置项 */
function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
arr.splice(index, 1);
}
</script>
<template>
<FormItem
label="请求头"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
>
<Row :gutter="8" v-for="(item, index) in props.header" :key="index">
<Col :span="7">
<FormItem
:name="[bind, 'header', index, 'key']"
:rules="{
required: true,
message: '参数名不能为空',
trigger: ['blur', 'change'],
}"
>
<Input placeholder="参数名不能为空" v-model:value="item.key" />
</FormItem>
</Col>
<Col :span="5">
<Select v-model:value="item.type">
<SelectOption
v-for="types in BPM_HTTP_REQUEST_PARAM_TYPES"
:key="types.value"
:label="types.label"
:value="types.value"
>
{{ types.label }}
</SelectOption>
</Select>
</Col>
<Col :span="10">
<FormItem
:name="[bind, 'header', index, 'value']"
:rules="{
required: true,
message: '参数值不能为空',
trigger: ['blur', 'change'],
}"
v-if="item.type === BpmHttpRequestParamTypeEnum.FIXED_VALUE"
>
<Input placeholder="请求头" v-model:value="item.value" />
</FormItem>
<FormItem
:name="[bind, 'header', index, 'value']"
:rules="{
required: true,
message: '参数值不能为空',
trigger: 'change',
}"
v-if="item.type === BpmHttpRequestParamTypeEnum.FROM_FORM"
>
<Select v-model:value="item.value" placeholder="请选择表单字段">
<SelectOption
v-for="(field, fIdx) in formFieldOptions"
:key="fIdx"
:label="field.title"
:value="field.field"
:disabled="!field.required"
>
{{ field.title }}
</SelectOption>
</Select>
</FormItem>
</Col>
<Col :span="2">
<div class="flex h-8 items-center">
<IconifyIcon
class="size-4 cursor-pointer text-red-500"
icon="lucide:trash-2"
@click="deleteHttpRequestParam(props.header, index)"
/>
</div>
</Col>
</Row>
<Button
type="link"
@click="addHttpRequestParam(props.header)"
class="flex items-center"
>
<template #icon>
<IconifyIcon class="size-4" icon="lucide:plus" />
</template>
添加一行
</Button>
</FormItem>
<FormItem
label="请求体"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
>
<Row :gutter="8" v-for="(item, index) in props.body" :key="index">
<Col :span="7">
<FormItem
:name="[bind, 'body', index, 'key']"
:rules="{
required: true,
message: '参数名不能为空',
trigger: ['blur', 'change'],
}"
>
<Input placeholder="参数名" v-model:value="item.key" />
</FormItem>
</Col>
<Col :span="5">
<Select v-model:value="item.type">
<SelectOption
v-for="types in BPM_HTTP_REQUEST_PARAM_TYPES"
:key="types.value"
:label="types.label"
:value="types.value"
>
{{ types.label }}
</SelectOption>
</Select>
</Col>
<Col :span="10">
<FormItem
:name="[bind, 'body', index, 'value']"
:rules="{
required: true,
message: '参数值不能为空',
trigger: ['blur', 'change'],
}"
v-if="item.type === BpmHttpRequestParamTypeEnum.FIXED_VALUE"
>
<Input placeholder="参数值" v-model:value="item.value" />
</FormItem>
<FormItem
:name="[bind, 'body', index, 'value']"
:rules="{
required: true,
message: '参数值不能为空',
trigger: 'change',
}"
v-if="item.type === BpmHttpRequestParamTypeEnum.FROM_FORM"
>
<Select v-model:value="item.value" placeholder="请选择表单字段">
<SelectOption
v-for="(field, fIdx) in formFieldOptions"
:key="fIdx"
:label="field.title"
:value="field.field"
:disabled="!field.required"
>
{{ field.title }}
</SelectOption>
</Select>
</FormItem>
</Col>
<Col :span="2">
<div class="flex h-8 items-center">
<IconifyIcon
class="size-4 cursor-pointer text-red-500"
icon="lucide:trash-2"
@click="deleteHttpRequestParam(props.body, index)"
/>
</div>
</Col>
</Row>
<Button
type="link"
@click="addHttpRequestParam(props.body)"
class="flex items-center"
>
<template #icon>
<IconifyIcon class="size-4" icon="lucide:plus" />
</template>
添加一行
</Button>
</FormItem>
</template>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import { toRefs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
Alert,
Button,
Col,
FormItem,
Input,
Row,
Select,
SelectOption,
} from 'ant-design-vue';
import { useFormFields } from '../../../helpers';
import HttpRequestParamSetting from './http-request-param-setting.vue';
defineOptions({ name: 'HttpRequestSetting' });
const props = defineProps({
setting: {
type: Object,
required: true,
},
responseEnable: {
type: Boolean,
required: true,
},
formItemPrefix: {
type: String,
required: true,
},
});
const emits = defineEmits(['update:setting']);
const { setting } = toRefs(props);
watch(
() => setting,
(val) => {
emits('update:setting', val);
},
);
/** 流程表单字段 */
const formFields = useFormFields();
/** 添加 HTTP 请求返回值设置项 */
function addHttpResponseSetting(responseSetting: Record<string, string>[]) {
responseSetting.push({
key: '',
value: '',
});
}
/** 删除 HTTP 请求返回值设置项 */
function deleteHttpResponseSetting(
responseSetting: Record<string, string>[],
index: number,
) {
responseSetting.splice(index, 1);
}
</script>
<template>
<FormItem>
<Alert
message="仅支持 POST 请求,以请求体方式接收参数"
type="warning"
show-icon
:closable="false"
/>
</FormItem>
<!-- 请求地址-->
<FormItem
label="请求地址"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
:name="[formItemPrefix, 'url']"
:rules="{
required: true,
message: '请求地址不能为空',
trigger: ['blur', 'change'],
}"
>
<Input v-model:value="setting.url" placeholder="请输入请求地址" />
</FormItem>
<!-- 请求头请求体设置-->
<HttpRequestParamSetting
:header="setting.header"
:body="setting.body"
:bind="formItemPrefix"
/>
<!-- 返回值设置-->
<div v-if="responseEnable">
<FormItem
label="返回值"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
>
<Alert
message="通过请求返回值, 可以修改流程表单的值"
type="warning"
show-icon
:closable="false"
/>
</FormItem>
<FormItem :wrapper-col="{ span: 24 }">
<Row
:gutter="8"
v-for="(item, index) in setting.response"
:key="index"
class="mb-2"
>
<Col :span="10">
<FormItem
:name="[formItemPrefix, 'response', index, 'key']"
:rules="{
required: true,
message: '表单字段不能为空',
trigger: ['blur', 'change'],
}"
>
<Select
v-model:value="item.key"
placeholder="请选择表单字段"
allow-clear
>
<SelectOption
v-for="(field, fIdx) in formFields"
:key="fIdx"
:label="field.title"
:value="field.field"
:disabled="!field.required"
>
{{ field.title }}
</SelectOption>
</Select>
</FormItem>
</Col>
<Col :span="12">
<FormItem
:name="[formItemPrefix, 'response', index, 'value']"
:rules="{
required: true,
message: '请求返回字段不能为空',
trigger: ['blur', 'change'],
}"
>
<Input v-model:value="item.value" placeholder="请求返回字段" />
</FormItem>
</Col>
<Col :span="2">
<div class="flex h-8 items-center">
<IconifyIcon
class="size-4 cursor-pointer text-red-500"
icon="lucide:trash-2"
@click="deleteHttpResponseSetting(setting.response!, index)"
/>
</div>
</Col>
</Row>
<Button
type="link"
@click="addHttpResponseSetting(setting.response!)"
class="flex items-center"
>
<template #icon>
<IconifyIcon class="size-4" icon="lucide:plus" />
</template>
添加一行
</Button>
</FormItem>
</div>
</template>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
Alert,
Divider,
Form,
FormItem,
Input,
Switch,
TypographyText,
} from 'ant-design-vue';
import HttpRequestParamSetting from './http-request-param-setting.vue';
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
formFieldOptions: {
type: Object,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const listenerFormRef = ref();
const configForm = computed({
get() {
return props.modelValue;
},
set(newValue) {
emit('update:modelValue', newValue);
},
});
const taskListener = ref([
{
name: '创建任务',
type: 'Create',
},
{
name: '指派任务执行人员',
type: 'Assign',
},
{
name: '完成任务',
type: 'Complete',
},
]);
async function validate() {
if (!listenerFormRef.value) return false;
return await listenerFormRef.value.validate();
}
defineExpose({ validate });
</script>
<template>
<Form ref="listenerFormRef" :model="configForm" :label-col="{ span: 24 }">
<div
v-for="(listener, listenerIdx) in taskListener"
:key="listenerIdx"
class="pl-2"
>
<Divider orientation="left">
<TypographyText tag="b" size="large">
{{ listener.name }}
</TypographyText>
</Divider>
<FormItem>
<Switch
v-model:checked="configForm[`task${listener.type}ListenerEnable`]"
checked-children="开启"
un-checked-children="关闭"
/>
</FormItem>
<div v-if="configForm[`task${listener.type}ListenerEnable`]">
<FormItem>
<Alert
message="仅支持 POST 请求,以请求体方式接收参数"
type="warning"
show-icon
:closable="false"
/>
</FormItem>
<FormItem
label="请求地址"
:name="`task${listener.type}ListenerPath`"
:rules="{
required: true,
message: '请求地址不能为空',
trigger: ['blur', 'change'],
}"
>
<Input
v-model:value="configForm[`task${listener.type}ListenerPath`]"
/>
</FormItem>
<HttpRequestParamSetting
:header="configForm[`task${listener.type}Listener`].header"
:body="configForm[`task${listener.type}Listener`].body"
:bind="`task${listener.type}Listener`"
/>
</div>
</div>
</Form>
</template>

View File

@@ -0,0 +1,297 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { RouterSetting, SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Col,
Form,
FormItem,
Input,
message,
Row,
Select,
SelectOption,
} from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { ConditionType } from '../../consts';
import { useNodeName, useWatchNode } from '../../helpers';
import Condition from './modules/condition.vue';
defineOptions({ name: 'RouterNodeConfig' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
const processNodeTree = inject<Ref<SimpleFlowNode>>('processNodeTree');
/** 当前节点 */
const currentNode = useWatchNode(props);
/** 节点名称 */
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
useNodeName(BpmNodeTypeEnum.ROUTER_BRANCH_NODE);
const routerGroups = ref<RouterSetting[]>([]);
const nodeOptions = ref<any[]>([]);
const conditionRef = ref<any[]>([]);
const formRef = ref();
/** 校验节点配置 */
async function validateConfig() {
// 校验路由分支选择
const routeIdValid = await formRef.value.validate().catch(() => false);
if (!routeIdValid) {
message.warning('请配置路由目标节点');
return false;
}
// 校验条件规则
let valid = true;
for (const item of conditionRef.value) {
if (item && !(await item.validate())) {
valid = false;
}
}
if (!valid) return false;
// 获取节点显示文本,如果为空,校验不通过
const showText = getShowText();
if (!showText) return false;
return true;
}
/** 保存配置 */
async function saveConfig() {
// 校验配置
if (!(await validateConfig())) {
return false;
}
// 保存配置
currentNode.value.name = nodeName.value!;
currentNode.value.showText = getShowText();
currentNode.value.routerGroups = routerGroups.value;
drawerApi.close();
return true;
}
const [Drawer, drawerApi] = useVbenDrawer({
title: nodeName.value,
onConfirm: saveConfig,
});
/** 打开路由节点配置抽屉,由父组件调用 */
function openDrawer(node: SimpleFlowNode) {
nodeOptions.value = [];
getRouterNode(processNodeTree?.value);
routerGroups.value = [];
nodeName.value = node.name;
if (node.routerGroups) {
routerGroups.value = node.routerGroups;
}
drawerApi.open();
}
/** 获取显示文本 */
function getShowText() {
if (
!routerGroups.value ||
!Array.isArray(routerGroups.value) ||
routerGroups.value.length <= 0
) {
message.warning('请配置路由!');
return '';
}
for (const route of routerGroups.value) {
if (!route.nodeId || !route.conditionType) {
message.warning('请完善路由配置项!');
return '';
}
if (
route.conditionType === ConditionType.EXPRESSION &&
!route.conditionExpression
) {
message.warning('请完善路由配置项!');
return '';
}
if (route.conditionType === ConditionType.RULE) {
for (const condition of route.conditionGroups.conditions) {
for (const rule of condition.rules) {
if (!rule.leftSide || !rule.rightSide) {
message.warning('请完善路由配置项!');
return '';
}
}
}
}
}
return `${routerGroups.value.length}条路由分支`;
}
/** 添加路由分支 */
function addRouterGroup() {
routerGroups.value.push({
nodeId: undefined,
conditionType: ConditionType.RULE,
conditionExpression: '',
conditionGroups: {
and: true,
conditions: [
{
and: true,
rules: [
{
opCode: '==',
leftSide: undefined,
rightSide: '',
},
],
},
],
},
});
}
/** 删除路由分支 */
function deleteRouterGroup(index: number) {
routerGroups.value.splice(index, 1);
}
/** 递归获取所有节点 */
function getRouterNode(node: any) {
// TODO 最好还需要满足以下要求
// 并行分支、包容分支内部节点不能跳转到外部节点
// 条件分支节点可以向上跳转到外部节点
while (true) {
if (!node) break;
if (
node.type !== BpmNodeTypeEnum.ROUTER_BRANCH_NODE &&
node.type !== BpmNodeTypeEnum.CONDITION_NODE
) {
nodeOptions.value.push({
label: node.name,
value: node.id,
});
}
if (!node.childNode || node.type === BpmNodeTypeEnum.END_EVENT_NODE) {
break;
}
if (node.conditionNodes && node.conditionNodes.length > 0) {
node.conditionNodes.forEach((item: any) => {
getRouterNode(item);
});
}
node = node.childNode;
}
}
defineExpose({ openDrawer }); // 暴露方法给父组件
</script>
<template>
<Drawer class="w-2/5">
<template #title>
<div class="flex items-center">
<Input
ref="inputRef"
v-if="showInput"
type="text"
class="mr-2 w-48"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div
v-else
class="flex cursor-pointer items-center"
@click="clickIcon()"
>
{{ nodeName }}
<IconifyIcon class="ml-1" icon="lucide:edit-3" />
</div>
</div>
</template>
<Form ref="formRef" :model="{ routerGroups }">
<Card
:body-style="{ padding: '10px' }"
class="mt-4"
v-for="(item, index) in routerGroups"
:key="index"
>
<template #title>
<div class="flex h-16 w-full items-center justify-between">
<div class="flex items-center font-normal">
<span class="font-medium">路由{{ index + 1 }}</span>
<FormItem
class="mb-0 ml-4 inline-block w-48"
:name="['routerGroups', index, 'nodeId']"
:rules="{
required: true,
message: '路由目标节点不能为空',
trigger: 'change',
}"
>
<Select
v-model:value="item.nodeId"
placeholder="请选择路由目标节点"
allow-clear
>
<SelectOption
v-for="node in nodeOptions"
:key="node.value"
:value="node.value"
>
{{ node.label }}
</SelectOption>
</Select>
</FormItem>
</div>
<Button
v-if="routerGroups.length > 1"
shape="circle"
class="flex items-center justify-center"
@click="deleteRouterGroup(index)"
>
<template #icon>
<IconifyIcon icon="lucide:x" />
</template>
</Button>
</div>
</template>
<Condition
:ref="(el) => (conditionRef[index] = el)"
:model-value="routerGroups[index] as Record<string, any>"
@update:model-value="(val) => (routerGroups[index] = val)"
/>
</Card>
</Form>
<Row class="mt-4">
<Col :span="24">
<Button
class="flex items-center p-0"
type="link"
@click="addRouterGroup"
>
<template #icon>
<IconifyIcon icon="lucide:settings" />
</template>
新增路由分支
</Button>
</Col>
</Row>
</Drawer>
</template>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { SimpleFlowNode } from '../../consts';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemUserApi } from '#/api/system/user';
import { inject, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Col,
Input,
Radio,
RadioGroup,
Row,
TabPane,
Tabs,
Tooltip,
TypographyText,
} from 'ant-design-vue';
import { BpmModelFormType, BpmNodeTypeEnum } from '#/utils';
import { FieldPermissionType, START_USER_BUTTON_SETTING } from '../../consts';
import {
useFormFieldsPermission,
useNodeName,
useWatchNode,
} from '../../helpers';
defineOptions({ name: 'StartUserNodeConfig' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 可发起流程的用户编号
const startUserIds = inject<Ref<any[]>>('startUserIds');
// 可发起流程的部门编号
const startDeptIds = inject<Ref<any[]>>('startDeptIds');
// 用户列表
const userOptions = inject<Ref<SystemUserApi.User[]>>('userList');
// 部门列表
const deptOptions = inject<Ref<SystemDeptApi.Dept[]>>('deptList');
// 当前节点
const currentNode = useWatchNode(props);
// 节点名称
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
useNodeName(BpmNodeTypeEnum.START_USER_NODE);
// 激活的 Tab 标签页
const activeTabName = ref('user');
// 表单字段权限配置
const { formType, fieldsPermissionConfig, getNodeConfigFormFields } =
useFormFieldsPermission(FieldPermissionType.WRITE);
function getUserNicknames(userIds: number[]): string {
if (!userIds || userIds.length === 0) {
return '';
}
const nicknames: string[] = [];
userIds.forEach((userId) => {
const found = userOptions?.value.find((item) => item.id === userId);
if (found && found.nickname) {
nicknames.push(found.nickname);
}
});
return nicknames.join(',');
}
function getDeptNames(deptIds: number[]): string {
if (!deptIds || deptIds.length === 0) {
return '';
}
const deptNames: string[] = [];
deptIds.forEach((deptId) => {
const found = deptOptions?.value.find((item) => item.id === deptId);
if (found && found.name) {
deptNames.push(found.name);
}
});
return deptNames.join(',');
}
// 使用 VbenDrawer
const [Drawer, drawerApi] = useVbenDrawer({
header: true,
closable: true,
onCancel() {
drawerApi.setState({ isOpen: false });
},
onConfirm() {
saveConfig();
},
});
// 保存配置
async function saveConfig() {
activeTabName.value = 'user';
currentNode.value.name = nodeName.value!;
currentNode.value.showText = '已设置';
// 设置表单权限
currentNode.value.fieldsPermission = fieldsPermissionConfig.value;
// 设置发起人的按钮权限
currentNode.value.buttonsSetting = START_USER_BUTTON_SETTING;
drawerApi.setState({ isOpen: false });
return true;
}
// 显示发起人节点配置,由父组件传过来
function showStartUserNodeConfig(node: SimpleFlowNode) {
nodeName.value = node.name;
// 表单字段权限
getNodeConfigFormFields(node.fieldsPermission);
drawerApi.open();
}
/** 批量更新权限 */
function updatePermission(type: string) {
fieldsPermissionConfig.value.forEach((field) => {
if (type === 'READ') {
field.permission = FieldPermissionType.READ;
} else if (type === 'WRITE') {
field.permission = FieldPermissionType.WRITE;
} else {
field.permission = FieldPermissionType.NONE;
}
});
}
/**
* 暴露方法给父组件
*/
defineExpose({ showStartUserNodeConfig });
</script>
<template>
<Drawer>
<template #title>
<div class="config-header">
<Input
ref="inputRef"
v-if="showInput"
type="text"
class="focus:border-blue-500 focus:shadow-[0_0_0_2px_rgba(24,144,255,0.2)] focus:outline-none"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div v-else class="node-name">
{{ nodeName }}
<IconifyIcon
class="ml-1"
icon="lucide:edit-3"
:size="16"
@click="clickIcon()"
/>
</div>
</div>
</template>
<Tabs v-model:active-key="activeTabName" type="card">
<TabPane tab="权限" key="user">
<TypographyText
v-if="
(!startUserIds || startUserIds.length === 0) &&
(!startDeptIds || startDeptIds.length === 0)
"
>
全部成员可以发起流程
</TypographyText>
<div v-else-if="startUserIds && startUserIds.length > 0">
<TypographyText v-if="startUserIds.length === 1">
{{ getUserNicknames(startUserIds) }} 可发起流程
</TypographyText>
<TypographyText v-else>
<Tooltip
class="box-item"
effect="dark"
placement="top"
:content="getUserNicknames(startUserIds)"
>
{{ getUserNicknames(startUserIds.slice(0, 2)) }} 等
{{ startUserIds.length }} 人可发起流程
</Tooltip>
</TypographyText>
</div>
<div v-else-if="startDeptIds && startDeptIds.length > 0">
<TypographyText v-if="startDeptIds.length === 1">
{{ getDeptNames(startDeptIds) }} 可发起流程
</TypographyText>
<TypographyText v-else>
<Tooltip
class="box-item"
effect="dark"
placement="top"
:content="getDeptNames(startDeptIds)"
>
{{ getDeptNames(startDeptIds.slice(0, 2)) }} 等
{{ startDeptIds.length }} 个部门可发起流程
</Tooltip>
</TypographyText>
</div>
</TabPane>
<TabPane
tab="表单字段权限"
key="fields"
v-if="formType === BpmModelFormType.NORMAL"
>
<div class="p-1">
<div class="mb-4 text-base font-bold">字段权限</div>
<!-- 表头 -->
<Row class="border border-gray-200 px-4 py-3">
<Col :span="8" class="font-bold">字段名称</Col>
<Col :span="16">
<Row>
<Col :span="8" class="flex items-center justify-center">
<span
class="cursor-pointer font-bold"
@click="updatePermission('READ')"
>
只读
</span>
</Col>
<Col :span="8" class="flex items-center justify-center">
<span
class="cursor-pointer font-bold"
@click="updatePermission('WRITE')"
>
可编辑
</span>
</Col>
<Col :span="8" class="flex items-center justify-center">
<span
class="cursor-pointer font-bold"
@click="updatePermission('NONE')"
>
隐藏
</span>
</Col>
</Row>
</Col>
</Row>
<!-- 表格内容 -->
<div v-for="(item, index) in fieldsPermissionConfig" :key="index">
<Row class="border border-t-0 border-gray-200 px-4 py-2">
<Col :span="8" class="flex items-center truncate">
{{ item.title }}
</Col>
<Col :span="16">
<RadioGroup v-model:value="item.permission" class="w-full">
<Row>
<Col :span="8" class="flex items-center justify-center">
<Radio
:value="FieldPermissionType.READ"
size="large"
:label="FieldPermissionType.READ"
/>
</Col>
<Col :span="8" class="flex items-center justify-center">
<Radio
:value="FieldPermissionType.WRITE"
size="large"
:label="FieldPermissionType.WRITE"
/>
</Col>
<Col :span="8" class="flex items-center justify-center">
<Radio
:value="FieldPermissionType.NONE"
size="large"
:label="FieldPermissionType.NONE"
/>
</Col>
</Row>
</RadioGroup>
</Col>
</Row>
</div>
</div>
</TabPane>
</Tabs>
</Drawer>
</template>

View File

@@ -0,0 +1,687 @@
<script setup lang="ts">
import type { Rule } from 'ant-design-vue/es/form';
import type { SelectValue } from 'ant-design-vue/es/select';
import type {
FormTriggerSetting,
SimpleFlowNode,
TriggerSetting,
} from '../../consts';
import { computed, getCurrentInstance, onMounted, reactive, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import {
Button,
Card,
Col,
Divider,
Form,
FormItem,
Input,
message,
Row,
Select,
SelectOption,
Tag,
} from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import {
DEFAULT_CONDITION_GROUP_VALUE,
TRIGGER_TYPES,
TriggerTypeEnum,
} from '../../consts';
import {
getConditionShowText,
useFormFields,
useFormFieldsAndStartUser,
useNodeName,
useWatchNode,
} from '../../helpers';
import ConditionDialog from './modules/condition-dialog.vue';
import HttpRequestSetting from './modules/http-request-setting.vue';
defineOptions({
name: 'TriggerNodeConfig',
});
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
const { proxy } = getCurrentInstance() as any;
// 抽屉配置
const [Drawer, drawerApi] = useVbenDrawer({
header: true,
closable: true,
title: '',
onConfirm() {
saveConfig();
},
});
// 当前节点
const currentNode = useWatchNode(props);
// 节点名称
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
useNodeName(BpmNodeTypeEnum.TRIGGER_NODE);
// 触发器表单配置
const formRef = ref(); // 表单 Ref
// 表单校验规则
const formRules: Record<string, Rule[]> = reactive({
type: [{ required: true, message: '触发器类型不能为空', trigger: 'change' }],
'httpRequestSetting.url': [
{ required: true, message: '请求地址不能为空', trigger: 'blur' },
],
});
// 触发器配置表单数据
const configForm = ref<TriggerSetting>({
type: TriggerTypeEnum.HTTP_REQUEST,
httpRequestSetting: {
url: '',
header: [],
body: [],
response: [],
},
formSettings: [
{
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
updateFormFields: {},
deleteFields: [],
},
],
});
// 流程表单字段
const formFields = useFormFields();
// 可选的修改的表单字段
const optionalUpdateFormFields = computed(() => {
return formFields.map((field) => ({
title: field.title,
field: field.field,
disabled: false,
}));
});
let originalSetting: TriggerSetting | undefined;
/** 触发器类型改变了 */
function changeTriggerType() {
if (configForm.value.type === TriggerTypeEnum.HTTP_REQUEST) {
configForm.value.httpRequestSetting =
originalSetting?.type === TriggerTypeEnum.HTTP_REQUEST &&
originalSetting.httpRequestSetting
? originalSetting.httpRequestSetting
: {
url: '',
header: [],
body: [],
response: [],
};
configForm.value.formSettings = undefined;
return;
}
if (configForm.value.type === TriggerTypeEnum.HTTP_CALLBACK) {
configForm.value.httpRequestSetting =
originalSetting?.type === TriggerTypeEnum.HTTP_CALLBACK &&
originalSetting.httpRequestSetting
? originalSetting.httpRequestSetting
: {
url: '',
header: [],
body: [],
response: [],
};
configForm.value.formSettings = undefined;
return;
}
if (configForm.value.type === TriggerTypeEnum.FORM_UPDATE) {
configForm.value.formSettings =
originalSetting?.type === TriggerTypeEnum.FORM_UPDATE &&
originalSetting.formSettings
? originalSetting.formSettings
: [
{
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
updateFormFields: {},
deleteFields: [],
},
];
configForm.value.httpRequestSetting = undefined;
return;
}
if (configForm.value.type === TriggerTypeEnum.FORM_DELETE) {
configForm.value.formSettings =
originalSetting?.type === TriggerTypeEnum.FORM_DELETE &&
originalSetting.formSettings
? originalSetting.formSettings
: [
{
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
updateFormFields: undefined,
deleteFields: [],
},
];
configForm.value.httpRequestSetting = undefined;
}
}
/** 添加新的修改表单设置 */
function addFormSetting() {
configForm.value.formSettings!.push({
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
updateFormFields: {},
deleteFields: [],
});
}
/** 删除修改表单设置 */
function deleteFormSetting(index: number) {
configForm.value.formSettings!.splice(index, 1);
}
/** 添加条件配置 */
function addFormSettingCondition(
index: number,
formSetting: FormTriggerSetting,
) {
const conditionDialog = proxy.$refs[`condition-${index}`][0];
// 使用modalApi来打开模态框并传递数据
conditionDialog.modalApi.setData(formSetting).open();
}
/** 删除条件配置 */
function deleteFormSettingCondition(formSetting: FormTriggerSetting) {
formSetting.conditionType = undefined;
}
/** 打开条件配置弹窗 */
function openFormSettingCondition(
index: number,
formSetting: FormTriggerSetting,
) {
const conditionDialog = proxy.$refs[`condition-${index}`][0];
// 使用 modalApi 来打开模态框并传递数据
conditionDialog.modalApi.setData(formSetting).open();
}
/** 处理条件配置保存 */
function handleConditionUpdate(index: number, condition: any) {
if (configForm.value.formSettings![index]) {
configForm.value.formSettings![index].conditionType =
condition.conditionType;
configForm.value.formSettings![index].conditionExpression =
condition.conditionExpression;
configForm.value.formSettings![index].conditionGroups =
condition.conditionGroups;
}
}
// 包含发起人字段的表单字段
const includeStartUserFormFields = useFormFieldsAndStartUser();
/** 条件配置展示 */
function showConditionText(formSetting: FormTriggerSetting) {
return getConditionShowText(
formSetting.conditionType,
formSetting.conditionExpression,
formSetting.conditionGroups,
includeStartUserFormFields,
);
}
/** 添加修改字段设置项 */
function addFormFieldSetting(formSetting: FormTriggerSetting) {
if (!formSetting) return;
if (!formSetting.updateFormFields) {
formSetting.updateFormFields = {};
}
formSetting.updateFormFields[''] = undefined;
}
/** 更新字段 KEY */
function updateFormFieldKey(
formSetting: FormTriggerSetting,
oldKey: string,
newKey: SelectValue,
) {
if (!formSetting?.updateFormFields || !newKey) return;
const value = formSetting.updateFormFields[oldKey];
delete formSetting.updateFormFields[oldKey];
formSetting.updateFormFields[String(newKey)] = value;
}
/** 删除修改字段设置项 */
function deleteFormFieldSetting(formSetting: FormTriggerSetting, key: string) {
if (!formSetting?.updateFormFields) return;
delete formSetting.updateFormFields[key];
}
/** 保存配置 */
async function saveConfig() {
if (!formRef.value) return false;
const valid = await formRef.value.validate();
if (!valid) return false;
const showText = getShowText();
if (!showText) return false;
currentNode.value.name = nodeName.value!;
currentNode.value.showText = showText;
switch (configForm.value.type) {
case TriggerTypeEnum.FORM_DELETE: {
configForm.value.httpRequestSetting = undefined;
// 清理修改字段相关的数据
configForm.value.formSettings?.forEach((setting) => {
setting.updateFormFields = undefined;
});
break;
}
case TriggerTypeEnum.FORM_UPDATE: {
configForm.value.httpRequestSetting = undefined;
// 清理删除字段相关的数据
configForm.value.formSettings?.forEach((setting) => {
setting.deleteFields = undefined;
});
break;
}
case TriggerTypeEnum.HTTP_REQUEST: {
configForm.value.formSettings = undefined;
break;
}
// No default
}
currentNode.value.triggerSetting = configForm.value;
drawerApi.close();
return true;
}
/** 获取节点展示内容 */
function getShowText(): string {
let showText = '';
switch (configForm.value.type) {
case TriggerTypeEnum.FORM_DELETE: {
for (const [index, setting] of configForm.value.formSettings!.entries()) {
if (!setting.deleteFields || setting.deleteFields.length === 0) {
message.warning(`请选择表单设置${index + 1}要删除的字段`);
return '';
}
}
showText = '删除表单数据';
break;
}
case TriggerTypeEnum.FORM_UPDATE: {
for (const [index, setting] of configForm.value.formSettings!.entries()) {
if (
!setting.updateFormFields ||
Object.keys(setting.updateFormFields).length === 0
) {
message.warning(`请添加表单设置${index + 1}的修改字段`);
return '';
}
}
showText = '修改表单数据';
break;
}
case TriggerTypeEnum.HTTP_CALLBACK:
case TriggerTypeEnum.HTTP_REQUEST: {
showText = `${configForm.value.httpRequestSetting?.url}`;
break;
}
// No default
}
return showText;
}
/** 显示触发器节点配置, 由父组件传过来 */
function showTriggerNodeConfig(node: SimpleFlowNode) {
nodeName.value = node.name;
originalSetting = node.triggerSetting
? cloneDeep(node.triggerSetting)
: undefined;
if (node.triggerSetting) {
configForm.value = {
type: node.triggerSetting.type,
httpRequestSetting: node.triggerSetting.httpRequestSetting || {
url: '',
header: [],
body: [],
response: [],
},
formSettings: node.triggerSetting.formSettings || [
{
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
updateFormFields: {},
deleteFields: [],
},
],
};
}
drawerApi.open();
}
// 暴露方法给父组件
defineExpose({ showTriggerNodeConfig });
onMounted(() => {
// 初始化可能需要的操作
});
</script>
<template>
<Drawer class="w-1/3">
<template #title>
<div class="config-header">
<Input
ref="inputRef"
v-if="showInput"
type="text"
class="focus:border-blue-500 focus:shadow-[0_0_0_2px_rgba(24,144,255,0.2)] focus:outline-none"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div v-else class="node-name">
{{ nodeName }}
<IconifyIcon class="ml-1" icon="lucide:edit-3" @click="clickIcon()" />
</div>
</div>
</template>
<div>
<Form
ref="formRef"
:model="configForm"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
:rules="formRules"
>
<FormItem label="触发器类型" name="type">
<Select v-model:value="configForm.type" @change="changeTriggerType">
<SelectOption
v-for="(item, index) in TRIGGER_TYPES"
:key="index"
:value="item.value"
:label="item.label"
>
{{ item.label }}
</SelectOption>
</Select>
</FormItem>
<!-- HTTP 请求触发器 -->
<div
v-if="
[
TriggerTypeEnum.HTTP_REQUEST,
TriggerTypeEnum.HTTP_CALLBACK,
].includes(configForm.type) && configForm.httpRequestSetting
"
>
<HttpRequestSetting
v-model:setting="configForm.httpRequestSetting"
:response-enable="configForm.type === TriggerTypeEnum.HTTP_REQUEST"
form-item-prefix="httpRequestSetting"
/>
</div>
<!-- 表单数据修改触发器 -->
<div v-if="configForm.type === TriggerTypeEnum.FORM_UPDATE">
<div
v-for="(formSetting, index) in configForm.formSettings"
:key="index"
>
<Card class="mt-4">
<template #title>
<div class="flex w-full items-center justify-between">
<span>修改表单设置 {{ index + 1 }}</span>
<Button
v-if="configForm.formSettings!.length > 1"
shape="circle"
class="flex items-center justify-center"
@click="deleteFormSetting(index)"
>
<template #icon>
<IconifyIcon icon="lucide:x" />
</template>
</Button>
</div>
</template>
<ConditionDialog
:ref="`condition-${index}`"
@update-condition="(val) => handleConditionUpdate(index, val)"
/>
<Row>
<Col :span="24">
<div class="cursor-pointer" v-if="formSetting.conditionType">
<Tag
color="success"
closable
class="text-sm"
@close="deleteFormSettingCondition(formSetting)"
@click="openFormSettingCondition(index, formSetting)"
>
{{ showConditionText(formSetting) }}
</Tag>
</div>
<Button
v-else
type="link"
class="flex items-center p-0"
@click="addFormSettingCondition(index, formSetting)"
>
<template #icon>
<IconifyIcon icon="lucide:link" />
</template>
添加条件
</Button>
</Col>
</Row>
<Divider>修改表单字段设置</Divider>
<!-- 表单字段修改设置 -->
<Row
:gutter="8"
v-for="key in Object.keys(formSetting.updateFormFields || {})"
:key="key"
>
<Col :span="8">
<FormItem>
<Select
:value="key || undefined"
@change="
(newKey) => updateFormFieldKey(formSetting, key, newKey)
"
placeholder="请选择表单字段"
:disabled="key !== ''"
allow-clear
>
<SelectOption
v-for="(field, fIdx) in optionalUpdateFormFields"
:key="fIdx"
:label="field.title"
:value="field.field"
:disabled="field.disabled"
>
{{ field.title }}
</SelectOption>
</Select>
</FormItem>
</Col>
<Col :span="4">
<FormItem>的值设置为</FormItem>
</Col>
<Col :span="10">
<FormItem
:name="['formSettings', index, 'updateFormFields', key]"
:rules="{
required: true,
message: '值不能为空',
trigger: 'blur',
}"
>
<Input
v-model:value="formSetting.updateFormFields![key]"
placeholder="请输入值"
allow-clear
:disabled="!key"
/>
</FormItem>
</Col>
<Col :span="2">
<div class="flex h-8 items-center">
<IconifyIcon
class="size-4 cursor-pointer text-red-500"
icon="lucide:trash-2"
@click="deleteFormFieldSetting(formSetting, key)"
/>
</div>
</Col>
</Row>
<!-- 添加表单字段按钮 -->
<Row>
<Col :span="24">
<Button
type="link"
class="flex items-center p-0"
@click="addFormFieldSetting(formSetting)"
>
<template #icon>
<IconifyIcon icon="lucide:file-cog" />
</template>
添加修改字段
</Button>
</Col>
</Row>
</Card>
</div>
<!-- 添加新的设置 -->
<Row class="mt-6">
<Col :span="24">
<Button
class="flex items-center p-0"
type="link"
@click="addFormSetting"
>
<template #icon>
<IconifyIcon icon="lucide:settings" />
</template>
添加设置
</Button>
</Col>
</Row>
</div>
<!-- 表单数据删除触发器 -->
<div v-if="configForm.type === TriggerTypeEnum.FORM_DELETE">
<div
v-for="(formSetting, index) in configForm.formSettings"
:key="index"
>
<Card class="mt-4">
<template #title>
<div class="flex w-full items-center justify-between">
<span>删除表单设置 {{ index + 1 }}</span>
<Button
v-if="configForm.formSettings!.length > 1"
shape="circle"
class="flex items-center justify-center"
@click="deleteFormSetting(index)"
>
<template #icon>
<IconifyIcon icon="lucide:x" />
</template>
</Button>
</div>
</template>
<!-- 条件设置 -->
<ConditionDialog
:ref="`condition-${index}`"
@update-condition="(val) => handleConditionUpdate(index, val)"
/>
<Row>
<Col :span="24">
<div class="cursor-pointer" v-if="formSetting.conditionType">
<Tag
color="success"
closable
class="text-sm"
@close="deleteFormSettingCondition(formSetting)"
@click="openFormSettingCondition(index, formSetting)"
>
{{ showConditionText(formSetting) }}
</Tag>
</div>
<Button
v-else
type="link"
class="flex items-center p-0"
@click="addFormSettingCondition(index, formSetting)"
>
<template #icon>
<IconifyIcon icon="lucide:link" />
</template>
添加条件
</Button>
</Col>
</Row>
<Divider>删除表单字段设置</Divider>
<!-- 表单字段删除设置 -->
<div class="flex flex-wrap gap-2">
<Select
v-model:value="formSetting.deleteFields"
mode="multiple"
placeholder="请选择要删除的字段"
class="w-full"
>
<SelectOption
v-for="field in formFields"
:key="field.field"
:label="field.title"
:value="field.field"
>
{{ field.title }}
</SelectOption>
</Select>
</div>
</Card>
</div>
<!-- 添加新的设置 -->
<Row class="mt-6">
<Col :span="24">
<Button
class="flex items-center p-0"
type="link"
@click="addFormSetting"
>
<template #icon>
<IconifyIcon icon="lucide:settings" />
</template>
添加设置
</Button>
</Col>
</Row>
</div>
</Form>
</div>
</Drawer>
</template>

View File

@@ -0,0 +1,48 @@
import { APPROVE_TYPE, ApproveType, TimeUnitType } from '../../consts';
/** 获取条件节点默认的名称 */
export function getDefaultConditionNodeName(
index: number,
defaultFlow: boolean | undefined,
): string {
if (defaultFlow) {
return '其它情况';
}
return `条件${index + 1}`;
}
/** 获取包容分支条件节点默认的名称 */
export function getDefaultInclusiveConditionNodeName(
index: number,
defaultFlow: boolean | undefined,
): string {
if (defaultFlow) {
return '其它情况';
}
return `包容条件${index + 1}`;
}
/** 转换时间单位字符串为枚举值 */
export function convertTimeUnit(strTimeUnit: string) {
if (strTimeUnit === 'M') {
return TimeUnitType.MINUTE;
}
if (strTimeUnit === 'H') {
return TimeUnitType.HOUR;
}
if (strTimeUnit === 'D') {
return TimeUnitType.DAY;
}
return TimeUnitType.HOUR;
}
/** 根据审批类型获取对应的文本描述 */
export function getApproveTypeText(approveType: ApproveType): string {
let approveTypeText = '';
APPROVE_TYPE.forEach((item) => {
if (item.value === approveType) {
approveTypeText = item.label;
}
});
return approveTypeText;
}

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import CopyTaskNodeConfig from '../nodes-config/copy-task-node-config.vue';
import NodeHandler from './node-handler.vue';
defineOptions({
name: 'CopyTaskNode',
});
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件。
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();
// 是否只读
const readonly = inject<Boolean>('readonly');
// 监控节点的变化
const currentNode = useWatchNode(props);
// 节点名称编辑
const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
currentNode,
BpmNodeTypeEnum.COPY_TASK_NODE,
);
const nodeSetting = ref();
// 打开节点配置
function openNodeConfig() {
if (readonly) {
return;
}
nodeSetting.value.showCopyTaskNodeConfig(currentNode.value);
nodeSetting.value.openDrawer();
}
// 删除节点。更新当前节点为孩子节点
function deleteNode() {
emits('update:flowNode', currentNode.value.childNode);
}
</script>
<template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !currentNode.showText },
`${useTaskStatusClass(currentNode?.activityStatus)}`,
]"
>
<div class="node-title-container">
<div class="node-title-icon copy-task">
<span class="iconfont icon-copy"></span>
</div>
<Input
ref="inputRef"
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div v-else class="node-title" @click="clickTitle">
{{ currentNode.name }}
</div>
</div>
<div class="node-content" @click="openNodeConfig">
<div
class="node-text"
:title="currentNode.showText"
v-if="currentNode.showText"
>
{{ currentNode.showText }}
</div>
<div class="node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.COPY_TASK_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="lucide:chevron-right" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
:size="18"
@click="deleteNode"
/>
</div>
</div>
</div>
<!-- 传递子节点给添加节点组件会在子节点前面添加节点 -->
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
<CopyTaskNodeConfig
v-if="!readonly && currentNode"
ref="nodeSetting"
:flow-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import DelayTimerNodeConfig from '../nodes-config/delay-timer-node-config.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'DelayTimerNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件。
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();
// 是否只读
const readonly = inject<Boolean>('readonly');
// 监控节点的变化
const currentNode = useWatchNode(props);
// 节点名称编辑
const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
currentNode,
BpmNodeTypeEnum.DELAY_TIMER_NODE,
);
const nodeSetting = ref();
// 打开节点配置
function openNodeConfig() {
if (readonly) {
return;
}
nodeSetting.value.openDrawer(currentNode.value);
}
// 删除节点。更新当前节点为孩子节点
function deleteNode() {
emits('update:flowNode', currentNode.value.childNode);
}
</script>
<template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !currentNode.showText },
`${useTaskStatusClass(currentNode?.activityStatus)}`,
]"
>
<div class="node-title-container">
<div class="node-title-icon delay-node">
<span class="iconfont icon-delay"></span>
</div>
<Input
ref="inputRef"
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div v-else class="node-title" @click="clickTitle">
{{ currentNode.name }}
</div>
</div>
<div class="node-content" @click="openNodeConfig">
<div
class="node-text"
:title="currentNode.showText"
v-if="currentNode.showText"
>
{{ currentNode.showText }}
</div>
<div class="node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.DELAY_TIMER_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="lucide:chevron-right" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
:size="18"
@click="deleteNode"
/>
</div>
</div>
</div>
<!-- 传递子节点给添加节点组件会在子节点前面添加节点 -->
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
<DelayTimerNodeConfig
v-if="!readonly && currentNode"
ref="nodeSetting"
:flow-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useTaskStatusClass, useWatchNode } from '../../helpers';
import ProcessInstanceModal from './modules/process-instance-modal.vue';
defineOptions({ name: 'EndEventNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
default: () => null,
},
});
// 监控节点变化
const currentNode = useWatchNode(props);
// 是否只读
const readonly = inject<Boolean>('readonly');
const processInstance = inject<Ref<any>>('processInstance', ref({}));
const [Modal, modalApi] = useVbenModal({
connectedComponent: ProcessInstanceModal,
destroyOnClose: true,
});
function nodeClick() {
if (readonly && processInstance && processInstance.value) {
const processInstanceInfo = [
{
startUser: processInstance.value.startUser,
createTime: processInstance.value.startTime,
endTime: processInstance.value.endTime,
status: processInstance.value.status,
durationInMillis: processInstance.value.durationInMillis,
},
];
modalApi
.setData(processInstanceInfo)
.setState({ title: '流程信息' })
.open();
}
}
</script>
<template>
<div class="end-node-wrapper">
<div
class="end-node-box cursor-pointer"
:class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
@click="nodeClick"
>
<span class="node-fixed-name" title="结束">结束</span>
</div>
</div>
<!-- 流程信息弹窗 -->
<Modal />
</template>

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { getCurrentInstance, inject, nextTick, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep, buildShortUUID as generateUUID } from '@vben/utils';
import { Button, Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import {
ConditionType,
DEFAULT_CONDITION_GROUP_VALUE,
NODE_DEFAULT_TEXT,
} from '../../consts';
import { getDefaultConditionNodeName, useTaskStatusClass } from '../../helpers';
import ConditionNodeConfig from '../nodes-config/condition-node-config.vue';
import ProcessNodeTree from '../process-node-tree.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'ExclusiveNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件
const emits = defineEmits<{
findParentNode: [nodeList: SimpleFlowNode[], nodeType: number];
recursiveFindParentNode: [
nodeList: SimpleFlowNode[],
curentNode: SimpleFlowNode,
nodeType: number,
];
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();
const { proxy } = getCurrentInstance() as any;
// 是否只读
const readonly = inject<Boolean>('readonly');
const currentNode = ref<SimpleFlowNode>(props.flowNode);
watch(
() => props.flowNode,
(newValue) => {
currentNode.value = newValue;
},
);
// 条件节点名称输入框引用
const inputRefs = ref<HTMLInputElement[]>([]);
// 节点名称输入框显示状态
const showInputs = ref<boolean[]>([]);
// 监听显示状态变化
watch(
showInputs,
(newValues) => {
// 当状态为 true 时, 自动聚焦
newValues.forEach((value, index) => {
if (value) {
// 当显示状态从 false 变为 true 时, 自动聚焦
nextTick(() => {
inputRefs.value[index]?.focus();
});
}
});
},
{ deep: true },
);
// 修改节点名称
function changeNodeName(index: number) {
showInputs.value[index] = false;
const conditionNode = currentNode.value.conditionNodes?.at(
index,
) as SimpleFlowNode;
conditionNode.name =
conditionNode.name ||
getDefaultConditionNodeName(
index,
conditionNode.conditionSetting?.defaultFlow,
);
}
// 点击条件名称
function clickEvent(index: number) {
showInputs.value[index] = true;
}
function conditionNodeConfig(nodeId: string) {
if (readonly) {
return;
}
const conditionNode = proxy.$refs[nodeId][0];
conditionNode.open();
}
// 新增条件
function addCondition() {
const conditionNodes = currentNode.value.conditionNodes;
if (conditionNodes) {
const len = conditionNodes.length;
const lastIndex = len - 1;
const conditionData: SimpleFlowNode = {
id: `Flow_${generateUUID()}`,
name: `条件${len}`,
showText: '',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionNodes: [],
conditionSetting: {
defaultFlow: false,
conditionType: ConditionType.RULE,
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
},
};
conditionNodes.splice(lastIndex, 0, conditionData);
}
}
// 删除条件
function deleteCondition(index: number) {
const conditionNodes = currentNode.value.conditionNodes;
if (conditionNodes) {
conditionNodes.splice(index, 1);
if (conditionNodes.length === 1) {
const childNode = currentNode.value.childNode;
// 更新此节点为后续孩子节点
emits('update:modelValue', childNode);
}
}
}
// 移动节点
function moveNode(index: number, to: number) {
// -1 :向左 1 向右
if (
currentNode.value.conditionNodes &&
currentNode.value.conditionNodes[index]
) {
currentNode.value.conditionNodes[index] =
currentNode.value.conditionNodes.splice(
index + to,
1,
currentNode.value.conditionNodes[index],
)[0] as SimpleFlowNode;
}
}
// 递归从父节点中查询匹配的节点
function recursiveFindParentNode(
nodeList: SimpleFlowNode[],
node: SimpleFlowNode,
nodeType: number,
) {
if (!node || node.type === BpmNodeTypeEnum.START_USER_NODE) {
return;
}
if (node.type === nodeType) {
nodeList.push(node);
}
// 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点条件分支节点NodeType.EXCLUSIVE_NODE) 继续查找
emits('findParentNode', nodeList, nodeType);
}
</script>
<template>
<div class="branch-node-wrapper">
<div class="branch-node-container">
<div
v-if="readonly"
class="branch-node-readonly"
:class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
>
<span class="iconfont icon-exclusive icon-size condition"></span>
</div>
<Button v-else class="branch-node-add" @click="addCondition">
添加条件
</Button>
<!-- 排他网关节点下面可以多个分支每个分支第一个节点是条件节点 NodeType.CONDITION_NODE -->
<div
class="branch-node-item"
v-for="(item, index) in currentNode.conditionNodes"
:key="index"
>
<template v-if="index === 0">
<div class="branch-line-first-top"></div>
<div class="branch-line-first-bottom"></div>
</template>
<template v-if="index + 1 === currentNode.conditionNodes?.length">
<div class="branch-line-last-top"></div>
<div class="branch-line-last-bottom"></div>
</template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !item.showText },
`${useTaskStatusClass(item.activityStatus)}`,
]"
>
<div class="branch-node-title-container">
<div v-if="!readonly && showInputs[index]">
<Input
:ref="
(el) => {
inputRefs[index] = el as HTMLInputElement;
}
"
type="text"
class="editable-title-input"
@blur="changeNodeName(index)"
@press-enter="changeNodeName(index)"
v-model:value="item.name"
/>
</div>
<div v-else class="branch-title" @click="clickEvent(index)">
{{ item.name }}
</div>
<div class="branch-priority">优先级{{ index + 1 }}</div>
</div>
<div
class="branch-node-content"
@click="conditionNodeConfig(item.id)"
>
<div
class="branch-node-text"
:title="item.showText"
v-if="item.showText"
>
{{ item.showText }}
</div>
<div class="branch-node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.CONDITION_NODE) }}
</div>
</div>
<div
class="node-toolbar"
v-if="
!readonly && index + 1 !== currentNode.conditionNodes?.length
"
>
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
:size="18"
@click="deleteCondition(index)"
/>
</div>
</div>
<div
class="branch-node-move move-node-left"
v-if="
!readonly &&
index !== 0 &&
index + 1 !== currentNode.conditionNodes?.length
"
@click="moveNode(index, -1)"
>
<IconifyIcon icon="lucide:chevron-left" />
</div>
<div
class="branch-node-move move-node-right"
v-if="
!readonly &&
currentNode.conditionNodes &&
index < currentNode.conditionNodes.length - 2
"
@click="moveNode(index, 1)"
>
<IconifyIcon icon="lucide:chevron-right" />
</div>
</div>
<NodeHandler
v-model:child-node="item.childNode"
:current-node="item"
/>
</div>
</div>
<!-- 条件节点配置 -->
<ConditionNodeConfig
:node-index="index"
:condition-node="item"
:ref="item.id"
/>
<!-- 递归显示子节点 -->
<ProcessNodeTree
v-if="item && item.childNode"
:parent-node="item"
v-model:flow-node="item.childNode"
@recursive-find-parent-node="recursiveFindParentNode"
/>
</div>
</div>
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { getCurrentInstance, inject, nextTick, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep, buildShortUUID as generateUUID } from '@vben/utils';
import { Button, Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import {
ConditionType,
DEFAULT_CONDITION_GROUP_VALUE,
NODE_DEFAULT_TEXT,
} from '../../consts';
import {
getDefaultInclusiveConditionNodeName,
useTaskStatusClass,
} from '../../helpers';
import ConditionNodeConfig from '../nodes-config/condition-node-config.vue';
import ProcessNodeTree from '../process-node-tree.vue';
import NodeHandler from './node-handler.vue';
defineOptions({
name: 'InclusiveNode',
});
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件
const emits = defineEmits<{
findParentNode: [nodeList: SimpleFlowNode[], nodeType: number];
recursiveFindParentNode: [
nodeList: SimpleFlowNode[],
curentNode: SimpleFlowNode,
nodeType: number,
];
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();
const { proxy } = getCurrentInstance() as any;
// 是否只读
const readonly = inject<Boolean>('readonly');
const currentNode = ref<SimpleFlowNode>(props.flowNode);
watch(
() => props.flowNode,
(newValue) => {
currentNode.value = newValue;
},
);
// 条件节点名称输入框引用
const inputRefs = ref<HTMLInputElement[]>([]);
// 节点名称输入框显示状态
const showInputs = ref<boolean[]>([]);
// 监听显示状态变化
watch(
showInputs,
(newValues) => {
// 当状态为 true 时, 自动聚焦
newValues.forEach((value, index) => {
if (value) {
// 当显示状态从 false 变为 true 时, 自动聚焦
nextTick(() => {
inputRefs.value[index]?.focus();
});
}
});
},
{ deep: true },
);
// 修改节点名称
function changeNodeName(index: number) {
showInputs.value[index] = false;
const conditionNode = currentNode.value.conditionNodes?.at(
index,
) as SimpleFlowNode;
conditionNode.name =
conditionNode.name ||
getDefaultInclusiveConditionNodeName(
index,
conditionNode.conditionSetting?.defaultFlow,
);
}
// 点击条件名称
function clickEvent(index: number) {
showInputs.value[index] = true;
}
function conditionNodeConfig(nodeId: string) {
if (readonly) {
return;
}
const conditionNode = proxy.$refs[nodeId][0];
conditionNode.open();
}
// 新增条件
function addCondition() {
const conditionNodes = currentNode.value.conditionNodes;
if (conditionNodes) {
const len = conditionNodes.length;
const lastIndex = len - 1;
const conditionData: SimpleFlowNode = {
id: `Flow_${generateUUID()}`,
name: `包容条件${len}`,
showText: '',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionNodes: [],
conditionSetting: {
defaultFlow: false,
conditionType: ConditionType.RULE,
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
},
};
conditionNodes.splice(lastIndex, 0, conditionData);
}
}
// 删除条件
function deleteCondition(index: number) {
const conditionNodes = currentNode.value.conditionNodes;
if (conditionNodes) {
conditionNodes.splice(index, 1);
if (conditionNodes.length === 1) {
const childNode = currentNode.value.childNode;
// 更新此节点为后续孩子节点
emits('update:modelValue', childNode);
}
}
}
// 移动节点
function moveNode(index: number, to: number) {
// -1 :向左 1 向右
if (
currentNode.value.conditionNodes &&
currentNode.value.conditionNodes[index]
) {
currentNode.value.conditionNodes[index] =
currentNode.value.conditionNodes.splice(
index + to,
1,
currentNode.value.conditionNodes[index],
)[0] as SimpleFlowNode;
}
}
// 递归从父节点中查询匹配的节点
function recursiveFindParentNode(
nodeList: SimpleFlowNode[],
node: SimpleFlowNode,
nodeType: number,
) {
if (!node || node.type === BpmNodeTypeEnum.START_USER_NODE) {
return;
}
if (node.type === nodeType) {
nodeList.push(node);
}
// 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点条件分支节点NodeType.INCLUSIVE_BRANCH_NODE) 继续查找
emits('findParentNode', nodeList, nodeType);
}
</script>
<template>
<div class="branch-node-wrapper">
<div class="branch-node-container">
<div
v-if="readonly"
class="branch-node-readonly"
:class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
>
<span class="iconfont icon-inclusive icon-size inclusive"></span>
</div>
<Button v-else class="branch-node-add" @click="addCondition">
添加条件
</Button>
<div
class="branch-node-item"
v-for="(item, index) in currentNode.conditionNodes"
:key="index"
>
<template v-if="index === 0">
<div class="branch-line-first-top"></div>
<div class="branch-line-first-bottom"></div>
</template>
<template v-if="index + 1 === currentNode.conditionNodes?.length">
<div class="branch-line-last-top"></div>
<div class="branch-line-last-bottom"></div>
</template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !item.showText },
`${useTaskStatusClass(item.activityStatus)}`,
]"
>
<div class="branch-node-title-container">
<div v-if="!readonly && showInputs[index]">
<Input
:ref="
(el) => {
inputRefs[index] = el as HTMLInputElement;
}
"
type="text"
class="editable-title-input"
@blur="changeNodeName(index)"
@press-enter="changeNodeName(index)"
v-model:value="item.name"
/>
</div>
<div v-else class="branch-title" @click="clickEvent(index)">
{{ item.name }}
</div>
</div>
<div
class="branch-node-content"
@click="conditionNodeConfig(item.id)"
>
<div
class="branch-node-text"
:title="item.showText"
v-if="item.showText"
>
{{ item.showText }}
</div>
<div class="branch-node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.CONDITION_NODE) }}
</div>
</div>
<div
class="node-toolbar"
v-if="
!readonly && index + 1 !== currentNode.conditionNodes?.length
"
>
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
:size="18"
@click="deleteCondition(index)"
/>
</div>
</div>
<div
class="branch-node-move move-node-left"
v-if="
!readonly &&
index !== 0 &&
index + 1 !== currentNode.conditionNodes?.length
"
@click="moveNode(index, -1)"
>
<IconifyIcon icon="lucide:chevron-left" />
</div>
<div
class="branch-node-move move-node-right"
v-if="
!readonly &&
currentNode.conditionNodes &&
index < currentNode.conditionNodes.length - 2
"
@click="moveNode(index, 1)"
>
<IconifyIcon icon="lucide:chevron-right" />
</div>
</div>
<NodeHandler
v-model:child-node="item.childNode"
:current-node="item"
/>
</div>
</div>
<!-- 条件节点配置 -->
<ConditionNodeConfig
:node-index="index"
:condition-node="item"
:ref="item.id"
/>
<!-- 递归显示子节点 -->
<ProcessNodeTree
v-if="item && item.childNode"
:parent-node="item"
v-model:flow-node="item.childNode"
@recursive-find-parent-node="recursiveFindParentNode"
/>
</div>
</div>
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,56 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { DICT_TYPE } from '#/utils';
/** 流程实例列表字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'startUser',
title: '发起人',
slots: {
default: ({ row }: { row: any }) => {
return row.startUser?.nickname;
},
},
minWidth: 100,
},
{
field: 'deptName',
title: '部门',
slots: {
default: ({ row }: { row: any }) => {
return row.startUser?.deptName;
},
},
minWidth: 100,
},
{
field: 'createTime',
title: '开始时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'endTime',
title: '结束时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'status',
title: '流程状态',
minWidth: 90,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS },
},
},
{
field: 'durationInMillis',
title: '耗时',
minWidth: 100,
formatter: 'formatPast2',
},
];
}

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { useGridColumns } from './process-instance-data';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
border: true,
height: 'auto',
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
const [Modal, modalApi] = useVbenModal({
footer: false,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
const data = modalApi.getData<any[]>();
// 填充列表数据
await gridApi.setGridOptions({ data });
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/4">
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,61 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { DICT_TYPE } from '#/utils';
/** 审批记录列表字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'assigneeUser',
title: '审批人',
slots: {
default: ({ row }: { row: any }) => {
return row.assigneeUser?.nickname || row.ownerUser?.nickname;
},
},
minWidth: 100,
},
{
field: 'deptName',
title: '部门',
slots: {
default: ({ row }: { row: any }) => {
return row.assigneeUser?.deptName || row.ownerUser?.deptName;
},
},
minWidth: 100,
},
{
field: 'createTime',
title: '开始时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'endTime',
title: '结束时间',
formatter: 'formatDateTime',
minWidth: 140,
},
{
field: 'status',
title: '审批状态',
minWidth: 90,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BPM_TASK_STATUS },
},
},
{
field: 'reason',
title: '审批建议',
minWidth: 160,
},
{
field: 'durationInMillis',
title: '耗时',
minWidth: 100,
formatter: 'formatPast2',
},
];
}

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { useGridColumns } from './task-list-data';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
border: true,
height: 'auto',
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
const [Modal, modalApi] = useVbenModal({
footer: false,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
const data = modalApi.getData<any[]>();
// 填充列表数据
await gridApi.setGridOptions({ data });
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/4">
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,351 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep, buildShortUUID as generateUUID } from '@vben/utils';
import { message, Popover } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import {
ApproveMethodType,
AssignEmptyHandlerType,
AssignStartUserHandlerType,
ConditionType,
DEFAULT_CONDITION_GROUP_VALUE,
NODE_DEFAULT_NAME,
RejectHandlerType,
} from '../../consts';
defineOptions({
name: 'NodeHandler',
});
const props = defineProps({
childNode: {
type: Object as () => SimpleFlowNode,
default: null,
},
currentNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
const emits = defineEmits(['update:childNode']);
const popoverShow = ref(false);
const readonly = inject<Boolean>('readonly'); // 是否只读
function addNode(type: number) {
// 校验:条件分支、包容分支后面,不允许直接添加并行分支
if (
type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE &&
[
BpmNodeTypeEnum.CONDITION_BRANCH_NODE,
BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE,
].includes(props.currentNode?.type)
) {
message.error('条件分支、包容分支后面,不允许直接添加并行分支');
return;
}
popoverShow.value = false;
if (
type === BpmNodeTypeEnum.USER_TASK_NODE ||
type === BpmNodeTypeEnum.TRANSACTOR_NODE
) {
const id = `Activity_${generateUUID()}`;
const data: SimpleFlowNode = {
id,
name: NODE_DEFAULT_NAME.get(type) as string,
showText: '',
type,
approveMethod: ApproveMethodType.SEQUENTIAL_APPROVE,
// 超时处理
rejectHandler: {
type: RejectHandlerType.FINISH_PROCESS,
},
timeoutHandler: {
enable: false,
},
assignEmptyHandler: {
type: AssignEmptyHandlerType.APPROVE,
},
assignStartUserHandlerType: AssignStartUserHandlerType.START_USER_AUDIT,
childNode: props.childNode,
taskCreateListener: {
enable: false,
},
taskAssignListener: {
enable: false,
},
taskCompleteListener: {
enable: false,
},
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.COPY_TASK_NODE) {
const data: SimpleFlowNode = {
id: `Activity_${generateUUID()}`,
name: NODE_DEFAULT_NAME.get(BpmNodeTypeEnum.COPY_TASK_NODE) as string,
showText: '',
type: BpmNodeTypeEnum.COPY_TASK_NODE,
childNode: props.childNode,
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.CONDITION_BRANCH_NODE) {
const data: SimpleFlowNode = {
name: '条件分支',
type: BpmNodeTypeEnum.CONDITION_BRANCH_NODE,
id: `GateWay_${generateUUID()}`,
childNode: props.childNode,
conditionNodes: [
{
id: `Flow_${generateUUID()}`,
name: '条件1',
showText: '',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionSetting: {
defaultFlow: false,
conditionType: ConditionType.RULE,
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
},
},
{
id: `Flow_${generateUUID()}`,
name: '其它情况',
showText: '未满足其它条件时,将进入此分支',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionSetting: {
defaultFlow: true,
},
},
],
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE) {
const data: SimpleFlowNode = {
name: '并行分支',
type: BpmNodeTypeEnum.PARALLEL_BRANCH_NODE,
id: `GateWay_${generateUUID()}`,
childNode: props.childNode,
conditionNodes: [
{
id: `Flow_${generateUUID()}`,
name: '并行1',
showText: '无需配置条件同时执行',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
},
{
id: `Flow_${generateUUID()}`,
name: '并行2',
showText: '无需配置条件同时执行',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
},
],
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE) {
const data: SimpleFlowNode = {
name: '包容分支',
type: BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE,
id: `GateWay_${generateUUID()}`,
childNode: props.childNode,
conditionNodes: [
{
id: `Flow_${generateUUID()}`,
name: '包容条件1',
showText: '',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionSetting: {
defaultFlow: false,
conditionType: ConditionType.RULE,
conditionGroups: cloneDeep(DEFAULT_CONDITION_GROUP_VALUE),
},
},
{
id: `Flow_${generateUUID()}`,
name: '其它情况',
showText: '未满足其它条件时,将进入此分支',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionSetting: {
defaultFlow: true,
},
},
],
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.DELAY_TIMER_NODE) {
const data: SimpleFlowNode = {
id: `Activity_${generateUUID()}`,
name: NODE_DEFAULT_NAME.get(BpmNodeTypeEnum.DELAY_TIMER_NODE) as string,
showText: '',
type: BpmNodeTypeEnum.DELAY_TIMER_NODE,
childNode: props.childNode,
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.ROUTER_BRANCH_NODE) {
const data: SimpleFlowNode = {
id: `GateWay_${generateUUID()}`,
name: NODE_DEFAULT_NAME.get(BpmNodeTypeEnum.ROUTER_BRANCH_NODE) as string,
showText: '',
type: BpmNodeTypeEnum.ROUTER_BRANCH_NODE,
childNode: props.childNode,
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.TRIGGER_NODE) {
const data: SimpleFlowNode = {
id: `Activity_${generateUUID()}`,
name: NODE_DEFAULT_NAME.get(BpmNodeTypeEnum.TRIGGER_NODE) as string,
showText: '',
type: BpmNodeTypeEnum.TRIGGER_NODE,
childNode: props.childNode,
};
emits('update:childNode', data);
}
if (type === BpmNodeTypeEnum.CHILD_PROCESS_NODE) {
const data: SimpleFlowNode = {
id: `Activity_${generateUUID()}`,
name: NODE_DEFAULT_NAME.get(BpmNodeTypeEnum.CHILD_PROCESS_NODE) as string,
showText: '',
type: BpmNodeTypeEnum.CHILD_PROCESS_NODE,
childNode: props.childNode,
childProcessSetting: {
calledProcessDefinitionKey: '',
calledProcessDefinitionName: '',
async: false,
skipStartUserNode: false,
startUserSetting: {
type: 1,
},
timeoutSetting: {
enable: false,
},
multiInstanceSetting: {
enable: false,
},
},
};
emits('update:childNode', data);
}
}
</script>
<template>
<div class="node-handler-wrapper">
<div class="node-handler">
<Popover trigger="hover" placement="right" width="auto" v-if="!readonly">
<template #content>
<div class="handler-item-wrapper">
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.USER_TASK_NODE)"
>
<div class="approve handler-item-icon">
<span class="iconfont icon-approve icon-size"></span>
</div>
<div class="handler-item-text">审批人</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.TRANSACTOR_NODE)"
>
<div class="transactor handler-item-icon">
<span class="iconfont icon-transactor icon-size"></span>
</div>
<div class="handler-item-text">办理人</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.COPY_TASK_NODE)"
>
<div class="handler-item-icon copy">
<span class="iconfont icon-size icon-copy"></span>
</div>
<div class="handler-item-text">抄送</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.CONDITION_BRANCH_NODE)"
>
<div class="handler-item-icon condition">
<span class="iconfont icon-size icon-exclusive"></span>
</div>
<div class="handler-item-text">条件分支</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.PARALLEL_BRANCH_NODE)"
>
<div class="handler-item-icon parallel">
<span class="iconfont icon-size icon-parallel"></span>
</div>
<div class="handler-item-text">并行分支</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE)"
>
<div class="handler-item-icon inclusive">
<span class="iconfont icon-size icon-inclusive"></span>
</div>
<div class="handler-item-text">包容分支</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.DELAY_TIMER_NODE)"
>
<div class="handler-item-icon delay">
<span class="iconfont icon-size icon-delay"></span>
</div>
<div class="handler-item-text">延迟器</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.ROUTER_BRANCH_NODE)"
>
<div class="handler-item-icon router">
<span class="iconfont icon-size icon-router"></span>
</div>
<div class="handler-item-text">路由分支</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.TRIGGER_NODE)"
>
<div class="handler-item-icon trigger">
<span class="iconfont icon-size icon-trigger"></span>
</div>
<div class="handler-item-text">触发器</div>
</div>
<div
class="handler-item"
@click="addNode(BpmNodeTypeEnum.CHILD_PROCESS_NODE)"
>
<div class="handler-item-icon child-process">
<span class="iconfont icon-size icon-child-process"></span>
</div>
<div class="handler-item-text">子流程</div>
</div>
</div>
</template>
<div class="add-icon"><IconifyIcon icon="lucide:plus" /></div>
</Popover>
</div>
</div>
</template>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, nextTick, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { buildShortUUID as generateUUID } from '@vben/utils';
import { Button, Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useTaskStatusClass } from '../../helpers';
import ProcessNodeTree from '../process-node-tree.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'ParallelNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件
const emits = defineEmits<{
findParnetNode: [nodeList: SimpleFlowNode[], nodeType: number];
recursiveFindParentNode: [
nodeList: SimpleFlowNode[],
curentNode: SimpleFlowNode,
nodeType: number,
];
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();
const currentNode = ref<SimpleFlowNode>(props.flowNode);
// 是否只读
const readonly = inject<Boolean>('readonly');
watch(
() => props.flowNode,
(newValue) => {
currentNode.value = newValue;
},
);
// 条件节点名称输入框引用
const inputRefs = ref<HTMLInputElement[]>([]);
// 节点名称输入框显示状态
const showInputs = ref<boolean[]>([]);
// 监听显示状态变化
watch(
showInputs,
(newValues) => {
// 当输入框显示时, 自动聚焦
newValues.forEach((value, index) => {
if (value) {
// 当显示状态从 false 变为 true 时, 自动聚焦
nextTick(() => {
inputRefs.value[index]?.focus();
});
}
});
},
{ deep: true },
);
// 修改节点名称
function changeNodeName(index: number) {
showInputs.value[index] = false;
const conditionNode = currentNode.value.conditionNodes?.at(
index,
) as SimpleFlowNode;
conditionNode.name = conditionNode.name || `并行${index + 1}`;
}
// 点击条件名称
function clickEvent(index: number) {
showInputs.value[index] = true;
}
// 新增条件
function addCondition() {
const conditionNodes = currentNode.value.conditionNodes;
if (conditionNodes) {
const len = conditionNodes.length;
const lastIndex = len - 1;
const conditionData: SimpleFlowNode = {
id: `Flow_${generateUUID()}`,
name: `并行${len}`,
showText: '无需配置条件同时执行',
type: BpmNodeTypeEnum.CONDITION_NODE,
childNode: undefined,
conditionNodes: [],
};
conditionNodes.splice(lastIndex, 0, conditionData);
}
}
// 删除条件
function deleteCondition(index: number) {
const conditionNodes = currentNode.value.conditionNodes;
if (conditionNodes) {
conditionNodes.splice(index, 1);
if (conditionNodes.length === 1) {
const childNode = currentNode.value.childNode;
// 更新此节点为后续孩子节点
emits('update:modelValue', childNode);
}
}
}
// 递归从父节点中查询匹配的节点
function recursiveFindParentNode(
nodeList: SimpleFlowNode[],
node: SimpleFlowNode,
nodeType: number,
) {
if (!node || node.type === BpmNodeTypeEnum.START_USER_NODE) {
return;
}
if (node.type === nodeType) {
nodeList.push(node);
}
// 条件节点 (NodeType.CONDITION_NODE) 比较特殊。需要调用其父节点并行节点NodeType.PARALLEL_NODE) 继续查找
emits('findParnetNode', nodeList, nodeType);
}
</script>
<template>
<div class="branch-node-wrapper">
<div class="branch-node-container">
<div
v-if="readonly"
class="branch-node-readonly"
:class="`${useTaskStatusClass(currentNode?.activityStatus)}`"
>
<span class="iconfont icon-parallel icon-size parallel"></span>
</div>
<Button
v-else
class="branch-node-add"
color="#626aef"
@click="addCondition"
plain
>
添加分支
</Button>
<div
class="branch-node-item"
v-for="(item, index) in currentNode.conditionNodes"
:key="index"
>
<template v-if="index === 0">
<div class="branch-line-first-top"></div>
<div class="branch-line-first-bottom"></div>
</template>
<template v-if="index + 1 === currentNode.conditionNodes?.length">
<div class="branch-line-last-top"></div>
<div class="branch-line-last-bottom"></div>
</template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="`${useTaskStatusClass(item.activityStatus)}`"
>
<div class="branch-node-title-container">
<div v-if="showInputs[index]">
<Input
:ref="
(el) => {
inputRefs[index] = el as HTMLInputElement;
}
"
type="text"
class="input-max-width editable-title-input"
@blur="changeNodeName(index)"
@press-enter="changeNodeName(index)"
v-model:value="item.name"
/>
</div>
<div v-else class="branch-title" @click="clickEvent(index)">
{{ item.name }}
</div>
<div class="branch-priority">无优先级</div>
</div>
<div class="branch-node-content">
<div
class="branch-node-text"
:title="item.showText"
v-if="item.showText"
>
{{ item.showText }}
</div>
<div class="branch-node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.CONDITION_NODE) }}
</div>
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
@click="deleteCondition(index)"
/>
</div>
</div>
</div>
<NodeHandler
v-model:child-node="item.childNode"
:current-node="item"
/>
</div>
</div>
<!-- 递归显示子节点 -->
<ProcessNodeTree
v-if="item && item.childNode"
:parent-node="item"
v-model:flow-node="item.childNode"
@recursive-find-parent-node="recursiveFindParentNode"
/>
</div>
</div>
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import RouterNodeConfig from '../nodes-config/router-node-config.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'RouterNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();
// 是否只读
const readonly = inject<Boolean>('readonly');
// 监控节点的变化
const currentNode = useWatchNode(props);
// 节点名称编辑
const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
currentNode,
BpmNodeTypeEnum.ROUTER_BRANCH_NODE,
);
const nodeSetting = ref();
// 打开节点配置
function openNodeConfig() {
if (readonly) {
return;
}
nodeSetting.value.openDrawer(currentNode.value);
}
// 删除节点。更新当前节点为孩子节点
function deleteNode() {
emits('update:flowNode', currentNode.value.childNode);
}
</script>
<template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !currentNode.showText },
`${useTaskStatusClass(currentNode?.activityStatus)}`,
]"
>
<div class="node-title-container">
<div class="node-title-icon router-node">
<span class="iconfont icon-router"></span>
</div>
<Input
ref="inputRef"
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div v-else class="node-title" @click="clickTitle">
{{ currentNode.name }}
</div>
</div>
<div class="node-content" @click="openNodeConfig">
<div
class="node-text"
:title="currentNode.showText"
v-if="currentNode.showText"
>
{{ currentNode.showText }}
</div>
<div class="node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.ROUTER_BRANCH_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="lucide:chevron-right" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
@click="deleteNode"
/>
</div>
</div>
</div>
<!-- 传递子节点给添加节点组件会在子节点前面添加节点 -->
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
<RouterNodeConfig
v-if="!readonly && currentNode"
ref="nodeSetting"
:flow-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
// TODO @芋艿:后续是不是把业务组件,挪到每个模块里;待定;
import type { Ref } from 'vue';
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import StartUserNodeConfig from '../nodes-config/start-user-node-config.vue';
import TaskListModal from './modules/task-list-modal.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'StartUserNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
default: () => null,
},
});
// 定义事件,更新父组件。
defineEmits<{
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();
const readonly = inject<Boolean>('readonly'); // 是否只读
const tasks = inject<Ref<any[]>>('tasks', ref([]));
// 监控节点变化
const currentNode = useWatchNode(props);
// 节点名称编辑
const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
currentNode,
BpmNodeTypeEnum.START_USER_NODE,
);
const nodeSetting = ref();
const [Modal, modalApi] = useVbenModal({
connectedComponent: TaskListModal,
destroyOnClose: true,
});
function nodeClick() {
if (readonly) {
// 只读模式,弹窗显示任务信息
if (tasks && tasks.value) {
// 过滤出当前节点的任务
const nodeTasks = tasks.value.filter(
(task) => task.taskDefinitionKey === currentNode.value.id,
);
// 弹窗显示任务信息
modalApi
.setData(nodeTasks)
.setState({ title: currentNode.value.name })
.open();
}
} else {
nodeSetting.value.showStartUserNodeConfig(currentNode.value);
}
}
</script>
<template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !currentNode.showText },
`${useTaskStatusClass(currentNode?.activityStatus)}`,
]"
>
<div class="node-title-container">
<div class="node-title-icon start-user">
<span class="iconfont icon-start-user"></span>
</div>
<Input
ref="inputRef"
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div v-else class="node-title" @click="clickTitle">
{{ currentNode.name }}
</div>
</div>
<div class="node-content" @click="nodeClick">
<div
class="node-text"
:title="currentNode.showText"
v-if="currentNode.showText"
>
{{ currentNode.showText }}
</div>
<div class="node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.START_USER_NODE) }}
</div>
<IconifyIcon icon="lucide:chevron-right" v-if="!readonly" />
</div>
</div>
<!-- 传递子节点给添加节点组件会在子节点前面添加节点 -->
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
</div>
<StartUserNodeConfig
v-if="!readonly && currentNode"
ref="nodeSetting"
:flow-node="currentNode"
/>
<!-- 审批记录弹窗 -->
<Modal />
</template>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import TriggerNodeConfig from '../nodes-config/trigger-node-config.vue';
import NodeHandler from './node-handler.vue';
defineOptions({
name: 'TriggerNode',
});
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
// 定义事件,更新父组件
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();
// 是否只读
const readonly = inject<Boolean>('readonly');
// 监控节点的变化
const currentNode = useWatchNode(props);
// 节点名称编辑
const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
currentNode,
BpmNodeTypeEnum.TRIGGER_NODE,
);
const nodeSetting = ref();
// 打开节点配置
function openNodeConfig() {
if (readonly) {
return;
}
nodeSetting.value.showTriggerNodeConfig(currentNode.value);
}
// 删除节点。更新当前节点为孩子节点
function deleteNode() {
emits('update:flowNode', currentNode.value.childNode);
}
</script>
<template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !currentNode.showText },
`${useTaskStatusClass(currentNode?.activityStatus)}`,
]"
>
<div class="node-title-container">
<div class="node-title-icon trigger-node">
<span class="iconfont icon-trigger"></span>
</div>
<Input
ref="inputRef"
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div v-else class="node-title" @click="clickTitle">
{{ currentNode.name }}
</div>
</div>
<div class="node-content" @click="openNodeConfig">
<div
class="node-text"
:title="currentNode.showText"
v-if="currentNode.showText"
>
{{ currentNode.showText }}
</div>
<div class="node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(BpmNodeTypeEnum.TRIGGER_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="lucide:chevron-right" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
:size="18"
@click="deleteNode"
/>
</div>
</div>
</div>
<!-- 传递子节点给添加节点组件会在子节点前面添加节点 -->
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
<TriggerNodeConfig
v-if="!readonly && currentNode"
ref="nodeSetting"
:flow-node="currentNode"
/>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { SimpleFlowNode } from '../../consts';
import { inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../../consts';
import { useNodeName2, useTaskStatusClass, useWatchNode } from '../../helpers';
import UserTaskNodeConfig from '../nodes-config/user-task-node-config.vue';
import TaskListModal from './modules/task-list-modal.vue';
// // 使用useVbenVxeGrid
// const [Grid, gridApi] = useVbenVxeGrid({
// gridOptions: {
// columns: columns.value,
// keepSource: true,
// border: true,
// height: 'auto',
// data: selectTasks.value,
// rowConfig: {
// keyField: 'id',
// },
// pagerConfig: {
// enabled: false,
// },
// toolbarConfig: {
// enabled: false,
// },
// } as VxeTableGridOptions<any>,
// });
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'UserTaskNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
const emits = defineEmits<{
findParentNode: [nodeList: SimpleFlowNode[], nodeType: BpmNodeTypeEnum];
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();
// 是否只读
const readonly = inject<Boolean>('readonly');
const tasks = inject<Ref<any[]>>('tasks', ref([]));
// 监控节点变化
const currentNode = useWatchNode(props);
// 节点名称编辑
const { showInput, changeNodeName, clickTitle, inputRef } = useNodeName2(
currentNode,
BpmNodeTypeEnum.USER_TASK_NODE,
);
const nodeSetting = ref();
const [Modal, modalApi] = useVbenModal({
connectedComponent: TaskListModal,
destroyOnClose: true,
});
function nodeClick() {
if (readonly) {
if (tasks && tasks.value) {
// 过滤出当前节点的任务
const nodeTasks = tasks.value.filter(
(task) => task.taskDefinitionKey === currentNode.value.id,
);
// 弹窗显示任务信息
modalApi
.setData(nodeTasks)
.setState({ title: currentNode.value.name })
.open();
}
} else {
// 编辑模式,打开节点配置、把当前节点传递给配置组件
nodeSetting.value.showUserTaskNodeConfig(currentNode.value);
}
}
function deleteNode() {
emits('update:flowNode', currentNode.value.childNode);
}
// 查找可以驳回用户节点
function findReturnTaskNodes(
matchNodeList: SimpleFlowNode[], // 匹配的节点
) {
// 从父节点查找
emits('findParentNode', matchNodeList, BpmNodeTypeEnum.USER_TASK_NODE);
}
</script>
<template>
<div class="node-wrapper">
<div class="node-container">
<div
class="node-box"
:class="[
{ 'node-config-error': !currentNode.showText },
`${useTaskStatusClass(currentNode?.activityStatus)}`,
]"
>
<div class="node-title-container">
<div
:class="`node-title-icon ${currentNode.type === BpmNodeTypeEnum.TRANSACTOR_NODE ? 'transactor-task' : 'user-task'}`"
>
<span
:class="`iconfont ${currentNode.type === BpmNodeTypeEnum.TRANSACTOR_NODE ? 'icon-transactor' : 'icon-approve'}`"
>
</span>
</div>
<Input
ref="inputRef"
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="changeNodeName()"
@press-enter="changeNodeName()"
v-model:value="currentNode.name"
:placeholder="currentNode.name"
/>
<div v-else class="node-title" @click="clickTitle">
{{ currentNode.name }}
</div>
</div>
<div class="node-content" @click="nodeClick">
<div
class="node-text"
:title="currentNode.showText"
v-if="currentNode.showText"
>
{{ currentNode.showText }}
</div>
<div class="node-text" v-else>
{{ NODE_DEFAULT_TEXT.get(currentNode.type) }}
</div>
<IconifyIcon icon="lucide:chevron-right" v-if="!readonly" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="lucide:circle-x"
:size="18"
@click="deleteNode"
/>
</div>
</div>
</div>
<!-- 传递子节点给添加节点组件会在子节点前面添加节点 -->
<NodeHandler
v-if="currentNode"
v-model:child-node="currentNode.childNode"
:current-node="currentNode"
/>
</div>
</div>
<UserTaskNodeConfig
v-if="currentNode"
ref="nodeSetting"
:flow-node="currentNode"
@find-return-task-nodes="findReturnTaskNodes"
/>
<!-- 审批记录弹窗 -->
<Modal />
</template>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../consts';
import { BpmNodeTypeEnum } from '#/utils';
import { useWatchNode } from '../helpers';
import CopyTaskNode from './nodes/copy-task-node.vue';
import DelayTimerNode from './nodes/delay-timer-node.vue';
import EndEventNode from './nodes/end-event-node.vue';
import ExclusiveNode from './nodes/exclusive-node.vue';
import InclusiveNode from './nodes/inclusive-node.vue';
import ParallelNode from './nodes/parallel-node.vue';
import RouterNode from './nodes/router-node.vue';
import StartUserNode from './nodes/start-user-node.vue';
import TriggerNode from './nodes/trigger-node.vue';
import UserTaskNode from './nodes/user-task-node.vue';
defineOptions({ name: 'ProcessNodeTree' });
const props = defineProps({
parentNode: {
type: Object as () => SimpleFlowNode,
default: () => null,
},
flowNode: {
type: Object as () => SimpleFlowNode,
default: () => null,
},
});
const emits = defineEmits<{
recursiveFindParentNode: [
nodeList: SimpleFlowNode[],
curentNode: SimpleFlowNode,
nodeType: number,
];
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();
const currentNode = useWatchNode(props);
// 用于删除节点
const handleModelValueUpdate = (updateValue: any) => {
emits('update:flowNode', updateValue);
};
const findParentNode = (nodeList: SimpleFlowNode[], nodeType: number) => {
emits('recursiveFindParentNode', nodeList, props.parentNode, nodeType);
};
// 递归从父节点中查询匹配的节点
function recursiveFindParentNode(
nodeList: SimpleFlowNode[],
findNode: SimpleFlowNode,
nodeType: number,
) {
if (!findNode) {
return;
}
if (findNode.type === BpmNodeTypeEnum.START_USER_NODE) {
nodeList.push(findNode);
return;
}
if (findNode.type === nodeType) {
nodeList.push(findNode);
}
emits('recursiveFindParentNode', nodeList, props.parentNode, nodeType);
}
</script>
<template>
<!-- 发起人节点 -->
<StartUserNode
v-if="currentNode && currentNode.type === BpmNodeTypeEnum.START_USER_NODE"
:flow-node="currentNode"
/>
<!-- 审批节点 -->
<UserTaskNode
v-if="
currentNode &&
(currentNode.type === BpmNodeTypeEnum.USER_TASK_NODE ||
currentNode.type === BpmNodeTypeEnum.TRANSACTOR_NODE)
"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
@find-parent-node="findParentNode"
/>
<!-- 抄送节点 -->
<CopyTaskNode
v-if="currentNode && currentNode.type === BpmNodeTypeEnum.COPY_TASK_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/>
<!-- 条件节点 -->
<ExclusiveNode
v-if="
currentNode && currentNode.type === BpmNodeTypeEnum.CONDITION_BRANCH_NODE
"
:flow-node="currentNode"
@update:model-value="handleModelValueUpdate"
@find-parent-node="findParentNode"
/>
<!-- 并行节点 -->
<ParallelNode
v-if="
currentNode && currentNode.type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE
"
:flow-node="currentNode"
@update:model-value="handleModelValueUpdate"
@find-parent-node="findParentNode"
/>
<!-- 包容分支节点 -->
<InclusiveNode
v-if="
currentNode && currentNode.type === BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE
"
:flow-node="currentNode"
@update:model-value="handleModelValueUpdate"
@find-parent-node="findParentNode"
/>
<!-- 延迟器节点 -->
<DelayTimerNode
v-if="currentNode && currentNode.type === BpmNodeTypeEnum.DELAY_TIMER_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/>
<!-- 路由分支节点 -->
<RouterNode
v-if="
currentNode && currentNode.type === BpmNodeTypeEnum.ROUTER_BRANCH_NODE
"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/>
<!-- 触发器节点 -->
<TriggerNode
v-if="currentNode && currentNode.type === BpmNodeTypeEnum.TRIGGER_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/>
<!-- 子流程节点 -->
<!-- <ChildProcessNode
v-if="currentNode && currentNode.type === NodeType.CHILD_PROCESS_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/> -->
<!-- 递归显示孩子节点 -->
<ProcessNodeTree
v-if="currentNode && currentNode.childNode"
v-model:flow-node="currentNode.childNode"
:parent-node="currentNode"
@recursive-find-parent-node="recursiveFindParentNode"
/>
<!-- 结束节点 -->
<EndEventNode
v-if="currentNode && currentNode.type === BpmNodeTypeEnum.END_EVENT_NODE"
:flow-node="currentNode"
/>
</template>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import type { SimpleFlowNode } from '../consts';
import type { BpmUserGroupApi } from '#/api/bpm/userGroup';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemPostApi } from '#/api/system/post';
import type { SystemRoleApi } from '#/api/system/role';
import type { SystemUserApi } from '#/api/system/user';
import { inject, onMounted, provide, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { getFormDetail } from '#/api/bpm/form';
import { getUserGroupSimpleList } from '#/api/bpm/userGroup';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimplePostList } from '#/api/system/post';
import { getSimpleRoleList } from '#/api/system/role';
import { getSimpleUserList } from '#/api/system/user';
import { BpmModelFormType, BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT, NodeId } from '../consts';
import SimpleProcessModel from './simple-process-model.vue';
defineOptions({
name: 'SimpleProcessDesigner',
});
const props = defineProps({
modelName: {
type: String,
required: false,
default: undefined,
},
// 流程表单 ID
modelFormId: {
type: Number,
required: false,
default: undefined,
},
// 表单类型
modelFormType: {
type: Number,
required: false,
default: BpmModelFormType.NORMAL,
},
// 可发起流程的人员编号
startUserIds: {
type: Array,
required: false,
default: undefined,
},
// 可发起流程的部门编号
startDeptIds: {
type: Array,
required: false,
default: undefined,
},
});
// 保存成功事件
const emits = defineEmits(['success']);
const processData = inject('processData') as Ref;
const loading = ref(false);
const formFields = ref<string[]>([]);
const formType = ref(props.modelFormType);
// 监听 modelFormType 变化
watch(
() => props.modelFormType,
(newVal) => {
formType.value = newVal;
},
);
// 监听 modelFormId 变化
watch(
() => props.modelFormId,
async (newVal) => {
if (newVal) {
const form = await getFormDetail(newVal);
formFields.value = form?.fields;
} else {
// 如果 modelFormId 为空,清空表单字段
formFields.value = [];
}
},
{ immediate: true },
);
const roleOptions = ref<SystemRoleApi.Role[]>([]); // 角色列表
const postOptions = ref<SystemPostApi.Post[]>([]); // 岗位列表
const userOptions = ref<SystemUserApi.User[]>([]); // 用户列表
const deptOptions = ref<SystemDeptApi.Dept[]>([]); // 部门列表
const deptTreeOptions = ref();
const userGroupOptions = ref<BpmUserGroupApi.UserGroup[]>([]); // 用户组列表
provide('formFields', formFields);
provide('formType', formType);
provide('roleList', roleOptions);
provide('postList', postOptions);
provide('userList', userOptions);
provide('deptList', deptOptions);
provide('userGroupList', userGroupOptions);
provide('deptTree', deptTreeOptions);
provide('startUserIds', props.startUserIds);
provide('startDeptIds', props.startDeptIds);
provide('tasks', []);
provide('processInstance', {});
const processNodeTree = ref<SimpleFlowNode | undefined>();
provide('processNodeTree', processNodeTree);
// 创建错误提示弹窗
const [ErrorModal, errorModalApi] = useVbenModal({
fullscreenButton: false,
});
// 添加更新模型的方法
function updateModel() {
if (!processNodeTree.value) {
processNodeTree.value = {
name: '发起人',
type: BpmNodeTypeEnum.START_USER_NODE,
id: NodeId.START_USER_NODE_ID,
showText: '默认配置',
childNode: {
id: NodeId.END_EVENT_NODE_ID,
name: '结束',
type: BpmNodeTypeEnum.END_EVENT_NODE,
},
};
// 初始化时也触发一次保存
saveSimpleFlowModel(processNodeTree.value);
}
}
async function saveSimpleFlowModel(
simpleModelNode: SimpleFlowNode | undefined,
) {
if (!simpleModelNode) {
return;
}
try {
processData.value = simpleModelNode;
emits('success', simpleModelNode);
} catch (error) {
console.error('保存失败:', error);
}
}
/**
* 校验节点设置。 暂时以 showText 为空作为节点错误配置的判断条件
*/
function validateNode(
node: SimpleFlowNode | undefined,
errorNodes: SimpleFlowNode[],
) {
if (node) {
const { type, showText, conditionNodes } = node;
if (type === BpmNodeTypeEnum.END_EVENT_NODE) {
return;
}
if (
type === BpmNodeTypeEnum.CONDITION_BRANCH_NODE ||
type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE ||
type === BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE
) {
// 1. 分支节点, 先校验各个分支
conditionNodes?.forEach((item) => {
validateNode(item, errorNodes);
});
// 2. 校验孩子节点
validateNode(node.childNode, errorNodes);
} else {
if (!showText) {
errorNodes.push(node);
}
validateNode(node.childNode, errorNodes);
}
}
}
onMounted(async () => {
try {
loading.value = true;
// 获得角色列表
roleOptions.value = await getSimpleRoleList();
// 获得岗位列表
postOptions.value = await getSimplePostList();
// 获得用户列表
userOptions.value = await getSimpleUserList();
// 获得部门列表
const deptList = await getSimpleDeptList();
deptOptions.value = deptList;
// 转换成树形结构
deptTreeOptions.value = handleTree(deptList);
// 获取用户组列表
userGroupOptions.value = await getUserGroupSimpleList();
// 加载流程数据
if (processData.value) {
processNodeTree.value = processData?.value;
} else {
updateModel();
}
} finally {
loading.value = false;
}
});
const validate = async () => {
const errorNodes: SimpleFlowNode[] = [];
validateNode(processNodeTree.value, errorNodes);
if (errorNodes.length === 0) {
return true;
} else {
// 设置错误节点数据并打开弹窗
errorModalApi.setData(errorNodes);
errorModalApi.open();
return false;
}
};
defineExpose({ validate });
</script>
<template>
<div v-loading="loading">
<SimpleProcessModel
v-if="processNodeTree"
:flow-node="processNodeTree"
:readonly="false"
@save="saveSimpleFlowModel"
/>
<ErrorModal title="流程设计校验不通过" class="w-2/5">
<div class="mb-2 text-base">以下节点配置不完善请修改相关配置</div>
<div
class="mb-3 rounded-md p-2 text-sm"
v-for="(item, index) in errorModalApi.getData()"
:key="index"
>
{{ item.name }} : {{ NODE_DEFAULT_TEXT.get(item.type) }}
</div>
<template #footer>
<Button type="primary" @click="errorModalApi.close()">知道了</Button>
</template>
</ErrorModal>
</div>
</template>

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../consts';
import { onMounted, provide, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlob, isString } from '@vben/utils';
import { Button, ButtonGroup, Modal, Row } from 'ant-design-vue';
import { BpmNodeTypeEnum } from '#/utils';
import { NODE_DEFAULT_TEXT } from '../consts';
import { useWatchNode } from '../helpers';
import ProcessNodeTree from './process-node-tree.vue';
defineOptions({
name: 'SimpleProcessModel',
});
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
readonly: {
type: Boolean,
required: false,
default: true,
},
});
const emits = defineEmits<{
save: [node: SimpleFlowNode | undefined];
}>();
const processNodeTree = useWatchNode(props);
provide('readonly', props.readonly);
// TODO 可优化:拖拽有点卡顿
/** 拖拽、放大缩小等操作 */
const scaleValue = ref(100);
const MAX_SCALE_VALUE = 200;
const MIN_SCALE_VALUE = 50;
const isDragging = ref(false);
const startX = ref(0);
const startY = ref(0);
const currentX = ref(0);
const currentY = ref(0);
const initialX = ref(0);
const initialY = ref(0);
function setGrabCursor() {
document.body.style.cursor = 'grab';
}
function resetCursor() {
document.body.style.cursor = 'default';
}
function startDrag(e: MouseEvent) {
isDragging.value = true;
startX.value = e.clientX - currentX.value;
startY.value = e.clientY - currentY.value;
setGrabCursor(); // 设置小手光标
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return;
e.preventDefault(); // 禁用文本选择
// 使用 requestAnimationFrame 优化性能
requestAnimationFrame(() => {
currentX.value = e.clientX - startX.value;
currentY.value = e.clientY - startY.value;
});
}
function stopDrag() {
isDragging.value = false;
resetCursor(); // 重置光标
}
function zoomIn() {
if (scaleValue.value === MAX_SCALE_VALUE) {
return;
}
scaleValue.value += 10;
}
function zoomOut() {
if (scaleValue.value === MIN_SCALE_VALUE) {
return;
}
scaleValue.value -= 10;
}
function processReZoom() {
scaleValue.value = 100;
}
function resetPosition() {
currentX.value = initialX.value;
currentY.value = initialY.value;
}
/** 校验节点设置 */
const errorDialogVisible = ref(false);
let errorNodes: SimpleFlowNode[] = [];
function validateNode(
node: SimpleFlowNode | undefined,
errorNodes: SimpleFlowNode[],
) {
if (node) {
const { type, showText, conditionNodes } = node;
if (type === BpmNodeTypeEnum.END_EVENT_NODE) {
return;
}
if (type === BpmNodeTypeEnum.START_USER_NODE) {
// 发起人节点暂时不用校验,直接校验孩子节点
validateNode(node.childNode, errorNodes);
}
if (
type === BpmNodeTypeEnum.USER_TASK_NODE ||
type === BpmNodeTypeEnum.COPY_TASK_NODE ||
type === BpmNodeTypeEnum.CONDITION_NODE
) {
if (!showText) {
errorNodes.push(node);
}
validateNode(node.childNode, errorNodes);
}
if (
type === BpmNodeTypeEnum.CONDITION_BRANCH_NODE ||
type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE ||
type === BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE
) {
// 分支节点
// 1. 先校验各个分支
conditionNodes?.forEach((item) => {
validateNode(item, errorNodes);
});
// 2. 校验孩子节点
validateNode(node.childNode, errorNodes);
}
}
}
/** 获取当前流程数据 */
async function getCurrentFlowData() {
try {
errorNodes = [];
validateNode(processNodeTree.value, errorNodes);
if (errorNodes.length > 0) {
errorDialogVisible.value = true;
return undefined;
}
return processNodeTree.value;
} catch (error) {
console.error('获取流程数据失败:', error);
return undefined;
}
}
defineExpose({
getCurrentFlowData,
});
/** 导出 JSON */
function exportJson() {
downloadFileFromBlob({
fileName: 'model.json',
source: new Blob([JSON.stringify(processNodeTree.value)]),
});
}
/** 导入 JSON */
const refFile = ref();
function importJson() {
refFile.value.click();
}
function importLocalFile() {
const file = refFile.value.files[0];
file.text().then((result: any) => {
if (isString(result)) {
processNodeTree.value = JSON.parse(result);
emits('save', processNodeTree.value);
}
});
}
// 在组件初始化时记录初始位置
onMounted(() => {
initialX.value = currentX.value;
initialY.value = currentY.value;
});
</script>
<template>
<div class="simple-process-model-container">
<div class="bg-card absolute right-0 top-0">
<Row type="flex" justify="end">
<ButtonGroup key="scale-control">
<Button v-if="!readonly" @click="exportJson">
<IconifyIcon icon="lucide:download" /> 导出
</Button>
<Button v-if="!readonly" @click="importJson">
<IconifyIcon icon="lucide:upload" />导入
</Button>
<!-- 用于打开本地文件-->
<input
v-if="!readonly"
type="file"
id="files"
ref="refFile"
class="hidden"
accept=".json"
@change="importLocalFile"
/>
<Button @click="processReZoom()">
<IconifyIcon icon="lucide:table-columns-split" />
</Button>
<Button :plain="true" @click="zoomOut()">
<IconifyIcon icon="lucide:zoom-out" />
</Button>
<Button class="w-20"> {{ scaleValue }}% </Button>
<Button :plain="true" @click="zoomIn()">
<IconifyIcon icon="lucide:zoom-in" />
</Button>
<Button @click="resetPosition">重置</Button>
</ButtonGroup>
</Row>
</div>
<div
class="simple-process-model"
:style="`transform: translate(${currentX}px, ${currentY}px) scale(${scaleValue / 100});`"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@mouseenter="setGrabCursor"
>
<ProcessNodeTree
v-if="processNodeTree"
v-model:flow-node="processNodeTree"
/>
</div>
</div>
<Modal
v-model:open="errorDialogVisible"
title="保存失败"
width="400"
:fullscreen="false"
>
<div class="mb-2">以下节点内容不完善请修改后保存</div>
<div
class="line-height-normal mb-3 rounded p-2"
v-for="(item, index) in errorNodes"
:key="index"
>
{{ item.name }} : {{ NODE_DEFAULT_TEXT.get(item.type) }}
</div>
<template #footer>
<Button type="primary" @click="errorDialogVisible = false">知道了</Button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../consts';
import { provide, ref, watch } from 'vue';
import { useWatchNode } from '../helpers';
import SimpleProcessModel from './simple-process-model.vue';
defineOptions({ name: 'SimpleProcessViewer' });
const props = withDefaults(
defineProps<{
flowNode: SimpleFlowNode;
// 流程实例
processInstance?: any;
// 流程任务
tasks?: any[];
}>(),
{
processInstance: undefined,
tasks: () => [] as any[],
},
);
const approveTasks = ref<any[]>(props.tasks);
const currentProcessInstance = ref(props.processInstance);
const simpleModel = useWatchNode(props);
watch(
() => props.tasks,
(newValue) => {
approveTasks.value = newValue;
},
);
watch(
() => props.processInstance,
(newValue) => {
currentProcessInstance.value = newValue;
},
);
// 提供给后代组件使用
provide('tasks', approveTasks);
provide('processInstance', currentProcessInstance);
</script>
<template>
<SimpleProcessModel :flow-node="simpleModel" :readonly="true" />
</template>

View File

@@ -0,0 +1,886 @@
import { BpmNodeTypeEnum, BpmTaskStatusEnum } from '#/utils';
interface DictDataType {
label: string;
value: number | string;
}
// 用户任务的审批类型。 【参考飞书】
export enum ApproveType {
/**
* 自动通过
*/
AUTO_APPROVE = 2,
/**
* 自动拒绝
*/
AUTO_REJECT = 3,
/**
* 人工审批
*/
USER = 1,
}
// 多人审批方式类型枚举 用于审批节点
export enum ApproveMethodType {
/**
* 多人或签(通过只需一人,拒绝只需一人)
*/
ANY_APPROVE = 3,
/**
* 多人会签(按通过比例)
*/
APPROVE_BY_RATIO = 2,
/**
* 随机挑选一人审批
*/
RANDOM_SELECT_ONE_APPROVE = 1,
/**
* 多人依次审批
*/
SEQUENTIAL_APPROVE = 4,
}
export enum NodeId {
/**
* 发起人节点 Id
*/
END_EVENT_NODE_ID = 'EndEvent',
/**
* 发起人节点 Id
*/
START_USER_NODE_ID = 'StartUserNode',
}
// 条件配置类型 用于条件节点配置
export enum ConditionType {
/**
* 条件表达式
*/
EXPRESSION = 1,
/**
* 条件规则
*/
RULE = 2,
}
// 操作按钮类型枚举 (用于审批节点)
export enum OperationButtonType {
/**
* 加签
*/
ADD_SIGN = 5,
/**
* 通过
*/
APPROVE = 1,
/**
* 抄送
*/
COPY = 7,
/**
* 委派
*/
DELEGATE = 4,
/**
* 拒绝
*/
REJECT = 2,
/**
* 退回
*/
RETURN = 6,
/**
* 转办
*/
TRANSFER = 3,
}
// 审批拒绝类型枚举
export enum RejectHandlerType {
/**
* 结束流程
*/
FINISH_PROCESS = 1,
/**
* 驳回到指定节点
*/
RETURN_USER_TASK = 2,
}
// 用户任务超时处理类型枚举
export enum TimeoutHandlerType {
/**
* 自动同意
*/
APPROVE = 2,
/**
* 自动拒绝
*/
REJECT = 3,
/**
* 自动提醒
*/
REMINDER = 1,
}
// 用户任务的审批人为空时,处理类型枚举
export enum AssignEmptyHandlerType {
/**
* 自动通过
*/
APPROVE = 1,
/**
* 转交给流程管理员
*/
ASSIGN_ADMIN = 4,
/**
* 指定人员审批
*/
ASSIGN_USER = 3,
/**
* 自动拒绝
*/
REJECT = 2,
}
// 用户任务的审批人与发起人相同时,处理类型枚举
export enum AssignStartUserHandlerType {
/**
* 转交给部门负责人审批
*/
ASSIGN_DEPT_LEADER = 3,
/**
* 自动跳过【参考飞书】1如果当前节点还有其他审批人则交由其他审批人进行审批2如果当前节点没有其他审批人则该节点自动通过
*/
SKIP = 2,
/**
* 由发起人对自己审批
*/
START_USER_AUDIT = 1,
}
// 时间单位枚举
export enum TimeUnitType {
/**
* 天
*/
DAY = 3,
/**
* 小时
*/
HOUR = 2,
/**
* 分钟
*/
MINUTE = 1,
}
/**
* 表单权限的枚举
*/
export enum FieldPermissionType {
/**
* 隐藏
*/
NONE = '3',
/**
* 只读
*/
READ = '1',
/**
* 编辑
*/
WRITE = '2',
}
/**
* 延迟类型
*/
export enum DelayTypeEnum {
/**
* 固定日期时间
*/
FIXED_DATE_TIME = 2,
/**
* 固定时长
*/
FIXED_TIME_DURATION = 1,
}
/**
* 触发器类型枚举
*/
export enum TriggerTypeEnum {
/**
* 表单数据删除触发器
*/
FORM_DELETE = 11,
/**
* 表单数据更新触发器
*/
FORM_UPDATE = 10,
/**
* 接收 HTTP 回调请求触发器
*/
HTTP_CALLBACK = 2,
/**
* 发送 HTTP 请求触发器
*/
HTTP_REQUEST = 1,
}
export enum ChildProcessStartUserTypeEnum {
/**
* 表单
*/
FROM_FORM = 2,
/**
* 同主流程发起人
*/
MAIN_PROCESS_START_USER = 1,
}
export enum ChildProcessStartUserEmptyTypeEnum {
/**
* 子流程管理员
*/
CHILD_PROCESS_ADMIN = 2,
/**
* 主流程管理员
*/
MAIN_PROCESS_ADMIN = 3,
/**
* 同主流程发起人
*/
MAIN_PROCESS_START_USER = 1,
}
export enum ChildProcessMultiInstanceSourceTypeEnum {
/**
* 固定数量
*/
FIXED_QUANTITY = 1,
/**
* 多选表单
*/
MULTIPLE_FORM = 3,
/**
* 数字表单
*/
NUMBER_FORM = 2,
}
// 候选人策略枚举 用于审批节点。抄送节点 )
export enum CandidateStrategy {
/**
* 审批人自选
*/
APPROVE_USER_SELECT = 34,
/**
* 部门的负责人
*/
DEPT_LEADER = 21,
/**
* 部门成员
*/
DEPT_MEMBER = 20,
/**
* 流程表达式
*/
EXPRESSION = 60,
/**
* 表单内部门负责人
*/
FORM_DEPT_LEADER = 51,
/**
* 表单内用户字段
*/
FORM_USER = 50,
/**
* 连续多级部门的负责人
*/
MULTI_LEVEL_DEPT_LEADER = 23,
/**
* 指定岗位
*/
POST = 22,
/**
* 指定角色
*/
ROLE = 10,
/**
* 发起人自己
*/
START_USER = 36,
/**
* 发起人部门负责人
*/
START_USER_DEPT_LEADER = 37,
/**
* 发起人连续多级部门的负责人
*/
START_USER_MULTI_LEVEL_DEPT_LEADER = 38,
/**
* 发起人自选
*/
START_USER_SELECT = 35,
/**
* 指定用户
*/
USER = 30,
/**
* 指定用户组
*/
USER_GROUP = 40,
}
export enum BpmHttpRequestParamTypeEnum {
/**
* 固定值
*/
FIXED_VALUE = 1,
/**
* 表单
*/
FROM_FORM = 2,
}
// 这里定义 HTTP 请求参数类型
export type HttpRequestParam = {
key: string;
type: number;
value: string;
};
// 监听器结构定义
export type ListenerHandler = {
body?: HttpRequestParam[];
enable: boolean;
header?: HttpRequestParam[];
path?: string;
};
/**
* 条件规则结构定义
*/
export type ConditionRule = {
leftSide: string | undefined;
opCode: string;
rightSide: string | undefined;
};
/**
* 条件结构定义
*/
export type Condition = {
// 条件规则的逻辑关系是否为且
and: boolean;
rules: ConditionRule[];
};
/**
* 条件组结构定义
*/
export type ConditionGroup = {
// 条件组的逻辑关系是否为且
and: boolean;
// 条件数组
conditions: Condition[];
};
/**
* 条件节点设置结构定义,用于条件节点
*/
export type ConditionSetting = {
// 条件表达式
conditionExpression?: string;
// 条件组
conditionGroups?: ConditionGroup;
// 条件类型
conditionType?: ConditionType;
// 是否默认的条件
defaultFlow?: boolean;
};
/**
* 审批拒绝结构定义
*/
export type RejectHandler = {
// 退回节点 Id
returnNodeId?: string;
// 审批拒绝类型
type: RejectHandlerType;
};
/**
* 审批超时结构定义
*/
export type TimeoutHandler = {
// 是否开启超时处理
enable: boolean;
// 执行动作是自动提醒, 最大提醒次数
maxRemindCount?: number;
// 超时时间设置
timeDuration?: string;
// 超时执行的动作
type?: number;
};
/**
* 审批人为空的结构定义
*/
export type AssignEmptyHandler = {
// 审批人为空的处理类型
type: AssignEmptyHandlerType;
// 指定用户的编号数组
userIds?: number[];
};
/**
* 延迟设置
*/
export type DelaySetting = {
// 延迟时间表达式
delayTime: string;
// 延迟类型
delayType: number;
};
/**
* 路由分支结构定义
*/
export type RouterSetting = {
conditionExpression: string;
conditionGroups: ConditionGroup;
conditionType: ConditionType;
nodeId: string | undefined;
};
/**
* 操作按钮权限结构定义
*/
export type ButtonSetting = {
displayName: string;
enable: boolean;
id: OperationButtonType;
};
/**
* HTTP 请求触发器结构定义
*/
export type HttpRequestTriggerSetting = {
// 请求体参数设置
body?: HttpRequestParam[];
// 请求头参数设置
header?: HttpRequestParam[];
// 请求响应设置
response?: Record<string, string>[];
// 请求 URL
url: string;
};
/**
* 流程表单触发器配置结构定义
*/
export type FormTriggerSetting = {
// 条件表达式
conditionExpression?: string;
// 条件组
conditionGroups?: ConditionGroup;
// 条件类型
conditionType?: ConditionType;
// 删除表单字段配置
deleteFields?: string[];
// 更新表单字段配置
updateFormFields?: Record<string, any>;
};
/**
* 触发器节点结构定义
*/
export type TriggerSetting = {
formSettings?: FormTriggerSetting[];
httpRequestSetting?: HttpRequestTriggerSetting;
type: TriggerTypeEnum;
};
export type IOParameter = {
source: string;
target: string;
};
export type StartUserSetting = {
emptyType?: ChildProcessStartUserEmptyTypeEnum;
formField?: string;
type: ChildProcessStartUserTypeEnum;
};
export type TimeoutSetting = {
enable: boolean;
timeExpression?: string;
type?: DelayTypeEnum;
};
export type MultiInstanceSetting = {
approveRatio?: number;
enable: boolean;
sequential?: boolean;
source?: string;
sourceType?: ChildProcessMultiInstanceSourceTypeEnum;
};
/**
* 子流程节点结构定义
*/
export type ChildProcessSetting = {
async: boolean;
calledProcessDefinitionKey: string;
calledProcessDefinitionName: string;
inVariables?: IOParameter[];
multiInstanceSetting: MultiInstanceSetting;
outVariables?: IOParameter[];
skipStartUserNode: boolean;
startUserSetting: StartUserSetting;
timeoutSetting: TimeoutSetting;
};
/**
* 节点结构定义
*/
export interface SimpleFlowNode {
id: string;
type: BpmNodeTypeEnum;
name: string;
showText?: string;
// 孩子节点
childNode?: SimpleFlowNode;
// 条件节点
conditionNodes?: SimpleFlowNode[];
// 审批类型
approveType?: ApproveType;
// 候选人策略
candidateStrategy?: number;
// 候选人参数
candidateParam?: string;
// 多人审批方式
approveMethod?: ApproveMethodType;
// 通过比例
approveRatio?: number;
// 审批按钮设置
buttonsSetting?: any[];
// 表单权限
fieldsPermission?: Array<Record<string, any>>;
// 审批任务超时处理
timeoutHandler?: TimeoutHandler;
// 审批任务拒绝处理
rejectHandler?: RejectHandler;
// 审批人为空的处理
assignEmptyHandler?: AssignEmptyHandler;
// 审批节点的审批人与发起人相同时,对应的处理类型
assignStartUserHandlerType?: number;
// 创建任务监听器
taskCreateListener?: ListenerHandler;
// 创建任务监听器
taskAssignListener?: ListenerHandler;
// 创建任务监听器
taskCompleteListener?: ListenerHandler;
// 条件设置
conditionSetting?: ConditionSetting;
// 活动的状态,用于前端节点状态展示
activityStatus?: BpmTaskStatusEnum;
// 延迟设置
delaySetting?: DelaySetting;
// 路由分支
routerGroups?: RouterSetting[];
defaultFlowId?: string;
// 签名
signEnable?: boolean;
// 审批意见
reasonRequire?: boolean;
// 触发器设置
triggerSetting?: TriggerSetting;
// 子流程
childProcessSetting?: ChildProcessSetting;
}
/**
* 条件组默认值
*/
export const DEFAULT_CONDITION_GROUP_VALUE = {
and: true,
conditions: [
{
and: true,
rules: [
{
opCode: '==',
leftSide: undefined,
rightSide: '',
},
],
},
],
};
export const NODE_DEFAULT_TEXT = new Map<number, string>();
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.USER_TASK_NODE, '请配置审批人');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.COPY_TASK_NODE, '请配置抄送人');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.CONDITION_NODE, '请设置条件');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.START_USER_NODE, '请设置发起人');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.DELAY_TIMER_NODE, '请设置延迟器');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.ROUTER_BRANCH_NODE, '请设置路由节点');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.TRIGGER_NODE, '请设置触发器');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.TRANSACTOR_NODE, '请设置办理人');
NODE_DEFAULT_TEXT.set(BpmNodeTypeEnum.CHILD_PROCESS_NODE, '请设置子流程');
export const NODE_DEFAULT_NAME = new Map<number, string>();
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.USER_TASK_NODE, '审批人');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.COPY_TASK_NODE, '抄送人');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.CONDITION_NODE, '条件');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.START_USER_NODE, '发起人');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.DELAY_TIMER_NODE, '延迟器');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.ROUTER_BRANCH_NODE, '路由分支');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.TRIGGER_NODE, '触发器');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.TRANSACTOR_NODE, '办理人');
NODE_DEFAULT_NAME.set(BpmNodeTypeEnum.CHILD_PROCESS_NODE, '子流程');
// 候选人策略。暂时不从字典中取。 后续可能调整。控制显示顺序
export const CANDIDATE_STRATEGY: DictDataType[] = [
{ label: '指定成员', value: CandidateStrategy.USER as any },
{ label: '指定角色', value: CandidateStrategy.ROLE as any },
{ label: '指定岗位', value: CandidateStrategy.POST as any },
{ label: '部门成员', value: CandidateStrategy.DEPT_MEMBER as any },
{ label: '部门负责人', value: CandidateStrategy.DEPT_LEADER as any },
{
label: '连续多级部门负责人',
value: CandidateStrategy.MULTI_LEVEL_DEPT_LEADER as any,
},
{ label: '发起人自选', value: CandidateStrategy.START_USER_SELECT as any },
{ label: '审批人自选', value: CandidateStrategy.APPROVE_USER_SELECT as any },
{ label: '发起人本人', value: CandidateStrategy.START_USER as any },
{
label: '发起人部门负责人',
value: CandidateStrategy.START_USER_DEPT_LEADER as any,
},
{
label: '发起人连续部门负责人',
value: CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER as any,
},
{ label: '用户组', value: CandidateStrategy.USER_GROUP as any },
{ label: '表单内用户字段', value: CandidateStrategy.FORM_USER as any },
{
label: '表单内部门负责人',
value: CandidateStrategy.FORM_DEPT_LEADER as any,
},
{ label: '流程表达式', value: CandidateStrategy.EXPRESSION as any },
];
// 审批节点 的审批类型
export const APPROVE_TYPE: DictDataType[] = [
{ label: '人工审批', value: ApproveType.USER as any },
{ label: '自动通过', value: ApproveType.AUTO_APPROVE as any },
{ label: '自动拒绝', value: ApproveType.AUTO_REJECT as any },
];
export const APPROVE_METHODS: DictDataType[] = [
{
label: '按顺序依次审批',
value: ApproveMethodType.SEQUENTIAL_APPROVE as any,
},
{
label: '会签(可同时审批,至少 % 人必须审批通过)',
value: ApproveMethodType.APPROVE_BY_RATIO as any,
},
{
label: '或签(可同时审批,有一人通过即可)',
value: ApproveMethodType.ANY_APPROVE as any,
},
{
label: '随机挑选一人审批',
value: ApproveMethodType.RANDOM_SELECT_ONE_APPROVE as any,
},
];
export const CONDITION_CONFIG_TYPES: DictDataType[] = [
{ label: '条件规则', value: ConditionType.RULE as any },
{ label: '条件表达式', value: ConditionType.EXPRESSION as any },
];
// 时间单位类型
export const TIME_UNIT_TYPES: DictDataType[] = [
{ label: '分钟', value: TimeUnitType.MINUTE as any },
{ label: '小时', value: TimeUnitType.HOUR as any },
{ label: '天', value: TimeUnitType.DAY as any },
];
// 超时处理执行动作类型
export const TIMEOUT_HANDLER_TYPES: DictDataType[] = [
{ label: '自动提醒', value: 1 },
{ label: '自动同意', value: 2 },
{ label: '自动拒绝', value: 3 },
];
export const REJECT_HANDLER_TYPES: DictDataType[] = [
{ label: '终止流程', value: RejectHandlerType.FINISH_PROCESS as any },
{ label: '驳回到指定节点', value: RejectHandlerType.RETURN_USER_TASK as any },
// { label: '结束任务', value: RejectHandlerType.FINISH_TASK }
];
export const ASSIGN_EMPTY_HANDLER_TYPES: DictDataType[] = [
{ label: '自动通过', value: 1 },
{ label: '自动拒绝', value: 2 },
{ label: '指定成员审批', value: 3 },
{ label: '转交给流程管理员', value: 4 },
];
export const ASSIGN_START_USER_HANDLER_TYPES: DictDataType[] = [
{ label: '由发起人对自己审批', value: 1 },
{ label: '自动跳过', value: 2 },
{ label: '转交给部门负责人审批', value: 3 },
];
// 比较运算符
export const COMPARISON_OPERATORS: DictDataType[] = [
{
value: '==',
label: '等于',
},
{
value: '!=',
label: '不等于',
},
{
value: '>',
label: '大于',
},
{
value: '>=',
label: '大于等于',
},
{
value: '<',
label: '小于',
},
{
value: '<=',
label: '小于等于',
},
];
// 审批操作按钮名称
export const OPERATION_BUTTON_NAME = new Map<number, string>();
OPERATION_BUTTON_NAME.set(OperationButtonType.APPROVE, '通过');
OPERATION_BUTTON_NAME.set(OperationButtonType.REJECT, '拒绝');
OPERATION_BUTTON_NAME.set(OperationButtonType.TRANSFER, '转办');
OPERATION_BUTTON_NAME.set(OperationButtonType.DELEGATE, '委派');
OPERATION_BUTTON_NAME.set(OperationButtonType.ADD_SIGN, '加签');
OPERATION_BUTTON_NAME.set(OperationButtonType.RETURN, '退回');
OPERATION_BUTTON_NAME.set(OperationButtonType.COPY, '抄送');
// 默认的按钮权限设置
export const DEFAULT_BUTTON_SETTING: ButtonSetting[] = [
{ id: OperationButtonType.APPROVE, displayName: '通过', enable: true },
{ id: OperationButtonType.REJECT, displayName: '拒绝', enable: true },
{ id: OperationButtonType.TRANSFER, displayName: '转办', enable: true },
{ id: OperationButtonType.DELEGATE, displayName: '委派', enable: true },
{ id: OperationButtonType.ADD_SIGN, displayName: '加签', enable: true },
{ id: OperationButtonType.RETURN, displayName: '退回', enable: true },
];
// 办理人默认的按钮权限设置
export const TRANSACTOR_DEFAULT_BUTTON_SETTING: ButtonSetting[] = [
{ id: OperationButtonType.APPROVE, displayName: '办理', enable: true },
{ id: OperationButtonType.REJECT, displayName: '拒绝', enable: false },
{ id: OperationButtonType.TRANSFER, displayName: '转办', enable: false },
{ id: OperationButtonType.DELEGATE, displayName: '委派', enable: false },
{ id: OperationButtonType.ADD_SIGN, displayName: '加签', enable: false },
{ id: OperationButtonType.RETURN, displayName: '退回', enable: false },
];
// 发起人的按钮权限。暂时定死,不可以编辑
export const START_USER_BUTTON_SETTING: ButtonSetting[] = [
{ id: OperationButtonType.APPROVE, displayName: '提交', enable: true },
{ id: OperationButtonType.REJECT, displayName: '拒绝', enable: false },
{ id: OperationButtonType.TRANSFER, displayName: '转办', enable: false },
{ id: OperationButtonType.DELEGATE, displayName: '委派', enable: false },
{ id: OperationButtonType.ADD_SIGN, displayName: '加签', enable: false },
{ id: OperationButtonType.RETURN, displayName: '退回', enable: false },
];
export const MULTI_LEVEL_DEPT: DictDataType[] = [
{ label: '第 1 级部门', value: 1 },
{ label: '第 2 级部门', value: 2 },
{ label: '第 3 级部门', value: 3 },
{ label: '第 4 级部门', value: 4 },
{ label: '第 5 级部门', value: 5 },
{ label: '第 6 级部门', value: 6 },
{ label: '第 7 级部门', value: 7 },
{ label: '第 8 级部门', value: 8 },
{ label: '第 9 级部门', value: 9 },
{ label: '第 10 级部门', value: 10 },
{ label: '第 11 级部门', value: 11 },
{ label: '第 12 级部门', value: 12 },
{ label: '第 13 级部门', value: 13 },
{ label: '第 14 级部门', value: 14 },
{ label: '第 15 级部门', value: 15 },
];
export const DELAY_TYPE = [
{ label: '固定时长', value: DelayTypeEnum.FIXED_TIME_DURATION },
{ label: '固定日期', value: DelayTypeEnum.FIXED_DATE_TIME },
];
export const BPM_HTTP_REQUEST_PARAM_TYPES = [
{
value: 1,
label: '固定值',
},
{
value: 2,
label: '表单',
},
];
export const TRIGGER_TYPES: DictDataType[] = [
{ label: '发送 HTTP 请求', value: TriggerTypeEnum.HTTP_REQUEST as any },
{ label: '接收 HTTP 回调', value: TriggerTypeEnum.HTTP_CALLBACK as any },
{ label: '修改表单数据', value: TriggerTypeEnum.FORM_UPDATE as any },
{ label: '删除表单数据', value: TriggerTypeEnum.FORM_DELETE as any },
];
export const CHILD_PROCESS_START_USER_TYPE = [
{
label: '同主流程发起人',
value: ChildProcessStartUserTypeEnum.MAIN_PROCESS_START_USER,
},
{ label: '表单', value: ChildProcessStartUserTypeEnum.FROM_FORM },
];
export const CHILD_PROCESS_START_USER_EMPTY_TYPE = [
{
label: '同主流程发起人',
value: ChildProcessStartUserEmptyTypeEnum.MAIN_PROCESS_START_USER,
},
{
label: '子流程管理员',
value: ChildProcessStartUserEmptyTypeEnum.CHILD_PROCESS_ADMIN,
},
{
label: '主流程管理员',
value: ChildProcessStartUserEmptyTypeEnum.MAIN_PROCESS_ADMIN,
},
];
export const CHILD_PROCESS_MULTI_INSTANCE_SOURCE_TYPE = [
{
label: '固定数量',
value: ChildProcessMultiInstanceSourceTypeEnum.FIXED_QUANTITY,
},
{
label: '数字表单',
value: ChildProcessMultiInstanceSourceTypeEnum.NUMBER_FORM,
},
{
label: '多选表单',
value: ChildProcessMultiInstanceSourceTypeEnum.MULTIPLE_FORM,
},
];

View File

@@ -0,0 +1,791 @@
import type { Ref } from 'vue';
import type {
ConditionGroup,
HttpRequestParam,
SimpleFlowNode,
} from './consts';
import type { BpmUserGroupApi } from '#/api/bpm/userGroup';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemPostApi } from '#/api/system/post';
import type { SystemRoleApi } from '#/api/system/role';
import type { SystemUserApi } from '#/api/system/user';
import { inject, nextTick, ref, toRaw, unref, watch } from 'vue';
import {
BpmNodeTypeEnum,
BpmTaskStatusEnum,
ProcessVariableEnum,
} from '#/utils';
import {
ApproveMethodType,
AssignEmptyHandlerType,
AssignStartUserHandlerType,
CandidateStrategy,
COMPARISON_OPERATORS,
ConditionType,
FieldPermissionType,
NODE_DEFAULT_NAME,
RejectHandlerType,
} from './consts';
export function useWatchNode(props: {
flowNode: SimpleFlowNode;
}): Ref<SimpleFlowNode> {
const node = ref<SimpleFlowNode>(props.flowNode);
watch(
() => props.flowNode,
(newValue) => {
node.value = newValue;
},
);
return node;
}
// 解析 formCreate 所有表单字段, 并返回
function parseFormCreateFields(formFields?: string[]) {
const result: Array<Record<string, any>> = [];
if (formFields) {
formFields.forEach((fieldStr: string) => {
parseFormFields(JSON.parse(fieldStr), result);
});
}
return result;
}
/**
* 解析表单组件的 field, title 等字段(递归,如果组件包含子组件)
*
* @param rule 组件的生成规则 https://www.form-create.com/v3/guide/rule
* @param fields 解析后表单组件字段
* @param parentTitle 如果是子表单,子表单的标题,默认为空
*/
export const parseFormFields = (
rule: Record<string, any>,
fields: Array<Record<string, any>> = [],
parentTitle: string = '',
) => {
const { type, field, $required, title: tempTitle, children } = rule;
if (field && tempTitle) {
let title = tempTitle;
if (parentTitle) {
title = `${parentTitle}.${tempTitle}`;
}
let required = false;
if ($required) {
required = true;
}
fields.push({
field,
title,
type,
required,
});
// TODO 子表单 需要处理子表单字段
// if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
// // 解析子表单的字段
// rule.props.rule.forEach((item) => {
// parseFields(item, fieldsPermission, title)
// })
// }
}
if (children && Array.isArray(children)) {
children.forEach((rule) => {
parseFormFields(rule, fields);
});
}
};
/**
* @description 表单数据权限配置,用于发起人节点 、审批节点、抄送节点
*/
export function useFormFieldsPermission(
defaultPermission: FieldPermissionType,
) {
// 字段权限配置. 需要有 field, title, permissioin 属性
const fieldsPermissionConfig = ref<Array<Record<string, any>>>([]);
const formType = inject<Ref<number | undefined>>('formType', ref()); // 表单类型
const formFields = inject<Ref<string[]>>('formFields', ref([])); // 流程表单字段
function getNodeConfigFormFields(
nodeFormFields?: Array<Record<string, string>>,
) {
nodeFormFields = toRaw(nodeFormFields);
fieldsPermissionConfig.value =
!nodeFormFields || nodeFormFields.length === 0
? getDefaultFieldsPermission(unref(formFields))
: mergeFieldsPermission(nodeFormFields, unref(formFields));
}
// 合并已经设置的表单字段权限,当前流程表单字段 (可能新增,或删除了字段)
function mergeFieldsPermission(
formFieldsPermisson: Array<Record<string, string>>,
formFields?: string[],
) {
let mergedFieldsPermission: Array<Record<string, any>> = [];
if (formFields) {
mergedFieldsPermission = parseFormCreateFields(formFields).map((item) => {
const found = formFieldsPermisson.find(
(fieldPermission) => fieldPermission.field === item.field,
);
return {
field: item.field,
title: item.title,
permission: found ? found.permission : defaultPermission,
};
});
}
return mergedFieldsPermission;
}
// 默认的表单权限: 获取表单的所有字段,设置字段默认权限为只读
function getDefaultFieldsPermission(formFields?: string[]) {
let defaultFieldsPermission: Array<Record<string, any>> = [];
if (formFields) {
defaultFieldsPermission = parseFormCreateFields(formFields).map(
(item) => {
return {
field: item.field,
title: item.title,
permission: defaultPermission,
};
},
);
}
return defaultFieldsPermission;
}
// 获取表单的所有字段,作为下拉框选项
const formFieldOptions = parseFormCreateFields(unref(formFields));
return {
formType,
fieldsPermissionConfig,
formFieldOptions,
getNodeConfigFormFields,
};
}
/**
* @description 获取流程表单的字段
*/
export function useFormFields() {
const formFields = inject<Ref<string[]>>('formFields', ref([])); // 流程表单字段
return parseFormCreateFields(unref(formFields));
}
// TODO @芋艿:后续需要把各种类似 useFormFieldsPermission 的逻辑,抽成一个通用方法。
/**
* @description 获取流程表单的字段和发起人字段
*/
export function useFormFieldsAndStartUser() {
const injectFormFields = inject<Ref<string[]>>('formFields', ref([])); // 流程表单字段
const formFields = parseFormCreateFields(unref(injectFormFields));
// 添加发起人
formFields.unshift({
field: ProcessVariableEnum.START_USER_ID,
title: '发起人',
required: true,
});
return formFields;
}
export type UserTaskFormType = {
approveMethod: ApproveMethodType;
approveRatio?: number;
assignEmptyHandlerType?: AssignEmptyHandlerType;
assignEmptyHandlerUserIds?: number[];
assignStartUserHandlerType?: AssignStartUserHandlerType;
buttonsSetting: any[];
candidateStrategy: CandidateStrategy;
deptIds?: number[]; // 部门
deptLevel?: number; // 部门层级
expression?: string; // 流程表达式
formDept?: string; // 表单内部门字段
formUser?: string; // 表单内用户字段
maxRemindCount?: number;
postIds?: number[]; // 岗位
reasonRequire: boolean;
rejectHandlerType?: RejectHandlerType;
returnNodeId?: string;
roleIds?: number[]; // 角色
signEnable: boolean;
taskAssignListener?: {
body: HttpRequestParam[];
header: HttpRequestParam[];
};
taskAssignListenerEnable?: boolean;
taskAssignListenerPath?: string;
taskCompleteListener?: {
body: HttpRequestParam[];
header: HttpRequestParam[];
};
taskCompleteListenerEnable?: boolean;
taskCompleteListenerPath?: string;
taskCreateListener?: {
body: HttpRequestParam[];
header: HttpRequestParam[];
};
taskCreateListenerEnable?: boolean;
taskCreateListenerPath?: string;
timeDuration?: number;
timeoutHandlerEnable?: boolean;
timeoutHandlerType?: number;
userGroups?: number[]; // 用户组
userIds?: number[]; // 用户
};
export type CopyTaskFormType = {
candidateStrategy: CandidateStrategy;
deptIds?: number[]; // 部门
deptLevel?: number; // 部门层级
expression?: string; // 流程表达式
formDept?: string; // 表单内部门字段
formUser?: string; // 表单内用户字段
postIds?: number[]; // 岗位
roleIds?: number[]; // 角色
userGroups?: number[]; // 用户组
userIds?: number[]; // 用户
};
/**
* @description 节点表单数据。 用于审批节点、抄送节点
*/
export function useNodeForm(nodeType: BpmNodeTypeEnum) {
const roleOptions = inject<Ref<SystemRoleApi.Role[]>>('roleList', ref([])); // 角色列表
const postOptions = inject<Ref<SystemPostApi.Post[]>>('postList', ref([])); // 岗位列表
const userOptions = inject<Ref<SystemUserApi.User[]>>('userList', ref([])); // 用户列表
const deptOptions = inject<Ref<SystemDeptApi.Dept[]>>('deptList', ref([])); // 部门列表
const userGroupOptions = inject<Ref<BpmUserGroupApi.UserGroup[]>>(
'userGroupList',
ref([]),
); // 用户组列表
const deptTreeOptions = inject<Ref<SystemDeptApi.Dept[]>>(
'deptTree',
ref([]),
); // 部门树
const formFields = inject<Ref<string[]>>('formFields', ref([])); // 流程表单字段
const configForm = ref<any | CopyTaskFormType | UserTaskFormType>();
if (
nodeType === BpmNodeTypeEnum.USER_TASK_NODE ||
nodeType === BpmNodeTypeEnum.TRANSACTOR_NODE
) {
configForm.value = {
candidateStrategy: CandidateStrategy.USER,
approveMethod: ApproveMethodType.SEQUENTIAL_APPROVE,
approveRatio: 100,
rejectHandlerType: RejectHandlerType.FINISH_PROCESS,
assignStartUserHandlerType: AssignStartUserHandlerType.START_USER_AUDIT,
returnNodeId: '',
timeoutHandlerEnable: false,
timeoutHandlerType: 1,
timeDuration: 6, // 默认 6小时
maxRemindCount: 1, // 默认 提醒 1次
buttonsSetting: [],
};
}
configForm.value = {
candidateStrategy: CandidateStrategy.USER,
};
function getShowText(): string {
let showText = '';
// 指定成员
if (
configForm.value?.candidateStrategy === CandidateStrategy.USER &&
configForm.value?.userIds?.length > 0
) {
const candidateNames: string[] = [];
userOptions?.value.forEach((item: any) => {
if (configForm.value?.userIds?.includes(item.id)) {
candidateNames.push(item.nickname);
}
});
showText = `指定成员:${candidateNames.join(',')}`;
}
// 指定角色
if (
configForm.value?.candidateStrategy === CandidateStrategy.ROLE &&
configForm.value.roleIds?.length > 0
) {
const candidateNames: string[] = [];
roleOptions?.value.forEach((item: any) => {
if (configForm.value?.roleIds?.includes(item.id)) {
candidateNames.push(item.name);
}
});
showText = `指定角色:${candidateNames.join(',')}`;
}
// 指定部门
if (
(configForm.value?.candidateStrategy === CandidateStrategy.DEPT_MEMBER ||
configForm.value?.candidateStrategy === CandidateStrategy.DEPT_LEADER ||
configForm.value?.candidateStrategy ===
CandidateStrategy.MULTI_LEVEL_DEPT_LEADER) &&
configForm.value?.deptIds?.length > 0
) {
const candidateNames: string[] = [];
deptOptions?.value.forEach((item) => {
if (configForm.value?.deptIds?.includes(item.id)) {
candidateNames.push(item.name);
}
});
if (
configForm.value.candidateStrategy === CandidateStrategy.DEPT_MEMBER
) {
showText = `部门成员:${candidateNames.join(',')}`;
} else if (
configForm.value.candidateStrategy === CandidateStrategy.DEPT_LEADER
) {
showText = `部门的负责人:${candidateNames.join(',')}`;
} else {
showText = `多级部门的负责人:${candidateNames.join(',')}`;
}
}
// 指定岗位
if (
configForm.value?.candidateStrategy === CandidateStrategy.POST &&
configForm.value.postIds?.length > 0
) {
const candidateNames: string[] = [];
postOptions?.value.forEach((item) => {
if (configForm.value?.postIds?.includes(item.id)) {
candidateNames.push(item.name);
}
});
showText = `指定岗位: ${candidateNames.join(',')}`;
}
// 指定用户组
if (
configForm.value?.candidateStrategy === CandidateStrategy.USER_GROUP &&
configForm.value?.userGroups?.length > 0
) {
const candidateNames: string[] = [];
userGroupOptions?.value.forEach((item) => {
if (configForm.value?.userGroups?.includes(item.id)) {
candidateNames.push(item.name);
}
});
showText = `指定用户组: ${candidateNames.join(',')}`;
}
// 表单内用户字段
if (configForm.value?.candidateStrategy === CandidateStrategy.FORM_USER) {
const formFieldOptions = parseFormCreateFields(unref(formFields));
const item = formFieldOptions.find(
(item) => item.field === configForm.value?.formUser,
);
showText = `表单用户:${item?.title}`;
}
// 表单内部门负责人
if (
configForm.value?.candidateStrategy === CandidateStrategy.FORM_DEPT_LEADER
) {
showText = `表单内部门负责人`;
}
// 审批人自选
if (
configForm.value?.candidateStrategy ===
CandidateStrategy.APPROVE_USER_SELECT
) {
showText = `审批人自选`;
}
// 发起人自选
if (
configForm.value?.candidateStrategy ===
CandidateStrategy.START_USER_SELECT
) {
showText = `发起人自选`;
}
// 发起人自己
if (configForm.value?.candidateStrategy === CandidateStrategy.START_USER) {
showText = `发起人自己`;
}
// 发起人的部门负责人
if (
configForm.value?.candidateStrategy ===
CandidateStrategy.START_USER_DEPT_LEADER
) {
showText = `发起人的部门负责人`;
}
// 发起人的部门负责人
if (
configForm.value?.candidateStrategy ===
CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER
) {
showText = `发起人连续部门负责人`;
}
// 流程表达式
if (configForm.value?.candidateStrategy === CandidateStrategy.EXPRESSION) {
showText = `流程表达式:${configForm.value.expression}`;
}
return showText;
}
/**
* 处理候选人参数的赋值
*/
function handleCandidateParam() {
let candidateParam: string | undefined;
if (!configForm.value) {
return candidateParam;
}
switch (configForm.value.candidateStrategy) {
case CandidateStrategy.DEPT_LEADER:
case CandidateStrategy.DEPT_MEMBER: {
candidateParam = configForm.value.deptIds?.join(',');
break;
}
case CandidateStrategy.EXPRESSION: {
candidateParam = configForm.value.expression;
break;
}
// 表单内部门的负责人
case CandidateStrategy.FORM_DEPT_LEADER: {
// 候选人参数格式: | 分隔 。左边为表单内部门字段。 右边为部门层级
const deptFieldOnForm = configForm.value.formDept;
candidateParam = deptFieldOnForm?.concat(
`|${configForm.value.deptLevel}`,
);
break;
}
case CandidateStrategy.FORM_USER: {
candidateParam = configForm.value?.formUser;
break;
}
// 指定连续多级部门的负责人
case CandidateStrategy.MULTI_LEVEL_DEPT_LEADER: {
// 候选人参数格式: | 分隔 。左边为部门(多个部门用 , 分隔)。 右边为部门层级
const deptIds = configForm.value.deptIds?.join(',');
candidateParam = deptIds?.concat(`|${configForm.value.deptLevel}`);
break;
}
case CandidateStrategy.POST: {
candidateParam = configForm.value.postIds?.join(',');
break;
}
case CandidateStrategy.ROLE: {
candidateParam = configForm.value.roleIds?.join(',');
break;
}
// 发起人部门负责人
case CandidateStrategy.START_USER_DEPT_LEADER:
case CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER: {
candidateParam = `${configForm.value.deptLevel}`;
break;
}
case CandidateStrategy.USER: {
candidateParam = configForm.value.userIds?.join(',');
break;
}
case CandidateStrategy.USER_GROUP: {
candidateParam = configForm.value.userGroups?.join(',');
break;
}
default: {
break;
}
}
return candidateParam;
}
/**
* 解析候选人参数
*/
function parseCandidateParam(
candidateStrategy: CandidateStrategy,
candidateParam: string | undefined,
) {
if (!configForm.value || !candidateParam) {
return;
}
switch (candidateStrategy) {
case CandidateStrategy.DEPT_LEADER:
case CandidateStrategy.DEPT_MEMBER: {
configForm.value.deptIds = candidateParam
.split(',')
.map((item) => +item);
break;
}
case CandidateStrategy.EXPRESSION: {
configForm.value.expression = candidateParam;
break;
}
// 表单内的部门负责人
case CandidateStrategy.FORM_DEPT_LEADER: {
// 候选人参数格式: | 分隔 。左边为表单内的部门字段。 右边为部门层级
const paramArray = candidateParam.split('|');
if (paramArray.length > 1) {
configForm.value.formDept = paramArray[0];
if (paramArray[1]) configForm.value.deptLevel = +paramArray[1];
}
break;
}
case CandidateStrategy.FORM_USER: {
configForm.value.formUser = candidateParam;
break;
}
// 指定连续多级部门的负责人
case CandidateStrategy.MULTI_LEVEL_DEPT_LEADER: {
// 候选人参数格式: | 分隔 。左边为部门(多个部门用 , 分隔)。 右边为部门层级
const paramArray = candidateParam.split('|') as string[];
if (paramArray.length > 1) {
configForm.value.deptIds = paramArray[0]
?.split(',')
.map((item) => +item);
if (paramArray[1]) configForm.value.deptLevel = +paramArray[1];
}
break;
}
case CandidateStrategy.POST: {
configForm.value.postIds = candidateParam
.split(',')
.map((item) => +item);
break;
}
case CandidateStrategy.ROLE: {
configForm.value.roleIds = candidateParam
.split(',')
.map((item) => +item);
break;
}
// 发起人部门负责人
case CandidateStrategy.START_USER_DEPT_LEADER:
case CandidateStrategy.START_USER_MULTI_LEVEL_DEPT_LEADER: {
configForm.value.deptLevel = +candidateParam;
break;
}
case CandidateStrategy.USER: {
configForm.value.userIds = candidateParam
.split(',')
.map((item) => +item);
break;
}
case CandidateStrategy.USER_GROUP: {
configForm.value.userGroups = candidateParam
.split(',')
.map((item) => +item);
break;
}
default: {
break;
}
}
}
return {
configForm,
roleOptions,
postOptions,
userOptions,
userGroupOptions,
deptTreeOptions,
handleCandidateParam,
parseCandidateParam,
getShowText,
};
}
/**
* @description 抽屉配置
*/
export function useDrawer() {
// 抽屉配置是否可见
const settingVisible = ref(false);
// 关闭配置抽屉
function closeDrawer() {
settingVisible.value = false;
}
// 打开配置抽屉
function openDrawer() {
settingVisible.value = true;
}
return {
settingVisible,
closeDrawer,
openDrawer,
};
}
/**
* @description 节点名称配置
*/
export function useNodeName(nodeType: BpmNodeTypeEnum) {
// 节点名称
const nodeName = ref<string>();
// 节点名称输入框
const showInput = ref(false);
// 输入框的引用
const inputRef = ref<HTMLInputElement | null>(null);
// 点击节点名称编辑图标
function clickIcon() {
showInput.value = true;
}
// 修改节点名称
function changeNodeName() {
showInput.value = false;
nodeName.value =
nodeName.value || (NODE_DEFAULT_NAME.get(nodeType) as string);
}
// 监听 showInput 的变化,当变为 true 时自动聚焦
watch(showInput, (value) => {
if (value) {
nextTick(() => {
inputRef.value?.focus();
});
}
});
return {
nodeName,
showInput,
inputRef,
clickIcon,
changeNodeName,
};
}
export function useNodeName2(
node: Ref<SimpleFlowNode>,
nodeType: BpmNodeTypeEnum,
) {
// 显示节点名称输入框
const showInput = ref(false);
// 输入框的引用
const inputRef = ref<HTMLInputElement | null>(null);
// 监听 showInput 的变化,当变为 true 时自动聚焦
watch(showInput, (value) => {
if (value) {
nextTick(() => {
inputRef.value?.focus();
});
}
});
// 修改节点名称
function changeNodeName() {
showInput.value = false;
node.value.name =
node.value.name || (NODE_DEFAULT_NAME.get(nodeType) as string);
console.warn('node.value.name===>', node.value.name);
}
// 点击节点标题进行输入
function clickTitle() {
showInput.value = true;
}
return {
showInput,
inputRef,
clickTitle,
changeNodeName,
};
}
/**
* @description 根据节点任务状态,获取节点任务状态样式
*/
export function useTaskStatusClass(
taskStatus: BpmTaskStatusEnum | undefined,
): string {
if (!taskStatus) {
return '';
}
if (taskStatus === BpmTaskStatusEnum.APPROVE) {
return 'status-pass';
}
if (taskStatus === BpmTaskStatusEnum.RUNNING) {
return 'status-running';
}
if (taskStatus === BpmTaskStatusEnum.REJECT) {
return 'status-reject';
}
if (taskStatus === BpmTaskStatusEnum.CANCEL) {
return 'status-cancel';
}
return '';
}
/** 条件组件文字展示 */
export function getConditionShowText(
conditionType: ConditionType | undefined,
conditionExpression: string | undefined,
conditionGroups: ConditionGroup | undefined,
fieldOptions: Array<Record<string, any>>,
) {
let showText: string | undefined;
if (conditionType === ConditionType.EXPRESSION && conditionExpression) {
showText = `表达式:${conditionExpression}`;
}
if (conditionType === ConditionType.RULE) {
// 条件组是否为与关系
const groupAnd = conditionGroups?.and;
let warningMessage: string | undefined;
const conditionGroup = conditionGroups?.conditions.map((item) => {
return `(${item.rules
.map((rule) => {
if (rule.leftSide && rule.rightSide) {
return `${getFormFieldTitle(
fieldOptions,
rule.leftSide,
)} ${getOpName(rule.opCode)} ${rule.rightSide}`;
} else {
// 有一条规则不完善。提示错误
warningMessage = '请完善条件规则';
return '';
}
})
.join(item.and ? ' 且 ' : ' 或 ')} ) `;
});
showText = warningMessage
? ''
: conditionGroup?.join(groupAnd ? ' 且 ' : ' 或 ');
}
return showText;
}
/** 获取表单字段名称*/
function getFormFieldTitle(
fieldOptions: Array<Record<string, any>>,
field: string,
) {
const item = fieldOptions.find((item) => item.field === field);
return item?.title;
}
/** 获取操作符名称 */
function getOpName(opCode: string): string | undefined {
const opName = COMPARISON_OPERATORS.find(
(item: any) => item.value === opCode,
);
return opName?.label;
}
/** 获取条件节点默认的名称 */
export function getDefaultConditionNodeName(
index: number,
defaultFlow: boolean | undefined,
): string {
if (defaultFlow) {
return '其它情况';
}
return `条件${index + 1}`;
}
/** 获取包容分支条件节点默认的名称 */
export function getDefaultInclusiveConditionNodeName(
index: number,
defaultFlow: boolean | undefined,
): string {
if (defaultFlow) {
return '其它情况';
}
return `包容条件${index + 1}`;
}

View File

@@ -0,0 +1,11 @@
import './styles/simple-process-designer.scss';
export { default as HttpRequestSetting } from './components/nodes-config/modules/http-request-setting.vue';
export { default as SimpleProcessDesigner } from './components/simple-process-designer.vue';
export { default as SimpleProcessViewer } from './components/simple-process-viewer.vue';
export type { SimpleFlowNode } from './consts';
export { parseFormFields } from './helpers';

View File

@@ -0,0 +1,759 @@
// TODO 整个样式是不是要重新优化一下
// iconfont 样式
@font-face {
font-family: iconfont; /* Project id 4495938 */
src:
url('iconfont.woff2?t=1737639517142') format('woff2'),
url('iconfont.woff?t=1737639517142') format('woff'),
url('iconfont.ttf?t=1737639517142') format('truetype');
}
// 配置节点头部
.config-header {
display: flex;
flex-direction: column;
.node-name {
display: flex;
align-items: center;
height: 24px;
font-size: 16px;
line-height: 24px;
cursor: pointer;
}
.divide-line {
width: 100%;
height: 1px;
margin-top: 16px;
background: #eee;
}
.config-editable-input {
max-width: 510px;
height: 24px;
font-size: 16px;
line-height: 24px;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.3s;
&:focus {
outline: 0;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
}
}
}
// 节点连线气泡卡片样式
.handler-item-wrapper {
display: flex;
flex-wrap: wrap;
width: 320px;
cursor: pointer;
.handler-item {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 12px;
}
.handler-item-icon {
width: 50px;
height: 50px;
text-align: center;
user-select: none;
background: #fff;
border: 1px solid #e2e2e2;
border-radius: 50%;
&:hover {
background: #e2e2e2;
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
}
.icon-size {
font-size: 25px;
line-height: 50px;
}
}
.approve {
color: #ff943e;
}
.copy {
color: #3296fa;
}
.condition {
color: #67c23a;
}
.parallel {
color: #626aef;
}
.inclusive {
color: #345da2;
}
.delay {
color: #e47470;
}
.trigger {
color: #3373d2;
}
.router {
color: #ca3a31;
}
.transactor {
color: #309;
}
.child-process {
color: #963;
}
.async-child-process {
color: #066;
}
.handler-item-text {
width: 80px;
margin-top: 4px;
font-size: 13px;
text-align: center;
}
}
// Simple 流程模型样式
.simple-process-model-container {
width: 100%;
height: 100%;
padding-top: 32px;
overflow-x: auto;
background-color: #fafafa;
.simple-process-model {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: fit-content;
background: url('./svg/simple-process-bg.svg') 0 0 repeat;
transform: scale(1);
transform-origin: 50% 0 0;
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
// 节点容器 定义节点宽度
.node-container {
width: 200px;
}
// 节点
.node-box {
position: relative;
display: flex;
flex-direction: column;
min-height: 70px;
padding: 5px 10px 8px;
cursor: pointer;
background-color: #fff;
border: 2px solid transparent;
border-radius: 8px;
box-shadow: 0 1px 4px 0 rgb(10 30 65 / 16%);
transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
&.status-pass {
background-color: #a9da90;
border-color: #67c23a;
}
&.status-pass:hover {
border-color: #67c23a;
}
&.status-running {
background-color: #e7f0fe;
border-color: #5a9cf8;
}
&.status-running:hover {
border-color: #5a9cf8;
}
&.status-reject {
background-color: #f6e5e5;
border-color: #e47470;
}
&.status-reject:hover {
border-color: #e47470;
}
&:hover {
border-color: #0089ff;
.node-toolbar {
opacity: 1;
}
.branch-node-move {
display: flex;
}
}
// 普通节点标题
.node-title-container {
display: flex;
align-items: center;
padding: 4px;
cursor: pointer;
border-radius: 4px 4px 0 0;
.node-title-icon {
display: flex;
align-items: center;
&.user-task {
color: #ff943e;
}
&.copy-task {
color: #3296fa;
}
&.start-user {
color: #676565;
}
&.delay-node {
color: #e47470;
}
&.trigger-node {
color: #3373d2;
}
&.router-node {
color: #ca3a31;
}
&.transactor-task {
color: #309;
}
&.child-process {
color: #963;
}
&.async-child-process {
color: #066;
}
}
.node-title {
margin-left: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
line-height: 18px;
color: #1f1f1f;
white-space: nowrap;
&:hover {
border-bottom: 1px dashed #f60;
}
}
}
// 条件节点标题
.branch-node-title-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
cursor: pointer;
border-radius: 4px 4px 0 0;
.input-max-width {
max-width: 115px !important;
}
.branch-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 600;
color: #f60;
white-space: nowrap;
&:hover {
border-bottom: 1px dashed #000;
}
}
.branch-priority {
min-width: 50px;
font-size: 12px;
}
}
.node-content {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 4px 8px;
margin-top: 4px;
line-height: 32px;
color: #111f2c;
background: rgb(0 0 0 / 3%);
border-radius: 4px;
.node-text {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2; /* 这将限制文本显示为两行 */
font-size: 14px;
line-height: 24px;
word-break: break-all;
-webkit-box-orient: vertical;
}
}
//条件节点内容
.branch-node-content {
display: flex;
align-items: center;
min-height: 32px;
padding: 4px 0;
margin-top: 4px;
line-height: 32px;
color: #111f2c;
background: rgb(0 0 0 / 3%);
border-radius: 4px;
.branch-node-text {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1; /* 这将限制文本显示为一行 */
font-size: 12px;
line-height: 24px;
word-break: break-all;
-webkit-box-orient: vertical;
}
}
// 节点操作 :删除
.node-toolbar {
position: absolute;
top: -20px;
right: 0;
display: flex;
opacity: 0;
.toolbar-icon {
vertical-align: middle;
text-align: center;
}
}
// 条件节点左右移动
.branch-node-move {
position: absolute;
display: none;
align-items: center;
justify-content: center;
width: 10px;
height: 100%;
cursor: pointer;
}
.move-node-left {
top: 0;
left: -2px;
background: rgb(126 134 142 / 8%);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.move-node-right {
top: 0;
right: -2px;
background: rgb(126 134 142 / 8%);
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
}
.node-config-error {
border-color: #ff5219 !important;
}
// 普通节点包装
.node-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
// 节点连线处理
.node-handler-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 70px;
user-select: none;
&::before {
position: absolute;
top: 0;
z-index: 0;
width: 2px;
height: 100%;
margin: auto;
content: '';
background-color: #dedede;
}
.node-handler {
.add-icon {
position: relative;
top: -5px;
display: flex;
align-items: center;
justify-content: center;
width: 25px;
height: 25px;
color: #fff;
cursor: pointer;
background-color: #0089ff;
border-radius: 50%;
&:hover {
transform: scale(1.1);
}
}
}
.node-handler-arrow {
position: absolute;
bottom: 0;
left: 50%;
display: flex;
transform: translateX(-50%);
}
}
// 条件节点包装
.branch-node-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 16px;
.branch-node-container {
position: relative;
display: flex;
min-width: fit-content;
&::before {
position: absolute;
left: 50%;
width: 4px;
height: 100%;
content: '';
background-color: #fafafa;
transform: translate(-50%);
}
.branch-node-add {
position: absolute;
top: -18px;
left: 50%;
z-index: 1;
display: flex;
align-items: center;
height: 36px;
padding: 0 10px;
font-size: 12px;
line-height: 36px;
border: 2px solid #dedede;
border-radius: 18px;
transform: translateX(-50%);
transform-origin: center center;
}
.branch-node-readonly {
position: absolute;
top: -18px;
left: 50%;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background-color: #fff;
border: 2px solid #dedede;
border-radius: 50%;
transform: translateX(-50%);
transform-origin: center center;
&.status-pass {
background-color: #e9f4e2;
border-color: #6bb63c;
}
&.status-pass:hover {
border-color: #6bb63c;
}
.icon-size {
font-size: 22px;
&.condition {
color: #67c23a;
}
&.parallel {
color: #626aef;
}
&.inclusive {
color: #345da2;
}
}
}
.branch-node-item {
position: relative;
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
min-width: 280px;
padding: 40px 40px 0;
background: transparent;
border-top: 2px solid #dedede;
border-bottom: 2px solid #dedede;
&::before {
position: absolute;
inset: 0;
width: 2px;
height: 100%;
margin: auto;
content: '';
background-color: #dedede;
}
}
// 覆盖条件节点第一个节点左上角的线
.branch-line-first-top {
position: absolute;
top: -5px;
left: -1px;
width: 50%;
height: 7px;
content: '';
background-color: #fafafa;
}
// 覆盖条件节点第一个节点左下角的线
.branch-line-first-bottom {
position: absolute;
bottom: -5px;
left: -1px;
width: 50%;
height: 7px;
content: '';
background-color: #fafafa;
}
// 覆盖条件节点最后一个节点右上角的线
.branch-line-last-top {
position: absolute;
top: -5px;
right: -1px;
width: 50%;
height: 7px;
content: '';
background-color: #fafafa;
}
// 覆盖条件节点最后一个节点右下角的线
.branch-line-last-bottom {
position: absolute;
right: -1px;
bottom: -5px;
width: 50%;
height: 7px;
content: '';
background-color: #fafafa;
}
}
}
.node-fixed-name {
display: inline-block;
width: auto;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
}
// 开始节点包装
.start-node-wrapper {
position: relative;
margin-top: 16px;
.start-node-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.start-node-box {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 90px;
height: 36px;
padding: 3px 4px;
color: #212121;
cursor: pointer;
background: #fafafa;
border-radius: 30px;
box-shadow: 0 1px 5px 0 rgb(10 30 65 / 8%);
}
}
}
// 结束节点包装
.end-node-wrapper {
margin-bottom: 16px;
.end-node-box {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 36px;
color: #212121;
background-color: #fff;
border: 2px solid transparent;
border-radius: 30px;
box-shadow: 0 1px 5px 0 rgb(10 30 65 / 8%);
&.status-pass {
background-color: #a9da90;
border-color: #6bb63c;
}
&.status-pass:hover {
border-color: #6bb63c;
}
&.status-reject {
background-color: #f6e5e5;
border-color: #e47470;
}
&.status-reject:hover {
border-color: #e47470;
}
&.status-cancel {
background-color: #eaeaeb;
border-color: #919398;
}
&.status-cancel:hover {
border-color: #919398;
}
}
}
// 可编辑的 title 输入框
.editable-title-input {
max-width: 145px;
height: 20px;
margin-left: 4px;
font-size: 12px;
line-height: 20px;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.3s;
&:focus {
outline: 0;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
}
}
}
}
.iconfont {
font-family: iconfont !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-trigger::before {
content: '\e6d3';
}
.icon-router::before {
content: '\e6b2';
}
.icon-delay::before {
content: '\e600';
}
.icon-start-user::before {
content: '\e679';
}
.icon-inclusive::before {
content: '\e602';
}
.icon-copy::before {
content: '\e7eb';
}
.icon-transactor::before {
content: '\e61c';
}
.icon-exclusive::before {
content: '\e717';
}
.icon-approve::before {
content: '\e715';
}
.icon-parallel::before {
content: '\e688';
}
.icon-async-child-process::before {
content: '\e6f2';
}
.icon-child-process::before {
content: '\e6c1';
}

View File

@@ -0,0 +1 @@
<svg width="22" height="22" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#FAFAFA" d="M0 0h22v22H0z"/><circle fill="#919BAE" cx="1" cy="1" r="1"/></g></svg>

After

Width:  |  Height:  |  Size: 192 B

View File

@@ -0,0 +1,2 @@
export { default as SummaryCard } from './summary-card.vue';
export type { SummaryCardProps } from './typing';

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { SummaryCardProps } from './typing';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Tooltip } from 'ant-design-vue';
/** 统计卡片 */
defineOptions({ name: 'SummaryCard' });
defineProps<SummaryCardProps>();
</script>
<template>
<div
class="flex flex-row items-center gap-3 rounded bg-[var(--el-bg-color-overlay)] p-4"
>
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded"
:class="`${iconColor} ${iconBgColor}`"
>
<IconifyIcon v-if="icon" :icon="icon" class="!text-6" />
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1">
<span class="text-base">{{ title }}</span>
<Tooltip :content="tooltip" placement="topLeft" v-if="tooltip">
<IconifyIcon
icon="lucide:circle-alert"
class="item-center !text-3 flex"
/>
</Tooltip>
</div>
<div class="flex flex-row items-baseline gap-2">
<div class="text-lg">
<CountTo
:prefix="prefix"
:end-val="value ?? 0"
:decimals="decimals ?? 0"
/>
</div>
<span
v-if="percent !== undefined"
:class="Number(percent) > 0 ? 'text-red-500' : 'text-green-500'"
>
<span class="text-sm">{{ Math.abs(Number(percent)) }}%</span>
<IconifyIcon
:icon="
Number(percent) > 0 ? 'lucide:chevron-up' : 'lucide:chevron-down'
"
class="ml-0.5 !text-sm"
/>
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,11 @@
export interface SummaryCardProps {
title: string;
tooltip?: string;
icon?: string;
iconColor?: string;
iconBgColor?: string;
prefix?: string;
value?: number;
decimals?: number;
percent?: number | string;
}

View File

@@ -0,0 +1,13 @@
export const ACTION_ICON = {
DOWNLOAD: 'lucide:download',
UPLOAD: 'lucide:upload',
ADD: 'lucide:plus',
EDIT: 'lucide:edit',
DELETE: 'lucide:trash-2',
REFRESH: 'lucide:refresh-cw',
SEARCH: 'lucide:search',
FILTER: 'lucide:filter',
MORE: 'lucide:ellipsis-vertical',
VIEW: 'lucide:eye',
COPY: 'lucide:copy',
};

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
// TODO @xingyu这个组件只有 pay 在用,和现有的 file-upload 和 image-upload 有点不一致。是不是可以考虑移除,只在 pay 那搞个复用的组件;
import type { InputProps, TextAreaProps } from 'ant-design-vue';
import type { FileUploadProps } from './typing';
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Col, Input, Row, Textarea } from 'ant-design-vue';
import FileUpload from './file-upload.vue';
const props = defineProps<{
defaultValue?: number | string;
fileUploadProps?: FileUploadProps;
inputProps?: InputProps;
inputType?: 'input' | 'textarea';
modelValue?: number | string;
textareaProps?: TextAreaProps;
}>();
const emits = defineEmits<{
(e: 'change', payload: number | string): void;
(e: 'update:value', payload: number | string): void;
(e: 'update:modelValue', payload: number | string): void;
}>();
const modelValue = useVModel(props, 'modelValue', emits, {
defaultValue: props.defaultValue,
passive: true,
});
function handleReturnText(text: string) {
modelValue.value = text;
emits('change', modelValue.value);
emits('update:value', modelValue.value);
emits('update:modelValue', modelValue.value);
}
const inputProps = computed(() => {
return {
...props.inputProps,
value: modelValue.value,
};
});
const textareaProps = computed(() => {
return {
...props.textareaProps,
value: modelValue.value,
};
});
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
};
});
</script>
<template>
<Row>
<Col :span="18">
<Input v-if="inputType === 'input'" v-bind="inputProps" />
<Textarea v-else :row="4" v-bind="textareaProps" />
</Col>
<Col :span="6">
<FileUpload
class="ml-4"
v-bind="fileUploadProps"
@return-text="handleReturnText"
/>
</Col>
</Row>
</template>

View File

@@ -0,0 +1,194 @@
import { isEmpty, isString, isUndefined } from './inference';
/**
* 将一个整数转换为分数保留传入的小数
* @param num
* @param digit
*/
export function formatToFractionDigit(
num: number | string | undefined,
digit: number = 2,
): string {
if (isUndefined(num)) return '0.00';
const parsedNumber = isString(num) ? Number.parseFloat(num) : num;
return (parsedNumber / 100).toFixed(digit);
}
/**
* 将一个整数转换为分数保留两位小数
* @param num
*/
export function formatToFraction(num: number | string | undefined): string {
return formatToFractionDigit(num, 2);
}
/**
* 将一个数转换为 1.00 这样
* 数据呈现的时候使用
*
* @param num 整数
*/
export function floatToFixed2(num: number | string | undefined): string {
let str = '0.00';
if (isUndefined(num)) return str;
const f = formatToFraction(num);
const decimalPart = f.toString().split('.')[1];
const len = decimalPart ? decimalPart.length : 0;
switch (len) {
case 0: {
str = `${f.toString()}.00`;
break;
}
case 1: {
str = `${f.toString()}0`;
break;
}
case 2: {
str = f.toString();
break;
}
}
return str;
}
/**
* 将一个分数转换为整数
* @param num
*/
export function convertToInteger(num: number | string | undefined): number {
if (isUndefined(num)) return 0;
const parsedNumber = isString(num) ? Number.parseFloat(num) : num;
return Math.round(parsedNumber * 100);
}
/**
* 元转分
*/
export function yuanToFen(amount: number | string): number {
return convertToInteger(amount);
}
/**
* 分转元
*/
export function fenToYuan(price: number | string): string {
return formatToFraction(price);
}
/**
* 计算环比
*
* @param value 当前数值
* @param reference 对比数值
*/
export function calculateRelativeRate(
value?: number,
reference?: number,
): number {
// 防止除0
if (!reference || reference === 0) return 0;
return Number.parseFloat(
((100 * ((value || 0) - reference)) / reference).toFixed(0),
);
}
// ========== ERP 专属方法 ==========
const ERP_COUNT_DIGIT = 3;
const ERP_PRICE_DIGIT = 2;
/**
* 【ERP】格式化 Input 数字
*
* 例如说:库存数量
*
* @param num 数量
* @package
* @return 格式化后的数量
*/
export function erpNumberFormatter(
num: number | string | undefined,
digit: number,
) {
if (num === null || num === undefined) {
return '';
}
if (typeof num === 'string') {
num = Number.parseFloat(num);
}
// 如果非 number则直接返回空串
if (Number.isNaN(num)) {
return '';
}
return num.toFixed(digit);
}
/**
* 【ERP】格式化数量保留三位小数
*
* 例如说:库存数量
*
* @param num 数量
* @return 格式化后的数量
*/
export function erpCountInputFormatter(num: number | string | undefined) {
return erpNumberFormatter(num, ERP_COUNT_DIGIT);
}
/**
* 【ERP】格式化数量保留三位小数
*
* @param cellValue 数量
* @return 格式化后的数量
*/
export function erpCountTableColumnFormatter(cellValue: any) {
return erpNumberFormatter(cellValue, ERP_COUNT_DIGIT);
}
/**
* 【ERP】格式化金额保留二位小数
*
* 例如说:库存数量
*
* @param num 数量
* @return 格式化后的数量
*/
export function erpPriceInputFormatter(num: number | string | undefined) {
return erpNumberFormatter(num, ERP_PRICE_DIGIT);
}
/**
* 【ERP】格式化金额保留二位小数
*
* @param cellValue 数量
* @return 格式化后的数量
*/
export function erpPriceTableColumnFormatter(cellValue: any) {
return erpNumberFormatter(cellValue, ERP_PRICE_DIGIT);
}
/**
* 【ERP】价格计算四舍五入保留两位小数
*
* @param price 价格
* @param count 数量
* @return 总价格。如果有任一为空,则返回 undefined
*/
export function erpPriceMultiply(price: number, count: number) {
if (isEmpty(price) || isEmpty(count)) return undefined;
return Number.parseFloat((price * count).toFixed(ERP_PRICE_DIGIT));
}
/**
* 【ERP】百分比计算四舍五入保留两位小数
*
* 如果 total 为 0则返回 0
*
* @param value 当前值
* @param total 总值
*/
export function erpCalculatePercentage(value: number, total: number) {
if (total === 0) return 0;
return ((value / total) * 100).toFixed(2);
}

View File

@@ -0,0 +1,67 @@
/**
* 根据支持的文件类型生成 accept 属性值
*
* @param supportedFileTypes 支持的文件类型数组,如 ['PDF', 'DOC', 'DOCX']
* @returns 用于文件上传组件 accept 属性的字符串
*/
export function generateAcceptedFileTypes(
supportedFileTypes: string[],
): string {
const allowedExtensions = supportedFileTypes.map((ext) => ext.toLowerCase());
const mimeTypes: string[] = [];
// 添加常见的 MIME 类型映射
if (allowedExtensions.includes('txt')) {
mimeTypes.push('text/plain');
}
if (allowedExtensions.includes('pdf')) {
mimeTypes.push('application/pdf');
}
if (allowedExtensions.includes('html') || allowedExtensions.includes('htm')) {
mimeTypes.push('text/html');
}
if (allowedExtensions.includes('csv')) {
mimeTypes.push('text/csv');
}
if (allowedExtensions.includes('xlsx') || allowedExtensions.includes('xls')) {
mimeTypes.push(
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
}
if (allowedExtensions.includes('docx') || allowedExtensions.includes('doc')) {
mimeTypes.push(
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
}
if (allowedExtensions.includes('pptx') || allowedExtensions.includes('ppt')) {
mimeTypes.push(
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
);
}
if (allowedExtensions.includes('xml')) {
mimeTypes.push('application/xml', 'text/xml');
}
if (
allowedExtensions.includes('md') ||
allowedExtensions.includes('markdown')
) {
mimeTypes.push('text/markdown');
}
if (allowedExtensions.includes('epub')) {
mimeTypes.push('application/epub+zip');
}
if (allowedExtensions.includes('eml')) {
mimeTypes.push('message/rfc822');
}
if (allowedExtensions.includes('msg')) {
mimeTypes.push('application/vnd.ms-outlook');
}
// 添加文件扩展名
const extensions = allowedExtensions.map((ext) => `.${ext}`);
return [...mimeTypes, ...extensions].join(',');
}

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import type { DocAlertProps } from './types';
import { ref } from 'vue';
import { isDocAlertEnable } from '@vben/hooks';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { openWindow } from '@vben-core/shared/utils';
defineOptions({
name: 'DocAlert',
});
const props = defineProps<DocAlertProps>();
/** 控制组件显示状态 */
const isVisible = ref(true);
function goToUrl() {
openWindow(props.url);
}
function close() {
isVisible.value = false;
}
</script>
<template>
<div
role="alert"
v-if="isDocAlertEnable() && isVisible"
class="border-primary bg-primary/10 relative my-2 flex h-8 w-full items-center gap-2 rounded-md border p-2"
>
<span class="grid shrink-0 place-items-center">
<VbenIcon icon="mdi:information-outline" class="text-primary size-5" />
</span>
<div class="text-primary min-w-0 flex-1 font-sans text-sm leading-none">
<span class="inline-block">{{ title }}</span>
<a
class="hover:text-success cursor-pointer break-all"
@click="goToUrl"
:title="url"
>
文档地址{{ url }}
</a>
</div>
<span class="grid shrink-0 cursor-pointer place-items-center">
<VbenIcon
icon="mdi:close"
class="text-primary size-5 hover:text-red-500"
@click="close"
/>
</span>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as DocAlert } from './doc-alert.vue';
export * from './types';

View File

@@ -0,0 +1,4 @@
export interface DocAlertProps {
title: string;
url: string;
}

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
defineOptions({
name: 'DocLink',
});
</script>
<template>
<div class="w-full sm:mx-auto md:max-w-md">
<div class="mt-4 flex items-center justify-between">
<span class="border-input w-[35%] border-b dark:border-gray-600"></span>
<span class="text-muted-foreground text-center text-xs uppercase">
萌新必读
</span>
<span class="border-input w-[35%] border-b dark:border-gray-600"></span>
</div>
<div class="mt-4 flex w-full justify-between">
<a
href="https://doc.iocoder.cn/"
target="_blank"
class="text-primary hover:text-primary/80 text-sm"
>
📚 开发指南
</a>
<a
href="https://doc.iocoder.cn/video/"
target="_blank"
class="text-primary hover:text-primary/80 text-sm"
>
🔥 视频教程
</a>
<a
href="https://www.iocoder.cn/Interview/good-collection/"
target="_blank"
class="text-primary hover:text-primary/80 text-sm"
>
面试手册
</a>
<a
href="http://static.yudao.iocoder.cn/mp/xinyu370.jpeg"
target="_blank"
class="text-primary hover:text-primary/80 text-sm"
>
🤝 外包咨询
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import { $t } from '@vben/locales';
import { openWindow } from '@vben/utils';
import { useVbenModal } from '@vben-core/popup-ui';
import { Badge, VbenButton, VbenButtonGroup } from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core';
defineOptions({
name: 'Help',
});
const keys = useMagicKeys();
whenever(keys['Alt+KeyH']!, () => {
modalApi.open();
});
const [Modal, modalApi] = useVbenModal({
draggable: true,
overlayBlur: 5,
footer: false,
onCancel() {
modalApi.close();
},
});
</script>
<template>
<Modal class="w-1/3" :title="$t('ui.widgets.qa')">
<div class="mt-2 flex flex-col">
<div class="mt-2 flex flex-col">
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="w-24 p-2">项目地址:</p>
<VbenButton
variant="link"
@click="
openWindow('https://gitee.com/yudaocode/yudao-ui-admin-vben')
"
>
Gitee
</VbenButton>
<VbenButton
variant="link"
@click="
openWindow('https://github.com/yudaocode/yudao-ui-admin-vben')
"
>
Github
</VbenButton>
</VbenButtonGroup>
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="w-24 p-2">issues:</p>
<VbenButton
variant="link"
@click="
openWindow(
'https://gitee.com/yudaocode/yudao-ui-admin-vben/issues',
)
"
>
Gitee
</VbenButton>
<VbenButton
variant="link"
@click="
openWindow(
'https://github.com/yudaocode/yudao-ui-admin-vben/issues',
)
"
>
Github
</VbenButton>
</VbenButtonGroup>
<VbenButtonGroup class="basis-1/3" :gap="2" border size="large">
<p class="w-24 p-2">开发文档:</p>
<VbenButton
variant="link"
@click="openWindow('https://doc.iocoder.cn/quick-start/')"
>
项目文档
</VbenButton>
<VbenButton variant="link" @click="openWindow('https://antdv.com/')">
antdv 文档
</VbenButton>
</VbenButtonGroup>
</div>
<div class="mt-2 flex justify-start">
<p class="w-24 p-2">软件外包:</p>
<img
src="/wx-xingyu.png"
alt="数舵科技"
class="cursor-pointer"
@click="openWindow('https://shuduokeji.com')"
/>
</div>
<p class="mt-2 flex justify-center pt-4 text-sm italic">
本项目采用 <Badge class="mx-2" variant="destructive">MIT</Badge>
开源协议个人与企业可100% 免费使用
</p>
</div>
</Modal>
</template>

View File

@@ -0,0 +1 @@
export { default as Help } from './help.vue';

View File

@@ -0,0 +1 @@
export { default as TenantDropdown } from './tenant-dropdown.vue';

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@vben-core/shadcn-ui';
interface Tenant {
id?: number;
name: string;
packageId: number;
contactName: string;
contactMobile: string;
accountCount: number;
expireTime: Date;
website: string;
status: number;
}
defineOptions({
name: 'TenantDropdown',
});
const props = defineProps<{
tenantList?: Tenant[];
visitTenantId?: null | number;
}>();
const emit = defineEmits(['success']);
// 租户列表
const tenants = computed(() => props.tenantList ?? []);
async function handleChange(id: number | undefined) {
if (!id) {
return;
}
const tenant = tenants.value.find((item) => item.id === id);
emit('success', tenant);
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="outline"
class="hover:bg-accent ml-1 mr-2 h-8 w-32 cursor-pointer rounded-full p-1.5"
>
<IconifyIcon icon="lucide:align-justify" class="mr-4" />
{{ $t('page.tenant.placeholder') }}
<!-- {{ tenants.find((item) => item.id === visitTenantId)?.name }} -->
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40 p-0 pb-1">
<DropdownMenuGroup>
<DropdownMenuItem
v-for="tenant in tenants"
:key="tenant.id"
:disabled="tenant.id === visitTenantId"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleChange(tenant.id)"
>
<template v-if="tenant.id === visitTenantId">
<IconifyIcon icon="lucide:check" class="mr-2" />
{{ tenant.name }}
</template>
<template v-else>
{{ tenant.name }}
</template>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
export { default as MarkdownIt } from 'markdown-it';
export { Transformer } from 'markmap-lib';
export { Toolbar } from 'markmap-toolbar';
export * from 'markmap-view';

View File

@@ -0,0 +1 @@
<svg t="1627279997305" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11904" width="40" height="40"><path d="M938.7008 669.525333L938.7008 249.412267c0-90.555733-73.5232-164.078933-164.1472-164.078933L249.378133 85.333333c-90.555733 0-164.078933 73.48906699-164.078933 164.078933l0 525.2096c0 90.555733 73.454933 164.078933 164.07893301 164.078933l525.20959999 0c80.725333 0 147.8656-58.368 161.553067-135.099733-43.52-18.8416-232.106667-100.283733-330.376533-147.182933-74.786133 90.589867-153.088 144.930133-271.121067 144.930133s-196.81279999-72.704-187.357867-161.655467c6.2464-58.402133 46.2848-153.9072 220.296533-137.5232 91.682133 8.6016 133.666133 25.736533 208.418133 50.414933 19.3536-35.4304 35.4304-74.513067 47.616-116.0192L292.0448 436.565333l0-32.8704 164.0448 0 0-58.9824L256 344.712533l1e-8-36.181333 200.12373299 0L456.123733 223.3344c0 0 1.809067-13.312 16.520533-13.31200001l82.056533 1e-8 0 98.474667 213.333333 0 0 36.181333-213.333333 1e-8 0 58.98239999 174.045867 0c-16.00853301 65.1264-40.277333 124.962133-70.690133 177.220267C708.608 599.176533 938.7008 669.525333 938.7008 669.525333L938.7008 669.525333 938.7008 669.525333 938.7008 669.525333zM321.57013299 744.994133c-124.7232 0-144.452267-78.7456-137.83039999-111.65013299 6.5536-32.733867 42.666667-75.502933 112.0256-75.50293301 79.6672 0 151.04 20.445867 236.714667 62.088533C472.302933 698.333867 398.370133 744.994133 321.57013299 744.994133L321.57013299 744.994133 321.57013299 744.994133zM321.57013299 744.994133" fill="#1296db" p-id="11905"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279586085" class="icon" viewBox="0 0 1036 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6737" xmlns:xlink="http://www.w3.org/1999/xlink" width="40.46875" height="40"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); }
</style></defs><path d="M27.587124 336.619083h69.148134a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916A13.978733 13.978733 0 0 0 96.735258 0.011183H27.587124a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m165.880969 0h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-27.584701a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m138.109886 322.629167h-110.525185a27.771084 27.771084 0 0 0-27.584701 28.14385v111.829867a27.771084 27.771084 0 0 0 27.584701 28.14385h110.525185a27.957467 27.957467 0 0 0 27.584701-28.14385v-111.829867a27.957467 27.957467 0 0 0-27.584701-28.14385z m484.596091-322.629167h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-27.5847a13.978733 13.978733 0 0 0-13.978734 13.978733v308.650434a13.978733 13.978733 0 0 0 13.978734 13.978733z m-469.871825 0H428.68358a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916A13.978733 13.978733 0 0 0 428.68358 0.011183h-83.126867a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m594.189361 0h69.148134a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-69.148135a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m-412.279444 126.181367H66.91396A67.470687 67.470687 0 0 0 0.002423 530.830286v425.139878a67.470687 67.470687 0 0 0 66.911537 68.029836h418.802853a67.470687 67.470687 0 0 0 66.911537-68.029836V487.775787a24.788954 24.788954 0 0 0-24.416188-24.975337z m-58.337914 433.899885a42.681733 42.681733 0 0 1-42.495349 43.054498H125.438257a42.681733 42.681733 0 0 1-42.495349-43.054498V590.100115a42.681733 42.681733 0 0 1 42.495349-43.054498h301.940642a42.681733 42.681733 0 0 1 42.495349 43.054498z m525.22761-433.899885a41.749817 41.749817 0 0 0-41.377051 42.122583v55.914934a41.377051 41.377051 0 1 0 82.940485 0v-55.914934a41.749817 41.749817 0 0 0-41.563434-42.122583z m0 223.659734a41.749817 41.749817 0 0 0-41.377051 42.122584V894.65012a45.477479 45.477479 0 0 1-45.291096 45.850246h-159.730327a43.240882 43.240882 0 0 0-43.613649 37.276622A41.9362 41.9362 0 0 0 745.534871 1024h233.538039a57.778765 57.778765 0 0 0 57.405999-58.337914V729.3283a41.749817 41.749817 0 0 0-41.377051-41.9362zM732.488053 322.64035V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-82.940485a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733h82.940485a13.978733 13.978733 0 0 0 13.79235-13.978733zM532.126208 0.011183c-11.36937 0-20.688525 6.337026-20.688526 13.978733v308.650434c0 7.828091 9.319156 13.978733 20.688526 13.978733s20.688525-6.337026 20.688525-13.978733V13.989916c0-7.641708-9.319156-13.978733-20.688525-13.978733z" p-id="6738" fill="#1977FD"></path><path d="M745.534871 462.80045a41.749817 41.749817 0 0 0-41.377051 42.122583v252.549117a41.377051 41.377051 0 1 0 82.940485 0V504.923033A41.749817 41.749817 0 0 0 745.534871 462.80045" p-id="6739" fill="#1977FD"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1 @@
<svg t="1627279878333" class="icon" viewBox="0 0 1285 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8535" width="40" height="40"><path d="M1141.76 855.04h-286.72c0 40.96 30.72 71.68 71.68 71.68h107.52c20.48 0 35.84 15.36 35.84 35.84s-15.36 35.84-35.84 35.84h-783.36c-20.48 0-35.84-15.36-35.84-35.84s15.36-35.84 35.84-35.84h107.52c40.96 0 71.68-30.72 71.68-71.68h-286.72c-76.8 0-143.36-61.44-143.36-143.36v-568.32c0-76.8 61.44-143.36 143.36-143.36h993.28c76.8 0 143.36 61.44 143.36 143.36v568.32c5.12 76.8-56.32 143.36-138.24 143.36z m71.68-711.68c0-40.96-30.72-71.68-71.68-71.68h-993.28c-40.96 0-71.68 30.72-71.68 71.68v568.32c0 40.96 30.72 71.68 71.68 71.68h993.28c40.96 0 71.68-30.72 71.68-71.68v-568.32z m-143.36 568.32h-855.04c-40.96 0-71.68-30.72-71.68-71.68v-424.96c0-40.96 30.72-71.68 71.68-71.68h855.04c40.96 0 71.68 30.72 71.68 71.68v424.96c0 40.96-30.72 71.68-71.68 71.68z" p-id="8536" fill="#1977FD"></path></svg>

After

Width:  |  Height:  |  Size: 939 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279238245" class="icon" viewBox="0 0 1115 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4112" width="43.5546875" height="40" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); }
</style></defs><path d="M751.388 68.267a34.133 34.133 0 0 1 0-68.267h227.556a91.022 91.022 0 0 1 91.022 91.022v227.556a34.133 34.133 0 1 1-68.266 0V91.022a22.756 22.756 0 0 0-22.756-22.755H751.388M1001.7 705.422a34.133 34.133 0 0 1 68.266 0v227.556A91.022 91.022 0 0 1 978.944 1024H748.885a34.133 34.133 0 0 1 0-68.267H978.49a22.756 22.756 0 0 0 22.755-22.755V705.422M364.09 955.733a34.133 34.133 0 1 1 0 68.267H136.533a91.022 91.022 0 0 1-91.022-91.022V705.422a34.133 34.133 0 0 1 68.267 0v227.556a22.756 22.756 0 0 0 22.755 22.755H364.09M113.778 318.578a34.133 34.133 0 1 1-68.267 0V91.022A91.022 91.022 0 0 1 136.533 0H364.09a34.133 34.133 0 0 1 0 68.267H136.533a22.756 22.756 0 0 0-22.755 22.755v227.556M34.133 477.867a34.133 34.133 0 0 0 0 68.266h168.619v-68.266z m1046.756 0H912.27v68.266h168.619a34.133 34.133 0 0 0 0-68.266zM202.752 157.24h709.746v320.627H202.752z m0 388.893h709.746V866.76H202.752z" fill="#1977FD" p-id="4113"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1645964864184" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8460" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40"><defs><style type="text/css"></style></defs><path d="M768.3 0 255.7 0c-70.8 0-128.1 57.4-128.1 128.1l0 767.8c0 70.8 57.4 128.1 128.1 128.1L512 1024l256.3 0c70.8 0 128.1-57.4 128.1-128.1L896.4 128.1C896.4 57.3 839 0 768.3 0zM383.9 96.1c0-17.7 14.3-32 32-32l192.2 0c17.7 0 32 14.3 32 32l0 0c0 17.7-14.3 32-32 32L415.9 128.1C398.2 128.1 383.9 113.8 383.9 96.1L383.9 96.1zM512 959.9 512 959.9 512 959.9c-35.4 0-64.1-28.8-64.1-64.1 0-35.4 28.7-64.1 64.1-64.1l0 0 0 0c35.4 0 64.1 28.7 64.1 64.1C576.1 931.1 547.4 959.9 512 959.9zM832.3 755.6c0 6.7-5.4 12.2-12.2 12.2L203.9 767.8c-6.7 0-12.2-5.4-12.2-12.2L191.7 204.3c0-6.7 5.4-12.2 12.2-12.2l616.3 0c6.7 0 12.2 5.4 12.2 12.2L832.4 755.6z" p-id="8461" fill="#1977FD"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1716345268026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5622" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M956.408445 419.226665a250.670939 250.670939 0 0 0-22.425219-209.609236A263.163526 263.163526 0 0 0 652.490412 85.715535 259.784384 259.784384 0 0 0 457.728923 0.008192a261.422756 261.422756 0 0 0-249.44216 178.582564 258.453206 258.453206 0 0 0-172.848261 123.901894c-57.03583 96.868753-44.031251 219.132275 32.153053 302.279661a250.670939 250.670939 0 0 0 22.32282 209.609237 263.163526 263.163526 0 0 0 281.595213 123.901893A259.067596 259.067596 0 0 0 566.271077 1023.990784a260.60357 260.60357 0 0 0 249.339762-178.889759 258.453206 258.453206 0 0 0 172.848261-123.901893c57.445423-96.868753 44.13365-218.82508-32.050655-302.074865zM566.578272 957.124721c-45.362429 0-89.496079-15.666934-124.516283-44.543243 1.638372-0.921584 4.198329-2.150363 6.143895-3.481541l206.537289-117.757998a32.35785 32.35785 0 0 0 16.895713-29.081105V474.82892l87.243317 49.97035c1.023983 0.307195 1.638372 1.228779 1.638372 2.252762v238.075953c0 105.8798-86.936122 191.689541-193.942303 191.996736zM148.588578 781.102113a189.846373 189.846373 0 0 1-23.346803-128.612213c1.535974 1.023983 4.09593 2.559956 6.143895 3.48154L337.922959 773.729439c10.444622 6.143896 23.346803 6.143896 34.098621 0l252.30931-143.664758v99.531108c0 1.023983-0.307195 1.945567-1.331177 2.559956l-208.892449 118.986778a196.297463 196.297463 0 0 1-265.518686-70.04041zM94.112704 335.97688c22.630015-39.013737 58.367008-68.81163 101.16948-84.171369V494.591784c0 11.7758 6.45109 22.93721 16.793315 28.978707l252.30931 143.767156L377.141493 716.796006a3.174346 3.174346 0 0 1-2.867152 0.307195l-208.892448-118.986777A190.870355 190.870355 0 0 1 94.215102 335.874482z m717.607001 164.861198L559.410394 357.070922 646.653711 307.20297a3.174346 3.174346 0 0 1 2.969549-0.307195l208.892449 118.986777a190.358364 190.358364 0 0 1 70.961994 262.139544 194.556693 194.556693 0 0 1-101.16948 84.171369V529.407192a31.538664 31.538664 0 0 0-16.588518-28.671513z m87.03852-129.329002c-1.74077-1.023983-4.300727-2.559956-6.246294-3.48154l-206.639687-117.757999a34.09862 34.09862 0 0 0-33.996222 0L399.566711 393.934295v-99.531108c0-1.023983 0.307195-1.945567 1.331178-2.559956l208.892449-119.089176a195.990268 195.990268 0 0 1 265.518686 70.450003c22.732414 38.706542 31.129071 84.171369 23.346803 128.305018zM352.258716 548.862861l-87.243317-49.560757a2.457558 2.457558 0 0 1-1.638372-2.252762V258.870991c0-105.8798 87.243317-191.996736 194.556692-191.689541a194.556693 194.556693 0 0 1 124.209089 44.543243c-1.638372 0.921584-4.198329 2.252762-6.143896 3.48154l-206.639687 117.757999a31.948257 31.948257 0 0 0-16.793315 29.081105l-0.307194 286.715126z m47.307995-100.759887L512 384.001664l112.535687 63.998912v127.997824l-112.228492 63.998912-112.535687-63.998912-0.307195-127.997824z" p-id="5623" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747409043186" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4834" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M44.416 853.333333v-85.205333a170.666667 170.666667 0 0 1 170.666667-170.666667h170.837333a37637.589333 37637.589333 0 0 1 0-206.165333C324.309333 352.170667 281.6 285.141333 281.6 211.968c0-116.906667 90.197333-211.072 231.168-211.072 140.970667 0 230.741333 94.208 230.741333 211.072 0 73.216-40.96 140.245333-102.528 179.328 0.256 0.170667 0.256 68.906667 0 206.165333h171.989334a170.666667 170.666667 0 0 1 170.666666 170.666667V853.333333a170.666667 170.666667 0 0 1-170.666666 170.666667H215.082667a170.666667 170.666667 0 0 1-170.666667-170.666667z m84.266667-84.650666v104.277333a85.333333 85.333333 0 0 0 85.333333 85.333333H811.52a85.333333 85.333333 0 0 0 85.333333-85.333333v-104.277333a85.333333 85.333333 0 0 0-85.333333-85.333334h-256.64l8.96-342.698666c66.944-21.333333 100.394667-64.256 100.394667-128.682667 0-61.952-57.344-129.322667-151.466667-129.322667-94.122667 0-146.645333 61.610667-146.645333 129.322667 0 71.466667 34.816 114.346667 104.362666 128.682667v342.698666H214.016a85.333333 85.333333 0 0 0-85.333333 85.333334z m167.125333 138.368c-50.432 0-91.434667-41.557333-91.434667-92.586667s41.002667-92.586667 91.434667-92.586667c50.389333 0 91.434667 41.557333 91.434667 92.586667 0 24.832-9.6 48.170667-27.008 65.706667-17.237333 17.322667-40.106667 26.88-64.426667 26.88z m0-119.466667a27.093333 27.093333 0 0 0-27.306667 26.88c0 14.805333 12.245333 26.88 27.306667 26.88a27.306667 27.306667 0 0 0 19.498667-8.106667 26.453333 26.453333 0 0 0 7.808-18.773333 27.093333 27.093333 0 0 0-27.306667-26.88z" fill="#1296db" p-id="4835"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1676209854312" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3033" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M173.077333 362.666667l91.114667-214.677334a65.6 65.6 0 0 1 86.016-34.773333c11.584 4.906667 24.96 10.282667 40.896 16.448 8.277333 3.2 16.789333 6.464 27.904 10.666667 28.202667 10.709333 39.296 14.933333 46.144 17.642666l51.477333-51.669333c28.181333-28.16 74.112-27.946667 102.570667 0.533333l195.925333 195.925334c16.426667 16.426667 23.445333 38.634667 21.056 59.904H896a42.666667 42.666667 0 0 1 42.666667 42.666666v490.666667a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667V405.333333a42.666667 42.666667 0 0 1 42.666667-42.666666h45.077333z m48.96 0h39.104l169.194667-169.770667-27.328-10.389333c-11.2-4.245333-19.818667-7.530667-28.224-10.794667a1459.2 1459.2 0 0 1-42.197333-17.002667 20.522667 20.522667 0 0 0-26.901334 10.88L222.037333 362.666667z m108.842667 0h454.954667a23.509333 23.509333 0 0 0-5.290667-25.322667l-195.925333-195.925333a23.36 23.36 0 0 0-33.024-0.213334L330.88 362.666667zM128 405.333333v490.666667h768V405.333333H128z m597.333333 320a85.333333 85.333333 0 1 1 0-170.666666 85.333333 85.333333 0 0 1 0 170.666666z m0-42.666666a42.666667 42.666667 0 1 0 0-85.333334 42.666667 42.666667 0 0 0 0 85.333334z" fill="#4296d5" p-id="3034"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279375144" class="icon" viewBox="0 0 1115 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4399" width="43.5546875" height="40" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); }
</style></defs><path d="M751.388 68.267a34.133 34.133 0 0 1 0-68.267h227.556a91.022 91.022 0 0 1 91.022 91.022v227.556a34.133 34.133 0 1 1-68.266 0V91.022a22.756 22.756 0 0 0-22.756-22.755H751.388M1001.7 705.422a34.133 34.133 0 0 1 68.266 0v227.556A91.022 91.022 0 0 1 978.944 1024H748.885a34.133 34.133 0 0 1 0-68.267H978.49a22.756 22.756 0 0 0 22.755-22.755V705.422M364.09 955.733a34.133 34.133 0 1 1 0 68.267H136.533a91.022 91.022 0 0 1-91.022-91.022V705.422a34.133 34.133 0 0 1 68.267 0v227.556a22.756 22.756 0 0 0 22.755 22.755H364.09M113.778 318.578a34.133 34.133 0 1 1-68.267 0V91.022A91.022 91.022 0 0 1 136.533 0H364.09a34.133 34.133 0 0 1 0 68.267H136.533a22.756 22.756 0 0 0-22.755 22.755v227.556M34.133 477.867a34.133 34.133 0 0 0 0 68.266h168.619v-68.266z m1046.756 0H912.27v68.266h168.619a34.133 34.133 0 0 0 0-68.266zM202.752 157.24h709.746v320.627H202.752z m0 388.893h709.746V866.76H202.752z" fill="#04C361" p-id="4400"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279586085" class="icon" viewBox="0 0 1036 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6737" xmlns:xlink="http://www.w3.org/1999/xlink" width="40.46875" height="40"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix&quot;) format(&quot;embedded-opentype&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2&quot;) format(&quot;woff2&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff&quot;) format(&quot;woff&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf&quot;) format(&quot;truetype&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont&quot;) format(&quot;svg&quot;); }</style></defs><path d="M27.587124 336.619083h69.148134a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916A13.978733 13.978733 0 0 0 96.735258 0.011183H27.587124a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m165.880969 0h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-27.584701a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m138.109886 322.629167h-110.525185a27.771084 27.771084 0 0 0-27.584701 28.14385v111.829867a27.771084 27.771084 0 0 0 27.584701 28.14385h110.525185a27.957467 27.957467 0 0 0 27.584701-28.14385v-111.829867a27.957467 27.957467 0 0 0-27.584701-28.14385z m484.596091-322.629167h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-27.5847a13.978733 13.978733 0 0 0-13.978734 13.978733v308.650434a13.978733 13.978733 0 0 0 13.978734 13.978733z m-469.871825 0H428.68358a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916A13.978733 13.978733 0 0 0 428.68358 0.011183h-83.126867a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m594.189361 0h69.148134a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-69.148135a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m-412.279444 126.181367H66.91396A67.470687 67.470687 0 0 0 0.002423 530.830286v425.139878a67.470687 67.470687 0 0 0 66.911537 68.029836h418.802853a67.470687 67.470687 0 0 0 66.911537-68.029836V487.775787a24.788954 24.788954 0 0 0-24.416188-24.975337z m-58.337914 433.899885a42.681733 42.681733 0 0 1-42.495349 43.054498H125.438257a42.681733 42.681733 0 0 1-42.495349-43.054498V590.100115a42.681733 42.681733 0 0 1 42.495349-43.054498h301.940642a42.681733 42.681733 0 0 1 42.495349 43.054498z m525.22761-433.899885a41.749817 41.749817 0 0 0-41.377051 42.122583v55.914934a41.377051 41.377051 0 1 0 82.940485 0v-55.914934a41.749817 41.749817 0 0 0-41.563434-42.122583z m0 223.659734a41.749817 41.749817 0 0 0-41.377051 42.122584V894.65012a45.477479 45.477479 0 0 1-45.291096 45.850246h-159.730327a43.240882 43.240882 0 0 0-43.613649 37.276622A41.9362 41.9362 0 0 0 745.534871 1024h233.538039a57.778765 57.778765 0 0 0 57.405999-58.337914V729.3283a41.749817 41.749817 0 0 0-41.377051-41.9362zM732.488053 322.64035V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-82.940485a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733h82.940485a13.978733 13.978733 0 0 0 13.79235-13.978733zM532.126208 0.011183c-11.36937 0-20.688525 6.337026-20.688526 13.978733v308.650434c0 7.828091 9.319156 13.978733 20.688526 13.978733s20.688525-6.337026 20.688525-13.978733V13.989916c0-7.641708-9.319156-13.978733-20.688525-13.978733z" p-id="6738" fill="#04C361"/><path d="M745.534871 462.80045a41.749817 41.749817 0 0 0-41.377051 42.122583v252.549117a41.377051 41.377051 0 1 0 82.940485 0V504.923033A41.749817 41.749817 0 0 0 745.534871 462.80045" p-id="6739" fill="#04C361"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1676209433089" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2990" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M608.6 290.3c67.1 0 121.7 50.5 121.7 112.9 0 19.4-5.6 38.4-15.7 55.5-15.3 25-39.8 43.5-69.4 52.3-7.9 2.3-13.9 3.2-19.4 3.2-13 0-23.1-10.2-23.1-23.1 0-13 10.2-23.1 23.1-23.1 0.9 0 2.8 0 5.1-0.9 19.9-5.6 35.6-17.1 44.4-32.4 6-9.7 8.8-20.4 8.8-31.5 0-36.6-33.8-66.6-75-66.6-14.4 0-28.2 3.7-40.7 10.6-21.8 12.5-34.7 33.3-34.7 56v193.9c0 39.3-21.8 75.4-57.9 95.8-19.4 11.1-41.2 16.7-63.4 16.7-67.1 0-121.7-50.5-121.7-112.9 0-19.4 5.6-38.4 15.7-55.5 15.3-25 39.8-43.5 69.4-52.3 8.3-2.3 13.9-3.2 19.4-3.2 13 0 23.1 10.2 23.1 23.1 0 13-10.2 23.1-23.1 23.1-0.9 0-2.8 0-5.1 0.9-19.9 6-35.6 17.6-44.4 32.4-6 9.7-8.8 20.4-8.8 31.5 0 36.6 33.8 66.6 75.4 66.6 14.4 0 28.2-3.7 40.7-10.6 21.8-12.5 34.7-33.3 34.7-56V403.3c0-39.3 21.8-75.4 57.9-95.8 19-11.6 40.7-17.2 63-17.2zM510.8 929c231.1 0 418.4-187.3 418.4-418.4S741.9 92.1 510.8 92.1 92.4 279.5 92.4 510.6 279.7 929 510.8 929z m0 22C267.5 951 70.3 753.8 70.3 510.6S267.5 70.1 510.8 70.1s440.5 197.2 440.5 440.5S754.1 951 510.8 951z" p-id="2991" fill="#58bf6b"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279375144" class="icon" viewBox="0 0 1115 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4399" width="43.5546875" height="40" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix&quot;) format(&quot;embedded-opentype&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2&quot;) format(&quot;woff2&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff&quot;) format(&quot;woff&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf&quot;) format(&quot;truetype&quot;), url(&quot;//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont&quot;) format(&quot;svg&quot;); }</style></defs><path d="M751.388 68.267a34.133 34.133 0 0 1 0-68.267h227.556a91.022 91.022 0 0 1 91.022 91.022v227.556a34.133 34.133 0 1 1-68.266 0V91.022a22.756 22.756 0 0 0-22.756-22.755H751.388M1001.7 705.422a34.133 34.133 0 0 1 68.266 0v227.556A91.022 91.022 0 0 1 978.944 1024H748.885a34.133 34.133 0 0 1 0-68.267H978.49a22.756 22.756 0 0 0 22.755-22.755V705.422M364.09 955.733a34.133 34.133 0 1 1 0 68.267H136.533a91.022 91.022 0 0 1-91.022-91.022V705.422a34.133 34.133 0 0 1 68.267 0v227.556a22.756 22.756 0 0 0 22.755 22.755H364.09M113.778 318.578a34.133 34.133 0 1 1-68.267 0V91.022A91.022 91.022 0 0 1 136.533 0H364.09a34.133 34.133 0 0 1 0 68.267H136.533a22.756 22.756 0 0 0-22.755 22.755v227.556M34.133 477.867a34.133 34.133 0 0 0 0 68.266h168.619v-68.266z m1046.756 0H912.27v68.266h168.619a34.133 34.133 0 0 0 0-68.266zM202.752 157.24h709.746v320.627H202.752z m0 388.893h709.746V866.76H202.752z" fill="#04C361" p-id="4400"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279797174" class="icon" viewBox="0 0 1260 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7665" xmlns:xlink="http://www.w3.org/1999/xlink" width="49.21875" height="40"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); }
</style></defs><path d="M797.14798 481.753a269.194 269.194 0 0 0 102.892-211.929C900.03998 120.99 779.02998 0 630.15698 0 481.28298 0 360.27398 120.99 360.27398 269.824c0 85.878 40.33 162.462 102.912 211.929A450.974 450.974 0 0 0 309.84198 582.774c-85.543 85.524-132.608 199.208-132.608 320.236 0 25.01 0 51.712 0.197 76.367a44.898 44.898 0 0 0 44.82 44.623h816.01a44.8 44.8 0 0 0 44.82-44.623V903.01c0-121.009-47.066-234.732-132.609-320.236a451.072 451.072 0 0 0-153.344-101.021z" p-id="7666" fill="#04C361"></path><path d="M1186.18898 580.391A378.644 378.644 0 0 0 1061.81198 473.03a223.783 223.783 0 0 0 64.237-157.657c0-49.742-15.872-96.67-45.746-136.074A225.34 225.34 0 0 0 964.70998 99.9a37.297 37.297 0 0 0-46.14 25.718c-5.592 19.89 5.79 40.724 25.6 46.356 63.114 18.196 107.363 77.135 107.363 143.4a148.913 148.913 0 0 1-81.23 133.06 38.065 38.065 0 0 0-20.363 36.608c1.32 15.203 11.58 28.16 25.975 32.65 125.479 39.601 209.703 155.038 209.703 287.173v63.074c0 20.638 16.62 37.534 37.16 37.711h0.196a37.396 37.396 0 0 0 37.337-37.336V805.06c-0.197-81.644-25.777-159.35-74.142-224.69z m-901.77-62.503a36.982 36.982 0 0 0 25.955-32.65 37.455 37.455 0 0 0-20.362-36.628 148.913 148.913 0 0 1-81.231-133.06c0-66.245 44.071-125.184 107.382-143.4a37.612 37.612 0 0 0 25.58-46.356 37.376 37.376 0 0 0-46.139-25.718 225.32 225.32 0 0 0-115.593 79.4 223.252 223.252 0 0 0-45.746 136.074c0 60.258 23.533 116.381 64.237 157.676A380.475 380.475 0 0 0 74.14498 580.569 373.839 373.839 0 0 0 0.00198 805.258v63.232c0 20.657 16.798 37.356 37.356 37.356h0.197a37.317 37.317 0 0 0 37.14-37.73V805.06c0-132.332 84.401-247.769 209.723-287.173z" p-id="7667" fill="#04C361"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB