Merge pull request !126 from xingyu/dev
pull/128/MERGE
xingyu 2025-06-04 05:54:06 +00:00 committed by Gitee
commit 4802671152
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
225 changed files with 12011 additions and 2527 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@vben/web-antd",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -12,16 +12,14 @@ export namespace CrmCustomerLimitConfigApi {
maxCount?: number;
dealCountEnabled?: boolean;
}
}
/**
*
*/
export enum LimitConfType {
/** 锁定客户数限制 */
CUSTOMER_LOCK_LIMIT = 2,
/** 拥有客户数限制 */
CUSTOMER_QUANTITY_LIMIT = 1,
}
/** 客户限制配置类型 */
export enum LimitConfType {
/** 锁定客户数限制 */
CUSTOMER_LOCK_LIMIT = 2,
/** 拥有客户数限制 */
CUSTOMER_QUANTITY_LIMIT = 1,
}
/** 查询客户限制配置列表 */

View File

@ -1,10 +1,12 @@
import type { PageParam, PageResult } from '@vben/request';
import type { PageResult } from '@vben/request';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { requestClient } from '#/api/request';
export namespace CrmOperateLogApi {
/** 操作日志查询参数 */
export interface OperateLogQuery extends PageParam {
export interface OperateLogQuery {
bizType: number;
bizId: number;
}
@ -24,7 +26,7 @@ export namespace CrmOperateLogApi {
/** 获得操作日志 */
export function getOperateLogPage(params: CrmOperateLogApi.OperateLogQuery) {
return requestClient.get<PageResult<CrmOperateLogApi.OperateLog>>(
return requestClient.get<PageResult<SystemOperateLogApi.OperateLog>>(
'/crm/operate-log/page',
{ params },
);

View File

@ -1,12 +1,11 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmPermissionApi {
/** 数据权限信息 */
export interface Permission {
id?: number; // 数据权限编号
userId: number; // 用户编号
ids?: number[];
userId?: number; // 用户编号
bizType: number; // Crm 类型
bizId: number; // Crm 类型数据编号
level: number; // 权限级别
@ -15,7 +14,6 @@ export namespace CrmPermissionApi {
nickname?: string; // 用户昵称
postNames?: string[]; // 岗位名称数组
createTime?: Date;
ids?: number[];
}
/** 数据权限转移请求 */
@ -26,33 +24,38 @@ export namespace CrmPermissionApi {
toBizTypes?: number[]; // 转移客户时,需要额外有【联系人】【商机】【合同】的 checkbox 选择
}
/**
* CRM
*/
export enum BizType {
CRM_BUSINESS = 4, // 商机
CRM_CLUE = 1, // 线索
CRM_CONTACT = 3, // 联系人
CRM_CONTRACT = 5, // 合同
CRM_CUSTOMER = 2, // 客户
CRM_PRODUCT = 6, // 产品
CRM_RECEIVABLE = 7, // 回款
CRM_RECEIVABLE_PLAN = 8, // 回款计划
}
/**
* CRM
*/
export enum PermissionLevel {
OWNER = 1, // 负责人
READ = 2, // 只读
WRITE = 3, // 读写
export interface PermissionListReq {
bizId: number; // 模块数据编号
bizType: number; // 模块类型
}
}
/**
* CRM
*/
export enum BizTypeEnum {
CRM_BUSINESS = 4, // 商机
CRM_CLUE = 1, // 线索
CRM_CONTACT = 3, // 联系人
CRM_CONTRACT = 5, // 合同
CRM_CUSTOMER = 2, // 客户
CRM_PRODUCT = 6, // 产品
CRM_RECEIVABLE = 7, // 回款
CRM_RECEIVABLE_PLAN = 8, // 回款计划
}
/**
* CRM
*/
export enum PermissionLevelEnum {
OWNER = 1, // 负责人
READ = 2, // 只读
WRITE = 3, // 读写
}
/** 获得数据权限列表(查询团队成员列表) */
export function getPermissionList(params: PageParam) {
return requestClient.get<PageResult<CrmPermissionApi.Permission>>(
export function getPermissionList(params: CrmPermissionApi.PermissionListReq) {
return requestClient.get<CrmPermissionApi.Permission[]>(
'/crm/permission/list',
{ params },
);

View File

@ -1,5 +1,3 @@
import type { PageParam } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace CrmProductCategoryApi {
@ -38,7 +36,7 @@ export function deleteProductCategory(id: number) {
}
/** 产品分类列表 */
export function getProductCategoryList(params?: PageParam) {
export function getProductCategoryList(params?: any) {
return requestClient.get<CrmProductCategoryApi.ProductCategory[]>(
'/crm/product-category/list',
{ params },

View File

@ -0,0 +1,71 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpAccountApi {
/** 公众号账号信息 */
export interface Account {
id?: number;
name: string;
account: string;
appId: string;
appSecret: string;
token: string;
aesKey?: string;
qrCodeUrl?: string;
remark?: string;
createTime?: Date;
}
export interface AccountSimple {
id: number;
name: string;
}
}
/** 查询公众号账号列表 */
export function getAccountPage(params: PageParam) {
return requestClient.get<PageResult<MpAccountApi.Account>>(
'/mp/account/page',
{
params,
},
);
}
/** 查询公众号账号详情 */
export function getAccount(id: number) {
return requestClient.get<MpAccountApi.Account>(`/mp/account/get?id=${id}`);
}
/** 查询公众号账号列表 */
export function getSimpleAccountList() {
return requestClient.get<MpAccountApi.AccountSimple[]>(
'/mp/account/list-all-simple',
);
}
/** 新增公众号账号 */
export function createAccount(data: MpAccountApi.Account) {
return requestClient.post('/mp/account/create', data);
}
/** 修改公众号账号 */
export function updateAccount(data: MpAccountApi.Account) {
return requestClient.put('/mp/account/update', data);
}
/** 删除公众号账号 */
export function deleteAccount(id: number) {
return requestClient.delete(`/mp/account/delete?id=${id}`);
}
/** 生成公众号账号二维码 */
export function generateAccountQrCode(id: number) {
return requestClient.post(`/mp/account/generate-qr-code?id=${id}`);
}
/** 清空公众号账号 API 配额 */
export function clearAccountQuota(id: number) {
return requestClient.post(`/mp/account/clear-quota?id=${id}`);
}

View File

@ -0,0 +1,49 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpAutoReplyApi {
/** 自动回复信息 */
export interface AutoReply {
id?: number;
accountId: number;
type: number;
keyword: string;
content: string;
status: number;
remark?: string;
createTime?: Date;
}
}
/** 查询自动回复列表 */
export function getAutoReplyPage(params: PageParam) {
return requestClient.get<PageResult<MpAutoReplyApi.AutoReply>>(
'/mp/auto-reply/page',
{
params,
},
);
}
/** 查询自动回复详情 */
export function getAutoReply(id: number) {
return requestClient.get<MpAutoReplyApi.AutoReply>(
`/mp/auto-reply/get?id=${id}`,
);
}
/** 新增自动回复 */
export function createAutoReply(data: MpAutoReplyApi.AutoReply) {
return requestClient.post('/mp/auto-reply/create', data);
}
/** 修改自动回复 */
export function updateAutoReply(data: MpAutoReplyApi.AutoReply) {
return requestClient.put('/mp/auto-reply/update', data);
}
/** 删除自动回复 */
export function deleteAutoReply(id: number) {
return requestClient.delete(`/mp/auto-reply/delete?id=${id}`);
}

View File

@ -0,0 +1,59 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpDraftApi {
/** 草稿文章信息 */
export interface Article {
title: string;
author: string;
digest: string;
content: string;
contentSourceUrl: string;
thumbMediaId: string;
showCoverPic: number;
needOpenComment: number;
onlyFansCanComment: number;
}
/** 草稿信息 */
export interface Draft {
id?: number;
accountId: number;
mediaId: string;
articles: Article[];
createTime?: Date;
}
}
/** 查询草稿列表 */
export function getDraftPage(params: PageParam) {
return requestClient.get<PageResult<MpDraftApi.Draft>>('/mp/draft/page', {
params,
});
}
/** 创建草稿 */
export function createDraft(accountId: number, articles: MpDraftApi.Article[]) {
return requestClient.post('/mp/draft/create', articles, {
params: { accountId },
});
}
/** 更新草稿 */
export function updateDraft(
accountId: number,
mediaId: string,
articles: MpDraftApi.Article[],
) {
return requestClient.put('/mp/draft/update', articles, {
params: { accountId, mediaId },
});
}
/** 删除草稿 */
export function deleteDraft(accountId: number, mediaId: string) {
return requestClient.delete('/mp/draft/delete', {
params: { accountId, mediaId },
});
}

View File

@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpFreePublishApi {
/** 自由发布文章信息 */
export interface FreePublish {
id?: number;
accountId: number;
mediaId: string;
articleId: string;
title: string;
author: string;
digest: string;
content: string;
thumbUrl: string;
status: number;
publishTime?: Date;
createTime?: Date;
}
}
/** 查询自由发布文章列表 */
export function getFreePublishPage(params: PageParam) {
return requestClient.get<PageResult<MpFreePublishApi.FreePublish>>(
'/mp/free-publish/page',
{
params,
},
);
}
/** 删除自由发布文章 */
export function deleteFreePublish(accountId: number, articleId: string) {
return requestClient.delete('/mp/free-publish/delete', {
params: { accountId, articleId },
});
}
/** 发布自由发布文章 */
export function submitFreePublish(accountId: number, mediaId: string) {
return requestClient.post('/mp/free-publish/submit', null, {
params: { accountId, mediaId },
});
}

View File

@ -0,0 +1,43 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
/** 素材类型枚举 */
export enum MaterialType {
IMAGE = 1, // 图片
THUMB = 4, // 缩略图
VIDEO = 3, // 视频
VOICE = 2, // 语音
}
export namespace MpMaterialApi {
/** 素材信息 */
export interface Material {
id?: number;
accountId: number;
type: MaterialType;
mediaId: string;
url: string;
name: string;
size: number;
remark?: string;
createTime?: Date;
}
}
/** 查询素材列表 */
export function getMaterialPage(params: PageParam) {
return requestClient.get<PageResult<MpMaterialApi.Material>>(
'/mp/material/page',
{
params,
},
);
}
/** 删除永久素材 */
export function deletePermanentMaterial(id: number) {
return requestClient.delete('/mp/material/delete-permanent', {
params: { id },
});
}

View File

@ -0,0 +1,58 @@
import { requestClient } from '#/api/request';
/** 菜单类型枚举 */
export enum MenuType {
CLICK = 'click', // 点击推事件
LOCATION_SELECT = 'location_select', // 发送位置
MEDIA_ID = 'media_id', // 下发消息
MINIPROGRAM = 'miniprogram', // 小程序
PIC_PHOTO_OR_ALBUM = 'pic_photo_or_album', // 拍照或者相册发图
PIC_SYSPHOTO = 'pic_sysphoto', // 系统拍照发图
PIC_WEIXIN = 'pic_weixin', // 微信相册发图
SCANCODE_PUSH = 'scancode_push', // 扫码推事件
SCANCODE_WAITMSG = 'scancode_waitmsg', // 扫码带提示
VIEW = 'view', // 跳转URL
VIEW_LIMITED = 'view_limited', // 跳转图文消息URL
}
export namespace MpMenuApi {
/** 菜单按钮信息 */
export interface MenuButton {
type: MenuType;
name: string;
key?: string;
url?: string;
mediaId?: string;
appId?: string;
pagePath?: string;
subButtons?: MenuButton[];
}
/** 菜单信息 */
export interface Menu {
accountId: number;
menus: MenuButton[];
}
}
/** 查询菜单列表 */
export function getMenuList(accountId: number) {
return requestClient.get<MpMenuApi.MenuButton[]>('/mp/menu/list', {
params: { accountId },
});
}
/** 保存菜单 */
export function saveMenu(accountId: number, menus: MpMenuApi.MenuButton[]) {
return requestClient.post('/mp/menu/save', {
accountId,
menus,
});
}
/** 删除菜单 */
export function deleteMenu(accountId: number) {
return requestClient.delete('/mp/menu/delete', {
params: { accountId },
});
}

View File

@ -0,0 +1,54 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
/** 消息类型枚举 */
export enum MessageType {
IMAGE = 'image', // 图片消息
MPNEWS = 'mpnews', // 公众号图文消息
MUSIC = 'music', // 音乐消息
NEWS = 'news', // 图文消息
TEXT = 'text', // 文本消息
VIDEO = 'video', // 视频消息
VOICE = 'voice', // 语音消息
WXCARD = 'wxcard', // 卡券消息
}
export namespace MpMessageApi {
/** 消息信息 */
export interface Message {
id?: number;
accountId: number;
type: MessageType;
openid: string;
content: string;
mediaId?: string;
status: number;
remark?: string;
createTime?: Date;
}
/** 发送消息请求 */
export interface SendMessageRequest {
accountId: number;
openid: string;
type: MessageType;
content: string;
mediaId?: string;
}
}
/** 查询消息列表 */
export function getMessagePage(params: PageParam) {
return requestClient.get<PageResult<MpMessageApi.Message>>(
'/mp/message/page',
{
params,
},
);
}
/** 发送消息 */
export function sendMessage(data: MpMessageApi.SendMessageRequest) {
return requestClient.post('/mp/message/send', data);
}

View File

@ -0,0 +1,84 @@
import type { PageParam } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpStatisticsApi {
/** 统计查询参数 */
export interface StatisticsQuery extends PageParam {
accountId: number;
beginDate: string;
endDate: string;
}
/** 消息发送概况数据 */
export interface UpstreamMessage {
refDate: string;
msgType: string;
msgUser: number;
msgCount: number;
}
/** 用户增减数据 */
export interface UserSummary {
refDate: string;
userSource: number;
newUser: number;
cancelUser: number;
cumulateUser: number;
}
/** 用户累计数据 */
export interface UserCumulate {
refDate: string;
cumulateUser: number;
}
/** 接口分析数据 */
export interface InterfaceSummary {
refDate: string;
callbackCount: number;
failCount: number;
totalTimeCost: number;
maxTimeCost: number;
}
}
/** 获取消息发送概况数据 */
export function getUpstreamMessage(params: MpStatisticsApi.StatisticsQuery) {
return requestClient.get<MpStatisticsApi.UpstreamMessage[]>(
'/mp/statistics/upstream-message',
{
params,
},
);
}
/** 获取用户增减数据 */
export function getUserSummary(params: MpStatisticsApi.StatisticsQuery) {
return requestClient.get<MpStatisticsApi.UserSummary[]>(
'/mp/statistics/user-summary',
{
params,
},
);
}
/** 获取用户累计数据 */
export function getUserCumulate(params: MpStatisticsApi.StatisticsQuery) {
return requestClient.get<MpStatisticsApi.UserCumulate[]>(
'/mp/statistics/user-cumulate',
{
params,
},
);
}
/** 获取接口分析数据 */
export function getInterfaceSummary(params: MpStatisticsApi.StatisticsQuery) {
return requestClient.get<MpStatisticsApi.InterfaceSummary[]>(
'/mp/statistics/interface-summary',
{
params,
},
);
}

View File

@ -0,0 +1,63 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpTagApi {
/** 标签信息 */
export interface Tag {
id?: number;
accountId: number;
name: string;
count?: number;
createTime?: Date;
}
/** 标签分页查询参数 */
export interface TagPageQuery extends PageParam {
accountId?: number;
name?: string;
}
}
/** 创建公众号标签 */
export function createTag(data: MpTagApi.Tag) {
return requestClient.post('/mp/tag/create', data);
}
/** 更新公众号标签 */
export function updateTag(data: MpTagApi.Tag) {
return requestClient.put('/mp/tag/update', data);
}
/** 删除公众号标签 */
export function deleteTag(id: number) {
return requestClient.delete('/mp/tag/delete', {
params: { id },
});
}
/** 获取公众号标签 */
export function getTag(id: number) {
return requestClient.get<MpTagApi.Tag>('/mp/tag/get', {
params: { id },
});
}
/** 获取公众号标签分页 */
export function getTagPage(params: MpTagApi.TagPageQuery) {
return requestClient.get<PageResult<MpTagApi.Tag>>('/mp/tag/page', {
params,
});
}
/** 获取公众号标签精简信息列表 */
export function getSimpleTagList() {
return requestClient.get<MpTagApi.Tag[]>('/mp/tag/list-all-simple');
}
/** 同步公众号标签 */
export function syncTag(accountId: number) {
return requestClient.post('/mp/tag/sync', null, {
params: { accountId },
});
}

View File

@ -0,0 +1,57 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MpUserApi {
/** 用户信息 */
export interface User {
id?: number;
accountId: number;
openid: string;
nickname: string;
avatar: string;
sex: number;
country: string;
province: string;
city: string;
language: string;
subscribe: boolean;
subscribeTime?: Date;
remark?: string;
tagIds?: number[];
createTime?: Date;
}
/** 用户分页查询参数 */
export interface UserPageQuery extends PageParam {
accountId?: number;
nickname?: string;
tagId?: number;
}
}
/** 更新公众号粉丝 */
export function updateUser(data: MpUserApi.User) {
return requestClient.put('/mp/user/update', data);
}
/** 获取公众号粉丝 */
export function getUser(id: number) {
return requestClient.get<MpUserApi.User>('/mp/user/get', {
params: { id },
});
}
/** 获取公众号粉丝分页 */
export function getUserPage(params: MpUserApi.UserPageQuery) {
return requestClient.get<PageResult<MpUserApi.User>>('/mp/user/page', {
params,
});
}
/** 同步公众号粉丝 */
export function syncUser(accountId: number) {
return requestClient.post('/mp/user/sync', null, {
params: { accountId },
});
}

View File

@ -0,0 +1,3 @@
export { default as OperateLog } from './operate-log.vue';
export type { OperateLogProps } from './typing';

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import type { OperateLogProps } from './typing';
import { 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)"
>
<p>{{ log.createTime }}</p>
<p>{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}</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

@ -1 +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,186 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { 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 showInput = ref(false);
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;
},
);
function clickIcon() {
showInput.value = true;
}
//
function blurEvent() {
showInput.value = false;
currentNode.value.name =
currentNode.value.name ||
getDefaultConditionNodeName(
props.nodeIndex,
currentNode.value?.conditionSetting?.defaultFlow,
);
}
defineExpose({ open }); // open
</script>
<template>
<Drawer class="w-[580px]">
<template #title>
<div class="flex items-center">
<Input
v-if="showInput"
type="text"
class="mr-2 w-48"
@blur="blurEvent()"
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="ep:edit-pen" />
</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,533 @@
<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 } from '#/utils';
import {
CANDIDATE_STRATEGY,
CandidateStrategy,
FieldPermissionType,
MULTI_LEVEL_DEPT,
NodeType,
} 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, blurEvent } = useNodeName(
NodeType.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(NodeType.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-[580px]">
<template #title>
<div class="config-header">
<Input
v-if="showInput"
type="text"
class="config-editable-input"
@blur="blurEvent()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div v-else class="node-name">
{{ nodeName }}
<IconifyIcon class="ml-1" icon="ep:edit-pen" @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-[16px] 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>
<style lang="scss" scoped>
.config-editable-input {
&:focus {
outline: 0;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
}
}
</style>

View File

@ -0,0 +1,247 @@
<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 {
DELAY_TYPE,
DelayTypeEnum,
NodeType,
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, blurEvent } = useNodeName(
NodeType.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-[480px]">
<template #title>
<div class="flex items-center">
<Input
v-if="showInput"
type="text"
class="mr-2 w-48"
@blur="blurEvent()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div
v-else
class="flex cursor-pointer items-center"
@click="clickIcon()"
>
{{ nodeName }}
<IconifyIcon class="ml-1" icon="ep:edit-pen" :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,69 @@
<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,
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: jason open useVbenModal onOpenChange
function open(conditionObj: any | undefined) {
if (conditionObj) {
conditionData.value.conditionType = conditionObj.conditionType;
conditionData.value.conditionExpression = conditionObj.conditionExpression;
conditionData.value.conditionGroups = conditionObj.conditionGroups;
}
modalApi.open();
}
// TODO: jason expose使modalApi.setData(formSetting).open()
defineExpose({ open });
</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/constants';
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-[#1890ff]"
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="#0089ff"
icon="ep:circle-close-filled"
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-[32px] 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-[24px] 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,227 @@
<script setup lang="ts">
import type { HttpRequestParam } from '../../../consts';
import { Plus, Trash2 } 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-[32px] items-center">
<Trash2
class="size-4 cursor-pointer text-red-500"
@click="deleteHttpRequestParam(props.header, index)"
/>
</div>
</Col>
</Row>
<Button
type="link"
@click="addHttpRequestParam(props.header)"
class="flex items-center"
>
<template #icon>
<Plus class="size-[18px]" />
</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-[32px] items-center">
<Trash2
class="size-4 cursor-pointer text-red-500"
@click="deleteHttpRequestParam(props.body, index)"
/>
</div>
</Col>
</Row>
<Button
type="link"
@click="addHttpRequestParam(props.body)"
class="flex items-center"
>
<template #icon>
<Plus class="size-[18px]" />
</template>
添加一行
</Button>
</FormItem>
</template>

View File

@ -0,0 +1,176 @@
<script setup lang="ts">
import { toRefs, watch } from 'vue';
import { Plus, Trash2 } 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-[32px] items-center">
<Trash2
class="size-4 cursor-pointer text-red-500"
@click="deleteHttpResponseSetting(setting.response!, index)"
/>
</div>
</Col>
</Row>
<Button
type="link"
@click="addHttpResponseSetting(setting.response!)"
class="flex items-center"
>
<template #icon>
<Plus class="size-[18px]" />
</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,294 @@
<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 { ConditionType, NodeType } 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, blurEvent } = useNodeName(
NodeType.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 !== NodeType.ROUTER_BRANCH_NODE &&
node.type !== NodeType.CONDITION_NODE
) {
nodeOptions.value.push({
label: node.name,
value: node.id,
});
}
if (!node.childNode || node.type === NodeType.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-[630px]">
<template #title>
<div class="flex items-center">
<Input
v-if="showInput"
type="text"
class="mr-2 w-48"
@blur="blurEvent()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div
v-else
class="flex cursor-pointer items-center"
@click="clickIcon()"
>
{{ nodeName }}
<IconifyIcon class="ml-1" icon="ep:edit-pen" />
</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-[180px]"
: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="ep:close" />
</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="ep:setting" />
</template>
新增路由分支
</Button>
</Col>
</Row>
</Drawer>
</template>

View File

@ -37,12 +37,14 @@ import {
} from '../../helpers';
defineOptions({ name: 'StartUserNodeConfig' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
//
const startUserIds = inject<Ref<any[]>>('startUserIds');
//
@ -59,10 +61,12 @@ const { nodeName, showInput, clickIcon, blurEvent } = useNodeName(
);
// Tab
const activeTabName = ref('user');
//
const { formType, fieldsPermissionConfig, getNodeConfigFormFields } =
useFormFieldsPermission(FieldPermissionType.WRITE);
const getUserNicknames = (userIds: number[]): string => {
function getUserNicknames(userIds: number[]): string {
if (!userIds || userIds.length === 0) {
return '';
}
@ -74,9 +78,9 @@ const getUserNicknames = (userIds: number[]): string => {
}
});
return nicknames.join(',');
};
}
const getDeptNames = (deptIds: number[]): string => {
function getDeptNames(deptIds: number[]): string {
if (!deptIds || deptIds.length === 0) {
return '';
}
@ -88,14 +92,14 @@ const getDeptNames = (deptIds: number[]): string => {
}
});
return deptNames.join(',');
};
}
// 使 VbenDrawer
const [Drawer, drawerApi] = useVbenDrawer({
header: true,
closable: true,
onCancel() {
drawerApi.close();
drawerApi.setState({ isOpen: false });
},
onConfirm() {
saveConfig();
@ -103,7 +107,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
});
//
const saveConfig = async () => {
async function saveConfig() {
activeTabName.value = 'user';
currentNode.value.name = nodeName.value!;
currentNode.value.showText = '已设置';
@ -111,20 +115,20 @@ const saveConfig = async () => {
currentNode.value.fieldsPermission = fieldsPermissionConfig.value;
//
currentNode.value.buttonsSetting = START_USER_BUTTON_SETTING;
drawerApi.close();
drawerApi.setState({ isOpen: false });
return true;
};
}
//
const showStartUserNodeConfig = (node: SimpleFlowNode) => {
function showStartUserNodeConfig(node: SimpleFlowNode) {
nodeName.value = node.name;
//
getNodeConfigFormFields(node.fieldsPermission);
drawerApi.open();
};
}
/** 批量更新权限 */
const updatePermission = (type: string) => {
function updatePermission(type: string) {
fieldsPermissionConfig.value.forEach((field) => {
if (type === 'READ') {
field.permission = FieldPermissionType.READ;
@ -134,7 +138,7 @@ const updatePermission = (type: string) => {
field.permission = FieldPermissionType.NONE;
}
});
};
}
/**
* 暴露方法给父组件
@ -289,4 +293,3 @@ defineExpose({ showStartUserNodeConfig });
</Tabs>
</Drawer>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,692 @@
<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, Trash2 } 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 {
DEFAULT_CONDITION_GROUP_VALUE,
NodeType,
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, blurEvent } = useNodeName(
NodeType.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];
// TODO: jason Modal 使 useVbenModal 使modalApi.setData(formSetting).open()
conditionDialog.open(formSetting);
}
/** 删除条件配置 */
function deleteFormSettingCondition(formSetting: FormTriggerSetting) {
formSetting.conditionType = undefined;
}
/** 打开条件配置弹窗 */
function openFormSettingCondition(
index: number,
formSetting: FormTriggerSetting,
) {
const conditionDialog = proxy.$refs[`condition-${index}`][0];
conditionDialog.open(formSetting);
}
/** 处理条件配置保存 */
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-[580px]">
<template #title>
<div class="config-header">
<Input
v-if="showInput"
type="text"
class="config-editable-input"
@blur="blurEvent()"
v-model:value="nodeName"
:placeholder="nodeName"
/>
<div v-else class="node-name">
{{ nodeName }}
<IconifyIcon class="ml-1" icon="ep:edit-pen" @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="ep:close" />
</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="ep: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-[32px] items-center">
<Trash2
class="size-4 cursor-pointer text-red-500"
@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="ep:memo" />
</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="ep:setting" />
</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="ep:close" />
</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="ep: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="ep:setting" />
</template>
添加设置
</Button>
</Col>
</Row>
</div>
</Form>
</div>
</Drawer>
</template>
<style lang="scss" scoped>
.config-editable-input {
&:focus {
outline: 0;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
}
}
</style>

View File

@ -34,7 +34,8 @@ import {
TypographyText,
} from 'ant-design-vue';
// TODO import { defaultProps4AntTree } from '#/utils/tree';
import { BpmModelFormType } from '#/utils';
import {
APPROVE_METHODS,
APPROVE_TYPE,
@ -65,20 +66,22 @@ import {
useNodeName,
useWatchNode,
} from '../../helpers';
import UserTaskListener from './modules/user-task-listener.vue';
import { convertTimeUnit, getApproveTypeText } from './utils';
// TODO import UserTaskListener from './components/UserTaskListener.vue';
defineOptions({ name: 'UserTaskNodeConfig' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
required: true,
},
});
const emits = defineEmits<{
findReturnTaskNodes: [nodeList: SimpleFlowNode[]];
}>();
const deptLevelLabel = computed(() => {
let label = '部门负责人来源';
if (
@ -95,6 +98,7 @@ const deptLevelLabel = computed(() => {
}
return label;
});
//
const currentNode = useWatchNode(props);
//
@ -102,19 +106,19 @@ const [Drawer, drawerApi] = useVbenDrawer({
header: true,
closable: true,
title: '',
onCancel() {
drawerApi.close();
},
onConfirm() {
saveConfig();
},
});
//
const { nodeName, showInput, clickIcon, blurEvent } = useNodeName(
NodeType.USER_TASK_NODE,
);
// Tab
const activeTabName = ref('user');
//
const {
formType,
@ -122,10 +126,12 @@ const {
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');
@ -138,7 +144,9 @@ const {
changeBtnDisplayName,
btnDisplayNameBlurEvent,
} = useButtonsSetting();
const approveType = ref(ApproveType.USER);
//
const formRef = ref(); // Ref
//
@ -200,7 +208,7 @@ const {
const configForm = tempConfigForm as Ref<UserTaskFormType>;
//
const changeCandidateStrategy = () => {
function changeCandidateStrategy() {
configForm.value.userIds = [];
configForm.value.deptIds = [];
configForm.value.roleIds = [];
@ -210,16 +218,16 @@ const changeCandidateStrategy = () => {
configForm.value.formUser = '';
configForm.value.formDept = '';
configForm.value.approveMethod = ApproveMethodType.SEQUENTIAL_APPROVE;
};
}
//
const approveMethodChanged = () => {
/** 审批方式改变 */
function approveMethodChanged() {
configForm.value.rejectHandlerType = RejectHandlerType.FINISH_PROCESS;
if (configForm.value.approveMethod === ApproveMethodType.APPROVE_BY_RATIO) {
configForm.value.approveRatio = 100;
}
formRef.value.clearValidate('approveRatio');
};
}
// 退
const returnTaskList = ref<SimpleFlowNode[]>([]);
//
@ -233,49 +241,59 @@ const {
cTimeoutMaxRemindCount,
} = useTimeoutHandler();
// TODO
// const userTaskListenerRef = ref();
const userTaskListenerRef = ref();
/** 节点类型名称 */
const nodeTypeName = computed(() => {
return currentNode.value.type === NodeType.TRANSACTOR_NODE ? '办理' : '审批';
});
/** 保存配置 */
const saveConfig = async () => {
//
currentNode.value.name = nodeName.value!;
//
currentNode.value.approveType = approveType.value;
//
if (approveType.value !== ApproveType.USER) {
currentNode.value.showText = getApproveTypeText(approveType.value);
drawerApi.close();
return true;
}
// TODO
// activeTabName.value = 'listener';
// await nextTick();
activeTabName.value = 'user';
/** 校验节点配置 */
async function validateConfig() {
if (!formRef.value) return false;
// TODO
// if (!userTaskListenerRef.value) return false;
// const valid =
// (await formRef.value.validate()) &&
// (await userTaskListenerRef.value.validate());
if (!userTaskListenerRef.value) return false;
if (!(await formRef.value.validate())) {
activeTabName.value = 'user';
//
const userFormValid = await formRef.value.validate().catch(() => false);
const listenerValid = await userTaskListenerRef.value.validate().catch(() => {
return false;
});
// Tab
if (!listenerValid) {
activeTabName.value = 'listener';
return false;
}
// Tab
if (!userFormValid) {
activeTabName.value = 'user';
return false;
}
// TODO
// if (!(await userTaskListenerRef.value.validate())) {
// activeTabName.value = 'listener';
// }
const showText = getShowText();
if (!showText) return false;
return true;
}
/** 保存配置 */
async function saveConfig() {
//
if (approveType.value !== ApproveType.USER) {
currentNode.value.name = nodeName.value!;
currentNode.value.approveType = approveType.value;
currentNode.value.showText = getApproveTypeText(approveType.value);
drawerApi.close();
return true;
}
//
if (!(await validateConfig())) {
return false;
}
//
currentNode.value.name = nodeName.value!;
//
currentNode.value.approveType = approveType.value;
//
currentNode.value.candidateStrategy = configForm.value.candidateStrategy;
// candidateParam
currentNode.value.candidateParam = handleCandidateParam();
@ -338,13 +356,13 @@ const saveConfig = async () => {
//
currentNode.value.reasonRequire = configForm.value.reasonRequire;
currentNode.value.showText = showText;
currentNode.value.showText = getShowText();
drawerApi.close();
return true;
};
}
/** 显示审批节点配置, 由父组件传过来 */
const showUserTaskNodeConfig = (node: SimpleFlowNode) => {
function showUserTaskNodeConfig(node: SimpleFlowNode) {
nodeName.value = node.name;
// 1
approveType.value =
@ -423,7 +441,7 @@ const showUserTaskNodeConfig = (node: SimpleFlowNode) => {
configForm.value.reasonRequire = node?.reasonRequire ?? false;
drawerApi.open();
};
}
defineExpose({ showUserTaskNodeConfig }); //
@ -535,7 +553,7 @@ function useTimeoutHandler() {
}
/** 批量更新权限 */
const updatePermission = (type: string) => {
function updatePermission(type: string) {
fieldsPermissionConfig.value.forEach((field) => {
if (type === 'READ') {
field.permission = FieldPermissionType.READ;
@ -545,7 +563,8 @@ const updatePermission = (type: string) => {
field.permission = FieldPermissionType.NONE;
}
});
};
}
//
onMounted(() => {
// ID
@ -630,11 +649,9 @@ onMounted(() => {
name="roleIds"
>
<Select
filterable
v-model:value="configForm.roleIds"
clearable
mode="multiple"
style="width: 100%"
>
<SelectOption
v-for="item in roleOptions"
@ -658,16 +675,18 @@ onMounted(() => {
label="指定部门"
name="deptIds"
>
<!-- TODO :replace-fields="defaultProps4AntTree" -->
<TreeSelect
v-model:value="configForm.deptIds"
:tree-data="deptTreeOptions"
:field-names="{
label: 'name',
value: 'id',
children: 'children',
}"
empty-text="加载中,请稍后"
multiple
node-key="id"
:check-strictly="true"
allow-clear
style="width: 100%"
tree-checkable
/>
</FormItem>
@ -677,11 +696,9 @@ onMounted(() => {
name="postIds"
>
<Select
filterable
v-model:value="configForm.postIds"
clearable
mode="multiple"
style="width: 100%"
>
<SelectOption
v-for="item in postOptions"
@ -699,11 +716,9 @@ onMounted(() => {
name="userIds"
>
<Select
filterable
v-model:value="configForm.userIds"
clearable
mode="multiple"
style="width: 100%"
>
<SelectOption
v-for="item in userOptions"
@ -723,11 +738,9 @@ onMounted(() => {
name="userGroups"
>
<Select
filterable
v-model:value="configForm.userGroups"
clearable
mode="multiple"
style="width: 100%"
>
<SelectOption
v-for="item in userGroupOptions"
@ -746,12 +759,7 @@ onMounted(() => {
label="表单内用户字段"
name="formUser"
>
<Select
filterable
v-model:value="configForm.formUser"
clearable
style="width: 100%"
>
<Select v-model:value="configForm.formUser" clearable>
<SelectOption
v-for="(item, idx) in userFieldOnFormOptions"
:key="idx"
@ -771,12 +779,7 @@ onMounted(() => {
label="表单内部门字段"
name="formDept"
>
<Select
filterable
v-model:value="configForm.formDept"
clearable
style="width: 100%"
>
<Select v-model:value="configForm.formDept" clearable>
<SelectOption
v-for="(item, idx) in deptFieldOnFormOptions"
:key="idx"
@ -802,7 +805,7 @@ onMounted(() => {
:label="deptLevelLabel!"
name="deptLevel"
>
<Select filterable v-model:value="configForm.deptLevel" clearable>
<Select v-model:value="configForm.deptLevel" clearable>
<SelectOption
v-for="(item, index) in MULTI_LEVEL_DEPT"
:key="index"
@ -821,11 +824,7 @@ onMounted(() => {
label="流程表达式"
name="expression"
>
<Textarea
v-model:value="configForm.expression"
clearable
style="width: 100%"
/>
<Textarea v-model:value="configForm.expression" clearable />
</FormItem>
<!-- 多人审批/办理 方式 -->
<FormItem :label="`多人${nodeTypeName}方式`" name="approveMethod">
@ -890,12 +889,7 @@ onMounted(() => {
label="驳回节点"
name="returnNodeId"
>
<Select
filterable
v-model:value="configForm.returnNodeId"
clearable
style="width: 100%"
>
<Select v-model:value="configForm.returnNodeId" clearable>
<SelectOption
v-for="item in returnTaskList"
:key="item.id"
@ -963,8 +957,7 @@ onMounted(() => {
<Col>
<FormItem name="timeDuration">
<InputNumber
class="mr-2"
:style="{ width: '100px' }"
class="mr-2 mt-0.5"
v-model:value="configForm.timeDuration"
:min="1"
controls-position="right"
@ -973,7 +966,6 @@ onMounted(() => {
</Col>
<Col>
<Select
filterable
v-model:value="timeUnit"
class="mr-2"
:style="{ width: '100px' }"
@ -1040,11 +1032,9 @@ onMounted(() => {
name="assignEmptyHandlerUserIds"
>
<Select
filterable
v-model:value="configForm.assignEmptyHandlerUserIds"
clearable
mode="multiple"
style="width: 100%"
>
<SelectOption
v-for="item in userOptions"
@ -1151,7 +1141,11 @@ onMounted(() => {
</div>
</div>
</TabPane>
<TabPane tab="表单字段权限" key="fields" v-if="formType === 10">
<TabPane
tab="表单字段权限"
key="fields"
v-if="formType === BpmModelFormType.NORMAL"
>
<div class="p-1">
<div class="mb-4 text-[16px] font-bold">字段权限</div>
@ -1225,20 +1219,14 @@ onMounted(() => {
</div>
</div>
</TabPane>
<TabPane tab="监听器" key="listener">
<!-- TODO 待实现 -->
<span>待实现</span>
<!-- <UserTaskListener
<TabPane tab="监听器" key="listener" :force-render="true">
<UserTaskListener
ref="userTaskListenerRef"
v-model:value="configForm"
v-model="configForm"
:form-field-options="formFieldOptions"
/> -->
/>
</TabPane>
</Tabs>
<template #footer>
<Button type="primary" @click="saveConfig"> </Button>
<Button @click="drawerApi.close()"> </Button>
</template>
</Drawer>
</template>
<style lang="scss" scoped>

View File

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

View File

@ -0,0 +1,117 @@
<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 { NODE_DEFAULT_TEXT, NodeType } 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, blurEvent, clickTitle } = useNodeName2(
currentNode,
NodeType.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
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="blurEvent()"
v-model="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(NodeType.COPY_TASK_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="ep:arrow-right-bold" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
: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,114 @@
<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 { NODE_DEFAULT_TEXT, NodeType } 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, blurEvent, clickTitle } = useNodeName2(
currentNode,
NodeType.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
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="blurEvent()"
v-model="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(NodeType.DELAY_TIMER_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="ep:arrow-right-bold" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
: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

@ -22,7 +22,7 @@ const processInstance = inject<Ref<any>>('processInstance', ref({}));
const processInstanceInfos = ref<any[]>([]); //
const nodeClick = () => {
function nodeClick() {
if (readonly && processInstance && processInstance.value) {
console.warn(
'TODO 只读模式,弹窗显示审批信息',
@ -30,7 +30,7 @@ const nodeClick = () => {
processInstanceInfos.value,
);
}
};
}
</script>
<template>
<div class="end-node-wrapper">
@ -44,4 +44,3 @@ const nodeClick = () => {
</div>
<!-- TODO 审批信息 -->
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,282 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { getCurrentInstance, inject, 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 {
ConditionType,
DEFAULT_CONDITION_GROUP_VALUE,
NODE_DEFAULT_TEXT,
NodeType,
} 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 showInputs = ref<boolean[]>([]);
//
function blurEvent(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: NodeType.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 === NodeType.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
type="text"
class="editable-title-input"
@blur="blurEvent(index)"
v-model="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(NodeType.CONDITION_NODE) }}
</div>
</div>
<div
class="node-toolbar"
v-if="
!readonly && index + 1 !== currentNode.conditionNodes?.length
"
>
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
: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="ep:arrow-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="ep:arrow-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,285 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { getCurrentInstance, inject, 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 {
ConditionType,
DEFAULT_CONDITION_GROUP_VALUE,
NODE_DEFAULT_TEXT,
NodeType,
} 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 showInputs = ref<boolean[]>([]);
//
function blurEvent(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: NodeType.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 === NodeType.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
type="text"
class="editable-title-input"
@blur="blurEvent(index)"
v-model="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(NodeType.CONDITION_NODE) }}
</div>
</div>
<div
class="node-toolbar"
v-if="
!readonly && index + 1 !== currentNode.conditionNodes?.length
"
>
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
: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="ep:arrow-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="ep:arrow-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

@ -33,11 +33,12 @@ const props = defineProps({
required: true,
},
});
const emits = defineEmits(['update:childNode']);
const popoverShow = ref(false);
const readonly = inject<Boolean>('readonly'); //
const addNode = (type: number) => {
function addNode(type: number) {
//
if (
type === NodeType.PARALLEL_BRANCH_NODE &&
@ -238,7 +239,7 @@ const addNode = (type: number) => {
};
emits('update:childNode', data);
}
};
}
</script>
<template>
<div class="node-handler-wrapper">
@ -334,5 +335,3 @@ const addNode = (type: number) => {
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,205 @@
<script setup lang="ts">
import type { SimpleFlowNode } from '../../consts';
import { inject, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { buildShortUUID as generateUUID } from '@vben/utils';
import { Button, Input } from 'ant-design-vue';
import { NODE_DEFAULT_TEXT, NodeType } 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 showInputs = ref<boolean[]>([]);
//
function blurEvent(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: NodeType.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 === NodeType.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
type="text"
class="input-max-width editable-title-input"
@blur="blurEvent(index)"
v-model="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(NodeType.CONDITION_NODE) }}
</div>
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
@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,116 @@
<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 { NODE_DEFAULT_TEXT, NodeType } 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, blurEvent, clickTitle } = useNodeName2(
currentNode,
NodeType.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
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="blurEvent()"
v-model="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(NodeType.ROUTER_BRANCH_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="ep:arrow-right-bold" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
@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

@ -15,17 +15,20 @@ import StartUserNodeConfig from '../nodes-config/start-user-node-config.vue';
import NodeHandler from './node-handler.vue';
defineOptions({ name: 'StartUserNode' });
const props = defineProps({
flowNode: {
type: Object as () => SimpleFlowNode,
default: () => null,
},
});
//
// eslint-disable-next-line unused-imports/no-unused-vars, no-unused-vars
const emits = defineEmits<{
// const emits = defineEmits<{
defineEmits<{
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();
const readonly = inject<Boolean>('readonly'); //
const tasks = inject<Ref<any[]>>('tasks', ref([]));
//
@ -41,7 +44,7 @@ const nodeSetting = ref();
//
const selectTasks = ref<any[] | undefined>([]); //
const nodeClick = () => {
function nodeClick() {
if (readonly) {
//
if (tasks && tasks.value) {
@ -58,7 +61,7 @@ const nodeClick = () => {
);
nodeSetting.value.showStartUserNodeConfig(currentNode.value);
}
};
}
</script>
<template>
<div class="node-wrapper">
@ -116,4 +119,3 @@ const nodeClick = () => {
/>
<!-- 审批记录 TODO -->
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,119 @@
<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 { NODE_DEFAULT_TEXT, NodeType } 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, blurEvent, clickTitle } = useNodeName2(
currentNode,
NodeType.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
v-if="!readonly && showInput"
type="text"
class="editable-title-input"
@blur="blurEvent()"
v-model="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(NodeType.TRIGGER_NODE) }}
</div>
<IconifyIcon v-if="!readonly" icon="ep:arrow-right-bold" />
</div>
<div v-if="!readonly" class="node-toolbar">
<div class="toolbar-icon">
<IconifyIcon
color="#0089ff"
icon="ep:circle-close-filled"
: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

@ -22,6 +22,7 @@ const props = defineProps({
required: true,
},
});
const emits = defineEmits<{
findParentNode: [nodeList: SimpleFlowNode[], nodeType: NodeType];
'update:flowNode': [node: SimpleFlowNode | undefined];
@ -39,7 +40,7 @@ const { showInput, blurEvent, clickTitle } = useNodeName2(
);
const nodeSetting = ref();
const nodeClick = () => {
function nodeClick() {
if (readonly) {
if (tasks && tasks.value) {
// TODO
@ -49,18 +50,18 @@ const nodeClick = () => {
//
nodeSetting.value.showUserTaskNodeConfig(currentNode.value);
}
};
}
const deleteNode = () => {
function deleteNode() {
emits('update:flowNode', currentNode.value.childNode);
};
}
//
const findReturnTaskNodes = (
function findReturnTaskNodes(
matchNodeList: SimpleFlowNode[], //
) => {
) {
//
emits('findParentNode', matchNodeList, NodeType.USER_TASK_NODE);
};
}
// const selectTasks = ref<any[] | undefined>([]); //
</script>
@ -135,4 +136,3 @@ const findReturnTaskNodes = (
/>
<!-- TODO 审批记录 -->
</template>
<style lang="scss" scoped></style>

View File

@ -3,11 +3,19 @@ import type { SimpleFlowNode } from '../consts';
import { NodeType } from '../consts';
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,
@ -18,6 +26,7 @@ const props = defineProps({
default: () => null,
},
});
const emits = defineEmits<{
recursiveFindParentNode: [
nodeList: SimpleFlowNode[],
@ -40,11 +49,11 @@ const findParentNode = (nodeList: SimpleFlowNode[], nodeType: number) => {
};
//
const recursiveFindParentNode = (
function recursiveFindParentNode(
nodeList: SimpleFlowNode[],
findNode: SimpleFlowNode,
nodeType: number,
) => {
) {
if (!findNode) {
return;
}
@ -57,7 +66,7 @@ const recursiveFindParentNode = (
nodeList.push(findNode);
}
emits('recursiveFindParentNode', nodeList, props.parentNode, nodeType);
};
}
</script>
<template>
<!-- 发起人节点 -->
@ -77,50 +86,50 @@ const recursiveFindParentNode = (
@find-parent-node="findParentNode"
/>
<!-- 抄送节点 -->
<!-- <CopyTaskNode
<CopyTaskNode
v-if="currentNode && currentNode.type === NodeType.COPY_TASK_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/> -->
/>
<!-- 条件节点 -->
<!-- <ExclusiveNode
<ExclusiveNode
v-if="currentNode && currentNode.type === NodeType.CONDITION_BRANCH_NODE"
:flow-node="currentNode"
@update:model-value="handleModelValueUpdate"
@find:parent-node="findFromParentNode"
/> -->
@find-parent-node="findParentNode"
/>
<!-- 并行节点 -->
<!-- <ParallelNode
<ParallelNode
v-if="currentNode && currentNode.type === NodeType.PARALLEL_BRANCH_NODE"
:flow-node="currentNode"
@update:model-value="handleModelValueUpdate"
@find:parent-node="findFromParentNode"
/> -->
@find-parent-node="findParentNode"
/>
<!-- 包容分支节点 -->
<!-- <InclusiveNode
<InclusiveNode
v-if="currentNode && currentNode.type === NodeType.INCLUSIVE_BRANCH_NODE"
:flow-node="currentNode"
@update:model-value="handleModelValueUpdate"
@find:parent-node="findFromParentNode"
/> -->
@find-parent-node="findParentNode"
/>
<!-- 延迟器节点 -->
<!-- <DelayTimerNode
<DelayTimerNode
v-if="currentNode && currentNode.type === NodeType.DELAY_TIMER_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/> -->
/>
<!-- 路由分支节点 -->
<!-- <RouterNode
<RouterNode
v-if="currentNode && currentNode.type === NodeType.ROUTER_BRANCH_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/> -->
/>
<!-- 触发器节点 -->
<!-- <TriggerNode
<TriggerNode
v-if="currentNode && currentNode.type === NodeType.TRIGGER_NODE"
:flow-node="currentNode"
@update:flow-node="handleModelValueUpdate"
/> -->
/>
<!-- 子流程节点 -->
<!-- <ChildProcessNode
v-if="currentNode && currentNode.type === NodeType.CHILD_PROCESS_NODE"
@ -141,4 +150,3 @@ const recursiveFindParentNode = (
:flow-node="currentNode"
/>
</template>
<style lang="scss" scoped></style>

View File

@ -11,9 +11,10 @@ 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, Modal } from 'ant-design-vue';
import { Button } from 'ant-design-vue';
import { getFormDetail } from '#/api/bpm/form';
import { getUserGroupSimpleList } from '#/api/bpm/userGroup';
@ -112,16 +113,20 @@ provide('tasks', []);
provide('processInstance', {});
const processNodeTree = ref<SimpleFlowNode | undefined>();
provide('processNodeTree', processNodeTree);
const errorDialogVisible = ref(false);
const errorNodes: SimpleFlowNode[] = [];
//
const [ErrorModal, errorModalApi] = useVbenModal({
fullscreenButton: false,
});
//
const updateModel = () => {
function updateModel() {
if (!processNodeTree.value) {
processNodeTree.value = {
name: '发起人',
type: NodeType.START_USER_NODE,
id: NodeId.START_USER_NODE_ID,
showText: '默认配置',
childNode: {
id: NodeId.END_EVENT_NODE_ID,
name: '结束',
@ -131,11 +136,11 @@ const updateModel = () => {
//
saveSimpleFlowModel(processNodeTree.value);
}
};
}
const saveSimpleFlowModel = async (
async function saveSimpleFlowModel(
simpleModelNode: SimpleFlowNode | undefined,
) => {
) {
if (!simpleModelNode) {
return;
}
@ -146,52 +151,40 @@ const saveSimpleFlowModel = async (
} catch (error) {
console.error('保存失败:', error);
}
};
}
/**
* 校验节点设置 暂时以 showText 为空 未节点错误配置
* 校验节点设置 暂时以 showText 为空作为节点错误配置的判断条件
*/
// eslint-disable-next-line unused-imports/no-unused-vars, no-unused-vars
const validateNode = (
function validateNode(
node: SimpleFlowNode | undefined,
errorNodes: SimpleFlowNode[],
) => {
) {
if (node) {
const { type, showText, conditionNodes } = node;
if (type === NodeType.END_EVENT_NODE) {
return;
}
if (type === NodeType.START_USER_NODE) {
//
validateNode(node.childNode, errorNodes);
}
if (
type === NodeType.USER_TASK_NODE ||
type === NodeType.COPY_TASK_NODE ||
type === NodeType.CONDITION_NODE
) {
if (!showText) {
errorNodes.push(node);
}
validateNode(node.childNode, errorNodes);
}
if (
type === NodeType.CONDITION_BRANCH_NODE ||
type === NodeType.PARALLEL_BRANCH_NODE ||
type === NodeType.INCLUSIVE_BRANCH_NODE
) {
//
// 1.
// 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 {
@ -220,38 +213,40 @@ onMounted(async () => {
}
});
const simpleProcessModelRef = ref();
defineExpose({});
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
ref="simpleProcessModelRef"
v-if="processNodeTree"
:flow-node="processNodeTree"
:readonly="false"
@save="saveSimpleFlowModel"
/>
<Modal
v-model="errorDialogVisible"
title="保存失败"
width="400"
:fullscreen="false"
>
<div class="mb-2">以下节点内容不完善请修改后保存</div>
<ErrorModal title="流程设计校验不通过" class="w-[600px]">
<div class="mb-2 text-base">以下节点配置不完善请修改相关配置</div>
<div
class="b-rounded-1 line-height-normal mb-3 bg-gray-100 p-2"
v-for="(item, index) in errorNodes"
class="mb-3 rounded-md bg-gray-100 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="errorDialogVisible = false">
知道了
</Button>
<Button type="primary" @click="errorModalApi.close()"></Button>
</template>
</Modal>
</ErrorModal>
</div>
</template>

View File

@ -49,22 +49,22 @@ const currentY = ref(0);
const initialX = ref(0);
const initialY = ref(0);
const setGrabCursor = () => {
function setGrabCursor() {
document.body.style.cursor = 'grab';
};
}
const resetCursor = () => {
function resetCursor() {
document.body.style.cursor = 'default';
};
}
const startDrag = (e: MouseEvent) => {
function startDrag(e: MouseEvent) {
isDragging.value = true;
startX.value = e.clientX - currentX.value;
startY.value = e.clientY - currentY.value;
setGrabCursor(); //
};
}
const onDrag = (e: MouseEvent) => {
function onDrag(e: MouseEvent) {
if (!isDragging.value) return;
e.preventDefault(); //
@ -73,44 +73,44 @@ const onDrag = (e: MouseEvent) => {
currentX.value = e.clientX - startX.value;
currentY.value = e.clientY - startY.value;
});
};
}
const stopDrag = () => {
function stopDrag() {
isDragging.value = false;
resetCursor(); //
};
}
const zoomIn = () => {
function zoomIn() {
if (scaleValue.value === MAX_SCALE_VALUE) {
return;
}
scaleValue.value += 10;
};
}
const zoomOut = () => {
function zoomOut() {
if (scaleValue.value === MIN_SCALE_VALUE) {
return;
}
scaleValue.value -= 10;
};
}
const processReZoom = () => {
function processReZoom() {
scaleValue.value = 100;
};
}
const resetPosition = () => {
function resetPosition() {
currentX.value = initialX.value;
currentY.value = initialY.value;
};
}
/** 校验节点设置 */
const errorDialogVisible = ref(false);
let errorNodes: SimpleFlowNode[] = [];
const validateNode = (
function validateNode(
node: SimpleFlowNode | undefined,
errorNodes: SimpleFlowNode[],
) => {
) {
if (node) {
const { type, showText, conditionNodes } = node;
if (type === NodeType.END_EVENT_NODE) {
@ -146,10 +146,10 @@ const validateNode = (
validateNode(node.childNode, errorNodes);
}
}
};
}
/** 获取当前流程数据 */
const getCurrentFlowData = async () => {
async function getCurrentFlowData() {
try {
errorNodes = [];
validateNode(processNodeTree.value, errorNodes);
@ -162,26 +162,26 @@ const getCurrentFlowData = async () => {
console.error('获取流程数据失败:', error);
return undefined;
}
};
}
defineExpose({
getCurrentFlowData,
});
/** 导出 JSON */
const exportJson = () => {
function exportJson() {
downloadFileFromBlob({
fileName: 'model.json',
source: new Blob([JSON.stringify(processNodeTree.value)]),
});
};
}
/** 导入 JSON */
const refFile = ref();
const importJson = () => {
function importJson() {
refFile.value.click();
};
const importLocalFile = () => {
}
function importLocalFile() {
const file = refFile.value.files[0];
file.text().then((result: any) => {
if (isString(result)) {
@ -189,7 +189,7 @@ const importLocalFile = () => {
emits('save', processNodeTree.value);
}
});
};
}
//
onMounted(() => {
@ -267,4 +267,3 @@ onMounted(() => {
</template>
</Modal>
</template>
<style lang="scss" scoped></style>

View File

@ -475,9 +475,9 @@ export type ListenerHandler = {
*
*/
export type ConditionRule = {
leftSide: string;
leftSide: string | undefined;
opCode: string;
rightSide: string;
rightSide: string | undefined;
};
/**
@ -564,7 +564,7 @@ export type RouterSetting = {
conditionExpression: string;
conditionGroups: ConditionGroup;
conditionType: ConditionType;
nodeId: string;
nodeId: string | undefined;
};
/**
@ -725,7 +725,7 @@ export const DEFAULT_CONDITION_GROUP_VALUE = {
rules: [
{
opCode: '==',
leftSide: '',
leftSide: undefined,
rightSide: '',
},
],

View File

@ -43,7 +43,7 @@ export function useWatchNode(props: {
}
// 解析 formCreate 所有表单字段, 并返回
const parseFormCreateFields = (formFields?: string[]) => {
function parseFormCreateFields(formFields?: string[]) {
const result: Array<Record<string, any>> = [];
if (formFields) {
formFields.forEach((fieldStr: string) => {
@ -51,7 +51,7 @@ const parseFormCreateFields = (formFields?: string[]) => {
});
}
return result;
};
}
/**
* field, title
@ -109,20 +109,20 @@ export function useFormFieldsPermission(
const formFields = inject<Ref<string[]>>('formFields', ref([])); // 流程表单字段
const getNodeConfigFormFields = (
function getNodeConfigFormFields(
nodeFormFields?: Array<Record<string, string>>,
) => {
) {
nodeFormFields = toRaw(nodeFormFields);
fieldsPermissionConfig.value =
!nodeFormFields || nodeFormFields.length === 0
? getDefaultFieldsPermission(unref(formFields))
: mergeFieldsPermission(nodeFormFields, unref(formFields));
};
}
// 合并已经设置的表单字段权限,当前流程表单字段 (可能新增,或删除了字段)
const mergeFieldsPermission = (
function mergeFieldsPermission(
formFieldsPermisson: Array<Record<string, string>>,
formFields?: string[],
) => {
) {
let mergedFieldsPermission: Array<Record<string, any>> = [];
if (formFields) {
mergedFieldsPermission = parseFormCreateFields(formFields).map((item) => {
@ -137,10 +137,10 @@ export function useFormFieldsPermission(
});
}
return mergedFieldsPermission;
};
}
// 默认的表单权限: 获取表单的所有字段,设置字段默认权限为只读
const getDefaultFieldsPermission = (formFields?: string[]) => {
function getDefaultFieldsPermission(formFields?: string[]) {
let defaultFieldsPermission: Array<Record<string, any>> = [];
if (formFields) {
defaultFieldsPermission = parseFormCreateFields(formFields).map(
@ -154,7 +154,7 @@ export function useFormFieldsPermission(
);
}
return defaultFieldsPermission;
};
}
// 获取表单的所有字段,作为下拉框选项
const formFieldOptions = parseFormCreateFields(unref(formFields));
@ -268,7 +268,6 @@ export function useNodeForm(nodeType: NodeType) {
const formFields = inject<Ref<string[]>>('formFields', ref([])); // 流程表单字段
const configForm = ref<any | CopyTaskFormType | UserTaskFormType>();
// eslint-disable-next-line unicorn/prefer-ternary
if (
nodeType === NodeType.USER_TASK_NODE ||
nodeType === NodeType.TRANSACTOR_NODE
@ -286,13 +285,12 @@ export function useNodeForm(nodeType: NodeType) {
maxRemindCount: 1, // 默认 提醒 1次
buttonsSetting: [],
};
} else {
configForm.value = {
candidateStrategy: CandidateStrategy.USER,
};
}
configForm.value = {
candidateStrategy: CandidateStrategy.USER,
};
const getShowText = (): string => {
function getShowText(): string {
let showText = '';
// 指定成员
if (
@ -428,12 +426,12 @@ export function useNodeForm(nodeType: NodeType) {
showText = `流程表达式:${configForm.value.expression}`;
}
return showText;
};
}
/**
*
*/
const handleCandidateParam = () => {
function handleCandidateParam() {
let candidateParam: string | undefined;
if (!configForm.value) {
return candidateParam;
@ -495,14 +493,14 @@ export function useNodeForm(nodeType: NodeType) {
}
}
return candidateParam;
};
}
/**
*
*/
const parseCandidateParam = (
function parseCandidateParam(
candidateStrategy: CandidateStrategy,
candidateParam: string | undefined,
) => {
) {
if (!configForm.value || !candidateParam) {
return;
}
@ -578,7 +576,7 @@ export function useNodeForm(nodeType: NodeType) {
break;
}
}
};
}
return {
configForm,
roleOptions,
@ -599,13 +597,13 @@ export function useDrawer() {
// 抽屉配置是否可见
const settingVisible = ref(false);
// 关闭配置抽屉
const closeDrawer = () => {
function closeDrawer() {
settingVisible.value = false;
};
}
// 打开配置抽屉
const openDrawer = () => {
function openDrawer() {
settingVisible.value = true;
};
}
return {
settingVisible,
closeDrawer,
@ -622,15 +620,15 @@ export function useNodeName(nodeType: NodeType) {
// 节点名称输入框
const showInput = ref(false);
// 点击节点名称编辑图标
const clickIcon = () => {
function clickIcon() {
showInput.value = true;
};
}
// 节点名称输入框失去焦点
const blurEvent = () => {
function blurEvent() {
showInput.value = false;
nodeName.value =
nodeName.value || (NODE_DEFAULT_NAME.get(nodeType) as string);
};
}
return {
nodeName,
showInput,
@ -643,15 +641,15 @@ export function useNodeName2(node: Ref<SimpleFlowNode>, nodeType: NodeType) {
// 显示节点名称输入框
const showInput = ref(false);
// 节点名称输入框失去焦点
const blurEvent = () => {
function blurEvent() {
showInput.value = false;
node.value.name =
node.value.name || (NODE_DEFAULT_NAME.get(nodeType) as string);
};
}
// 点击节点标题进行输入
const clickTitle = () => {
function clickTitle() {
showInput.value = true;
};
}
return {
showInput,
clickTitle,
@ -722,18 +720,40 @@ export function getConditionShowText(
}
/** 获取表单字段名称*/
const getFormFieldTitle = (
function getFormFieldTitle(
fieldOptions: Array<Record<string, any>>,
field: string,
) => {
) {
const item = fieldOptions.find((item) => item.field === field);
return item?.title;
};
}
/** 获取操作符名称 */
const getOpName = (opCode: string): string | undefined => {
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

@ -334,12 +334,14 @@
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: 2; /* 这将限制文本显示为两行 */
-webkit-line-clamp: 1; /* 这将限制文本显示为一行 */
font-size: 12px;
line-height: 24px;
word-break: break-all;
@ -478,6 +480,8 @@
top: -18px;
left: 50%;
z-index: 1;
display: flex;
align-items: center;
height: 36px;
padding: 0 10px;
font-size: 12px;
@ -644,7 +648,8 @@
width: 80px;
height: 36px;
color: #212121;
border: 2px solid #fafafa;
background-color: #fff;
border: 2px solid transparent;
border-radius: 30px;
box-shadow: 0 1px 5px 0 rgb(10 30 65 / 8%);

View File

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

View File

@ -123,8 +123,8 @@ export function useGridColumns<T = BpmCategoryApi.CategoryVO>(
field: 'userIds',
title: '成员',
minWidth: 200,
formatter: (row) => {
return getMemberNames(row.cellValue);
formatter: ({ cellValue }) => {
return getMemberNames(cellValue);
},
},
{

View File

@ -216,7 +216,8 @@ const validateAllSteps = async () => {
await validateBasic();
} catch {
currentStep.value = 0;
throw new Error('请完善基本信息');
message.warning('请完善基本信息');
return false;
}
//
@ -224,25 +225,19 @@ const validateAllSteps = async () => {
await validateForm();
} catch {
currentStep.value = 1;
throw new Error('请完善自定义表单信息');
message.warning('请完善自定义表单信息');
return false;
}
// TODO
//
try {
await validateProcess();
} catch {
currentStep.value = 2;
throw new Error('请设计流程');
return false;
}
//
try {
await validateProcess();
} catch {
currentStep.value = 2;
throw new Error('请设计流程');
}
// TODO
return true;
};
@ -251,7 +246,10 @@ const validateAllSteps = async () => {
const handleSave = async () => {
try {
//
await validateAllSteps();
const result = await validateAllSteps();
if (!result) {
return;
}
//
const modelData = {
@ -297,7 +295,7 @@ const handleSave = async () => {
}
} catch (error: any) {
console.error('保存失败:', error);
message.warning(error.message || '请完善所有步骤的必填信息');
// message.warning(error.msg || '');
}
};
@ -338,7 +336,6 @@ const handleDeploy = async () => {
/** 步骤切换处理 */
const handleStepClick = async (index: number) => {
try {
console.warn('handleStepClick', index);
if (index !== 0) {
await validateBasic();
}
@ -348,23 +345,13 @@ const handleStepClick = async (index: number) => {
if (index !== 2) {
await validateProcess();
}
//
currentStep.value = index;
//
if (index === 2) {
// TODO
// await nextTick();
// //
// await new Promise((resolve) => setTimeout(resolve, 200));
// if (processDesignRef.value?.refresh) {
// await processDesignRef.value.refresh();
// }
}
} catch (error) {
console.error('步骤切换失败:', error);
message.warning('请先完善当前步骤必填信息');
if (currentStep.value !== 2) {
message.warning('请先完善当前步骤必填信息');
}
}
};

View File

@ -22,9 +22,8 @@ import {
Tooltip,
} from 'ant-design-vue';
import { DeptSelectModal } from '#/components/dept-select-modal';
import { DeptSelectModal, UserSelectModal } from '#/components/select-modal';
import { ImageUpload } from '#/components/upload';
import { UserSelectModal } from '#/components/user-select-modal';
import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '#/utils';
const props = defineProps({

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { computed, inject, nextTick } from 'vue';
import { computed, inject, nextTick, ref } from 'vue';
import { BpmModelType } from '#/utils';
@ -13,12 +13,21 @@ const modelData = defineModel<any>();
const processData = inject('processData') as Ref;
const simpleDesign = ref();
/** 表单校验 */
const validate = async () => {
//
if (!processData.value) {
throw new Error('请设计流程');
}
if (modelData.value.type === BpmModelType.SIMPLE) {
//
const validateResult = await simpleDesign.value?.validateConfig();
if (!validateResult) {
throw new Error('请完善设计配置');
}
}
return true;
};
/** 处理设计器保存成功 */
@ -41,9 +50,7 @@ const handleDesignSuccess = async (data?: any) => {
const showDesigner = computed(() => {
return Boolean(modelData.value?.key && modelData.value?.name);
});
defineExpose({
validate,
});
defineExpose({ validate });
</script>
<template>
<div class="h-full">
@ -61,6 +68,7 @@ defineExpose({
:start-user-ids="modelData.startUserIds"
:start-dept-ids="modelData.startDeptIds"
@success="handleDesignSuccess"
ref="simpleDesign"
/>
</template>
</div>

View File

@ -17,12 +17,17 @@ defineProps<{
const emit = defineEmits(['success']);
const designerRef = ref();
//
/** 保存成功回调 */
const handleSuccess = (data?: any) => {
if (data) {
emit('success', data);
}
};
/** 设计器配置校验 */
const validateConfig = async () => {
return await designerRef.value.validate();
};
defineExpose({ validateConfig });
</script>
<template>
<ContentWrap :body-style="{ padding: '20px 16px' }">
@ -37,4 +42,3 @@ const handleSuccess = (data?: any) => {
/>
</ContentWrap>
</template>
<style lang="scss" scoped></style>

View File

@ -1389,4 +1389,3 @@ defineExpose({ loadTodoTask });
<!-- 签名弹窗 -->
<Signature ref="signRef" @success="handleSignFinish" />
</template>
<style lang="scss" scoped></style>

View File

@ -10,7 +10,7 @@ import { formatDateTime, isEmpty } from '@vben/utils';
import { Avatar, Button, Image, Timeline, Tooltip } from 'ant-design-vue';
import { UserSelectModal } from '#/components/user-select-modal';
import { UserSelectModal } from '#/components/select-modal';
import {
BpmCandidateStrategyEnum,
BpmNodeTypeEnum,

View File

@ -133,8 +133,8 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
field: 'durationInMillis',
title: '耗时',
minWidth: 180,
formatter: ({ row }) => {
return `${formatPast2(row.durationInMillis)}`;
formatter: ({ cellValue }) => {
return `${formatPast2(cellValue)}`;
},
},
{

View File

@ -89,8 +89,8 @@ export function useGridColumns<T = BpmTaskApi.TaskVO>(
field: 'durationInMillis',
title: '耗时',
minWidth: 180,
formatter: ({ row }) => {
return `${formatPast2(row.durationInMillis)}`;
formatter: ({ cellValue }) => {
return `${formatPast2(cellValue)}`;
},
},
{

View File

@ -1,16 +1,5 @@
import type { Ref } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useAccess } from '@vben/access';
import { DICT_TYPE } from '#/utils';
const { hasAccessByCodes } = useAccess();
export interface LeftSideItem {
name: string;
menu: string;
@ -110,786 +99,3 @@ export const useLeftSides = (
},
];
};
/** 分配给我的线索 列表的搜索表单 */
export function useClueFollowFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
];
}
/** 分配给我的线索 列表的字段 */
export function useClueFollowColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '线索名称',
minWidth: 160,
fixed: 'left',
slots: { default: 'name' },
},
{
field: 'source',
title: '线索来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastContent',
title: '最后跟进记录',
minWidth: 200,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
];
}
/** 合同审核列表的搜索表单 */
export function useContractAuditFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: 10,
},
];
}
/** 合同提醒列表的搜索表单 */
export function useContractRemindFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'expiryType',
label: '到期状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTRACT_EXPIRY_TYPE,
},
defaultValue: 1,
},
];
}
/** 合同审核列表的字段 */
export function useContractColumns<T = CrmContractApi.Contract>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'no',
title: '合同编号',
minWidth: 160,
fixed: 'left',
},
{
field: 'name',
title: '合同名称',
minWidth: 160,
slots: {
default: 'name',
},
},
{
field: 'customerName',
title: '客户名称',
minWidth: 160,
slots: {
default: 'customerName',
},
},
{
field: 'businessName',
title: '商机名称',
minWidth: 160,
slots: {
default: 'businessName',
},
},
{
field: 'price',
title: '合同金额(元)',
minWidth: 120,
formatter: 'formatNumber',
},
{
field: 'orderDate',
title: '下单时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'startTime',
title: '合同开始时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'endTime',
title: '合同结束时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'contactName',
title: '客户签约人',
minWidth: 130,
slots: {
default: 'contactName',
},
},
{
field: 'signUserName',
title: '公司签约人',
minWidth: 130,
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'totalReceivablePrice',
title: '已回款金额(元)',
minWidth: 140,
formatter: 'formatNumber',
},
{
field: 'noReceivablePrice',
title: '未回款金额(元)',
minWidth: 120,
formatter: 'formatNumber',
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 120,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'auditStatus',
title: '合同状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'no',
nameTitle: '合同编号',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'processDetail',
show: hasAccessByCodes(['crm:contract:update']),
},
],
},
},
];
}
/** 客户跟进列表的搜索表单 */
export function useCustomerFollowFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
];
}
/** 待进入公海客户列表的搜索表单 */
export function useCustomerPutPoolFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: 1,
},
];
}
/** 今日需联系客户列表的搜索表单 */
export function useCustomerTodayContactFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'contactStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTACT_STATUS,
},
defaultValue: 1,
},
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: 1,
},
];
}
/** 客户列表的字段 */
export function useCustomerColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '客户名称',
minWidth: 160,
slots: {
default: 'name',
},
},
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'lockStatus',
title: '锁定状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'dealStatus',
title: '成交状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastContent',
title: '最后跟进记录',
minWidth: 200,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 200,
},
{
field: 'poolDay',
title: '距离进入公海天数',
minWidth: 180,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
];
}
/** 回款审核列表的搜索表单 */
export function useReceivableAuditFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: 10,
},
];
}
/** 回款审核列表的字段 */
export function useReceivableAuditColumns<T = CrmReceivableApi.Receivable>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'no',
title: '回款编号',
minWidth: 180,
fixed: 'left',
slots: {
default: 'no',
},
},
{
field: 'customerName',
title: '客户名称',
minWidth: 120,
slots: {
default: 'customerName',
},
},
{
field: 'contractNo',
title: '合同编号',
minWidth: 180,
slots: {
default: 'contractNo',
},
},
{
field: 'returnTime',
title: '回款日期',
minWidth: 150,
formatter: 'formatDateTime',
},
{
field: 'price',
title: '回款金额(元)',
minWidth: 140,
formatter: 'formatNumber',
},
{
field: 'returnType',
title: '回款方式',
minWidth: 130,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contract.totalPrice',
title: '合同金额(元)',
minWidth: 140,
formatter: 'formatNumber',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 120,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 120,
},
{
field: 'auditStatus',
title: '回款状态',
minWidth: 120,
fixed: 'right',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
field: 'operation',
title: '操作',
width: 140,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '角色',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'processDetail',
text: '查看审批',
show: hasAccessByCodes(['crm:receivable:update']),
},
],
},
},
];
}
/** 回款计划提醒列表的搜索表单 */
export function useReceivablePlanRemindFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'remindType',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: RECEIVABLE_REMIND_TYPE,
},
defaultValue: 1,
},
];
}
/** 回款计划提醒列表的字段 */
export function useReceivablePlanRemindColumns<T = CrmReceivableApi.Receivable>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'customerName',
title: '客户名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'customerName',
},
},
{
field: 'contractNo',
title: '合同编号',
minWidth: 200,
},
{
field: 'period',
title: '期数',
minWidth: 160,
slots: {
default: 'period',
},
},
{
field: 'price',
title: '计划回款金额(元)',
minWidth: 120,
formatter: 'formatNumber',
},
{
field: 'returnTime',
title: '计划回款日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remindDays',
title: '提前几天提醒',
minWidth: 150,
},
{
field: 'remindTime',
title: '提醒日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'returnType',
title: '回款方式',
minWidth: 120,
fixed: 'right',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'receivable.price',
title: '实际回款金额(元)',
minWidth: 160,
formatter: 'formatNumber',
},
{
field: 'receivable.returnTime',
title: '实际回款日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'operation',
title: '操作',
width: 140,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'customerName',
nameTitle: '客户名称',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'receivableForm',
text: '创建回款',
show: hasAccessByCodes(['crm:receivable:create']),
},
],
},
},
];
}

View File

@ -10,17 +10,16 @@ import * as ContractApi from '#/api/crm/contract';
import * as CustomerApi from '#/api/crm/customer';
import * as ReceivableApi from '#/api/crm/receivable';
import * as ReceivablePlanApi from '#/api/crm/receivable/plan';
import { DocAlert } from '#/components/doc-alert';
import { useLeftSides } from './data';
import ClueFollowList from './modules/ClueFollowList.vue';
import ContractAuditList from './modules/ContractAuditList.vue';
import ContractRemindList from './modules/ContractRemindList.vue';
import CustomerFollowList from './modules/CustomerFollowList.vue';
import CustomerPutPoolRemindList from './modules/CustomerPutPoolRemindList.vue';
import CustomerTodayContactList from './modules/CustomerTodayContactList.vue';
import ReceivableAuditList from './modules/ReceivableAuditList.vue';
import ReceivablePlanRemindList from './modules/ReceivablePlanRemindList.vue';
import ClueFollowList from './modules/clue-follow-list.vue';
import ContractAuditList from './modules/contract-audit-list.vue';
import ContractRemindList from './modules/contract-remind-list.vue';
import CustomerFollowList from './modules/customer-follow-list.vue';
import CustomerPutPoolRemindList from './modules/customer-put-pool-remind-list.vue';
import CustomerTodayContactList from './modules/customer-today-contact-list.vue';
import ReceivableAuditList from './modules/receivable-audit-list.vue';
import ReceivablePlanRemindList from './modules/receivable-plan-remind-list.vue';
defineOptions({ name: 'CrmBacklog' });
@ -92,12 +91,6 @@ onMounted(async () => {
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【通用】跟进记录、待办事项"
url="https://doc.iocoder.cn/crm/follow-up/"
/>
</template>
<div class="flex h-full w-full">
<Card class="w-1/5">
<List item-layout="horizontal" :data-source="leftSides">
@ -105,11 +98,17 @@ onMounted(async () => {
<List.Item>
<List.Item.Meta>
<template #title>
<a @click="sideClick(item)"> {{ item.name }} </a>
<a @click="sideClick(item)">
{{ item.name }}
</a>
</template>
</List.Item.Meta>
<template #extra v-if="item.count.value && item.count.value > 0">
<Badge :count="item.count.value" />
<template #extra>
<Badge
:color="item.menu === leftMenu ? 'blue' : 'red'"
:count="item.count.value"
:show-zero="true"
/>
</template>
</List.Item>
</template>

View File

@ -1,90 +0,0 @@
<!-- 待回款提醒 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage } from '#/api/crm/receivable';
import {
useReceivablePlanRemindColumns,
useReceivablePlanRemindFormSchema,
} from '../data';
const { push } = useRouter();
/** 打开回款详情 */
function openDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 创建回款 */
function openReceivableForm(row: CrmReceivableApi.Receivable) {
// Todo:
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmReceivableApi.Receivable>) {
switch (code) {
case 'receivableForm': {
openReceivableForm(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useReceivablePlanRemindFormSchema(),
},
gridOptions: {
columns: useReceivablePlanRemindColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="">
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #period="{ row }">
<Button type="link" @click="openDetail(row)">{{ row.period }}</Button>
</template>
</Grid>
</template>

View File

@ -1,5 +1,6 @@
<!-- 分配给我的线索 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import { useRouter } from 'vue-router';
@ -8,22 +9,34 @@ import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCluePage } from '#/api/crm/clue';
import { useGridColumns } from '#/views/crm/clue/data';
import { useClueFollowColumns, useClueFollowFormSchema } from '../data';
import { FOLLOWUP_STATUS } from '../data';
const { push } = useRouter();
/** 打开线索详情 */
function onDetail(row: CrmClueApi.Clue) {
function handleDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useClueFollowFormSchema(),
schema: [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
],
},
gridOptions: {
columns: useClueFollowColumns(),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -45,14 +58,17 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmClueApi.Clue>,
});
</script>
<template>
<Grid table-title="线">
<Grid>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
<Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
</template>
<template #actions="{ row }">
<Button type="link" @click="handleDetail(row)"></Button>
</template>
</Grid>
</template>

View File

@ -1,21 +1,22 @@
<!-- 待审核合同 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
import { useGridColumns } from '#/views/crm/contract/data';
import { useContractAuditFormSchema, useContractColumns } from '../data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
/** 查看审批 */
function openProcessDetail(row: CrmContractApi.Contract) {
function handleProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
@ -23,43 +24,41 @@ function openProcessDetail(row: CrmContractApi.Contract) {
}
/** 打开合同详情 */
function openContractDetail(row: CrmContractApi.Contract) {
function handleContractDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmContractApi.Contract) {
function handleCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 打开联系人详情 */
function openContactDetail(row: CrmContractApi.Contract) {
function handleContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 打开商机详情 */
function openBusinessDetail(row: CrmContractApi.Contract) {
function handleBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmContractApi.Contract>) {
switch (code) {
case 'processDetail': {
openProcessDetail(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useContractAuditFormSchema(),
schema: [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: 10,
},
],
},
gridOptions: {
columns: useContractColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -81,31 +80,43 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
});
</script>
<template>
<Grid table-title="">
<Grid>
<template #name="{ row }">
<Button type="link" @click="openContractDetail(row)">
<Button type="link" @click="handleContractDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #businessName="{ row }">
<Button type="link" @click="openBusinessDetail(row)">
<Button type="link" @click="handleBusinessDetail(row)">
{{ row.businessName }}
</Button>
</template>
<template #contactName="{ row }">
<Button type="link" @click="openContactDetail(row)">
<Button type="link" @click="handleContactDetail(row)">
{{ row.contactName }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看审批',
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: handleProcessDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</template>

View File

@ -1,21 +1,22 @@
<!-- 即将到期的合同 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
import { useGridColumns } from '#/views/crm/contract/data';
import { useContractColumns, useContractRemindFormSchema } from '../data';
import { CONTRACT_EXPIRY_TYPE } from '../data';
const { push } = useRouter();
/** 查看审批 */
function openProcessDetail(row: CrmContractApi.Contract) {
function handleProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
@ -23,43 +24,41 @@ function openProcessDetail(row: CrmContractApi.Contract) {
}
/** 打开合同详情 */
function openContractDetail(row: CrmContractApi.Contract) {
function handleContractDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmContractApi.Contract) {
function handleCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 打开联系人详情 */
function openContactDetail(row: CrmContractApi.Contract) {
function handleContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 打开商机详情 */
function openBusinessDetail(row: CrmContractApi.Contract) {
function handleBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmContractApi.Contract>) {
switch (code) {
case 'processDetail': {
openProcessDetail(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useContractRemindFormSchema(),
schema: [
{
fieldName: 'expiryType',
label: '到期状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTRACT_EXPIRY_TYPE,
},
defaultValue: 1,
},
],
},
gridOptions: {
columns: useContractColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -81,31 +80,43 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
});
</script>
<template>
<Grid table-title="">
<Grid>
<template #name="{ row }">
<Button type="link" @click="openContractDetail(row)">
<Button type="link" @click="handleContractDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #businessName="{ row }">
<Button type="link" @click="openBusinessDetail(row)">
<Button type="link" @click="handleBusinessDetail(row)">
{{ row.businessName }}
</Button>
</template>
<template #contactName="{ row }">
<Button type="link" @click="openContactDetail(row)">
{{ row.contactName }}
<template #signContactName="{ row }">
<Button type="link" @click="handleContactDetail(row)">
{{ row.signContactName }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看审批',
type: 'link',
auth: ['crm:contract:update'],
onClick: handleProcessDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</template>

View File

@ -1,5 +1,6 @@
<!-- 分配给我的客户 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
@ -8,22 +9,34 @@ import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { useCustomerColumns, useCustomerFollowFormSchema } from '../data';
import { FOLLOWUP_STATUS } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCustomerFollowFormSchema(),
schema: [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
],
},
gridOptions: {
columns: useCustomerColumns(),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -45,14 +58,17 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Grid table-title="">
<Grid>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
<Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
</template>
<template #actions="{ row }">
<Button type="link" @click="handleDetail(row)"></Button>
</template>
</Grid>
</template>

View File

@ -1,5 +1,6 @@
<!-- 待进入公海的客户 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
@ -8,22 +9,34 @@ import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { useCustomerColumns, useCustomerPutPoolFormSchema } from '../data';
import { SCENE_TYPES } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCustomerPutPoolFormSchema(),
schema: [
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: 1,
},
],
},
gridOptions: {
columns: useCustomerColumns(),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -45,14 +58,17 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Grid table-title="">
<Grid>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
<Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
</template>
<template #actions="{ row }">
<Button type="link" @click="handleDetail(row)"></Button>
</template>
</Grid>
</template>

View File

@ -1,5 +1,6 @@
<!-- 今日需联系客户 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
@ -8,22 +9,44 @@ import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { useCustomerColumns, useCustomerTodayContactFormSchema } from '../data';
import { CONTACT_STATUS, SCENE_TYPES } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCustomerTodayContactFormSchema(),
schema: [
{
fieldName: 'contactStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTACT_STATUS,
},
defaultValue: 1,
},
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: 1,
},
],
},
gridOptions: {
columns: useCustomerColumns(),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -45,14 +68,17 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Grid table-title="">
<Grid>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
<Button type="link" @click="handleDetail(row)">{{ row.name }}</Button>
</template>
<template #actions="{ row }">
<Button type="link" @click="handleDetail(row)"></Button>
</template>
</Grid>
</template>

View File

@ -1,6 +1,6 @@
<!-- 待审核回款 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useRouter } from 'vue-router';
@ -9,16 +9,14 @@ import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage } from '#/api/crm/receivable';
import { useGridColumns } from '#/views/crm/receivable/data';
import {
useReceivableAuditColumns,
useReceivableAuditFormSchema,
} from '../data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
/** 查看审批 */
function openProcessDetail(row: CrmReceivableApi.Receivable) {
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
@ -26,39 +24,37 @@ function openProcessDetail(row: CrmReceivableApi.Receivable) {
}
/** 打开回款详情 */
function openDetail(row: CrmReceivableApi.Receivable) {
function handleDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmReceivableApi.Receivable) {
function handleCustomerDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 打开合同详情 */
function openContractDetail(row: CrmReceivableApi.Receivable) {
function handleContractDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmReceivableApi.Receivable>) {
switch (code) {
case 'processDetail': {
openProcessDetail(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useReceivableAuditFormSchema(),
schema: [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: 10,
},
],
},
gridOptions: {
columns: useReceivableAuditColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -79,26 +75,29 @@ const [Grid] = useVbenVxeGrid({
refresh: { code: 'query' },
search: true,
},
},
} as VxeTableGridOptions<CrmReceivableApi.Receivable>,
});
</script>
<template>
<Grid table-title="">
<Grid>
<template #no="{ row }">
<Button type="link" @click="openDetail(row)">
<Button type="link" @click="handleDetail(row)">
{{ row.no }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #contractNo="{ row }">
<Button type="link" @click="openContractDetail(row)">
<Button type="link" @click="handleContractDetail(row)">
{{ row.contractNo }}
</Button>
</template>
<template #actions="{ row }">
<Button type="link" @click="handleProcessDetail(row)"></Button>
</template>
</Grid>
</template>

View File

@ -0,0 +1,101 @@
<!-- 待回款提醒 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmReceivablePlanApi } from '#/api/crm/receivable/plan';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePlanPage } from '#/api/crm/receivable/plan';
import Form from '#/views/crm/receivable/modules/form.vue';
import { useGridColumns } from '#/views/crm/receivable/plan/data';
import { RECEIVABLE_REMIND_TYPE } from '../data';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 打开回款详情 */
function handleDetail(row: CrmReceivablePlanApi.Plan) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function handleCustomerDetail(row: CrmReceivablePlanApi.Plan) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 创建回款 */
function handleCreateReceivable(row: CrmReceivablePlanApi.Plan) {
formModalApi.setData({ plan: row }).open();
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'remindType',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: RECEIVABLE_REMIND_TYPE,
},
defaultValue: 1,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePlanPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmReceivablePlanApi.Plan>,
});
</script>
<template>
<div>
<FormModal />
<Grid>
<template #customerName="{ row }">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #period="{ row }">
<Button type="link" @click="handleDetail(row)">{{ row.period }}</Button>
</template>
<template #actions="{ row }">
<Button type="link" @click="handleCreateReceivable(row)">
创建回款
</Button>
</template>
</Grid>
</div>
</template>

View File

@ -1,12 +1,5 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import { useAccess } from '@vben/access';
import { getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@ -37,7 +30,6 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入商机金额',
},
rules: 'required',
@ -58,16 +50,6 @@ export function useFormSchema(): VbenFormSchema[] {
label: '备注',
component: 'Textarea',
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: false,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
];
}
@ -79,133 +61,85 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '商机名称',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = CrmBusinessApi.Business>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '商机名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'name',
},
slots: { default: 'name' },
},
{
field: 'customerName',
title: '客户名称',
minWidth: 120,
fixed: 'left',
slots: {
default: 'customerName',
},
slots: { default: 'customerName' },
},
{
field: 'totalPrice',
title: '商机金额(元)',
minWidth: 140,
formatter: 'formatNumber',
},
{
field: 'dealTime',
title: '预计成交日期',
minWidth: 180,
formatter: 'formatDate',
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDate',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'statusTypeName',
title: '商机状态组',
minWidth: 140,
fixed: 'right',
},
{
field: 'statusName',
title: '商机阶段',
minWidth: 120,
fixed: 'right',
},
{
field: 'operation',
title: '操作',
width: 130,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '商机',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['crm:business:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['crm:business:delete']),
},
],
},
slots: { default: 'actions' },
},
];
}

View File

@ -1,19 +1,15 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteBusiness,
exportBusiness,
@ -38,23 +34,23 @@ function onRefresh() {
}
/** 导出表格 */
async function onExport() {
async function handleExport() {
const data = await exportBusiness(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '商机.xls', source: data });
}
/** 创建商机 */
function onCreate() {
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑商机 */
function onEdit(row: CrmBusinessApi.Business) {
function handleEdit(row: CrmBusinessApi.Business) {
formModalApi.setData(row).open();
}
/** 删除商机 */
async function onDelete(row: CrmBusinessApi.Business) {
async function handleDelete(row: CrmBusinessApi.Business) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
@ -70,38 +66,21 @@ async function onDelete(row: CrmBusinessApi.Business) {
}
/** 查看商机详情 */
function onDetail(row: CrmBusinessApi.Business) {
function handleDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function onCustomerDetail(row: CrmBusinessApi.Business) {
function handleCustomerDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmBusinessApi.Business>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -142,34 +121,59 @@ const [Grid, gridApi] = useVbenVxeGrid({
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:business:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['商机']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['crm:business:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商机']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:business:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:business:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">
<Button type="link" @click="handleDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="onCustomerDetail(row)">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:business:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:business:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -1,7 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
<div>businessInfo</div>
</template>

View File

@ -0,0 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>businessList</div>
</template>

View File

@ -1,18 +1,11 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useAccess } from '@vben/access';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
import { getDeptList } from '#/api/system/dept';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@ -34,12 +27,16 @@ export function useFormSchema(): VbenFormSchema[] {
{
fieldName: 'deptIds',
label: '应用部门',
component: 'TreeSelect',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getDeptList();
return handleTree(data);
},
multiple: true,
treeCheckable: true,
showCheckedStrategy: 'SHOW_PARENT',
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '请选择应用部门',
treeDefaultExpandAll: true,
},
},
{
@ -56,79 +53,33 @@ export function useFormSchema(): VbenFormSchema[] {
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '状态组名',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = CrmBusinessStatusApi.BusinessStatus>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '状态组名',
minWidth: 200,
},
{
field: 'deptNames',
title: '应用部门',
minWidth: 200,
formatter: ({ cellValue }) => {
return cellValue?.length > 0 ? cellValue.join(' ') : '全公司';
},
formatter: ({ cellValue }) =>
cellValue?.length > 0 ? cellValue.join(' ') : '全公司',
},
{
field: 'creator',
title: '创建人',
minWidth: 100,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 160,
fixed: 'right',
align: 'center',
cellRender: {
name: 'TableAction',
props: {
actions: [
{
label: '编辑',
code: 'edit',
show: hasAccessByCodes(['crm:business-status:update']),
},
{
label: '删除',
code: 'delete',
show: hasAccessByCodes(['crm:business-status:delete']),
},
],
onActionClick,
},
},
slots: { default: 'actions' },
},
];
}

View File

@ -1,16 +1,12 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteBusinessStatus,
getBusinessStatusPage,
@ -18,7 +14,7 @@ import {
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
@ -32,12 +28,12 @@ function onRefresh() {
}
/** 创建商机状态 */
function onCreate() {
function handleCreate() {
formModalApi.setData(null).open();
}
/** 删除商机状态 */
async function onDelete(row: CrmBusinessStatusApi.BusinessStatus) {
async function handleDelete(row: CrmBusinessStatusApi.BusinessStatus) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
@ -53,33 +49,13 @@ async function onDelete(row: CrmBusinessStatusApi.BusinessStatus) {
}
/** 编辑商机状态 */
function onEdit(row: CrmBusinessStatusApi.BusinessStatus) {
function handleEdit(row: CrmBusinessStatusApi.BusinessStatus) {
formModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmBusinessStatusApi.BusinessStatus>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -120,14 +96,41 @@ const [Grid, gridApi] = useVbenVxeGrid({
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:business-status:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['商机状态']) }}
</Button>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商机状态']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:post:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:business-status:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:business-status:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>

View File

@ -1,19 +1,15 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { formatDateTime } from '@vben/utils';
import { getAreaTree } from '#/api/system/area';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
@ -164,14 +160,11 @@ export function useGridFormSchema(): VbenFormSchema[] {
}
/** 列表的字段 */
export function useGridColumns<T = CrmClueApi.Clue>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '线索名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'name',
@ -180,7 +173,6 @@ export function useGridColumns<T = CrmClueApi.Clue>(
{
field: 'source',
title: '线索来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
@ -189,27 +181,22 @@ export function useGridColumns<T = CrmClueApi.Clue>(
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
@ -218,7 +205,6 @@ export function useGridColumns<T = CrmClueApi.Clue>(
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
@ -227,66 +213,40 @@ export function useGridColumns<T = CrmClueApi.Clue>(
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'operation',
title: '操作',
width: 130,
width: 140,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '线索',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['crm:clue:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['crm:clue:delete']),
},
],
},
slots: { default: 'actions' },
},
];
}

View File

@ -1,19 +1,15 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteClue, exportClue, getCluePage } from '#/api/crm/clue';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
@ -33,55 +29,43 @@ function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportClue(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '线索.xls', source: data });
}
/** 创建线索 */
function onCreate() {
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑线索 */
function onEdit(row: CrmClueApi.Clue) {
function handleEdit(row: CrmClueApi.Clue) {
formModalApi.setData(row).open();
}
/** 删除线索 */
async function onDelete(row: CrmClueApi.Clue) {
async function handleDelete(row: CrmClueApi.Clue) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
key: 'action_key_msg',
});
try {
await deleteClue(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} catch {
hideLoading();
}
}
/** 查看线索详情 */
function onDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
/** 导出表格 */
async function handleExport() {
const data = await exportClue(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '线索.xls', source: data });
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<CrmClueApi.Clue>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
/** 查看线索详情 */
function handleDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
@ -89,7 +73,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -130,29 +114,54 @@ const [Grid, gridApi] = useVbenVxeGrid({
<FormModal @success="onRefresh" />
<Grid table-title="线">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:clue:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['线索']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['crm:clue:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['线索']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:clue:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:clue:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">
<Button type="link" @click="handleDetail(row)">
{{ row.name }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:clue:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:clue:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -5,16 +5,18 @@ import { defineAsyncComponent, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ArrowLeft } from '@vben/icons';
import { Button, Card, Modal, Tabs } from 'ant-design-vue';
import { getClue, transformClue } from '#/api/crm/clue';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import { useDetailSchema } from '../data';
import ClueForm from './form.vue';
import TransferForm from './transfer.vue';
const ClueDetailsInfo = defineAsyncComponent(() => import('./detail-info.vue'));
@ -22,6 +24,7 @@ const loading = ref(false);
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const clueId = ref(0);
@ -57,22 +60,23 @@ async function loadClueDetail() {
}
/** 返回列表页 */
function onBack() {
function handleBack() {
tabs.closeCurrentTab();
router.push('/crm/clue');
}
/** 编辑线索 */
function onEdit() {
function handleEdit() {
formModalApi.setData({ id: clueId }).open();
}
/** 转移线索 */
function onTransfer() {
transferModalApi.setData({ id: clueId }).open();
function handleTransfer() {
transferModalApi.setData({ bizType: BizTypeEnum.CRM_CLUE }).open();
}
/** 转化为客户 */
async function onTransform() {
async function handleTransform() {
try {
await Modal.confirm({
title: '提示',
@ -99,14 +103,14 @@ onMounted(async () => {
<Page auto-content-height :title="clue?.name" :loading="loading">
<template #extra>
<div class="flex items-center gap-2">
<Button @click="onBack">
<Button @click="handleBack">
<ArrowLeft class="size-5" />
返回
</Button>
<Button
v-if="permissionListRef?.validateWrite"
type="primary"
@click="onEdit"
@click="handleEdit"
v-access:code="['crm:clue:update']"
>
{{ $t('ui.actionTitle.edit') }}
@ -114,7 +118,7 @@ onMounted(async () => {
<Button
v-if="permissionListRef?.validateOwnerUser"
type="primary"
@click="onTransfer"
@click="handleTransfer"
v-access:code="['crm:clue:update']"
>
转移
@ -122,7 +126,7 @@ onMounted(async () => {
<Button
v-if="permissionListRef?.validateOwnerUser && !clue?.transformStatus"
type="primary"
@click="onTransform"
@click="handleTransform"
v-access:code="['crm:clue:update']"
>
转化为客户
@ -141,7 +145,13 @@ onMounted(async () => {
<ClueDetailsInfo :clue="clue" />
</Tabs.TabPane>
<Tabs.TabPane tab="团队成员" key="3">
<div>团队成员</div>
<PermissionList
ref="permissionListRef"
:biz-id="clue.id!"
:biz-type="BizTypeEnum.CRM_CLUE"
:show-action="true"
@quit-team="handleBack"
/>
</Tabs.TabPane>
<Tabs.TabPane tab="操作日志" key="4">
<div>操作日志</div>

View File

@ -0,0 +1,385 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { formatDateTime } from '@vben/utils';
import { getSimpleContactList } from '#/api/crm/contact';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getAreaTree } from '#/api/system/area';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '联系人姓名',
component: 'Input',
rules: 'required',
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleUserList(),
fieldNames: {
label: 'nickname',
value: 'id',
},
},
},
{
fieldName: 'customerId',
label: '客户名称',
component: 'ApiSelect',
componentProps: {
api: () => getCustomerSimpleList(),
fieldNames: {
label: 'nickname',
value: 'id',
},
},
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
},
{
fieldName: 'qq',
label: 'QQ',
component: 'Input',
},
{
fieldName: 'post',
label: '职位',
component: 'Input',
},
{
fieldName: 'master',
label: '关键决策人',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
},
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
},
},
{
fieldName: 'parentId',
label: '直属上级',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleContactList(),
fieldNames: {
label: 'name',
value: 'id',
},
},
},
{
fieldName: 'areaId',
label: '地址',
component: 'ApiTreeSelect',
componentProps: {
api: () => getAreaTree(),
fieldNames: { label: 'name', value: 'id', children: 'children' },
},
},
{
fieldName: 'detailAddress',
label: '详细地址',
component: 'Input',
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '客户',
component: 'ApiSelect',
componentProps: {
api: () => getCustomerSimpleList(),
fieldNames: {
label: 'name',
value: 'id',
},
},
},
{
fieldName: 'name',
label: '姓名',
component: 'Input',
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
},
{
fieldName: 'email',
label: '电子邮箱',
component: 'Input',
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '联系人姓名',
fixed: 'left',
slots: { default: 'name' },
},
{
field: 'customerName',
title: '客户名称',
fixed: 'left',
slots: { default: 'customerName' },
},
{
field: 'sex',
title: '性别',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'mobile',
title: '手机',
},
{
field: 'telephone',
title: '电话',
},
{
field: 'email',
title: '邮箱',
},
{
field: 'post',
title: '职位',
},
{
field: 'detailAddress',
title: '地址',
},
{
field: 'master',
title: '关键决策人',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'parentId',
title: '直属上级',
slots: { default: 'parentId' },
},
{
field: 'ownerUserName',
title: '负责人',
},
{
field: 'ownerUserDeptName',
title: '所属部门',
},
{
field: 'contactNextTime',
title: '下次联系时间',
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [...useDetailBaseSchema(), ...useDetailSystemSchema()];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '客户名称',
},
{
field: 'source',
label: '客户来源',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: data?.source,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'telephone',
label: '电话',
},
{
field: 'email',
label: '邮箱',
},
{
field: 'wechat',
label: '微信',
},
{
field: 'qq',
label: 'QQ',
},
{
field: 'industryId',
label: '客户行业',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
value: data?.industryId,
}),
},
{
field: 'level',
label: '客户级别',
content: (data) =>
h(DictTag, { type: DICT_TYPE.CRM_CUSTOMER_LEVEL, value: data?.level }),
},
{
field: 'areaName',
label: '地址',
},
{
field: 'detailAddress',
label: '详细地址',
},
{
field: 'contactNextTime',
label: '下次联系时间',
content: (data) => formatDateTime(data?.contactNextTime) as string,
},
{
field: 'remark',
label: '备注',
},
];
}
/** 详情页的系统字段 */
export function useDetailSystemSchema(): DescriptionItemSchema[] {
return [
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'ownerUserDeptName',
label: '所属部门',
},
{
field: 'contactLastTime',
label: '最后跟进时间',
content: (data) => formatDateTime(data?.contactLastTime) as string,
},
{
field: 'createTime',
label: '创建时间',
content: (data) => formatDateTime(data?.createTime) as string,
},
{
field: 'updateTime',
label: '更新时间',
content: (data) => formatDateTime(data?.updateTime) as string,
},
];
}

View File

@ -1,38 +1,207 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContactApi } from '#/api/crm/contact';
import { Button } from 'ant-design-vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteContact,
exportContact,
getContactPage,
} from '#/api/crm/contact';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportContact(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '联系人.xls', source: data });
}
/** 创建联系人 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑联系人 */
function handleEdit(row: CrmContactApi.Contact) {
formModalApi.setData(row).open();
}
/** 删除联系人 */
async function handleDelete(row: CrmContactApi.Contact) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteContact(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 查看联系人详情 */
function handleDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContactPage({
page: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmContactApi.Contact>,
});
function onChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
</script>
<template>
<Page>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contact/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contact/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid>
<template #top>
<Tabs class="border-none" @change="onChangeSceneType">
<Tabs.TabPane tab="我负责的" key="1" />
<Tabs.TabPane tab="我参与的" key="2" />
<Tabs.TabPane tab="下属负责的" key="3" />
</Tabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['联系人']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:contact:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:contact:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<Button type="link" @click="handleDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #parentId="{ row }">
<Button type="link" @click="handleDetail(row)">
{{ row.parentId }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:contact:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.detail'),
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:contact:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>contactInfo</div>
</template>

View File

@ -0,0 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>contactList</div>
</template>

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { CrmContactApi } from '#/api/crm/contact';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { createContact, getContact, updateContact } from '#/api/crm/contact';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmContactApi.Contact>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['联系人'])
: $t('ui.actionTitle.create', ['联系人']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
// 2
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as CrmContactApi.Contact;
try {
await (formData.value?.id ? updateContact(data) : createContact(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<CrmContactApi.Contact>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getContact(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -1,38 +1,104 @@
<script lang="ts" setup>
import type { CrmContractConfigApi } from '#/api/crm/contract/config';
import { onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Card, message } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
import { useVbenForm } from '#/adapter/form';
import {
getContractConfig,
saveContractConfig,
} from '#/api/crm/contract/config';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
//
labelClass: 'w-2/6',
},
layout: 'horizontal',
wrapperClass: 'grid-cols-1',
actionWrapperClass: 'text-center',
schema: [
{
component: 'RadioGroup',
fieldName: 'notifyEnabled',
label: '提前提醒设置',
componentProps: {
options: [
{ label: '提醒', value: true },
{ label: '不提醒', value: false },
],
},
},
{
component: 'InputNumber',
fieldName: 'notifyDays',
componentProps: {
min: 0,
precision: 0,
},
renderComponentContent: () => ({
addonBefore: () => '提前',
addonAfter: () => '天提醒',
}),
dependencies: {
triggerFields: ['notifyEnabled'],
trigger(values) {
values.notifyDays = undefined;
},
show: (value) => value.notifyEnabled,
},
},
],
//
handleSubmit: onSubmit,
});
async function onSubmit() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
//
const data = (await formApi.getValues()) as CrmContractConfigApi.Config;
if (!data.notifyEnabled) {
data.notifyDays = undefined;
}
formApi.setValues(data);
try {
await saveContractConfig(data);
//
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
formApi.setValues(data);
}
}
async function getConfigInfo() {
try {
const res = await getContractConfig();
formApi.setValues(res);
} catch (error) {
console.error(error);
}
}
onMounted(() => {
getConfigInfo();
});
</script>
<template>
<Page>
<DocAlert
title="【合同】合同管理、合同提醒"
url="https://doc.iocoder.cn/crm/contract/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/config/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/config/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Card title="合同配置设置">
<Form class="w-1/4" />
</Card>
</Page>
</template>

View File

@ -0,0 +1,276 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSimpleBusinessList } from '#/api/crm/business';
import { getSimpleContactList } from '#/api/crm/contact';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { floatToFixed2 } from '#/utils';
import { DICT_TYPE } from '#/utils/dict';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'no',
label: '合同编号',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入合同编号',
},
},
{
fieldName: 'name',
label: '合同名称',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入合同名称',
},
},
{
fieldName: 'customerId',
label: '客户',
component: 'ApiSelect',
rules: 'required',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
},
},
{
fieldName: 'businessId',
label: '商机',
component: 'ApiSelect',
rules: 'required',
componentProps: {
api: getSimpleBusinessList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择商机',
},
},
{
fieldName: 'totalPrice',
label: '合同金额',
component: 'InputNumber',
rules: 'required',
componentProps: {
placeholder: '请输入合同金额',
min: 0,
precision: 2,
},
},
{
fieldName: 'orderDate',
label: '下单时间',
component: 'DatePicker',
rules: 'required',
componentProps: {
placeholder: '请选择下单时间',
},
},
{
fieldName: 'startTime',
label: '合同开始时间',
component: 'DatePicker',
rules: 'required',
componentProps: {
placeholder: '请选择合同开始时间',
},
},
{
fieldName: 'endTime',
label: '合同结束时间',
component: 'DatePicker',
rules: 'required',
componentProps: {
placeholder: '请选择合同结束时间',
},
},
{
fieldName: 'signContactId',
label: '客户签约人',
component: 'ApiSelect',
rules: 'required',
componentProps: {
api: getSimpleContactList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户签约人',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'no',
label: '合同编号',
component: 'Input',
},
{
fieldName: 'name',
label: '合同名称',
component: 'Input',
},
{
fieldName: 'customerId',
label: '客户',
component: 'ApiSelect',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
},
},
];
}
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '合同编号',
field: 'no',
minWidth: 150,
fixed: 'left',
},
{
title: '合同名称',
field: 'name',
minWidth: 150,
fixed: 'left',
slots: { default: 'name' },
},
{
title: '客户名称',
field: 'customerName',
minWidth: 150,
slots: { default: 'customerName' },
},
{
title: '商机名称',
field: 'businessName',
minWidth: 150,
slots: { default: 'businessName' },
},
{
title: '合同金额(元)',
field: 'totalPrice',
minWidth: 150,
formatter: 'formatNumber',
},
{
title: '下单时间',
field: 'orderDate',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '合同开始时间',
field: 'startTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '合同结束时间',
field: 'endTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '客户签约人',
field: 'signContactName',
minWidth: 150,
slots: { default: 'signContactName' },
},
{
title: '公司签约人',
field: 'signUserName',
minWidth: 150,
},
{
title: '已回款金额(元)',
field: 'totalReceivablePrice',
minWidth: 150,
formatter: 'formatNumber',
},
{
title: '未回款金额(元)',
field: 'unpaidPrice',
minWidth: 150,
formatter: ({ row }) => {
return floatToFixed2(row.totalPrice - row.totalReceivablePrice);
},
},
{
title: '最后跟进时间',
field: 'contactLastTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '负责人',
field: 'ownerUserName',
minWidth: 150,
},
{
title: '所属部门',
field: 'ownerUserDeptName',
minWidth: 150,
},
{
title: '更新时间',
field: 'updateTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '创建时间',
field: 'createTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '创建人',
field: 'creatorName',
minWidth: 150,
},
{
title: '备注',
field: 'remark',
minWidth: 150,
},
{
title: '合同状态',
field: 'auditStatus',
fixed: 'right',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
title: '操作',
field: 'actions',
fixed: 'right',
width: 130,
slots: { default: 'actions' },
},
];
}

View File

@ -1,38 +1,265 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { Button } from 'ant-design-vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteContract,
exportContract,
getContractPage,
submitContract,
} from '#/api/crm/contract';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportContract(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '合同.xls', source: data });
}
/** 创建合同 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑合同 */
function handleEdit(row: CrmContractApi.Contract) {
formModalApi.setData(row).open();
}
/** 删除合同 */
async function handleDelete(row: CrmContractApi.Contract) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteContract(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 提交审核 */
async function handleSubmit(row: CrmContractApi.Contract) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.submitting', [row.name]),
key: 'action_key_msg',
});
try {
await submitContract(row.id as number);
message.success({
content: $t('ui.actionMessage.submitSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 查看合同详情 */
function handleDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 查看联系人详情 */
function handleContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.signContactId } });
}
/** 查看商机详情 */
function handleBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.businessId } });
}
/** 查看审批详情 */
function handleProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContractPage({
page: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
});
function onChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
</script>
<template>
<Page>
<DocAlert
title="【合同】合同管理、合同提醒"
url="https://doc.iocoder.cn/crm/contract/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【合同】合同管理、合同提醒"
url="https://doc.iocoder.cn/crm/contract/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid>
<template #top>
<Tabs class="border-none" @change="onChangeSceneType">
<Tabs.TabPane tab="我负责的" key="1" />
<Tabs.TabPane tab="我参与的" key="2" />
<Tabs.TabPane tab="下属负责的" key="3" />
</Tabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['合同']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:contract:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:contract:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<Button type="link" @click="handleDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="handleCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #businessName="{ row }">
<Button type="link" @click="handleBusinessDetail(row)">
{{ row.businessName }}
</Button>
</template>
<template #signContactName="{ row }">
<Button type="link" @click="handleContactDetail(row)">
{{ row.signContactName }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:contract:update'],
onClick: handleEdit.bind(null, row),
ifShow: row.auditStatus === 0,
},
]"
:drop-down-actions="[
{
label: '提交审核',
type: 'link',
auth: ['crm:contract:update'],
onClick: handleSubmit.bind(null, row),
ifShow: row.auditStatus === 0,
},
{
label: '查看审批',
type: 'link',
auth: ['crm:contract:update'],
onClick: handleProcessDetail.bind(null, row),
ifShow: row.auditStatus !== 0,
},
{
label: $t('common.detail'),
type: 'link',
auth: ['crm:contract:query'],
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
auth: ['crm:contract:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>contractInfo</div>
</template>

View File

@ -0,0 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>contractList</div>
</template>

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { CrmContractApi } from '#/api/crm/contract';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import {
createContract,
getContract,
updateContract,
} from '#/api/crm/contract';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmContractApi.Contract>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['合同'])
: $t('ui.actionTitle.create', ['合同']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
// 2
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as CrmContractApi.Contract;
try {
await (formData.value?.id ? updateContract(data) : createContract(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<CrmContractApi.Contract>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getContract(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -1,19 +1,16 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { formatDateTime } from '@vben/utils';
import { getAreaTree } from '#/api/system/area';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
@ -48,9 +45,13 @@ export function useFormSchema(): VbenFormSchema[] {
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'Select',
component: 'ApiSelect',
componentProps: {
api: 'getSimpleUserList',
api: () => getSimpleUserList(),
fieldNames: {
label: 'nickname',
value: 'id',
},
},
rules: 'required',
},
@ -153,14 +154,11 @@ export function useGridFormSchema(): VbenFormSchema[] {
}
/** 列表的字段 */
export function useGridColumns<T = CrmCustomerApi.Customer>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '客户名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'name',
@ -169,7 +167,6 @@ export function useGridColumns<T = CrmCustomerApi.Customer>(
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
@ -178,27 +175,22 @@ export function useGridColumns<T = CrmCustomerApi.Customer>(
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
@ -207,7 +199,6 @@ export function useGridColumns<T = CrmCustomerApi.Customer>(
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
@ -216,61 +207,36 @@ export function useGridColumns<T = CrmCustomerApi.Customer>(
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 130,
width: 180,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '线索',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['crm:clue:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['crm:clue:delete']),
},
],
},
slots: { default: 'actions' },
},
];
}

View File

@ -1,20 +1,16 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCustomer,
exportCustomer,
@ -40,23 +36,23 @@ function onRefresh() {
}
/** 导出表格 */
async function onExport() {
async function handleExport() {
const data = await exportCustomer(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '客户.xls', source: data });
}
/** 创建客户 */
function onCreate() {
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑客户 */
function onEdit(row: CrmCustomerApi.Customer) {
function handleEdit(row: CrmCustomerApi.Customer) {
formModalApi.setData(row).open();
}
/** 删除客户 */
async function onDelete(row: CrmCustomerApi.Customer) {
async function handleDelete(row: CrmCustomerApi.Customer) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
@ -74,33 +70,16 @@ async function onDelete(row: CrmCustomerApi.Customer) {
}
/** 查看客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmCustomerApi.Customer>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
@ -145,9 +124,8 @@ function onChangeSceneType(key: number | string) {
</template>
<FormModal @success="onRefresh" />
<Grid>
<template #toolbar-actions>
<template #top>
<Tabs class="border-none" @change="onChangeSceneType">
<Tabs.TabPane tab="我负责的" key="1" />
<Tabs.TabPane tab="我参与的" key="2" />
@ -155,29 +133,60 @@ function onChangeSceneType(key: number | string) {
</Tabs>
</template>
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:customer:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['客户']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['crm:customer:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['客户']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:customer:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:customer:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">
<Button type="link" @click="handleDetail(row)">
{{ row.name }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:customer:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.detail'),
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:customer:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,140 @@
import type { VbenFormSchema } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { handleTree } from '@vben/utils';
import { LimitConfType } from '#/api/crm/customer/limitConfig';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { DICT_TYPE } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(confType: LimitConfType): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'userIds',
label: '规则适用人群',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
fieldNames: {
label: 'nickname',
value: 'id',
},
multiple: true,
allowClear: true,
},
rules: 'required',
},
{
fieldName: 'deptIds',
label: '规则适用部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
multiple: true,
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '请选择规则适用部门',
treeDefaultExpandAll: true,
},
rules: 'required',
},
{
fieldName: 'maxCount',
label:
confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT
? '拥有客户数上限'
: '锁定客户数上限',
component: 'InputNumber',
},
{
fieldName: 'dealCountEnabled',
label: '成交客户是否占用拥有客户数',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
},
dependencies: {
triggerFields: [''],
show: () => confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
confType: LimitConfType,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
fixed: 'left',
},
{
field: 'users',
title: '规则适用人群',
formatter: ({ cellValue }) => {
return cellValue
.map((user: any) => {
return user.nickname;
})
.join(',');
},
},
{
field: 'depts',
title: '规则适用部门',
formatter: ({ cellValue }) => {
return cellValue
.map((dept: any) => {
return dept.name;
})
.join(',');
},
},
{
field: 'maxCount',
title:
confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT
? '拥有客户数上限'
: '锁定客户数上限',
},
{
field: 'dealCountEnabled',
title: '成交客户是否占用拥有客户数',
visible: confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,38 +1,170 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerLimitConfigApi } from '#/api/crm/customer/limitConfig';
import { Button } from 'ant-design-vue';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCustomerLimitConfig,
getCustomerLimitConfigPage,
LimitConfType,
} from '#/api/crm/customer/limitConfig';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const configType = ref(LimitConfType.CUSTOMER_QUANTITY_LIMIT);
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 创建规则 */
function handleCreate(type: LimitConfType) {
formModalApi.setData({ type }).open();
}
/** 编辑规则 */
function handleEdit(
row: CrmCustomerLimitConfigApi.CustomerLimitConfig,
type: LimitConfType,
) {
formModalApi.setData({ id: row.id, type }).open();
}
/** 删除规则 */
async function handleDelete(
row: CrmCustomerLimitConfigApi.CustomerLimitConfig,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
key: 'action_key_msg',
});
try {
await deleteCustomerLimitConfig(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(configType.value),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerLimitConfigPage({
page: page.currentPage,
pageSize: page.pageSize,
type: configType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmCustomerLimitConfigApi.CustomerLimitConfig>,
});
function onChangeConfigType(key: number | string) {
configType.value = key as LimitConfType;
gridApi.setGridOptions({
columns: useGridColumns(configType.value),
});
onRefresh();
}
</script>
<template>
<Page>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/limitConfig/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/limitConfig/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal />
<Grid>
<template #top>
<Tabs class="border-none" @change="onChangeConfigType">
<Tabs.TabPane
tab="拥有客户数限制"
:key="LimitConfType.CUSTOMER_QUANTITY_LIMIT"
/>
<Tabs.TabPane
tab="锁定客户数限制"
:key="LimitConfType.CUSTOMER_LOCK_LIMIT"
/>
</Tabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['规则']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:customer-limit-config:create'],
onClick: handleCreate.bind(null, configType),
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['crm:customer-limit-config:update'],
onClick: handleEdit.bind(null, row, configType),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:customer-limit-config:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { CrmCustomerLimitConfigApi } from '#/api/crm/customer/limitConfig';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createCustomerLimitConfig,
getCustomerLimitConfig,
LimitConfType,
updateCustomerLimitConfig,
} from '#/api/crm/customer/limitConfig';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmCustomerLimitConfigApi.CustomerLimitConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['规则'])
: $t('ui.actionTitle.create', ['规则']);
});
const confType = ref<LimitConfType>(LimitConfType.CUSTOMER_LOCK_LIMIT);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(confType.value),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as CrmCustomerLimitConfigApi.CustomerLimitConfig;
try {
await (formData.value?.id
? updateCustomerLimitConfig(data)
: createCustomerLimitConfig(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
let data =
modalApi.getData<CrmCustomerLimitConfigApi.CustomerLimitConfig>();
if (!data) {
return;
}
if (data.type) {
confType.value = data.type as LimitConfType;
}
formApi.setState({ schema: useFormSchema(confType.value) });
modalApi.lock();
try {
if (data.id) {
data = await getCustomerLimitConfig(data.id as number);
}
formData.value = data;
// values
await formApi.setValues(data);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -1,7 +1,5 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
<div>detail-info</div>
</template>

View File

@ -0,0 +1,4 @@
<script lang="ts" setup></script>
<template>
<div>customerList</div>
</template>

View File

@ -1,7 +1,209 @@
<script lang="ts" setup></script>
<script setup lang="ts">
import type { CrmCustomerApi } from '#/api/crm/customer';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { defineAsyncComponent, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Modal, Tabs } from 'ant-design-vue';
import { getCustomer, updateCustomerDealStatus } from '#/api/crm/customer';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { useDetailSchema } from '../data';
const CustomerDetailsInfo = defineAsyncComponent(
() => import('./detail-info.vue'),
);
const loading = ref(false);
const route = useRoute();
const router = useRouter();
const customerId = ref(0);
const customer = ref<CrmCustomerApi.Customer>({} as CrmCustomerApi.Customer);
const permissionListRef = ref(); // Ref
const [Description] = useDescription({
componentProps: {
bordered: false,
column: 4,
class: 'mx-4',
},
schema: useDetailSchema(),
});
/** 加载详情 */
async function loadCustomerDetail() {
loading.value = true;
customerId.value = Number(route.params.id);
const data = await getCustomer(customerId.value);
await getOperateLog();
customer.value = data;
loading.value = false;
}
/** 编辑 */
function handleEdit() {
// formModalApi.setData({ id: clueId }).open();
}
/** 转移线索 */
function handleTransfer() {
// transferModalApi.setData({ id: clueId }).open();
}
/** 锁定客户 */
function handleLock() {
// transferModalApi.setData({ id: clueId }).open();
}
/** 解锁客户 */
function handleUnlock() {
// transferModalApi.setData({ id: clueId }).open();
}
/** 领取客户 */
function handleReceive() {
// transferModalApi.setData({ id: clueId }).open();
}
/** 分配客户 */
function handleDistributeForm() {
// transferModalApi.setData({ id: clueId }).open();
}
/** 客户放入公海 */
function handlePutPool() {
// transferModalApi.setData({ id: clueId }).open();
}
/** 更新成交状态操作 */
async function handleUpdateDealStatus() {
const dealStatus = !customer.value.dealStatus;
try {
await Modal.confirm({
title: '提示',
content: `确定更新成交状态为【${dealStatus ? '已成交' : '未成交'}】吗?`,
});
await updateCustomerDealStatus(customerId.value, dealStatus);
Modal.success({
title: '成功',
content: '更新成交状态成功',
});
await loadCustomerDetail();
} catch {
//
}
}
/** 获取操作日志 */
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); //
async function getOperateLog() {
if (!customerId.value) {
return;
}
const data = await getOperateLogPage({
bizType: BizTypeEnum.CRM_CUSTOMER,
bizId: customerId.value,
});
logList.value = data.list;
}
//
onMounted(async () => {
await loadCustomerDetail();
});
</script>
<template>
<div>
<p>待完成</p>
</div>
<Page auto-content-height :title="customer?.name" :loading="loading">
<template #extra>
<div class="flex items-center gap-2">
<Button
v-if="permissionListRef?.validateWrite"
type="primary"
@click="handleEdit"
v-access:code="['crm:customer:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
v-if="permissionListRef?.validateOwnerUser"
type="primary"
@click="handleTransfer"
>
转移
</Button>
<Button
v-if="permissionListRef?.validateWrite"
@click="handleUpdateDealStatus"
>
更改成交状态
</Button>
<Button
v-if="customer.lockStatus && permissionListRef?.validateOwnerUser"
@click="handleUnlock"
>
解锁
</Button>
<Button
v-if="!customer.lockStatus && permissionListRef?.validateOwnerUser"
@click="handleLock"
>
锁定
</Button>
<Button v-if="!customer.ownerUserId" @click="handleReceive">
领取
</Button>
<Button v-if="!customer.ownerUserId" @click="handleDistributeForm">
分配
</Button>
<Button
v-if="customer.ownerUserId && permissionListRef?.validateOwnerUser"
@click="handlePutPool"
>
放入公海
</Button>
</div>
</template>
<Card>
<Description :data="customer" />
</Card>
<Card class="mt-4">
<Tabs>
<Tabs.TabPane tab="跟进记录" key="1">
<div>跟进记录</div>
</Tabs.TabPane>
<Tabs.TabPane tab="基本信息" key="2">
<CustomerDetailsInfo />
</Tabs.TabPane>
<Tabs.TabPane tab="联系人" key="3">
<div>联系人</div>
</Tabs.TabPane>
<Tabs.TabPane tab="团队成员" key="4">
<div>团队成员</div>
</Tabs.TabPane>
<Tabs.TabPane tab="商机" key="5">
<div>商机</div>
</Tabs.TabPane>
<Tabs.TabPane tab="合同" key="6">
<div>合同</div>
</Tabs.TabPane>
<Tabs.TabPane tab="回款" key="7">
<div>回款</div>
</Tabs.TabPane>
<Tabs.TabPane tab="操作日志" key="8">
<OperateLog :log-list="logList" />
</Tabs.TabPane>
</Tabs>
</Card>
</Page>
</template>

Some files were not shown because too many files have changed in this diff Show More