Merge branch 'master' of http://47.109.37.87:3000/by2025/admin-vben5
Some checks are pending
Gitea Actions Demo / Explore-Gitea-Actions (push) Waiting to run

# Conflicts:
#	apps/web-antd/src/views/sis/acAdmin/dp-tree.vue
This commit is contained in:
lxj
2025-08-07 09:49:37 +08:00
67 changed files with 7041 additions and 237 deletions

View File

@@ -42,6 +42,14 @@ export function applicationAdd(data: ApplicationForm) {
return requestClient.postWithMsg<void>('/property/application', data);
}
/**
* 领用审核
* @param data
*/
export function applicationVerified(data: ApplicationForm) {
return requestClient.postWithMsg<void>('/property/application/verified', data);
}
/**
* 更新资产领用
* @param data

View File

@@ -12,6 +12,22 @@ import { requestClient } from '#/api/request';
export function workOrdersTypeList(params?: WorkOrdersTypeQuery) {
return requestClient.get<PageResult<WorkOrdersTypeVO>>('/property/workOrdersType/list', { params });
}
/**
* 查询工单类型不分页
* @param params
* @returns 工单类型管理列表
*/
export function workOrdersTypeListAll(params?: WorkOrdersTypeQuery) {
return requestClient.get<WorkOrdersTypeVO[]>('/property/workOrdersType/queryList', { params });
}
/**
* 查询工单类型树结构
* @param params
*/
export function workOrdersTypeTree(params?: WorkOrdersTypeQuery) {
return requestClient.get<WorkOrdersTypeVO[]>('/property/workOrdersType/typeTree', { params });
}
/**
* 导出工单类型管理列表

View File

@@ -34,7 +34,9 @@ export interface WorkOrdersTypeVO {
/**
* 是否支持转单(0支持,1不支持)
*/
isTransfers: number;
isTransfers: string;
excludeId: string;
}
export interface WorkOrdersTypeForm extends BaseEntity {
@@ -72,6 +74,11 @@ export interface WorkOrdersTypeForm extends BaseEntity {
* 是否支持转单(0支持,1不支持)
*/
isTransfers?: number;
/**
* 上级类型id
*/
parentId?: string;
}
export interface WorkOrdersTypeQuery extends PageQuery {
@@ -109,4 +116,9 @@ export interface WorkOrdersTypeQuery extends PageQuery {
* 日期范围参数
*/
params?: any;
/**
* 是否过滤子级
*/
filterSubNodes?: boolean;
}

View File

@@ -109,6 +109,7 @@ async function setupPackageSelect() {
const assets = await assetList({
pageNum: 1,
pageSize: 1000,
params: {stock: 1} //库存不为0
});
assetsData.value = assets.rows
const options = users.rows.map((item) => ({
@@ -145,7 +146,8 @@ async function setupPackageSelect() {
if (assetInfo) {
formApi.updateSchema([{
componentProps: {
max:assetInfo.stock
max: assetInfo.stock,
min: 1
},
fieldName: 'number',
}])

View File

@@ -28,7 +28,7 @@ export const querySchema: FormSchemaGetter = () => [
options:getDictOptions(DictEnum.WY_ZCSHZT)
},
fieldName: 'state',
label: '领用状态',
label: '审核状态',
},
// {
// component: 'Input',
@@ -78,7 +78,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'applicationTime',
},
{
title: '领用状态',
title: '审核状态',
field: 'state',
slots: {
default: ({ row }) => {

View File

@@ -14,7 +14,7 @@ import {
import {
applicationExport,
applicationList,
applicationRemove, applicationUpdate,
applicationRemove, applicationVerified,
} from '#/api/property/assetManage/application';
import type {ApplicationForm} from '#/api/property/assetManage/application/model';
import {commonDownloadExcel} from '#/utils/file/download';
@@ -98,7 +98,7 @@ async function handleAudit(row: Required<ApplicationForm>, status: number) {
info.state = status
info.acceptanceTime = new Date()
info.acceptanceUserId = userStore.userInfo?.userId
await applicationUpdate(info)
await applicationVerified(info)
await tableApi.query();
}

View File

@@ -1,6 +1,7 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import {getDictOptions} from "#/utils/dict";
import {renderDict} from "#/utils/render";
export const querySchema: FormSchemaGetter = () => [
@@ -69,19 +70,24 @@ export const querySchema: FormSchemaGetter = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '仓库id',
field: 'depotId',
title: '仓库',
field: 'depotName',
minWidth:150,
},
{
title: '资产id',
field: 'assetId',
title: '资产',
field: 'assetName',
width:180,
},
{
title: '流转类型',
field: 'type',
width:120,
slots:{
default:({row})=>{
return renderDict(row.type,'wy_cklzlx')
}
}
},
{
@@ -95,8 +101,8 @@ export const columns: VxeGridProps['columns'] = [
width:150,
},
{
title: '操作人id',
field: 'userId',
title: '操作人',
field: 'userName',
width:150,
},
{

View File

@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import arrangementModal from './arrangement-modal.vue';
import { useVbenModal } from '@vben/common-ui';
import { arrangementCalender } from '#/api/property/attendanceManagement/arrangement'; // 导入接口
import { Modal } from 'ant-design-vue';
const emit = defineEmits<{
(e: 'changeView', value: boolean): void;
@@ -19,6 +20,8 @@ const calendarData = reactive<any[]>([]);
const loading = ref(false);
// 查询日历数据
const fetchCalendarData = async (month?: string) => {
console.log(123);
try {
loading.value = true;
const params = {
@@ -32,47 +35,150 @@ const fetchCalendarData = async (month?: string) => {
month ||
selectedDate.value?.format('YYYY-MM') ||
dayjs().format('YYYY-MM'); //当前月份的开始日期
// 清空之前的数据
calendarData.length = 0;
// 生成日历渲染数据结构
for (const row of res.rows) {
if (row.endDate) {
const startDate = dayjs(row.startDate);
const endDate = dayjs(row.endDate);
const currentMonthStart = dayjs(currentMonth).startOf('month');
const currentMonthEnd = dayjs(currentMonth).endOf('month');
//当开始时间小于当前月份的开始日期
if (row.startDate <= currentMonth) {
console.log(row, '小');
if (startDate.isBefore(currentMonthStart) || startDate.isSame(currentMonthStart, 'day')) {
console.log(row, '小 - 开始时间小于等于当前月份开始时间');
console.log('开始时间:', row.startDate);
console.log('结束时间:', row.endDate);
console.log('当前月份开始:', currentMonthStart.format('YYYY-MM-DD'));
console.log('当前月份结束:', currentMonthEnd.format('YYYY-MM-DD'));
//如果结束时间小于当前月份的结束时间则生成当前月份开始时间到row.endDate结束时间的n条数据
//如果结束时间大于等于当前月份的结束时间则生成当前月份开始时间到当前月份结束时间即当前月份天数的n条数据
} else {
//当开始时间大于当前月份的开始日期
//如果结束时间小于当前月份的结束时间则生成开始时间到结束时间的n条数据
console.log(row, '大');
}
} else {
// 确定结束日期取row.endDate和当前月份结束日期的较小值
const actualEndDate = endDate.isBefore(currentMonthEnd) ? endDate : currentMonthEnd;
console.log('实际结束日期:', actualEndDate.format('YYYY-MM-DD'));
// 生成从当前月份开始到实际结束日期的数据
let currentDate = currentMonthStart;
let generatedCount = 0;
while (currentDate.isSame(actualEndDate, 'day') || currentDate.isBefore(actualEndDate, 'day')) {
calendarData.push({
data: row.startDate,
date: currentDate.format('YYYY-MM-DD'),
item: {
id: row.id,
groupName: row.attendanceGroup.groupName,
groupId: row.attendanceGroup.id,
startDate: row.startDate,
endDate: row.endDate,
},
});
generatedCount++;
currentDate = currentDate.add(1, 'day');
}
console.log(`生成了 ${generatedCount} 条数据`);
} else {
//当开始时间大于当前月份的开始日期
//如果结束时间小于当前月份的结束时间则生成开始时间到结束时间的n条数据
console.log(row, '大 - 开始时间大于当前月份开始时间');
console.log('开始时间:', row.startDate);
console.log('结束时间:', row.endDate);
console.log('当前月份开始:', currentMonthStart.format('YYYY-MM-DD'));
console.log('当前月份结束:', currentMonthEnd.format('YYYY-MM-DD'));
// 确定结束日期取row.endDate和当前月份结束日期的较小值
const actualEndDate = endDate.isBefore(currentMonthEnd) ? endDate : currentMonthEnd;
console.log('实际结束日期:', actualEndDate.format('YYYY-MM-DD'));
// 生成从开始日期到实际结束日期的数据
let currentDate = startDate;
let generatedCount = 0;
while (currentDate.isSame(actualEndDate, 'day') || currentDate.isBefore(actualEndDate, 'day')) {
calendarData.push({
date: currentDate.format('YYYY-MM-DD'),
item: {
id: row.id,
groupName: row.attendanceGroup.groupName,
groupId: row.attendanceGroup.id,
startDate: row.startDate,
endDate: row.endDate,
},
});
generatedCount++;
currentDate = currentDate.add(1, 'day');
}
console.log(`生成了 ${generatedCount} 条数据`);
}
} else {
// 没有结束日期的情况,只在开始日期添加一条数据
calendarData.push({
date: row.startDate,
item: {
id: row.id,
groupName: row.attendanceGroup.groupName,
groupId: row.attendanceGroup.id,
startDate: row.startDate,
endDate: row.endDate,
},
});
}
}
// if (response) {
// calendarData.value = response.data || [];
// console.log('日历数据:', calendarData.value);
// }
console.log('日历数据:', calendarData);
//变量calendarData,日期相同的数据放到一个对象中数据结构为scheduleData
// 将calendarData按日期分组形成scheduleData结构
const groupedData = new Map<string, any[]>();
// 遍历calendarData按日期分组
calendarData.forEach(item => {
const date = item.date;
if (!groupedData.has(date)) {
groupedData.set(date, []);
}
groupedData.get(date)!.push(item.item);
});
// 转换为scheduleData格式
scheduleData.length = 0; // 清空原有数据
groupedData.forEach((items, date) => {
scheduleData.push({
date: date,
list: items.map(item => ({
type: 'success' as BadgeProps['status'],
content: item.groupName || '排班安排',
item: item
}))
});
});
console.log('处理后的scheduleData:', scheduleData);
// 测试:验证生成的日历数据
console.log('=== 日历数据验证 ===');
console.log('当前月份:', currentMonth);
console.log('原始数据条数:', res.rows.length);
console.log('生成的日历数据条数:', calendarData.length);
console.log('分组后的数据条数:', scheduleData.length);
// 验证是否有重复日期
const dateCounts = new Map<string, number>();
calendarData.forEach(item => {
const count = dateCounts.get(item.date) || 0;
dateCounts.set(item.date, count + 1);
});
console.log('日期分布:', Object.fromEntries(dateCounts));
console.log('=== 验证完成 ===');
} catch (error) {
console.error('获取日历数据失败:', error);
message.error('获取日历数据失败');
} finally {
loading.value = false;
}
};
// 切换视图模式
function handleViewModeChange(e: RadioChangeEvent): void {
// 将父组件的isCalenderView变为true
emit('changeView', e.target.value);
}
// 日历模拟数据
const scheduleData: {
date: string;
list: { type: BadgeProps['status']; content: string }[];
@@ -81,18 +187,25 @@ const scheduleData: {
{ date: '2025-07-06', list: [{ type: 'success', content: '8月8日事件' }] },
// ...
];
// 切换视图模式
function handleViewModeChange(e: RadioChangeEvent): void {
// 将父组件的isCalenderView变为true
emit('changeView', e.target.value);
}
const getListData2 = (
current: Dayjs,
): { type: BadgeProps['status']; content: string }[] => {
): { type: BadgeProps['status']; content: string; item?: any }[] => {
const dateStr = current.format('YYYY-MM-DD');
// 优先使用接口数据
if (calendarData.length > 0) {
const found = calendarData.find((item) => item.date === dateStr);
// 使用scheduleData结构
if (scheduleData.length > 0) {
const found = scheduleData.find((item) => item.date === dateStr);
if (found) {
return found.list || [];
return found.list;
}
}
// 如果没有找到数据,返回空数组
return [];
};
@@ -130,13 +243,8 @@ const getListData = (
return listData || [];
};
const getMonthData = (value: Dayjs) => {
if (value.month() === 8) {
return 1394;
}
};
function customHeader() {
// 返回想要的VNode或null
// 返回想要的VNode或null
return null; // 什么都不显示
}
const [ArrangementModal, modalApi] = useVbenModal({
@@ -147,6 +255,57 @@ function handleAdd() {
modalApi.open();
}
// 编辑排班
function handleEdit(item: any) {
console.log(815328);
// modalApi.setData({
// id: item.id,
// groupId: item.groupId,
// startDate: item.startDate,
// endDate: item.endDate,
// });
// modalApi.open();
}
// 删除排班
function handleDelete(item: any) {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个排班安排吗?',
onOk: async () => {
try {
// 这里需要调用删除接口
// await deleteArrangement(item.id);
message.success('删除成功');
fetchCalendarData();
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败');
}
},
});
}
// 查看详情
function handleViewDetails(item: any) {
// 这里可以打开详情弹窗或跳转到详情页面
console.log('查看详情:', item);
}
// 获取指定日期的所有排班项目
function getScheduleItemsByDate(date: string) {
const found = scheduleData.find((item) => item.date === date);
return found ? found.list : [];
}
// 查看某日期的所有排班详情
function handleViewDateDetails(date: string) {
const items = getScheduleItemsByDate(date);
console.log(`${date} 的所有排班安排:`, items);
// 这里可以打开详情弹窗显示该日期的所有排班
}
// 页面初始化时加载数据
onMounted(() => {
fetchCalendarData();
@@ -169,6 +328,7 @@ onMounted(() => {
picker="month"
v-model:value="selectedDate"
@change="fetchCalendarData()"
@select="fetchCalendarData()"
/>
</div>
</div>
@@ -187,29 +347,32 @@ onMounted(() => {
<ul class="events">
<li v-for="item in getListData2(current)" :key="item.content">
<span>
<!-- <Badge :status="item.type" :text="item.content" /> -->
<span>{{ item.content }}</span>
<a style="margin-left: 4px; color: #1890ff; cursor: pointer"
>编辑</a
<div class="action-buttons">
<a
style="margin-left: 4px; color: #1890ff; cursor: pointer"
@click.stop="handleEdit(item.item)"
>
<a style="margin-left: 4px; color: #ff4d4f; cursor: pointer"
>删除</a
编辑
</a>
<a
style="margin-left: 4px; color: #ff4d4f; cursor: pointer"
@click.stop="handleDelete(item.item)"
>
删除
</a>
</div>
</span>
</li>
<a
v-if="getListData2(current).length > 0"
style="margin-left: 4px; color: #1890ff; cursor: pointer"
>详情</a
@click.stop="handleViewDateDetails(current.format('YYYY-MM-DD'))"
>
详情
</a>
</ul>
</template>
<template #monthCellRender="{ current }">
<div v-if="getMonthData(current)" class="notes-month">
<section>{{ getMonthData(current) }}</section>
<span>Backlog number</span>
</div>
</template>
</Calendar>
</div>
<ArrangementModal @reload="fetchCalendarData()" />
@@ -228,6 +391,32 @@ onMounted(() => {
text-overflow: ellipsis;
font-size: 12px;
}
.events li {
margin-bottom: 4px;
padding: 4px;
/* background-color: #f6ffed; */
/* border: 1px solid #b7eb8f; */
border-radius: 4px;
font-size: 12px;
}
.events li span {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.events li .action-buttons {
display: flex;
gap: 4px;
margin-top: 2px;
}
.events li a {
font-size: 11px;
text-decoration: none;
}
.events li a:hover {
text-decoration: underline;
}
.notes-month {
text-align: center;
font-size: 28px;

View File

@@ -152,8 +152,7 @@ export const modalSchema: FormSchemaGetter = () => [
{
label: '工单类型',
fieldName: 'type',
component: 'ApiSelect',
componentProps: {},
component: 'TreeSelect',
rules: 'selectRequired',
},
{

View File

@@ -17,7 +17,10 @@ import workOrdersDetail from './work-orders-detail.vue';
import ordersModal from './orders-modal.vue';
import {columns, querySchema} from './data';
import {onMounted, ref} from "vue";
import {workOrdersTypeList} from "#/api/property/businessManagement/workOrdersType";
import {
workOrdersTypeList,
workOrdersTypeListAll
} from "#/api/property/businessManagement/workOrdersType";
const ordersTypeList = ref<any[]>([]);
const ordersType = ref<string>('0');
@@ -130,11 +133,10 @@ function handleMultiDelete() {
async function queryOrderType() {
let params = {
pageSize: 1000,
pageNum: 1
filterSubNodes:true
}
const res = await workOrdersTypeList(params)
ordersTypeList.value = res.rows.map((item) => ({
const res = await workOrdersTypeListAll(params)
ordersTypeList.value = res.map((item) => ({
label: item.orderTypeName,
value: item.id,
}));

View File

@@ -14,9 +14,7 @@ import {
import {defaultFormValueGetter, useBeforeCloseDiff} from '#/utils/popup';
import {modalSchema} from './data';
import {personList} from "#/api/property/resident/person";
import {renderDictValue} from "#/utils/render";
import {workOrdersTypeList} from "#/api/property/businessManagement/workOrdersType";
import {workOrdersTypeTree} from "#/api/property/businessManagement/workOrdersType";
const emit = defineEmits<{ reload: [] }>();
@@ -61,7 +59,7 @@ const [BasicModal, modalApi] = useVbenModal({
return null;
}
modalApi.modalLoading(true);
await queryPersonData()
await queryWorkOrdersType()
const {id} = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
@@ -106,34 +104,35 @@ async function handleClosed() {
resetInitialized();
}
async function queryPersonData() {
let params = {
pageSize: 1000,
pageNum: 1,
}
const res = await personList(params);
const options = res.rows.map((user) => ({
label: user.userName + '-' + renderDictValue(user.gender, 'sys_user_sex') + '-' + user.phone,
value: user.id,
}));
formApi.updateSchema([{
componentProps: () => ({
options: options,
showSearch:true,
filterOption: filterOption
}),
fieldName: 'initiatorName',
},
async function queryWorkOrdersType() {
const options = await workOrdersTypeTree()
formApi.updateSchema([
{
componentProps: () => ({
options: options,
class: 'w-full',
fieldNames: {
key: 'id',
label: 'orderTypeName',
value: 'id',
children: 'children',
},
placeholder: '请选择工单类型',
showSearch: true,
filterOption: filterOption
treeData: options,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
treeNodeFilterProp: 'orderTypeName',
treeNodeLabelProp: 'orderTypeName',
}),
fieldName: 'handler',
}])
fieldName: 'type',
},
]);
}
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
</script>
<template>

View File

@@ -4,11 +4,11 @@ import {renderDict} from "#/utils/render";
import {getDictOptions} from "#/utils/dict";
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'orderTypeNo',
label: '工单类型编号',
},
// {
// component: 'Input',
// fieldName: 'orderTypeNo',
// label: '工单类型编号',
// },
{
component: 'Input',
fieldName: 'orderTypeName',
@@ -25,7 +25,7 @@ export const querySchema: FormSchemaGetter = () => [
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
// { type: 'checkbox', width: 60 },
// {
// title: '工单类型编号',
// field: 'orderTypeNo',
@@ -33,6 +33,8 @@ export const columns: VxeGridProps['columns'] = [
{
title: '类型名称',
field: 'orderTypeName',
treeNode: true,
minWidth:180,
},
{
title: '运作模式',
@@ -42,15 +44,17 @@ export const columns: VxeGridProps['columns'] = [
return renderDict(row.operationMode, 'pro_operation_pattern');
},
},
minWidth:'120'
width:180,
},
{
title: '排序值',
field: 'sort',
width:180,
},
{
title: '累计工单数量',
field: 'number',
width:180,
},
{
title: '是否支持转单',
@@ -60,7 +64,7 @@ export const columns: VxeGridProps['columns'] = [
return renderDict(row.isTransfers, 'support_transferring_orders');
},
},
minWidth:'120'
width:180,
},
{
field: 'action',
@@ -87,6 +91,11 @@ export const modalSchema: FormSchemaGetter = () => [
component: 'Input',
rules: 'required',
},
{
label: '父级类型',
fieldName: 'parentId',
component: 'Select',
},
{
label: '运作模式',
fieldName: 'operationMode',

View File

@@ -8,13 +8,14 @@ import {
type VxeGridProps
} from '#/adapter/vxe-table';
import {
workOrdersTypeList,
workOrdersTypeListAll,
workOrdersTypeRemove,
} from '#/api/property/businessManagement/workOrdersType';
import type { WorkOrdersTypeForm } from '#/api/property/businessManagement/workOrdersType/model';
import workOrdersTypeModal from './workOrdersType-modal.vue';
import workOrdersTypeDetail from './workOrdersType-detail.vue';
import { columns, querySchema } from './data';
import {ref} from "vue";
const formOptions: VbenFormProps = {
commonConfig: {
@@ -39,21 +40,27 @@ const gridOptions: VxeGridProps = {
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
pagerConfig: {
enabled:false,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await workOrdersTypeList({
pageNum: page.currentPage,
pageSize: page.pageSize,
query: async (_, formValues = {}) => {
const resp = await workOrdersTypeListAll({
...formValues,
});
return { rows: resp };
},
},
},
rowConfig: {
keyField: 'id',
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
},
// 表格全局唯一表示 保存列配置需要用到
id: 'property-workOrdersType-index'
};
@@ -87,6 +94,7 @@ async function handleEdit(row: Required<WorkOrdersTypeForm>) {
}
async function handleDelete(row: Required<WorkOrdersTypeForm>) {
console.log(row,'======row')
await workOrdersTypeRemove(row.id);
await tableApi.query();
}
@@ -111,14 +119,14 @@ function handleMultiDelete() {
<BasicTable table-title="工单类型列表">
<template #toolbar-tools>
<Space>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['property:workOrdersType:remove']"
@click="handleMultiDelete">
{{ $t('pages.common.delete') }}
</a-button>
<!-- <a-button-->
<!-- :disabled="!vxeCheckboxChecked(tableApi)"-->
<!-- danger-->
<!-- type="primary"-->
<!-- v-access:code="['property:workOrdersType:remove']"-->
<!-- @click="handleMultiDelete">-->
<!-- {{ $t('pages.common.delete') }}-->
<!-- </a-button>-->
<a-button
type="primary"
v-access:code="['property:workOrdersType:add']"
@@ -151,6 +159,7 @@ function handleMultiDelete() {
danger
v-access:code="['property:workOrdersType:remove']"
@click.stop=""
:disabled="row.children?.length>0"
>
{{ $t('pages.common.delete') }}
</ghost-button>

View File

@@ -30,9 +30,9 @@ async function handleOpenChange(open: boolean) {
<template>
<BasicModal :footer="false" :fullscreen-button="false" title="工单类型信息" class="w-[70%]">
<Descriptions v-if="workOrdersTypeInfoDetail" size="small" :column="2" bordered :labelStyle="{width:'120px'}">
<DescriptionsItem label="工单类型编号">
{{ workOrdersTypeInfoDetail.orderTypeNo }}
</DescriptionsItem>
<!-- <DescriptionsItem label="工单类型编号">-->
<!-- {{ workOrdersTypeInfoDetail.orderTypeNo }}-->
<!-- </DescriptionsItem>-->
<DescriptionsItem label="类型名称">
{{ workOrdersTypeInfoDetail.orderTypeName }}
</DescriptionsItem>

View File

@@ -4,7 +4,11 @@ import { useVbenModal } from '@vben/common-ui';
import {$t} from '@vben/locales';
import {cloneDeep} from '@vben/utils';
import {useVbenForm} from '#/adapter/form';
import { workOrdersTypeAdd, workOrdersTypeInfo, workOrdersTypeUpdate } from '#/api/property/businessManagement/workOrdersType';
import {
workOrdersTypeAdd,
workOrdersTypeInfo, workOrdersTypeListAll,
workOrdersTypeUpdate
} from '#/api/property/businessManagement/workOrdersType';
import {defaultFormValueGetter, useBeforeCloseDiff} from '#/utils/popup';
import {modalSchema} from './data';
@@ -40,7 +44,7 @@ const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
const [BasicModal, modalApi] = useVbenModal({
// 在这里更改宽度
class: 'w-[60%]',
class: 'w-[70%]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
@@ -50,10 +54,9 @@ const [BasicModal, modalApi] = useVbenModal({
return null;
}
modalApi.modalLoading(true);
const {id} = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
await initTypeOptions(id)
if (isUpdate.value && id) {
const record = await workOrdersTypeInfo(id);
record.operationMode = record.operationMode?.toString();
@@ -90,6 +93,25 @@ async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
async function initTypeOptions(id:string) {
let params = {
excludeId:id,
filterSubNodes: true //过滤子级
}
const typeList = await workOrdersTypeListAll(params)
formApi.updateSchema([{
componentProps: () => ({
options: typeList,
showSearch: true,
optionFilterProp: 'orderTypeName',
fieldNames: {label: 'orderTypeName', value: 'id'},
allowClear: true,
}),
fieldName: 'parentId',
},
]);
}
</script>
<template>

View File

@@ -32,49 +32,26 @@ export const querySchema: FormSchemaGetter = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
// {
// title: '任务编号',
// field: 'id',
// width:'auto'
// },
// {
// title: '巡检计划',
// field: 'planName',
// minWidth:200
// },
// {
// title: '巡检时间范围',
// field: 'planInsTime',
// width:'auto'
//
// },
// // {
// // title: '实际巡检时间',
// // field: 'endDate',
// // width:180
// //
// // },
// {
// title: '签到状态',
// field: 'actInsTime',
// width:150,
// },
// {
// title: '巡检人',
// field: 'planUserName',
// width:'auto'
//
// },
// {
// title: '巡检方式',
// field: 'taskType',
// width:'auto',
// slots: {
// default: ({ row }) => {
// return renderDict(row.taskType, 'wy_xjqdfs');
// },
// },
// },
{
title: '巡检计划',
field: 'planId',
minWidth:200
},
{
title: '巡检任务',
field: 'taskId',
width:150
},
{
title: '巡检路线',
field: 'routeId',
width:150
},
{
title: '巡检点',
field: 'pointId',
width:150
},
{
title: '签到类型',
field: 'signType',
@@ -118,7 +95,7 @@ export const columns: VxeGridProps['columns'] = [
{
title: '备注',
field: 'remark',
minWidth:120
width:180
},
// {
// field: 'action',

View File

@@ -1,7 +1,6 @@
import {type FormSchemaGetter, z} from '#/adapter/form';
import {type FormSchemaGetter} from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
@@ -10,8 +9,6 @@ export const querySchema: FormSchemaGetter = () => [
},
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
@@ -61,19 +58,85 @@ export const modalSchema: FormSchemaGetter = () => [
component: 'Input',
rules: 'required',
},
{
label: '巡检点',
fieldName: 'pointId',
component: 'ApiSelect',
componentProps:{
mode: 'multiple',
},
rules: z.array(z.string()).min(1, { message: '请选择' }),
formItemClass: 'col-span-2',
},
{
label: '备注',
fieldName: 'remark',
component: 'Textarea',
},
];
//!!!!!!
export const querySchemaPoint: FormSchemaGetter = () => [
{
component: 'ApiSelect',
fieldName: 'pointId',
label: '巡检点名称',
componentProps: {},
},
];
export const columnsPoint: VxeGridProps['columns'] = [
{
title: '巡检点ID',
field: 'pointId',
},
{
title: '巡检点名称',
field: 'pointName',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];
export const modalSchemaPoint: FormSchemaGetter = () => [
{
label: '主键id',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '巡检点名称',
fieldName: 'pointId',
rules: 'required',
component: 'ApiSelect',
componentProps: {},
},
{
label: '开始时间',
fieldName: 'startTime',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
placeholder: '请选择开始时间',
},
rules: 'required',
},
{
label: '结束时间',
fieldName: 'endTime',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
placeholder: '请选择开始时间',
},
rules: 'required',
},
{
label: '排序',
fieldName: 'sort',
component: 'Input',
rules: 'required',
},
];

View File

@@ -1,14 +1,8 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { ref } from 'vue';
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import { ref } from 'vue';
import {
useVbenVxeGrid,
vxeCheckboxChecked,
@@ -33,15 +27,6 @@ const formOptions: VbenFormProps = {
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 处理区间选择器RangePicker时间格式 将一个字段映射为两个字段 搜索/导出会用到
// 不需要直接删除
// fieldMappingTime: [
// [
// 'createTime',
// ['params[beginTime]', 'params[endTime]'],
// ['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
// ],
// ],
};
const gridOptions: VxeGridProps = {
@@ -114,6 +99,12 @@ function handleMultiDelete() {
},
});
}
const parentData = ref({})
const handleUpdate = (dataSet) => {
parentData.value = dataSet; // 更新父组件的数据源
console.log(parentData.value,111123);
};
</script>
<template>
@@ -163,6 +154,6 @@ function handleMultiDelete() {
</Space>
</template>
</BasicTable>
<InspectionRouteModal @reload="tableApi.query()" />
<InspectionRouteModal @reload="tableApi.query()" @update-data="handleUpdate"/>
</Page>
</template>

View File

@@ -4,7 +4,12 @@ import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { inspectionRouteAdd, inspectionRouteInfo, inspectionRouteUpdate } from '#/api/property/inspectionManagement/inspectionRoute';
import {
inspectionRouteAdd,
inspectionRouteInfo,
inspectionRouteList,
inspectionRouteUpdate
} from '#/api/property/inspectionManagement/inspectionRoute';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import {
inspectionPointList,
@@ -20,11 +25,8 @@ const title = computed(() => {
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-1',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
}
@@ -41,8 +43,8 @@ const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
},
);
const pointList = ref<any[]>([]);
const [BasicModal, modalApi] = useVbenModal({
// 在这里更改宽度
class: 'w-[60%]',
fullscreenButton: false,
onBeforeClose,
@@ -59,11 +61,15 @@ const [BasicModal, modalApi] = useVbenModal({
if (isUpdate.value && id) {
const record = await inspectionRouteInfo(id);
record.pointId = record.pointId?.split(',')
pointList.value = (record.inspectionRoutePointVoList || []).map(item => ({
pointId: item.pointId ?? '',
pointName: item.pointName ?? '',
}));
await tableApi.reload();
console.log(pointList.value,111)
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
});
@@ -75,9 +81,11 @@ async function handleConfirm() {
if (!valid) {
return;
}
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
const data = cloneDeep(await formApi.getValues());
data.pointId = data.pointId?.join(',')
let data = {
...cloneDeep(await formApi.getValues()),
inspectionRoutePointBoList:[pointData.value]
}
console.log(data,333)
await (isUpdate.value ? inspectionRouteUpdate(data) : inspectionRouteAdd(data));
resetInitialized();
emit('reload');
@@ -104,12 +112,11 @@ async function queryWorkOrdersType() {
label: item.pointName,
value: item.id,
}));
formApi.updateSchema([{
tableApi.formApi.updateSchema([{
componentProps: () => ({
options: options,
showSearch: true,
filterOption: filterOption,
mode: 'multiple',
}),
fieldName: 'pointId',
}])
@@ -118,11 +125,100 @@ async function queryWorkOrdersType() {
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
//!!!!!!
import { Page, type VbenFormProps } from '@vben/common-ui';
import { Space } from 'ant-design-vue';
import {
useVbenVxeGrid,
type VxeGridProps
} from '#/adapter/vxe-table';
import dayjs from 'dayjs';
import type { InspectionRouteForm } from '#/api/property/inspectionManagement/inspectionRoute/model';
import pointModal from './point-modal.vue';
import { columnsPoint, querySchemaPoint } from './data';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 90,
componentProps: {
allowClear: true,
},
},
schema: querySchemaPoint(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
highlight: true,
reserve: true,
},
columns: columnsPoint,
height: 'auto',
keepSource: true,
pagerConfig: {},
rowConfig: {
keyField: 'id',
},
id: 'property-inspectionRoutePoint-index'
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [PointModal, pointModalApi] = useVbenModal({
connectedComponent: pointModal,
});
function handleAdd() {
pointModalApi.setData({});
pointModalApi.open();
}
async function handleEdit(row: Required<InspectionRouteForm>) {
pointModalApi.setData({ id: row.id });
pointModalApi.open();
}
const pointData = ref({})
const handlePoint = (data) => {
data.startTime = dayjs(data.startTime).format('YYYY-MM-DD HH:mm:ss')
data.endTime = dayjs(data.endTime).format('YYYY-MM-DD HH:mm:ss')
pointData.value = data;
};
</script>
<template>
<BasicModal :title="title">
<BasicForm />
<Page :auto-content-height="true" style="background-color: #F1F3F6">
<BasicTable table-title="巡检点" :grid-options="gridOptions">
<template #toolbar-tools>
<Space>
<a-button
type="primary"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
@click.stop="handleEdit(row)"
>
{{ '巡检点' }}
</ghost-button>
</Space>
</template>
</BasicTable>
<PointModal @reload="tableApi.query()" @update-data="handlePoint"/>
</Page>
</BasicModal>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { inspectionRouteInfo } from '#/api/property/inspectionManagement/inspectionRoute';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import {
inspectionPointList,
} from '#/api/property/inspectionManagement/inspectionPoint';
import { modalSchemaPoint } from './data';
import {ref} from "vue";
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
labelWidth: 100,
componentProps: {
class: 'w-full',
}
},
schema: modalSchemaPoint(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicModal, modalApi] = useVbenModal({
class: 'w-[550px]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
await queryWorkOrdersType()
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await inspectionRouteInfo(id);
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
emit('update-data', data);
resetInitialized();
emit('reload');
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
async function queryWorkOrdersType() {
let params = {
pageSize: 1000,
pageNum: 1
}
const res = await inspectionPointList(params)
const options = res.rows.map((item) => ({
label: item.pointName,
value: item.id,
}));
formApi.updateSchema([{
componentProps: () => ({
options: options,
showSearch: true,
filterOption: filterOption,
}),
fieldName: 'pointId',
}])
}
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
</script>
<template>
<BasicModal title="选择巡检点">
<BasicForm />
</BasicModal>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import {
addFullName,
cloneDeep,
getPopupContainer,
listToTree,
} from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import {
categoryAdd,
categoryInfo,
categoryList,
categoryUpdate,
} from '#/api/workflow/category';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 80,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function setupCategorySelect() {
const listData = await categoryList();
const treeData = listToTree(listData, {
id: 'categoryId',
pid: 'parentId',
});
addFullName(treeData, 'categoryName', ' / ');
formApi.updateSchema([
{
fieldName: 'parentId',
componentProps: {
treeData,
treeLine: { showLeafIcon: false },
fieldNames: { label: 'categoryName', value: 'categoryId' },
treeDefaultExpandAll: true,
treeNodeLabelProp: 'fullName',
getPopupContainer,
},
},
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id, parentId } = modalApi.getData() as {
id?: number | string;
parentId?: number | string;
};
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await categoryInfo(id);
await formApi.setValues(record);
}
if (parentId) {
await formApi.setValues({ parentId });
}
await setupCategorySelect();
await markInitialized();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? categoryUpdate(data) : categoryAdd(data));
resetInitialized();
emit('reload');
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicModal :title="title" class="min-h-[500px]">
<BasicForm />
</BasicModal>
</template>

View File

@@ -0,0 +1,69 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
export const querySchema: FormSchemaGetter = () => [
{
fieldName: 'categoryName',
label: '分类名称',
component: 'Input',
},
{
fieldName: 'categoryCode',
label: '分类编码',
component: 'Input',
},
];
export const columns: VxeGridProps['columns'] = [
{
field: 'categoryName',
title: '分类名称',
treeNode: true,
},
{
field: 'orderNum',
title: '排序',
},
{
field: 'createTime',
title: '创建时间',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 'auto',
},
];
export const modalSchema: FormSchemaGetter = () => [
{
label: 'categoryId',
fieldName: 'categoryId',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
fieldName: 'parentId',
label: '父级分类',
rules: 'required',
defaultValue: 100,
component: 'TreeSelect',
},
{
fieldName: 'categoryName',
label: '分类名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'orderNum',
label: '排序',
component: 'InputNumber',
},
];

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { nextTick } from 'vue';
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, type VxeGridProps } from '#/adapter/vxe-table';
import { categoryList, categoryRemove } from '#/api/workflow/category';
import categoryModal from './category-modal.vue';
import { columns, querySchema } from './data';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
columns,
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues = {}) => {
const resp = await categoryList({
...formValues,
});
return { rows: resp };
},
// 默认请求接口后展开全部 不需要可以删除这段
querySuccess: () => {
nextTick(() => {
expandAll();
});
},
},
},
/**
* 虚拟滚动 默认关闭
*/
scrollY: {
enabled: false,
gt: 0,
},
rowConfig: {
keyField: 'categoryId',
},
treeConfig: {
parentField: 'parentId',
rowField: 'categoryId',
transform: true,
},
// 表格全局唯一表示 保存列配置需要用到
id: 'workflow-category-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({ formOptions, gridOptions });
const [CategoryModal, modalApi] = useVbenModal({
connectedComponent: categoryModal,
});
function handleAdd(row?: Recordable<any>) {
modalApi.setData({ parentId: row?.categoryId });
modalApi.open();
}
async function handleEdit(row: Recordable<any>) {
modalApi.setData({ id: row.categoryId });
modalApi.open();
}
async function handleDelete(row: Recordable<any>) {
await categoryRemove(row.categoryId);
await tableApi.query();
}
function expandAll() {
tableApi.grid?.setAllTreeExpand(true);
}
function collapseAll() {
tableApi.grid?.setAllTreeExpand(false);
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="流程分类列表">
<template #toolbar-tools>
<Space>
<a-button @click="collapseAll">
{{ $t('pages.common.collapse') }}
</a-button>
<a-button @click="expandAll">
{{ $t('pages.common.expand') }}
</a-button>
<a-button
type="primary"
v-access:code="['workflow:category:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-access:code="['workflow:category:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button>
<ghost-button
class="btn-success"
v-access:code="['workflow:category:edit']"
@click.stop="handleAdd(row)"
>
{{ $t('pages.common.add') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button
danger
v-access:code="['workflow:category:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<CategoryModal @reload="tableApi.query()" />
</Page>
</template>

View File

@@ -0,0 +1,149 @@
<!-- 流程发起(启动)的弹窗 -->
<script setup lang="ts">
import type { CompleteTaskReqData } from '#/api/workflow/task/model';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { completeTask, getTaskByTaskId } from '#/api/workflow/task';
import { CopyComponent } from '.';
interface Emits {
/**
* 完成
*/
complete: [];
}
const emit = defineEmits<Emits>();
interface ModalProps {
taskId: string;
taskVariables: Record<string, any>;
variables?: any; // 这个干啥的
}
const [BasicModal, modalApi] = useVbenModal({
title: '流程发起',
fullscreenButton: false,
onConfirm: handleSubmit,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
const { taskId } = modalApi.getData() as ModalProps;
// 查询是否有按钮权限
const resp = await getTaskByTaskId(taskId);
const buttonPermissions: Record<string, boolean> = {};
resp.buttonList.forEach((item) => {
buttonPermissions[item.code] = item.show;
});
// 是否具有抄送权限
const copyPermission = buttonPermissions?.copy ?? false;
formApi.updateSchema([
{
fieldName: 'flowCopyList',
dependencies: {
if: copyPermission,
triggerFields: [''],
},
},
]);
},
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: [
{
fieldName: 'messageType',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '站内信', value: '1', disabled: true },
{ label: '邮件', value: '2' },
{ label: '短信', value: '3' },
],
},
label: '通知方式',
defaultValue: ['1'],
},
{
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
maxCount: 10,
maxSize: 20,
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
},
defaultValue: [],
label: '附件上传',
formItemClass: 'items-start',
},
{
fieldName: 'flowCopyList',
component: 'Input',
defaultValue: [],
label: '抄送人',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { messageType, flowCopyList, attachment } = cloneDeep(
await formApi.getValues(),
);
const { taskId, taskVariables, variables } =
modalApi.getData() as ModalProps;
// 需要转换数据 抄送人员
const flowCCList = (flowCopyList as Array<any>).map((item) => ({
userId: item.userId,
userName: item.nickName,
}));
const requestData = {
fileId: attachment.join(','),
messageType,
flowCopyList: flowCCList,
taskId,
taskVariables,
variables,
} as CompleteTaskReqData;
await completeTask(requestData);
modalApi.close();
emit('complete');
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm>
<template #flowCopyList="slotProps">
<CopyComponent v-model:user-list="slotProps.modelValue" />
</template>
</BasicForm>
</BasicModal>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed } from 'vue';
import { VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue';
import { renderDict } from '#/utils/render';
import { getDiffTimeString } from './helper';
interface Props extends TaskInfo {
active: boolean;
}
const props = withDefaults(defineProps<{ info: Props; rowKey?: string }>(), {
rowKey: 'id',
});
const emit = defineEmits<{ click: [string] }>();
/**
* TODO: 这里要优化 事件没有用到
*/
function handleClick() {
const idKey = props.rowKey as keyof TaskInfo;
emit('click', props.info[idKey]);
}
const diffUpdateTimeString = computed(() => {
return getDiffTimeString(props.info.updateTime);
});
</script>
<template>
<div
:class="{
'border-primary': info.active,
}"
class="cursor-pointer rounded-lg border-[1px] border-solid p-3 transition-shadow duration-300 ease-in-out hover:shadow-lg"
@click.stop="handleClick"
>
<Descriptions :column="1" :title="info.flowName" size="middle">
<template #extra>
<component
:is="renderDict(info.flowStatus, DictEnum.WF_BUSINESS_STATUS)"
/>
</template>
<DescriptionsItem label="当前任务">
<div class="font-bold">{{ info.nodeName }}</div>
</DescriptionsItem>
<DescriptionsItem label="提交时间">
{{ info.createTime }}
</DescriptionsItem>
<!-- <DescriptionsItem label="更新时间">
{{ info.updateTime }}
</DescriptionsItem> -->
</Descriptions>
<div class="flex w-full items-center justify-between text-[14px]">
<div class="flex items-center gap-1 overflow-hidden whitespace-nowrap">
<VbenAvatar
:alt="info.createByName"
class="bg-primary size-[24px] rounded-full text-[10px] text-white"
src=""
/>
<span class="overflow-hidden text-ellipsis opacity-50">
{{ info.createByName }}
</span>
</div>
<div class="text-nowrap opacity-50">
<Tooltip placement="top" :title="`更新时间: ${info.updateTime}`">
<div class="flex items-center gap-1">
<span class="icon-[mdi--clock-outline] size-[16px]"></span>
<span>{{ diffUpdateTimeString }}前更新</span>
</div>
</Tooltip>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-descriptions .ant-descriptions-header) {
margin-bottom: 12px !important;
}
:deep(.ant-descriptions-item) {
padding-bottom: 8px !important;
}
</style>

View File

@@ -0,0 +1,26 @@
<!-- 审批终止 Modal弹窗的content属性专用 用于填写审批意见 -->
<script setup lang="ts">
import { Textarea } from 'ant-design-vue';
defineOptions({
name: 'ApprovalContent',
inheritAttrs: false,
});
defineProps<{ description: string; value: string }>();
defineEmits<{ 'update:value': [string] }>();
</script>
<template>
<div class="flex flex-col gap-2">
<div>{{ description }}</div>
<Textarea
:allow-clear="true"
:auto-size="true"
:value="value"
placeholder="审批意见(可选)"
@change="(e) => $emit('update:value', e.target.value!)"
/>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<!--
审批详情
约定${task.formPath}/frame 为内嵌表单 用于展示 需要在本地路由添加
apps/web-antd/src/router/routes/workflow-iframe.ts
-->
<script setup lang="ts">
import type { FlowInfoResponse } from '#/api/workflow/instance/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { Divider, Skeleton } from 'ant-design-vue';
import { ApprovalTimeline } from '.';
defineOptions({
name: 'ApprovalDetails',
inheritAttrs: false,
});
defineProps<{
currentFlowInfo: FlowInfoResponse;
iframeHeight: number;
iframeLoaded: boolean;
task: TaskInfo;
}>();
</script>
<template>
<div>
<!-- 约定${task.formPath}/frame 为内嵌表单 用于展示 需要在本地路由添加 -->
<iframe
v-show="iframeLoaded"
:src="`${task.formPath}/iframe?readonly=true&id=${task.businessId}`"
:style="{ height: `${iframeHeight}px` }"
class="w-full"
></iframe>
<Skeleton v-show="!iframeLoaded" :paragraph="{ rows: 6 }" active />
<Divider />
<ApprovalTimeline :list="currentFlowInfo.list" />
</div>
</template>

View File

@@ -0,0 +1,227 @@
<!-- 审批同意的弹窗 -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type {
CompleteTaskReqData,
NextNodeInfo,
} from '#/api/workflow/task/model';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from '@vben/utils';
import { message } from 'ant-design-vue';
import { omit } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { completeTask, getNextNodeList } from '#/api/workflow/task';
import { CopyComponent } from '.';
const emit = defineEmits<{ complete: [] }>();
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: [
{
fieldName: 'taskId',
component: 'Input',
label: '任务ID',
dependencies: {
show: false,
triggerFields: [''],
},
},
{
fieldName: 'messageType',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '站内信', value: '1', disabled: true },
{ label: '邮件', value: '2' },
{ label: '短信', value: '3' },
],
},
label: '通知方式',
defaultValue: ['1'],
},
{
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
maxCount: 10,
maxSize: 20,
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
},
defaultValue: [],
label: '附件上传',
formItemClass: 'items-start',
},
{
fieldName: 'flowCopyList',
component: 'Input',
defaultValue: [],
label: '抄送人',
},
{
fieldName: 'assigneeMap',
component: 'Input',
label: '下一步审批人',
},
{
fieldName: 'message',
component: 'Textarea',
label: '审批意见',
formItemClass: 'items-start',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
interface ModalProps {
taskId: string;
// 是否具有抄送权限
copyPermission: boolean;
// 是有具有选人权限
assignPermission: boolean;
}
// 自定义添加选人属性 给组件v-for绑定
const nextNodeInfo = ref<(NextNodeInfo & { selectUserList: User[] })[]>([]);
const [BasicModal, modalApi] = useVbenModal({
title: '审批通过',
fullscreenButton: false,
class: 'min-h-[365px]',
onConfirm: handleSubmit,
async onOpenChange(isOpen) {
if (!isOpen) {
await formApi.resetForm();
return null;
}
modalApi.modalLoading(true);
const { taskId, copyPermission, assignPermission } =
modalApi.getData() as ModalProps;
// 是否显示抄送选择
formApi.updateSchema([
{
fieldName: 'flowCopyList',
dependencies: {
if: copyPermission,
triggerFields: [''],
},
},
{
fieldName: 'assigneeMap',
dependencies: {
if: assignPermission,
triggerFields: [''],
},
},
]);
// 获取下一节点名称
if (assignPermission) {
const resp = await getNextNodeList({ taskId });
nextNodeInfo.value = resp.map((item) => ({
...item,
// 用于给组件绑定
selectUserList: [],
}));
}
await formApi.setFieldValue('taskId', taskId);
modalApi.modalLoading(false);
},
});
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
// 需要转换数据 抄送人员
const flowCopyList = (data.flowCopyList as Array<any>).map((item) => ({
userId: item.userId,
userName: item.nickName,
}));
const requestData = {
...omit(data, ['attachment']),
fileId: data.attachment.join(','),
taskVariables: {},
variables: {},
flowCopyList,
} as CompleteTaskReqData;
// 选人
if (modalApi.getData()?.assignPermission) {
// 判断是否选中
for (const item of nextNodeInfo.value) {
if (item.selectUserList.length === 0) {
message.warn(`未选择节点[${item.nodeName}]审批人`);
return;
}
}
const assigneeMap: { [key: string]: string } = {};
nextNodeInfo.value.forEach((item) => {
assigneeMap[item.nodeCode] = item.selectUserList
.map((u) => u.userId)
.join(',');
});
requestData.assigneeMap = assigneeMap;
}
await completeTask(requestData);
modalApi.close();
emit('complete');
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm>
<template #flowCopyList="slotProps">
<CopyComponent v-model:user-list="slotProps.modelValue" />
</template>
<template #assigneeMap>
<div
v-for="item in nextNodeInfo"
:key="item.nodeCode"
class="flex items-center gap-2"
>
<template v-if="item.permissionFlag">
<span class="opacity-70">{{ item.nodeName }}</span>
<CopyComponent
:allow-user-ids="item.permissionFlag"
v-model:user-list="item.selectUserList"
/>
</template>
<template v-else>
<span class="text-red-500">没有权限, 请联系管理员</span>
</template>
</div>
</template>
</BasicForm>
</BasicModal>
</template>

View File

@@ -0,0 +1,556 @@
<!-- 该文件需要重构 但我没空 -->
<script setup lang="ts">
import type { User } from '#/api/core/user';
import type { FlowInfoResponse } from '#/api/workflow/instance/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Fallback, useVbenModal, VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils';
import { CopyOutlined } from '@ant-design/icons-vue';
import { useClipboard, useEventListener } from '@vueuse/core';
import {
Card,
Divider,
Dropdown,
Menu,
MenuItem,
message,
Modal,
Space,
TabPane,
Tabs,
} from 'ant-design-vue';
import { isObject } from 'lodash-es';
import {
cancelProcessApply,
deleteByInstanceIds,
flowInfo,
} from '#/api/workflow/instance';
import {
getTaskByTaskId,
taskOperation,
terminationTask,
updateAssignee,
} from '#/api/workflow/task';
import { renderDict } from '#/utils/render';
import { approvalModal, approvalRejectionModal, flowInterfereModal } from '.';
import ApprovalDetails from './approval-details.vue';
import FlowPreview from './flow-preview.vue';
import { approveWithReasonModal } from './helper';
import userSelectModal from './user-select-modal.vue';
defineOptions({
name: 'ApprovalPanel',
inheritAttrs: false,
});
// eslint-disable-next-line no-use-before-define
const props = defineProps<{ task?: TaskInfo; type: ApprovalType }>();
/**
* 下面按钮点击后会触发的事件
*/
const emit = defineEmits<{ reload: [] }>();
const currentTask = ref<TaskInfo>();
/**
* 是否显示 加签/减签操作
*/
const showMultiActions = computed(() => {
if (!currentTask.value) {
return false;
}
if (Number(currentTask.value.nodeRatio) > 0) {
return true;
}
return false;
});
/**
* 按钮权限
*/
const buttonPermissions = computed(() => {
const record: Record<string, boolean> = {};
if (!currentTask.value) {
return record;
}
currentTask.value.buttonList.forEach((item) => {
record[item.code] = item.show;
});
return record;
});
// 是否显示 `其他` 按钮
const showButtonOther = computed(() => {
const moreCollections = new Set(['addSign', 'subSign', 'transfer', 'trust']);
return Object.keys(buttonPermissions.value).some(
(key) => moreCollections.has(key) && buttonPermissions.value[key],
);
});
/**
* myself 我发起的
* readonly 只读 只用于查看
* approve 审批
* admin 流程监控 - 待办任务使用
*/
type ApprovalType = 'admin' | 'approve' | 'myself' | 'readonly';
const showFooter = computed(() => {
if (props.type === 'readonly') {
return false;
}
// 我发起的 && [已完成, 已作废] 不显示
if (
props.type === 'myself' &&
['finish', 'invalid'].includes(props.task?.flowStatus ?? '')
) {
return false;
}
return true;
});
const currentFlowInfo = ref<FlowInfoResponse>();
/**
* card的loading状态
*/
const loading = ref(false);
const iframeLoaded = ref(false);
const iframeHeight = ref(300);
useEventListener('message', (event) => {
const data = event.data as { [key: string]: any; type: string };
if (!isObject(data)) return;
/**
* iframe通信 加载完毕后才显示表单 解决卡顿问题
*/
if (data.type === 'mounted') {
iframeLoaded.value = true;
}
/**
* 高度与表单高度保持一致
*/
if (data.type === 'height') {
const height = data.height;
iframeHeight.value = height;
}
});
async function handleLoadInfo(task: TaskInfo | undefined) {
try {
if (!task) return null;
loading.value = true;
iframeLoaded.value = false;
const resp = await flowInfo(task.businessId);
currentFlowInfo.value = resp;
const taskResp = await getTaskByTaskId(props.task!.id);
currentTask.value = taskResp;
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
watch(() => props.task, handleLoadInfo);
onUnmounted(() => (currentFlowInfo.value = undefined));
// 进行中 可以撤销
const revocable = computed(() => props.task?.flowStatus === 'waiting');
async function handleCancel() {
Modal.confirm({
title: '提示',
content: '确定要撤销该申请吗?',
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await cancelProcessApply({
businessId: props.task!.businessId,
message: '申请人撤销流程!',
});
emit('reload');
},
});
}
/**
* 是否可编辑/删除
*/
const editableAndRemoveable = computed(() => {
if (!props.task) {
return false;
}
return ['back', 'cancel', 'draft'].includes(props.task.flowStatus);
});
const router = useRouter();
function handleEdit() {
const path = props.task?.formPath;
if (path) {
router.push({ path, query: { id: props.task!.businessId } });
}
}
function handleRemove() {
Modal.confirm({
title: '提示',
content: '确定删除该申请吗?',
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await deleteByInstanceIds([props.task!.id]);
emit('reload');
},
});
}
/**
* 审批驳回
*/
const [RejectionModal, rejectionModalApi] = useVbenModal({
connectedComponent: approvalRejectionModal,
});
function handleRejection() {
rejectionModalApi.setData({
taskId: props.task?.id,
definitionId: props.task?.definitionId,
nodeCode: props.task?.nodeCode,
});
rejectionModalApi.open();
}
/**
* 审批终止
*/
function handleTermination() {
approveWithReasonModal({
title: '审批终止',
description: '确定终止当前审批流程吗?',
onOk: async (reason) => {
await terminationTask({ taskId: props.task!.id, comment: reason });
emit('reload');
},
});
}
/**
* 审批通过
*/
const [ApprovalModal, approvalModalApi] = useVbenModal({
connectedComponent: approvalModal,
});
function handleApproval() {
// 是否具有抄送权限
const copyPermission = buttonPermissions.value?.copy ?? false;
// 是否具有选人权限
const assignPermission = buttonPermissions.value?.pop ?? false;
approvalModalApi.setData({
taskId: props.task?.id,
copyPermission,
assignPermission,
});
approvalModalApi.open();
}
/**
* TODO: 1提取公共函数 2原版是可以填写意见的(message参数)
*/
/**
* 委托
*/
const [DelegationModal, delegationModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleDelegation(userList: User[]) {
if (userList.length === 0) return;
const current = userList[0];
approveWithReasonModal({
title: '委托',
description: `确定委托给[${current?.nickName}]吗?`,
onOk: async (reason) => {
await taskOperation(
{ taskId: props.task!.id, userId: current!.userId, message: reason },
'delegateTask',
);
emit('reload');
},
});
}
/**
* 转办
*/
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleTransfer(userList: User[]) {
if (userList.length === 0) return;
const current = userList[0];
approveWithReasonModal({
title: '转办',
description: `确定转办给[${current?.nickName}]吗?`,
onOk: async (reason) => {
await taskOperation(
{ taskId: props.task!.id, userId: current!.userId, message: reason },
'transferTask',
);
emit('reload');
},
});
}
const [AddSignatureModal, addSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleAddSignature(userList: User[]) {
if (userList.length === 0) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认加签吗?',
centered: true,
onOk: async () => {
await taskOperation({ taskId: props.task!.id, userIds }, 'addSignature');
emit('reload');
},
});
}
const [ReductionSignatureModal, reductionSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleReductionSignature(userList: User[]) {
if (userList.length === 0) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认减签吗?',
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: props.task!.id, userIds },
'reductionSignature',
);
emit('reload');
},
});
}
// 流程干预
const [FlowInterfereModal, flowInterfereModalApi] = useVbenModal({
connectedComponent: flowInterfereModal,
});
function handleFlowInterfere() {
flowInterfereModalApi.setData({ taskId: props.task?.id });
flowInterfereModalApi.open();
}
// 修改办理人
const [UpdateAssigneeModal, updateAssigneeModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleUpdateAssignee(userList: User[]) {
if (userList.length === 0) return;
const current = userList[0];
if (!current) return;
Modal.confirm({
title: '修改办理人',
content: `确定修改办理人为${current?.nickName}吗?`,
centered: true,
onOk: async () => {
await updateAssignee([props.task!.id], current.userId);
emit('reload');
},
});
}
/**
* 不加legacy在本地开发没有问题
* 打包后在一些设备会无法复制 使用legacy来保证兼容性
*/
const { copy } = useClipboard({ legacy: true });
async function handleCopy(text: string) {
await copy(text);
message.success('复制成功');
}
</script>
<template>
<Card
v-if="task"
:body-style="{ overflowY: 'auto', height: '100%' }"
:loading="loading"
class="thin-scrollbar flex-1 overflow-y-hidden"
size="small"
>
<template #title>
<div class="flex items-center gap-2">
<div>编号: {{ task.id }}</div>
<CopyOutlined class="cursor-pointer" @click="handleCopy(task.id)" />
</div>
</template>
<template #extra>
<a-button size="small" @click="() => handleLoadInfo(task)">
<div class="flex items-center justify-center">
<span class="icon-[material-symbols--refresh] size-24px"></span>
</div>
</a-button>
</template>
<div class="flex flex-col gap-5 p-4">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<div class="text-2xl font-bold">{{ task.flowName }}</div>
<div>
<component
:is="renderDict(task.flowStatus, DictEnum.WF_BUSINESS_STATUS)"
/>
</div>
</div>
<div class="flex items-center gap-2">
<VbenAvatar
:alt="task.createByName"
class="bg-primary size-[28px] rounded-full text-white"
src=""
/>
<span>{{ task.createByName }}</span>
<div class="flex items-center opacity-50">
<div class="flex items-center gap-1">
<span class="icon-[bxs--category-alt] size-[16px]"></span>
流程分类: {{ task.categoryName }}
</div>
<Divider type="vertical" />
<div class="flex items-center gap-1">
<span class="icon-[mdi--clock-outline] size-[16px]"></span>
提交时间: {{ task.createTime }}
</div>
</div>
</div>
</div>
<Tabs v-if="currentFlowInfo" class="flex-1">
<TabPane key="1" tab="审批详情">
<ApprovalDetails
:current-flow-info="currentFlowInfo"
:iframe-loaded="iframeLoaded"
:iframe-height="iframeHeight"
:task="task"
/>
</TabPane>
<TabPane key="2" tab="审批流程图">
<FlowPreview :instance-id="currentFlowInfo.instanceId" />
</TabPane>
</Tabs>
</div>
<!-- 固定底部 -->
<div class="h-[57px]"></div>
<div
v-if="showFooter"
class="border-t-solid bg-background absolute bottom-0 left-0 w-full border-t-[1px] p-3"
>
<div class="flex justify-end">
<Space v-if="type === 'myself'">
<a-button
v-if="revocable"
danger
type="primary"
@click="handleCancel"
>
撤销申请
</a-button>
<a-button v-if="editableAndRemoveable" @click="handleEdit">
重新编辑
</a-button>
<a-button
v-if="editableAndRemoveable"
danger
type="primary"
@click="handleRemove"
>
删除
</a-button>
</Space>
<Space v-if="type === 'approve'">
<a-button type="primary" @click="handleApproval">通过</a-button>
<a-button
v-if="buttonPermissions?.termination"
danger
type="primary"
@click="handleTermination"
>
终止
</a-button>
<a-button
v-if="buttonPermissions?.back"
danger
type="primary"
@click="handleRejection"
>
驳回
</a-button>
<Dropdown
:get-popup-container="getPopupContainer"
placement="bottomRight"
>
<template #overlay>
<Menu>
<MenuItem
v-if="buttonPermissions?.trust"
key="1"
@click="() => delegationModalApi.open()"
>
委托
</MenuItem>
<MenuItem
v-if="buttonPermissions?.transfer"
key="2"
@click="() => transferModalApi.open()"
>
转办
</MenuItem>
<MenuItem
v-if="showMultiActions && buttonPermissions?.addSign"
key="3"
@click="() => addSignatureModalApi.open()"
>
加签
</MenuItem>
<MenuItem
v-if="showMultiActions && buttonPermissions?.subSign"
key="4"
@click="() => reductionSignatureModalApi.open()"
>
减签
</MenuItem>
</Menu>
</template>
<a-button v-if="showButtonOther"> 其他 </a-button>
</Dropdown>
<ApprovalModal @complete="$emit('reload')" />
<RejectionModal @complete="$emit('reload')" />
<DelegationModal mode="single" @finish="handleDelegation" />
<TransferModal mode="single" @finish="handleTransfer" />
<AddSignatureModal mode="multiple" @finish="handleAddSignature" />
<ReductionSignatureModal
mode="multiple"
@finish="handleReductionSignature"
/>
</Space>
<Space v-if="type === 'admin'">
<a-button @click="handleFlowInterfere"> 流程干预 </a-button>
<a-button @click="() => updateAssigneeModalApi.open()">
修改办理人
</a-button>
<FlowInterfereModal @complete="$emit('reload')" />
<UpdateAssigneeModal mode="single" @finish="handleUpdateAssignee" />
</Space>
</div>
</div>
</Card>
<Fallback v-else title="点击左侧选择" />
</template>

View File

@@ -0,0 +1,146 @@
<!-- 审批驳回窗口 -->
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep, getPopupContainer } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { backProcess, getBackTaskNode } from '#/api/workflow/task';
const emit = defineEmits<{ complete: [] }>();
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: [
{
fieldName: 'taskId',
component: 'Input',
label: '任务ID',
dependencies: {
show: false,
triggerFields: [''],
},
},
{
fieldName: 'messageType',
component: 'CheckboxGroup',
componentProps: {
options: [
{ label: '站内信', value: '1', disabled: true },
{ label: '邮件', value: '2' },
{ label: '短信', value: '3' },
],
},
label: '通知方式',
defaultValue: ['1'],
},
{
fieldName: 'nodeCode',
component: 'Select',
componentProps: {
getPopupContainer,
},
label: '驳回节点',
},
{
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
maxCount: 10,
maxSize: 20,
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
},
defaultValue: [],
label: '附件上传',
formItemClass: 'items-start',
},
{
fieldName: 'message',
component: 'Textarea',
label: '审批意见',
formItemClass: 'items-start',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
interface ModalProps {
taskId: string;
definitionId: string;
nodeCode: string;
}
const [BasicModal, modalApi] = useVbenModal({
title: '审批驳回',
fullscreenButton: false,
class: 'min-h-[365px]',
onConfirm: handleSubmit,
async onOpenChange(isOpen) {
if (!isOpen) {
await formApi.resetForm();
return null;
}
modalApi.modalLoading(true);
const { taskId, definitionId, nodeCode } = modalApi.getData() as ModalProps;
await formApi.setFieldValue('taskId', taskId);
const resp = await getBackTaskNode(definitionId, nodeCode);
const options = resp.map((item) => ({
label: item.nodeName,
value: item.nodeCode,
}));
formApi.updateSchema([
{
fieldName: 'nodeCode',
componentProps: {
options,
},
},
]);
// 默认选中第一个节点
if (options.length > 0) {
formApi.setFieldValue('nodeCode', options[0]?.value);
}
modalApi.modalLoading(false);
},
});
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
// 附件join
data.fileId = data.attachment?.join?.(',');
// 取消attachment参数的传递
data.attachment = undefined;
await backProcess(data);
modalApi.close();
emit('complete');
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm />
</BasicModal>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { Flow } from '#/api/workflow/instance/model';
import { onMounted, ref } from 'vue';
import { VbenAvatar } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { TimelineItem } from 'ant-design-vue';
import { ossInfo } from '#/api/system/oss';
import { renderDict } from '#/utils/render';
defineOptions({
name: 'ApprovalTimelineItem',
});
const props = defineProps<{ item: Flow }>();
interface AttachmentInfo {
ossId: string;
url: string;
name: string;
}
/**
* 处理附件信息
*/
const attachmentInfo = ref<AttachmentInfo[]>([]);
onMounted(async () => {
if (!props.item.ext) {
return null;
}
const resp = await ossInfo(props.item.ext.split(','));
attachmentInfo.value = resp.map((item) => ({
ossId: item.ossId,
url: item.url,
name: item.originalName,
}));
});
</script>
<template>
<TimelineItem>
<template #dot>
<div class="relative rounded-full border">
<VbenAvatar
:alt="item.approveName"
class="bg-primary size-[36px] rounded-full text-white"
src=""
/>
</div>
</template>
<div class="ml-2 flex flex-col gap-0.5">
<div class="flex items-center gap-1">
<div class="font-bold">{{ item.nodeName }}</div>
<component :is="renderDict(item.flowStatus, DictEnum.WF_TASK_STATUS)" />
</div>
<div>{{ item.approveName }}</div>
<div>{{ item.updateTime }}</div>
<div v-if="item.message" class="rounded-lg border p-1">
<div class="break-all opacity-70">{{ item.message }}</div>
</div>
<div v-if="attachmentInfo.length > 0" class="flex flex-wrap gap-2">
<!-- 这里下载的文件名不是原始文件名 -->
<a
v-for="attachment in attachmentInfo"
:key="attachment.ossId"
:href="attachment.url"
class="text-primary"
target="_blank"
>
<div class="flex items-center gap-1">
<span class="icon-[mingcute--attachment-line] size-[18px]"></span>
<span>{{ attachment.name }}</span>
</div>
</a>
</div>
</div>
</TimelineItem>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { Flow } from '#/api/workflow/instance/model';
import { Timeline } from 'ant-design-vue';
import ApprovalTimelineItem from './approval-timeline-item.vue';
const props = defineProps<{
list: Flow[];
}>();
</script>
<template>
<Timeline v-if="props.list.length > 0">
<ApprovalTimelineItem
v-for="item in props.list"
:key="item.id"
:item="item"
/>
</Timeline>
</template>

View File

@@ -0,0 +1,98 @@
<!--抄送组件-->
<script setup lang="ts">
import type { PropType } from 'vue';
import type { User } from '#/api/system/user/model';
import { computed } from 'vue';
import { useVbenModal, VbenAvatar } from '@vben/common-ui';
import { Avatar, AvatarGroup, Tooltip } from 'ant-design-vue';
import { userSelectModal } from '.';
defineOptions({
name: 'CopyComponent',
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{ allowUserIds?: string; ellipseNumber?: number }>(),
{
/**
* 最大显示的头像数量 超过显示为省略号头像
*/
ellipseNumber: 3,
/**
* 允许选择允许选择的人员ID 会当做参数拼接在uselist接口
*/
allowUserIds: '',
},
);
const emit = defineEmits<{ cancel: []; finish: [User[]] }>();
const [UserSelectModal, modalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
const userListModel = defineModel('userList', {
type: Array as PropType<User[]>,
default: () => [],
});
function handleOpen() {
modalApi.setData({ userList: userListModel.value });
modalApi.open();
}
function handleFinish(userList: User[]) {
// 清空 直接赋值[]会丢失响应性
userListModel.value.splice(0, userListModel.value.length);
userListModel.value.push(...userList);
emit('finish', userList);
}
const displayedList = computed(() => {
return userListModel.value.slice(0, props.ellipseNumber);
});
</script>
<template>
<div class="flex items-center gap-2">
<AvatarGroup v-if="userListModel.length > 0">
<Tooltip
v-for="user in displayedList"
:key="user.userId"
:title="user.nickName"
placement="top"
>
<div>
<VbenAvatar
:alt="user.nickName"
class="bg-primary size-[36px] cursor-pointer rounded-full border text-white"
src=""
/>
</div>
</Tooltip>
<Tooltip
:title="`等${userListModel.length - props.ellipseNumber}人`"
placement="top"
>
<Avatar
v-if="userListModel.length > ellipseNumber"
class="flex size-[36px] cursor-pointer items-center justify-center rounded-full border bg-[gray] text-white"
>
+{{ userListModel.length - props.ellipseNumber }}
</Avatar>
</Tooltip>
</AvatarGroup>
<a-button size="small" @click="handleOpen">选择人员</a-button>
<UserSelectModal
:allow-user-ids="allowUserIds"
@cancel="$emit('cancel')"
@finish="handleFinish"
/>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { useAppConfig, useTabs } from '@vben/hooks';
import { stringify } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { useEventListener } from '@vueuse/core';
defineOptions({ name: 'FlowDesigner' });
const route = useRoute();
const definitionId = route.query.definitionId as string;
const disabled = route.query.disabled === 'true';
const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
const params = {
Authorization: `Bearer ${accessStore.accessToken}`,
id: definitionId,
clientid: clientId,
disabled,
};
/**
* iframe设计器的地址
*/
const url = `${import.meta.env.VITE_GLOB_API_URL}/warm-flow-ui/index.html?${stringify(params)}`;
const { closeCurrentTab } = useTabs();
const router = useRouter();
function messageHandler(event: MessageEvent) {
switch (event.data.method) {
case 'close': {
// 关闭当前tab
closeCurrentTab();
// 跳转到流程定义列表
router.push('/workflow/processDefinition');
break;
}
}
}
// iframe监听组件内设计器保存事件
useEventListener('message', messageHandler);
</script>
<template>
<iframe :src="url" class="size-full"></iframe>
</template>

View File

@@ -0,0 +1,38 @@
<!-- 弹窗查看流程信息 -->
<script setup lang="ts">
import type { TaskInfo } from '#/api/workflow/task/model';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { getTaskByBusinessId } from '#/api/workflow/instance';
import { ApprovalPanel } from '.';
interface ModalProps {
businessId: string;
}
const taskInfo = ref<TaskInfo>();
const [BasicModal, modalApi] = useVbenModal({
title: '流程信息',
class: 'w-[1000px]',
footer: false,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
const { businessId } = modalApi.getData() as ModalProps;
const taskResp = await getTaskByBusinessId(businessId);
taskInfo.value = taskResp;
},
});
</script>
<template>
<BasicModal>
<ApprovalPanel :task="taskInfo" type="readonly" />
</BasicModal>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Modal } from 'ant-design-vue';
import {
getTaskByTaskId,
taskOperation,
terminationTask,
} from '#/api/workflow/task';
import { userSelectModal } from '.';
const emit = defineEmits<{ complete: [] }>();
const taskInfo = ref<TaskInfo>();
/**
* 是否显示 加签/减签操作
*/
const showMultiActions = computed(() => {
if (!taskInfo.value) {
return false;
}
if (Number(taskInfo.value.nodeRatio) > 0) {
return true;
}
return false;
});
const [BasicModal, modalApi] = useVbenModal({
title: '流程干预',
class: 'w-[800px]',
fullscreenButton: false,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
const { taskId } = modalApi.getData() as { taskId: string };
taskInfo.value = await getTaskByTaskId(taskId);
},
});
/**
* 转办
*/
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleTransfer(userList: User[]) {
if (userList.length === 0 || !taskInfo.value) return;
const current = userList[0];
Modal.confirm({
title: '转办',
content: `确定转办给${current?.nickName}吗?`,
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: taskInfo.value!.id, userId: current!.userId },
'transferTask',
);
emit('complete');
},
});
}
/**
* 审批终止
*/
function handleTermination() {
if (!taskInfo.value) {
return;
}
Modal.confirm({
title: '审批终止',
content: '确定终止当前审批流程吗?',
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await terminationTask({ taskId: taskInfo.value!.id });
emit('complete');
},
});
}
const [AddSignatureModal, addSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleAddSignature(userList: User[]) {
if (userList.length === 0 || !taskInfo.value) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认加签吗?',
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: taskInfo.value!.id, userIds },
'addSignature',
);
emit('complete');
},
});
}
const [ReductionSignatureModal, reductionSignatureModalApi] = useVbenModal({
connectedComponent: userSelectModal,
});
function handleReductionSignature(userList: User[]) {
if (userList.length === 0 || !taskInfo.value) return;
const userIds = userList.map((user) => user.userId);
Modal.confirm({
title: '提示',
content: '确认减签吗?',
centered: true,
onOk: async () => {
await taskOperation(
{ taskId: taskInfo.value!.id, userIds },
'reductionSignature',
);
emit('complete');
},
});
}
</script>
<template>
<BasicModal>
<Descriptions v-if="taskInfo" :column="2" bordered size="small">
<DescriptionsItem label="任务名称">
{{ taskInfo.nodeName }}
</DescriptionsItem>
<DescriptionsItem label="节点编码">
{{ taskInfo.nodeCode }}
</DescriptionsItem>
<DescriptionsItem label="开始时间">
{{ taskInfo.createTime }}
</DescriptionsItem>
<DescriptionsItem label="流程实例ID">
{{ taskInfo.instanceId }}
</DescriptionsItem>
<DescriptionsItem label="版本号">
{{ taskInfo.version }}
</DescriptionsItem>
<DescriptionsItem label="业务ID">
{{ taskInfo.businessId }}
</DescriptionsItem>
</Descriptions>
<TransferModal mode="single" @finish="handleTransfer" />
<AddSignatureModal mode="multiple" @finish="handleAddSignature" />
<ReductionSignatureModal
mode="multiple"
@finish="handleReductionSignature"
/>
<template #footer>
<template v-if="showMultiActions">
<a-button @click="() => addSignatureModalApi.open()">加签</a-button>
<a-button @click="() => reductionSignatureModalApi.open()">
减签
</a-button>
</template>
<a-button @click="() => transferModalApi.open()">转办</a-button>
<a-button danger type="primary" @click="handleTermination">终止</a-button>
</template>
</BasicModal>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useAppConfig } from '@vben/hooks';
import { stringify } from '@vben/request';
import { useAccessStore } from '@vben/stores';
defineOptions({ name: 'FlowPreview' });
const props = defineProps<{ instanceId: string }>();
const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
const params = {
Authorization: `Bearer ${accessStore.accessToken}`,
id: props.instanceId,
clientid: clientId,
type: 'FlowChart',
};
/**
* iframe地址
*/
const url = `${import.meta.env.VITE_GLOB_API_URL}/warm-flow-ui/index.html?${stringify(params)}`;
</script>
<template>
<iframe :src="url" class="h-[500px] w-full border"></iframe>
</template>

View File

@@ -0,0 +1,60 @@
import { defineComponent, h, ref } from 'vue';
import { Modal } from 'ant-design-vue';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import ApprovalContent from './approval-content.vue';
export interface ApproveWithReasonModalProps {
title: string;
description: string;
onOk: (reason: string) => void;
}
/**
* 带审批意见的confirm
* @param props props
*/
export function approveWithReasonModal(props: ApproveWithReasonModalProps) {
const { onOk, title, description } = props;
const content = ref('');
Modal.confirm({
title,
content: h(
defineComponent({
setup() {
return () =>
h(ApprovalContent, {
description,
value: content.value,
'onUpdate:value': (v) => (content.value = v),
});
},
}),
),
centered: true,
okButtonProps: { danger: true },
onOk: () => onOk(content.value),
});
}
dayjs.extend(duration);
dayjs.extend(relativeTime);
/**
* 计算相差的时间
* @param dateTime 时间字符串
* @returns 相差的时间
*/
export function getDiffTimeString(dateTime: string) {
// 计算相差秒数
const diffSeconds = dayjs().diff(dayjs(dateTime), 'second');
/**
* 转为时间显示(x月 x天)
* https://dayjs.fenxianglu.cn/category/duration.html#%E4%BA%BA%E6%80%A7%E5%8C%96
*
*/
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
return diffText;
}

View File

@@ -0,0 +1,30 @@
export { default as applyModal } from './apply-modal.vue';
export { default as ApprovalCard } from './approval-card.vue';
/**
* 审批同意
*/
export { default as approvalModal } from './approval-modal.vue';
export { default as ApprovalPanel } from './approval-panel.vue';
/**
* 审批驳回
*/
export { default as approvalRejectionModal } from './approval-rejection-modal.vue';
export { default as ApprovalTimeline } from './approval-timeline.vue';
/**
* 选择抄送人
*/
export { default as CopyComponent } from './copy-component.vue';
/**
* 详情信息 modal
*/
export { default as flowInfoModal } from './flow-info-modal.vue';
/**
* 流程干预 modal
*/
export { default as flowInterfereModal } from './flow-interfere-modal.vue';
/**
* 选人 支持单选/多选
*/
export { default as userSelectModal } from './user-select-modal.vue';

View File

@@ -0,0 +1,378 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { User } from '#/api';
import { ref } from 'vue';
import { useVbenModal, VbenAvatar } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { userList } from '#/api/system/user';
import DeptTree from '#/views/system/user/dept-tree.vue';
defineOptions({
name: 'UserSelectModal',
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{ allowUserIds?: string; mode?: 'multiple' | 'single' }>(),
{
mode: 'multiple',
/**
* 允许选择允许选择的人员ID 会当做参数拼接在uselist接口
*/
allowUserIds: '',
},
);
const emit = defineEmits<{
/**
* 取消的事件
*/
cancel: [];
/**
* 选择完成的事件
*/
finish: [User[]];
}>();
const [BasicModal, modalApi] = useVbenModal({
title: '选择人员',
class: 'w-[1060px]',
fullscreenButton: false,
onClosed: () => emit('cancel'),
onConfirm: handleSubmit,
async onOpened() {
const { userList = [] } = modalApi.getData() as { userList: User[] };
// 暂时只处理多选 目前并没有单选的情况
if (props.mode === 'multiple') {
// 左边选中
await tableApi.grid.setCheckboxRow(userList, true);
// 右边赋值
await rightTableApi.grid.loadData(userList);
}
},
});
// 左边部门用
const selectDeptId = ref<string[]>([]);
const formOptions: VbenFormProps = {
schema: [
{
component: 'Input',
fieldName: 'userName',
label: '用户账号',
hideLabel: true,
componentProps: {
placeholder: '请输入账号',
},
},
],
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-2',
handleReset: async () => {
selectDeptId.value = [];
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'row',
},
radioConfig: {
trigger: 'row',
strict: true,
},
columns: [
{
type: props.mode === 'single' ? 'radio' : 'checkbox',
width: 60,
resizable: false,
},
{
field: 'userName',
title: '用户',
headerAlign: 'left',
resizable: false,
slots: {
default: 'user',
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
layouts: ['PrevPage', 'Number', 'NextPage', 'Sizes', 'Total'],
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectDeptId.value.length === 1) {
formValues.deptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(formValues, 'deptId');
}
// 加载完毕需要设置选中的行
if (props.mode === 'multiple') {
const records = rightTableApi.grid.getData();
await tableApi.grid.setCheckboxRow(records, true);
}
if (props.mode === 'single') {
const records = rightTableApi.grid.getData();
if (records.length === 1) {
await tableApi.grid.setRadioRow(records[0]);
}
}
const params: any = {
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
};
// 添加参数
if (props.allowUserIds) {
params.userIds = props.allowUserIds;
}
return await userList(params);
},
},
},
rowConfig: {
keyField: 'userId',
},
toolbarConfig: {
enabled: false,
},
showOverflow: false,
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
// 需要控制不同的事件 radio也会触发checkbox事件
checkboxChange: checkBoxEvent,
checkboxAll: checkBoxEvent,
radioChange: radioEvent,
},
});
function checkBoxEvent() {
if (props.mode !== 'multiple') {
return;
}
/**
* 给右边表格赋值
* records拿到的是当前页的选中数据
* reserveRecords拿到的是其他页选中的数据
*/
const records = tableApi.grid.getCheckboxRecords();
const reserveRecords = tableApi.grid.getCheckboxReserveRecords();
const realRecords = [...records, ...reserveRecords];
rightTableApi.grid.loadData(realRecords);
}
function radioEvent() {
if (props.mode !== 'single') {
return;
}
// 给右边表格赋值
const records = tableApi.grid.getRadioRecord();
rightTableApi.grid.loadData([records]);
}
const rightGridOptions: VxeGridProps = {
checkboxConfig: {},
columns: [
{
field: 'nickName',
title: '昵称',
width: 200,
resizable: false,
slots: {
default: 'user',
},
},
{
field: 'action',
title: '操作',
width: 120,
slots: { default: 'action' },
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
enabled: false,
},
rowConfig: {
keyField: 'userId',
},
toolbarConfig: {
enabled: false,
},
showOverflow: false,
};
const [RightBasicTable, rightTableApi] = useVbenVxeGrid({
gridOptions: rightGridOptions,
});
async function handleRemoveItem(row: any) {
if (props.mode === 'multiple') {
await tableApi.grid.setCheckboxRow(row, false);
}
if (props.mode === 'single') {
await tableApi.grid.clearRadioRow();
}
const data = rightTableApi.grid.getData();
await rightTableApi.grid.loadData(data.filter((item) => item !== row));
// 这个方法有问题
// await rightTableApi.grid.remove(row);
}
function handleRemoveAll() {
if (props.mode === 'multiple') {
tableApi.grid.clearCheckboxRow();
tableApi.grid.clearCheckboxReserve();
}
if (props.mode === 'single') {
tableApi.grid.clearRadioRow();
}
rightTableApi.grid.loadData([]);
}
async function handleDeptQuery() {
await tableApi.reload();
// 重置后恢复 保存勾选的数据
const records = rightTableApi.grid.getData();
if (props.mode === 'multiple') {
tableApi?.grid.setCheckboxRow(records, true);
}
if (props.mode === 'single' && records.length === 1) {
tableApi.grid.setRadioRow(records[0]);
}
}
function handleSubmit() {
const records = rightTableApi.grid.getData();
console.log(records);
emit('finish', records);
modalApi.close();
}
</script>
<template>
<BasicModal>
<div class="flex min-h-[600px]">
<DeptTree
v-model:select-dept-id="selectDeptId"
:show-search="false"
class="w-[230px]"
@reload="() => tableApi.reload()"
@select="handleDeptQuery"
/>
<div class="h-[600px] w-[450px]">
<BasicTable>
<template #user="{ row }">
<div class="flex items-center gap-2">
<VbenAvatar
:alt="row.nickName"
:src="row.avatar ?? ''"
:class="{ 'bg-primary': !row.avatar }"
class="size-[32px] rounded-full text-white"
/>
<div class="flex flex-col items-baseline text-[12px]">
<div>{{ row.nickName }}</div>
<div class="opacity-50">
{{ row.phonenumber || '暂无手机号' }}
</div>
</div>
</div>
</template>
</BasicTable>
</div>
<div class="flex h-[600px] flex-col">
<div class="flex w-full px-4">
<div class="flex w-full items-center justify-between">
<div>已选中人员</div>
<div>
<a-button size="small" @click="handleRemoveAll">
清空选中
</a-button>
</div>
</div>
</div>
<RightBasicTable id="user-select-right-table">
<template #user="{ row }">
<div class="flex items-center gap-2 overflow-hidden">
<VbenAvatar
:alt="row.nickName"
:src="row.avatar ?? ''"
:class="{ 'bg-primary': !row.avatar }"
class="size-[32px] rounded-full text-white"
/>
<div class="flex flex-col items-baseline text-[12px]">
<div class="overflow-ellipsis whitespace-nowrap">
{{ row.nickName }}
</div>
<div class="opacity-50">
{{ row.phonenumber || '暂无手机号' }}
</div>
</div>
</div>
</template>
<template #action="{ row }">
<a-button size="small" @click="handleRemoveItem(row)">
移除
</a-button>
</template>
</RightBasicTable>
</div>
</div>
</BasicModal>
</template>
<style scoped>
:deep(div.vben-link) {
display: none;
}
:deep(.vxe-body--row) {
cursor: pointer;
}
</style>
<style lang="scss">
/**
默认显示右边的滚动条 防止出现滚动条被挤压
*/
#user-select-right-table {
div.vxe-table--body-wrapper.body--wrapper {
overflow: scroll;
}
}
</style>

View File

@@ -0,0 +1,62 @@
import type { LeaveForm, LeaveQuery, LeaveVO } from './model';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
* 查询请假申请列表
* @param params
* @returns 请假申请列表
*/
export function leaveList(params?: LeaveQuery) {
return requestClient.get<PageResult<LeaveVO>>('/workflow/leave/list', {
params,
});
}
/**
* 导出请假申请列表
* @param params
* @returns 请假申请列表
*/
export function leaveExport(params?: LeaveQuery) {
return commonExport('/workflow/leave/export', params ?? {});
}
/**
* 查询请假申请详情
* @param id id
* @returns 请假申请详情
*/
export function leaveInfo(id: ID) {
return requestClient.get<LeaveVO>(`/workflow/leave/${id}`);
}
/**
* 新增请假申请
* @param data
* @returns void
*/
export function leaveAdd(data: LeaveForm) {
return requestClient.postWithMsg<LeaveVO>('/workflow/leave', data);
}
/**
* 更新请假申请
* @param data
* @returns void
*/
export function leaveUpdate(data: LeaveForm) {
return requestClient.putWithMsg<LeaveVO>('/workflow/leave', data);
}
/**
* 删除请假申请
* @param id id
* @returns void
*/
export function leaveRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/workflow/leave/${id}`);
}

View File

@@ -0,0 +1,107 @@
import type { BaseEntity, PageQuery } from '#/api/common';
export interface LeaveVO {
/**
* 主键
*/
id: number | string;
/**
* 请假类型
*/
leaveType: string;
/**
* 开始时间
*/
startDate: string;
/**
* 结束时间
*/
endDate: string;
/**
* 请假天数
*/
leaveDays: number;
/**
* 请假原因
*/
remark: string;
/**
*
*/
status: string;
}
export interface LeaveForm extends BaseEntity {
/**
* 主键
*/
id?: number | string;
/**
* 请假类型
*/
leaveType?: string;
/**
* 开始时间
*/
startDate?: string;
/**
* 结束时间
*/
endDate?: string;
/**
* 请假天数
*/
leaveDays?: number;
/**
* 请假原因
*/
remark?: string;
/**
*
*/
status?: string;
}
export interface LeaveQuery extends PageQuery {
/**
* 请假类型
*/
leaveType?: string;
/**
* 开始时间
*/
startDate?: string;
/**
* 结束时间
*/
endDate?: string;
/**
* 请假天数
*/
leaveDays?: number;
/**
*
*/
status?: string;
/**
* 日期范围参数
*/
params?: any;
}

View File

@@ -0,0 +1,175 @@
import type { FormSchemaGetter, VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils';
import dayjs from 'dayjs';
import { OptionsTag } from '#/components/table';
import { renderDict } from '#/utils/render';
export const leaveTypeOptions = [
{ label: '病假', value: '1' },
{ label: '事假', value: '2' },
{ label: '年假', value: '3' },
{ label: '婚假', value: '4' },
{ label: '产假', value: '5' },
{ label: '其他', value: '7' },
];
export const leaveFlowOptions = [
{ label: '请假流程-普通', value: 'leave1' },
{ label: '请假流程-排他网关', value: 'leave2' },
{ label: '请假流程-并行网关', value: 'leave3' },
{ label: '请假流程-会签', value: 'leave4' },
{ label: '请假申请-并行会签网关', value: 'leave5' },
{ label: '请假申请-排他并行网关', value: 'leave6' },
];
export const querySchema: FormSchemaGetter = () => [
{
component: 'InputNumber',
componentProps: {
min: 1,
},
fieldName: 'startLeaveDays',
label: '请假天数',
},
{
component: 'InputNumber',
componentProps: {
min: 1,
},
fieldName: 'endLeaveDays',
label: '至',
labelClass: 'justify-center',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '请假类型',
field: 'leaveType',
slots: {
default: ({ row }) => {
return <OptionsTag options={leaveTypeOptions} value={row.leaveType} />;
},
},
},
{
title: '开始时间',
field: 'startDate',
formatter: ({ cellValue }) => dayjs(cellValue).format('YYYY-MM-DD'),
},
{
title: '结束时间',
field: 'endDate',
formatter: ({ cellValue }) => dayjs(cellValue).format('YYYY-MM-DD'),
},
{
title: '请假天数',
field: 'leaveDays',
formatter: ({ cellValue }) => `${cellValue}`,
},
{
title: '请假原因',
field: 'remark',
},
{
title: '流程状态',
field: 'status',
slots: {
default: ({ row }) => {
return renderDict(row.status, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 'auto',
},
];
export const modalSchema: (isEdit: boolean) => VbenFormSchema[] = (
isEdit: boolean,
) => [
{
label: '主键',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '流程类型',
fieldName: 'flowType',
component: 'Select',
help: '这里仅仅为了发起流程方便, 实际不应该包含此字段',
componentProps: {
options: leaveFlowOptions,
getPopupContainer,
},
defaultValue: 'leave1',
rules: 'selectRequired',
dependencies: {
show: () => isEdit,
triggerFields: [''],
},
},
{
label: '请假类型',
fieldName: 'leaveType',
component: 'Select',
componentProps: {
options: leaveTypeOptions,
getPopupContainer,
},
rules: 'selectRequired',
formItemClass: 'col-span-1',
},
{
label: '开始时间',
fieldName: 'dateRange',
component: 'RangePicker',
componentProps(model) {
return {
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
onChange: (dates: [string, string]) => {
if (!dates) {
model.leaveDays = null;
return;
}
const [start, end] = dates;
const leaveDays = dayjs(end).diff(dayjs(start), 'day') + 1;
model.leaveDays = leaveDays;
},
};
},
rules: 'required',
formItemClass: 'col-span-1',
},
{
label: '请假天数',
fieldName: 'leaveDays',
component: 'Input',
componentProps: {
disabled: true,
},
rules: 'required',
},
{
label: '请假原因',
fieldName: 'remark',
component: 'Textarea',
formItemClass: 'items-start',
},
];

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { LeaveForm } from './api/model';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import { cancelProcessApply } from '#/api/workflow/instance';
import { commonDownloadExcel } from '#/utils/file/download';
import { flowInfoModal } from '../components';
import { leaveExport, leaveList, leaveRemove } from './api';
import { columns, querySchema } from './data';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 选中 需要根据状态判断
checkMethod: ({ row }) => ['back', 'cancel', 'draft'].includes(row.status),
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await leaveList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
// 表格全局唯一表示 保存列配置需要用到
id: 'workflow-leave-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const router = useRouter();
function handleAdd() {
router.push('/workflow/leaveEdit/index');
}
async function handleEdit(row: Required<LeaveForm>) {
router.push({ path: '/workflow/leaveEdit/index', query: { id: row.id } });
}
async function handleDelete(row: Required<LeaveForm>) {
await leaveRemove(row.id);
await tableApi.query();
}
async function handleRevoke(row: Required<LeaveForm>) {
await cancelProcessApply({
businessId: row.id,
message: '申请人撤销流程!',
});
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: Required<LeaveForm>) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await leaveRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(
leaveExport,
'请假申请数据',
tableApi.formApi.form.values,
{
fieldMappingTime: formOptions.fieldMappingTime,
},
);
}
const [FlowInfoModal, flowInfoModalApi] = useVbenModal({
connectedComponent: flowInfoModal,
});
function handleInfo(row: Required<LeaveForm>) {
flowInfoModalApi.setData({ businessId: row.id });
flowInfoModalApi.open();
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="请假申请列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['workflow:leave:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['workflow:leave:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['workflow:leave:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-if="['draft', 'cancel', 'back'].includes(row.status)"
v-access:code="['workflow:leave:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认撤销?"
@confirm="handleRevoke(row)"
>
<ghost-button
v-if="['waiting'].includes(row.status)"
v-access:code="['workflow:leave:edit']"
@click.stop=""
>
撤销
</ghost-button>
</Popconfirm>
<ghost-button v-if="row.status !== 'draft'" @click="handleInfo(row)">
详情
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button
v-if="['draft', 'cancel', 'back'].includes(row.status)"
danger
v-access:code="['workflow:leave:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<FlowInfoModal />
</Page>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { LeaveVO } from './api/model';
import { computed } from 'vue';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
import dayjs from 'dayjs';
import { leaveTypeOptions } from './data';
defineOptions({
name: 'LeaveDescription',
inheritAttrs: false,
});
const props = defineProps<{ data: LeaveVO }>();
const leaveType = computed(() => {
return (
leaveTypeOptions.find((item) => item.value === props.data.leaveType)
?.label ?? '未知'
);
});
function formatDate(date: string) {
return dayjs(date).format('YYYY-MM-DD');
}
</script>
<template>
<Descriptions :column="1" size="middle">
<DescriptionsItem label="请假类型">
{{ leaveType }}
</DescriptionsItem>
<DescriptionsItem label="请假时间">
{{ formatDate(data.startDate) }} - {{ formatDate(data.endDate) }}
</DescriptionsItem>
<DescriptionsItem label="请假时长">
{{ data.leaveDays }}
</DescriptionsItem>
<DescriptionsItem label="请假原因">
{{ data.remark || '无' }}
</DescriptionsItem>
</Descriptions>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { LeaveVO } from './api/model';
import type { StartWorkFlowReqData } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { Card } from 'ant-design-vue';
import dayjs from 'dayjs';
import { cloneDeep, omit } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { startWorkFlow } from '#/api/workflow/task';
import { applyModal } from '../components';
import { leaveAdd, leaveInfo, leaveUpdate } from './api';
import { modalSchema } from './data';
import LeaveDescription from './leave-description.vue';
const route = useRoute();
const readonly = route.query?.readonly === 'true';
const id = route.query?.id as string;
/**
* id存在&readonly时候
*/
const showActionBtn = computed(() => {
return !readonly;
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 100,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
disabled: readonly,
},
},
schema: modalSchema(!readonly),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const leaveDescription = ref<LeaveVO>();
const showDescription = computed(() => {
return readonly && leaveDescription.value;
});
const cardRef = useTemplateRef('cardRef');
onMounted(async () => {
// 只读 获取信息赋值
if (id) {
const resp = await leaveInfo(id);
leaveDescription.value = resp;
await formApi.setValues(resp);
const dateRange = [dayjs(resp.startDate), dayjs(resp.endDate)];
await formApi.setFieldValue('dateRange', dateRange);
/**
* window.parent最近的上一级父页面
* 主要解决内嵌iframe卡顿的问题
*/
if (readonly) {
// 渲染完毕才显示表单
window.parent.postMessage({ type: 'mounted' }, '*');
// 获取表单高度 内嵌时保持一致
setTimeout(() => {
const el = cardRef.value?.$el as HTMLDivElement;
// 获取高度
const height = el?.offsetHeight ?? 0;
if (height) {
window.parent.postMessage({ type: 'height', height }, '*');
}
});
}
}
});
const router = useRouter();
/**
* 提取通用逻辑
*/
async function handleSaveOrUpdate() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
let data = cloneDeep(await formApi.getValues()) as any;
data = omit(data, 'flowType');
// 处理日期
data.startDate = dayjs(data.dateRange[0]).format('YYYY-MM-DD HH:mm:ss');
data.endDate = dayjs(data.dateRange[1]).format('YYYY-MM-DD HH:mm:ss');
if (id) {
data.id = id;
return await leaveUpdate(data);
} else {
return await leaveAdd(data);
}
}
const [ApplyModal, applyModalApi] = useVbenModal({
connectedComponent: applyModal,
});
/**
* 暂存 草稿状态
*/
async function handleTempSave() {
try {
await handleSaveOrUpdate();
router.push('/demo/leave');
} catch (error) {
console.error(error);
}
}
/**
* 保存业务 & 发起流程
*/
async function handleStartWorkFlow() {
try {
// 保存业务
const leaveResp = await handleSaveOrUpdate();
// 启动流程
const taskVariables = {
leaveDays: leaveResp!.leaveDays,
userList: ['1', '3', '4'],
};
const formValues = await formApi.getValues();
const flowCode = formValues?.flowType ?? 'leave1';
const startWorkFlowData: StartWorkFlowReqData = {
businessId: leaveResp!.id,
flowCode,
variables: taskVariables,
};
const { taskId } = await startWorkFlow(startWorkFlowData);
// 打开窗口
applyModalApi.setData({
taskId,
taskVariables,
variables: {},
});
applyModalApi.open();
} catch (error) {
console.error(error);
}
}
function handleComplete() {
formApi.resetForm();
router.push('/demo/leave');
}
/**
* 显示详情时 需要较小的padding
*/
const cardSize = computed(() => {
return showDescription.value ? 'small' : 'default';
});
</script>
<template>
<Card ref="cardRef" :size="cardSize">
<div id="leave-form">
<!-- 使用v-if会影响生命周期 -->
<BasicForm v-show="!showDescription" />
<LeaveDescription v-if="showDescription" :data="leaveDescription!" />
<div v-if="showActionBtn" class="flex justify-end gap-2">
<a-button @click="handleTempSave">暂存</a-button>
<a-button type="primary" @click="handleStartWorkFlow">提交</a-button>
</div>
<ApplyModal @complete="handleComplete" />
</div>
</Card>
</template>
<style lang="scss">
html:has(#leave-form) {
/**
去除顶部进度条样式
*/
#nprogress {
display: none;
}
}
</style>

View File

@@ -0,0 +1,11 @@
<!--
后端版本>=5.4.0 这个从本地路由变为从后台返回
未修改文件名 而是新加了这个文件
-->
<script setup lang="ts">
import LeaveFormPage from './leave-form.vue';
</script>
<template>
<LeaveFormPage />
</template>

View File

@@ -3,7 +3,8 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { getDictOptions } from '#/utils/dict';
import { renderDict } from '#/utils/render';
import {resident_unitList} from '#/api/property/resident/unit/index'
import dayjs from 'dayjs';
export const modalSchema: FormSchemaGetter = () => [
{
@@ -25,6 +26,15 @@ export const modalSchema: FormSchemaGetter = () => [
label: '部门',
fieldName: 'departmentId',
component: 'ApiSelect',
componentProps:{
api: async () => {
const res = await resident_unitList({pageSize:1000000000,pageNum:1})
return res;
},
resultField:'rows',
labelField:'name',
valueField:'id'
},
rules:'required'
},
{
@@ -32,43 +42,126 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'leaveType',
component: 'Select',
componentProps: {
options:getDictOptions('wy_qjlx')
},
rules:'required'
},
{
label: '',
fieldName: 'Placeholder',
component: ''
},
{
label: '开始时间',
fieldName: 'startTime',
component: 'DatePicker',
componentProps: {
componentProps: (values)=>({
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
rules:'required'
onChange: (value: string) => {
if (value && values.endTime) {
const start = dayjs(value);
const end = dayjs(values.endTime);
const diffMinutes = end.diff(start, 'minute');
if (diffMinutes < 0) {
values.totalDuration = '时间计算错误';
return;
}
const days = Math.floor(diffMinutes / (24 * 60));
const remainingMinutes = diffMinutes % (24 * 60);
const hours = Math.floor(remainingMinutes / 60);
const minutes = remainingMinutes % 60;
let durationText = '';
if (days > 0) {
durationText += `${days}`;
}
if (hours > 0 || days > 0) {
durationText += `${hours}小时`;
}
durationText += `${minutes}分钟`;
// 更新合计时间字段
values.totalDuration = durationText;
} else {
// 如果开始时间被清空,也清空合计时间
values.totalDuration = '';
}
},
}),
rules:'required'
},
{
label: '结束时间',
fieldName: 'endTime',
component: 'DatePicker',
componentProps: {
componentProps: (values)=>({
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
placeholder:!values.startTime?'请先选择开始时间':'请选择结束时间(结束时间不能早于开始时间)',
disabled:!values.startTime,
disabledDate: (current: any) => {
if (!values.startTime) return false;
// 只允许选择大于等于开始时间的日期,将小于等于的时间禁用
return current && current <= dayjs(values.startTime).startOf('second');
},
rules:'required'
onChange: (value: string) => {
if (values.startTime && value) {
const start = dayjs(values.startTime);
const end = dayjs(value);
const diffMinutes = end.diff(start, 'minute');
if (diffMinutes < 0) {
values.totalDuration = '时间计算错误';
return;
}
const days = Math.floor(diffMinutes / (24 * 60));
const remainingMinutes = diffMinutes % (24 * 60);
const hours = Math.floor(remainingMinutes / 60);
const minutes = remainingMinutes % 60;
let durationText = '';
if (days > 0) {
durationText += `${days}`;
}
if (hours > 0 || days > 0) {
durationText += `${hours}小时`;
}
durationText += `${minutes}分钟`;
// 更新合计时间字段
values.totalDuration = durationText;
} else {
// 如果结束时间被清空,也清空合计时间
values.totalDuration = '';
}
},
}),
rules:'required',
dependencies:{
triggerFields:['startTime']
}
},
{
label: '合计时间',
fieldName: 'totalDuration',
component: 'Input',
disabled:true,
rules:'required'
rules:'required',
componentProps:{
placeholder:'请选择开始时间与结束时间'
}
},
{
label: '请假事由',
fieldName: 'reason',
component: 'Textarea',
rules:'required'
rules:'required',
formItemClass: 'col-span-2'
}
];

View File

@@ -12,6 +12,7 @@ import { useVbenForm } from '#/adapter/form';
import { useVbenModal} from '@vben/common-ui';
import {modalSchema} from "./data";
import { useVModel } from '@vueuse/core';
import { Button, message } from 'ant-design-vue';
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
@@ -25,8 +26,30 @@ const [BasicForm, formApi] = useVbenForm({
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2 gap-x-10 gap-y-2',
wrapperClass: 'grid-cols-2',
});
// 提交处理函数
async function handleSubmit() {
const { valid } = await formApi.validate();
if (!valid) {
message.error('请完善表单信息');
return;
}
const formData = await formApi.getValues();
formData.startTime = dayjs(formData.startTime).format('YYYY-MM-DD HH:mm:ss');
formData.endTime = dayjs(formData.endTime).format('YYYY-MM-DD HH:mm:ss');
console.log('提交数据:', formData);
// await leaveApplicationAdd(formData);
message.success('提交成功');
await formApi.resetForm();
}
async function cleanForm () {
await formApi.resetForm();
}
</script>
<template>
@@ -34,6 +57,23 @@ const [BasicForm, formApi] = useVbenForm({
<div class="text-2xl font-bold">请假申请</div>
<div class="m-6 bg-white p-4">
<BasicForm/>
<div class="flex justify-center mt-6 gap-2">
<Button type="primary" @click="handleSubmit">提交申请</Button>
<Button @click="cleanForm">重置</Button>
</div>
</div>
</div>
</template>
<style scoped>
/* 使用 :deep() 穿透 scoped 样式,影响子组件 */
:deep(.ant-input[disabled]),
:deep(.ant-input-number-disabled .ant-input-number-input),
:deep(.ant-select-disabled .ant-select-selection-item),
:deep(.ant-picker-disabled .ant-picker-input > input) {
/* 设置一个更深的颜色 */
color: rgb(0 0 0 / 65%) !important;
/* 有些浏览器需要这个来覆盖默认颜色 */
-webkit-text-fill-color: rgb(0 0 0 / 65%) !important;
}
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { CategoryTree } from '#/api/workflow/category/model';
import { onMounted, ref } from 'vue';
import { SyncOutlined } from '@ant-design/icons-vue';
import { InputSearch, Skeleton, Tree } from 'ant-design-vue';
import { categoryTree } from '#/api/workflow/category';
defineOptions({ inheritAttrs: false });
const emit = defineEmits<{
/**
* 点击刷新按钮的事件
*/
reload: [];
/**
* 点击节点的事件
*/
select: [];
}>();
const selectCode = defineModel('selectCode', {
required: true,
type: Array as PropType<number[] | string[]>,
});
const searchValue = defineModel('searchValue', {
type: String,
default: '',
});
const categoryTreeArray = ref<CategoryTree[]>([]);
/** 骨架屏加载 */
const showTreeSkeleton = ref<boolean>(true);
async function loadTree() {
showTreeSkeleton.value = true;
searchValue.value = '';
selectCode.value = [];
const treeData = await categoryTree();
categoryTreeArray.value = treeData;
showTreeSkeleton.value = false;
}
async function handleReload() {
await loadTree();
emit('reload');
}
onMounted(loadTree);
</script>
<template>
<div :class="$attrs.class">
<Skeleton
:loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="p-[8px]"
>
<div
class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"
>
<!-- 固定在顶部 必须加上bg-background背景色 否则会产生'穿透'效果 -->
<div class="bg-background z-100 sticky left-0 top-0 p-[8px]">
<InputSearch
v-model:value="searchValue"
:placeholder="$t('pages.common.search')"
size="small"
>
<template #enterButton>
<a-button @click="handleReload">
<SyncOutlined class="text-primary" />
</a-button>
</template>
</InputSearch>
</div>
<div class="h-full overflow-x-hidden px-[8px]">
<Tree
v-bind="$attrs"
v-if="categoryTreeArray.length > 0"
v-model:selected-keys="selectCode"
:class="$attrs.class"
:field-names="{ title: 'label', key: 'id' }"
:show-line="{ showLeafIcon: false }"
:tree-data="categoryTreeArray"
:virtual="false"
default-expand-all
@select="$emit('select')"
>
<template #title="{ label }">
<span v-if="label.indexOf(searchValue) > -1">
{{ label.substring(0, label.indexOf(searchValue)) }}
<span style="color: #f50">{{ searchValue }}</span>
{{
label.substring(
label.indexOf(searchValue) + searchValue.length,
)
}}
</span>
<span v-else>{{ label }}</span>
</template>
</Tree>
</div>
</div>
</Skeleton>
</div>
</template>

View File

@@ -0,0 +1,36 @@
import { optionsToEnum } from '@vben/utils';
export const activityStatusOptions = [
{
label: '激活',
value: 1,
color: 'success',
enumName: 'Active',
},
{
label: '挂起',
value: 0,
color: 'error',
enumName: 'Suspended',
},
] as const;
export const ActivityStatusEnum = optionsToEnum(activityStatusOptions);
export const publishStatusOptions = [
{
label: '已发布',
value: 1,
color: 'success',
},
{
label: '未发布',
value: 0,
color: 'warning',
},
{
label: '失效',
value: 9,
color: 'error',
},
];

View File

@@ -0,0 +1,103 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { OptionsTag } from '#/components/table';
import { publishStatusOptions } from './constant';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'flowName',
label: '流程名称',
},
{
component: 'Input',
fieldName: 'flowCode',
label: '流程code',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'flowName',
title: '流程名称',
minWidth: 150,
},
{
field: 'flowCode',
title: '流程code',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
minWidth: 80,
formatter: ({ cellValue }) => `V${cellValue}.0`,
},
{
field: 'activityStatus',
title: '激活状态',
minWidth: 100,
slots: {
default: 'activityStatus',
},
},
{
field: 'isPublish',
title: '发布状态',
minWidth: 100,
slots: {
default: ({ row }) => {
const cellValue = row.isPublish;
return (
<OptionsTag options={publishStatusOptions as any} value={cellValue} />
);
},
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 'auto',
},
];
export const modalSchema: FormSchemaGetter = () => [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'id',
},
{
component: 'TreeSelect',
fieldName: 'category',
label: '流程分类',
rules: 'selectRequired',
},
{
component: 'Input',
fieldName: 'flowCode',
label: '流程code',
rules: 'required',
},
{
component: 'Input',
fieldName: 'flowName',
label: '流程名称',
rules: 'required',
},
{
component: 'Input',
fieldName: 'formPath',
label: '表单路径',
rules: 'required',
},
];

View File

@@ -0,0 +1,11 @@
<!--
后端版本>=5.4.0 这个从本地路由变为从后台返回
未修改文件名 而是新加了这个文件
-->
<script setup lang="ts">
import FlowDesignerPage from '../components/flow-designer.vue';
</script>
<template>
<FlowDesignerPage />
</template>

View File

@@ -0,0 +1,378 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { RadioChangeEvent } from 'ant-design-vue';
import type { VbenFormProps } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { getVxePopupContainer } from '@vben/utils';
import {
message,
Modal,
Popconfirm,
RadioGroup,
Space,
Switch,
} from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
unPublishList,
workflowDefinitionActive,
workflowDefinitionCopy,
workflowDefinitionDelete,
workflowDefinitionExport,
workflowDefinitionList,
workflowDefinitionPublish,
} from '#/api/workflow/definition';
import { downloadByData } from '#/utils/file/download';
import CategoryTree from './category-tree.vue';
import { columns, querySchema } from './data';
import processDefinitionDeployModal from './process-definition-deploy-modal.vue';
import processDefinitionModal from './process-definition-modal.vue';
// 左边部门用
const selectedCode = ref<number[] | string[]>([]);
const formOptions: VbenFormProps = {
schema: querySchema(),
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectedCode.value = [];
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'default',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectedCode.value.length === 1) {
formValues.category = selectedCode.value[0];
} else {
Reflect.deleteProperty(formValues, 'category');
}
return await currentTableApi.value({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 100,
},
rowConfig: {
keyField: 'id',
},
id: 'workflow-definition-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
// 左边的切换
const statusOptions = [
{ label: '已发布流程', value: 1 },
{ label: '未发布流程', value: 0 },
];
const currentStatus = ref(1);
const currentTableApi = computed(() => {
if (currentStatus.value === 1) {
return workflowDefinitionList;
}
return unPublishList;
});
async function handleStatusChange(e: RadioChangeEvent) {
currentStatus.value = e.target.value as number;
await tableApi.reload();
}
async function handleDelete(row: Recordable<any>) {
await workflowDefinitionDelete(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: any) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await workflowDefinitionDelete(ids);
await tableApi.query();
},
});
}
const router = useRouter();
/**
* 流程设计/预览
* @param row row
* @param disabled true为预览false为设计
*/
function handleDesign(row: any, disabled: boolean) {
router.push({
path: '/workflow/design/index',
query: { definitionId: row.id, disabled: String(disabled) },
});
}
/**
* 激活/挂起流程
* @param row row
*/
async function handleActive(row: any, status: boolean | number | string) {
const lastStatus = status === 1 ? 0 : 1;
try {
await workflowDefinitionActive(row.id, !!status);
await tableApi.query();
} catch (error) {
row.activityStatus = lastStatus;
console.error(error);
}
}
/**
* 发布流程
* @param row row
*/
async function handlePublish(row: any) {
await workflowDefinitionPublish(row.id);
await tableApi.query();
}
/**
* 复制流程
* @param row row
*/
async function handleCopy(row: any) {
await workflowDefinitionCopy(row.id);
// 跳转到未发布流程tab
currentStatus.value = 0;
await tableApi.reload();
}
const [ProcessDefinitionModal, modalApi] = useVbenModal({
connectedComponent: processDefinitionModal,
});
/**
* 新增流程
*/
function handleAdd() {
modalApi.setData({});
modalApi.open();
}
/**
* 编辑流程
*/
function handleEdit(row: any) {
modalApi.setData({ id: row.id });
modalApi.open();
}
/**
* 导出xml
* @param row row
*/
async function handleExportXml(row: any) {
const hideLoading = message.loading($t('pages.common.downloadLoading'), 0);
try {
const blob = await workflowDefinitionExport(row.id);
downloadByData(blob, `${row.flowName}-${Date.now()}.json`);
} catch (error) {
console.error(error);
} finally {
hideLoading();
}
}
const [ProcessDefinitionDeployModal, deployModalApi] = useVbenModal({
connectedComponent: processDefinitionDeployModal,
});
/**
* 部署流程xml
*/
function handleDeploy() {
if (selectedCode.value.length === 0) {
message.warning('请先选择流程分类');
return;
}
const selectedCategory = selectedCode.value[0];
if (selectedCategory === 0) {
message.warning('不可选择根目录进行部署, 请选择子分类');
return;
}
deployModalApi.setData({ category: selectedCategory });
deployModalApi.open();
}
// 部署流程json
async function handleDeploySuccess() {
// 跳转到未发布
currentStatus.value = 0;
await tableApi.reload();
}
// 新增完成需要跳转到未发布
async function handleReload(type: 'add' | 'update') {
if (type === 'add') {
currentStatus.value = 0;
}
await tableApi.reload();
}
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-[8px]">
<CategoryTree
v-model:select-code="selectedCode"
class="w-[260px]"
@reload="() => tableApi.reload()"
@select="() => tableApi.reload()"
/>
<BasicTable class="flex-1 overflow-hidden">
<template #toolbar-actions>
<RadioGroup
v-model:value="currentStatus"
:options="statusOptions"
button-style="solid"
option-type="button"
@change="handleStatusChange"
/>
</template>
<template #toolbar-tools>
<Space>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['system:user:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button v-access:code="['system:user:add']" @click="handleDeploy">
部署
</a-button>
<a-button
type="primary"
v-access:code="['system:user:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #activityStatus="{ row }">
<Switch
v-model:checked="row.activityStatus"
:checked-value="1"
:unchecked-value="0"
checked-children="激活"
un-checked-children="挂起"
@change="(status) => handleActive(row, status)"
/>
</template>
<template #action="{ row }">
<div class="flex flex-col gap-1">
<div>
<a-button size="small" type="link" @click="handleEdit(row)">
编辑信息
</a-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<a-button danger size="small" type="link" @click.stop="">
删除流程
</a-button>
</Popconfirm>
</div>
<div>
<a-button
size="small"
type="link"
@click="handleDesign(row, !!row.isPublish)"
>
{{ row.isPublish ? '查看流程' : '设计流程' }}
</a-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
:title="`确认发布流程[${row.flowName}]?`"
placement="left"
@confirm="handlePublish(row)"
>
<a-button v-if="!row.isPublish" size="small" type="link">
发布流程
</a-button>
</Popconfirm>
</div>
<div>
<Popconfirm
:get-popup-container="getVxePopupContainer"
:title="`确认复制流程[${row.flowName}]?`"
placement="left"
@confirm="handleCopy(row)"
>
<a-button size="small" type="link"> 复制流程 </a-button>
</Popconfirm>
<a-button size="small" type="link" @click="handleExportXml(row)">
导出流程
</a-button>
</div>
</div>
</template>
</BasicTable>
</div>
<ProcessDefinitionModal @reload="handleReload" />
<ProcessDefinitionDeployModal @reload="handleDeploySuccess" />
</Page>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { InBoxIcon } from '@vben/icons';
import { Upload } from 'ant-design-vue';
import { workflowDefinitionImport } from '#/api/workflow/definition';
const emit = defineEmits<{ reload: [] }>();
const UploadDragger = Upload.Dragger;
const [BasicModal, modalApi] = useVbenModal({
onCancel: handleCancel,
onConfirm: handleSubmit,
});
const fileList = ref<UploadFile[]>([]);
async function handleSubmit() {
try {
modalApi.modalLoading(true);
if (fileList.value.length !== 1) {
handleCancel();
return;
}
const data = {
file: fileList.value[0]!.originFileObj as Blob,
category: modalApi.getData().category,
};
await workflowDefinitionImport(data);
emit('reload');
handleCancel();
} catch (error) {
console.warn(error);
modalApi.close();
} finally {
modalApi.modalLoading(false);
}
}
function handleCancel() {
modalApi.close();
fileList.value = [];
}
</script>
<template>
<BasicModal
:close-on-click-modal="false"
:fullscreen-button="false"
title="流程部署"
>
<!-- z-index不设置会遮挡模板下载loading -->
<!-- 手动处理 而不是放入文件就上传 -->
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="true"
accept="application/json"
>
<p class="ant-upload-drag-icon flex items-center justify-center">
<InBoxIcon class="text-primary size-[48px]" />
</p>
<p class="ant-upload-text">点击或者拖拽到此处上传[json]文件</p>
</UploadDragger>
</BasicModal>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { addFullName, cloneDeep, getPopupContainer } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { categoryTree } from '#/api/workflow/category';
import {
workflowDefinitionAdd,
workflowDefinitionInfo,
workflowDefinitionUpdate,
} from '#/api/workflow/definition';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
const emit = defineEmits<{ reload: [type: 'add' | 'update'] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 90,
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function setupCategorySelect() {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
formApi.updateSchema([
{
componentProps: {
fieldNames: {
label: 'label',
value: 'id',
},
getPopupContainer,
// 设置弹窗滚动高度 默认256
listHeight: 300,
showSearch: true,
treeData: tree,
treeDefaultExpandAll: true,
// 默认展开的树节点
// treeDefaultExpandedKeys: [0],
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'label',
treeNodeLabelProp: 'fullName',
},
fieldName: 'category',
},
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, modalApi] = useVbenModal({
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
// 加载分类树选择
await setupCategorySelect();
if (isUpdate.value && id) {
const record = await workflowDefinitionInfo(id);
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
if (isUpdate.value) {
await workflowDefinitionUpdate(data);
emit('reload', 'update');
} else {
await workflowDefinitionAdd(data);
emit('reload', 'add');
}
resetInitialized();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :fullscreen-button="false" :title="title" class="w-[550px]">
<div class="min-h-[400px]">
<BasicForm />
</div>
</BasicDrawer>
</template>

View File

@@ -0,0 +1,91 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { OptionsTag } from '#/components/table';
import { renderDict } from '#/utils/render';
import { activityStatusOptions } from '../processDefinition/constant';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
label: '任务名称',
fieldName: 'nodeName',
},
{
component: 'Input',
label: '流程名称',
fieldName: 'flowName',
},
{
component: 'Input',
label: '流程编码',
fieldName: 'flowCode',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'flowName',
title: '流程名称',
minWidth: 150,
},
{
field: 'nodeName',
title: '任务名称',
minWidth: 150,
},
{
field: 'flowCode',
title: '流程编码',
minWidth: 150,
},
{
field: 'createByName',
title: '申请人',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
minWidth: 150,
formatter: ({ cellValue }) => `V${cellValue}.0`,
},
{
field: 'activityStatus',
title: '状态',
minWidth: 100,
slots: {
default: ({ row }) => {
const cellValue = row.activityStatus;
return (
<OptionsTag
options={activityStatusOptions as any}
value={cellValue}
/>
);
},
},
},
{
field: 'flowStatus',
title: '流程状态',
minWidth: 100,
slots: {
default: ({ row }) => {
return renderDict(row.flowStatus, DictEnum.WF_BUSINESS_STATUS);
},
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 200,
},
];

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import type { RadioChangeEvent } from 'ant-design-vue';
import type { VbenFormProps } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, RadioGroup, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
deleteByInstanceIds,
pageByFinish,
pageByRunning,
} from '#/api/workflow/instance';
import CategoryTree from '#/views/workflow/processDefinition/category-tree.vue';
import { flowInfoModal } from '../components';
import { columns, querySchema } from './data';
import instanceInvalidModal from './instance-invalid-modal.vue';
import instanceVariableModal from './instance-variable-modal.vue';
// 左边分类用
const selectedCode = ref<number[] | string[]>([]);
const formOptions: VbenFormProps = {
schema: querySchema(),
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectedCode.value = [];
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
};
const typeOptions = [
{ label: '运行中', value: 'process_running' },
{ label: '已完成', value: 'process_completed' },
];
let currentTypeApi = pageByRunning;
const currentType = ref('process_running');
async function handleTypeChange(e: RadioChangeEvent) {
const { value } = e.target;
switch (value) {
case 'process_completed': {
currentTypeApi = pageByFinish;
break;
}
case 'process_running': {
currentTypeApi = pageByRunning;
break;
}
}
await tableApi.reload();
}
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'default',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectedCode.value.length === 1) {
formValues.category = selectedCode.value[0];
} else {
Reflect.deleteProperty(formValues, 'category');
}
return await currentTypeApi({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 66,
},
rowConfig: {
keyField: 'id',
},
id: 'workflow-definition-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [InstanceInvalidModal, instanceInvalidModalApi] = useVbenModal({
connectedComponent: instanceInvalidModal,
});
async function handleInvalid(row: Recordable<any>) {
instanceInvalidModalApi.setData({ id: row.id });
instanceInvalidModalApi.open();
}
async function handleDelete(row: Recordable<any>) {
await deleteByInstanceIds(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: any) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await deleteByInstanceIds(ids);
await tableApi.query();
},
});
}
const [InstanceVariableModal, instanceVariableModalApi] = useVbenModal({
connectedComponent: instanceVariableModal,
});
function handleVariable(row: Recordable<any>) {
instanceVariableModalApi.setData({ record: row.variable });
instanceVariableModalApi.open();
}
const [FlowInfoModal, flowInfoModalApi] = useVbenModal({
connectedComponent: flowInfoModal,
});
function handleInfo(row: any) {
console.log(row);
flowInfoModalApi.setData({ businessId: row.businessId });
flowInfoModalApi.open();
}
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-[8px]">
<CategoryTree
v-model:select-code="selectedCode"
class="w-[260px]"
@reload="() => tableApi.reload()"
@select="() => tableApi.reload()"
/>
<BasicTable class="flex-1 overflow-hidden">
<template #toolbar-actions>
<RadioGroup
v-model:value="currentType"
:options="typeOptions"
button-style="solid"
option-type="button"
@change="handleTypeChange"
/>
</template>
<template #toolbar-tools>
<Space>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['system:user:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<div class="flex flex-col">
<div v-if="currentType === 'process_running'">
<a-button
danger
size="small"
type="link"
@click.stop="handleInvalid(row)"
>
作废流程
</a-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<a-button danger size="small" type="link" @click.stop="">
删除流程
</a-button>
</Popconfirm>
</div>
<div>
<a-button size="small" type="link" @click.stop="handleInfo(row)">
流程预览
</a-button>
<a-button
size="small"
type="link"
@click.stop="handleVariable(row)"
>
变量查看
</a-button>
</div>
</div>
</template>
</BasicTable>
</div>
<InstanceInvalidModal @reload="() => tableApi.reload()" />
<InstanceVariableModal />
<FlowInfoModal />
</Page>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep } from 'lodash-es';
import { useVbenForm } from '#/adapter/form';
import { workflowInstanceInvalid } from '#/api/workflow/instance';
const emit = defineEmits<{ reload: [] }>();
const [BasicModal, modalApi] = useVbenModal({
onConfirm: handleSubmit,
onCancel: handleCancel,
fullscreenButton: false,
title: '作废原因',
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
componentProps: {
class: 'w-full',
},
labelWidth: 80,
},
layout: 'vertical',
schema: [
{
fieldName: 'comment',
label: '作废原因',
component: 'Textarea',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function handleCancel() {
modalApi.close();
await formApi.resetForm();
}
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
data.id = modalApi.getData().id;
await workflowInstanceInvalid(data as any);
emit('reload');
handleCancel();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
</script>
<template>
<BasicModal>
<BasicForm />
</BasicModal>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue';
import { JsonPreview, useVbenModal } from '@vben/common-ui';
const data = ref({});
const [BasicModal, modalApi] = useVbenModal({
title: '流程变量',
fullscreenButton: false,
footer: false,
onOpenChange: (visible) => {
if (!visible) {
data.value = {};
return null;
}
const recordString = modalApi.getData().record;
data.value = JSON.parse(recordString);
},
});
</script>
<template>
<BasicModal>
<div class="min-h-[400px] overflow-y-auto">
<JsonPreview :data="data" />
</div>
</BasicModal>
</template>

View File

@@ -0,0 +1,360 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Segmented,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce, uniqueId } from 'lodash-es';
import { categoryTree } from '#/api/workflow/category';
import { pageByAllTaskFinish, pageByAllTaskWait } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
/**
* 流程监控 - 待办任务页面的id不唯一 改为前端处理
*/
interface TaskItem extends TaskInfo {
active: boolean;
randomId: string;
}
const taskList = ref<TaskItem[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const typeOptions = [
{ label: '待办任务', value: 'todo' },
{ label: '已办任务', value: 'done' },
];
const currentType = ref('todo');
const currentApi = computed(() => {
if (currentType.value === 'todo') {
return pageByAllTaskWait;
}
return pageByAllTaskFinish;
});
const approvalType = computed(() => {
if (currentType.value === 'done') {
return 'readonly';
}
return 'admin';
});
async function handleTypeChange() {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
taskList.value = [];
await nextTick();
await reload(true);
}
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await currentApi.value({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({
...item,
active: false,
randomId: uniqueId(),
}));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
console.log('scrollTop + clientHeight', scrollTop + clientHeight);
console.log('scrollHeight', scrollHeight);
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await currentApi.value({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({
...item,
active: false,
randomId: uniqueId(),
})),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskItem) {
const { randomId } = item;
// 点击的是同一个
if (lastSelectId.value === randomId) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.randomId === randomId;
});
lastSelectId.value = randomId;
}
const { refreshTab } = useTabs();
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
});
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<Segmented
v-model:value="currentType"
:options="typeOptions"
block
class="mb-2"
@change="handleTypeChange"
/>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.randomId"
:info="item"
class="mx-2"
row-key="randomId"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel
:task="currentTask"
:type="approvalType"
@reload="refreshTab"
/>
</div>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -0,0 +1,7 @@
/**
* 底部偏移量
* 在缩放时会差大概0.5px 导致触底逻辑不会触发
* 在这里设置手动补偿
* @see https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IC28RE#note_40175381
*/
export const bottomOffset = 2;

View File

@@ -0,0 +1,257 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { getPopupContainer } from '@vben/utils';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { pageByCurrent } from '#/api/workflow/instance';
import { ApprovalCard, ApprovalPanel } from '../components';
import { bottomOffset } from './constant';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
category: null as null | number, // 流程分类
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
}
loading.value = true;
const resp = await pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByCurrent({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
const { refreshTab } = useTabs();
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="myself" @reload="refreshTab" />
</div>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -0,0 +1,299 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskCopy } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await pageByTaskCopy({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByTaskCopy({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
});
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="readonly" />
</div>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -0,0 +1,299 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { addFullName, getPopupContainer } from '@vben/utils';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskFinish } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await pageByTaskFinish({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByTaskFinish({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
});
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="readonly" />
</div>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -0,0 +1,302 @@
<!-- eslint-disable no-use-before-define -->
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import type { TaskInfo } from '#/api/workflow/task/model';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { addFullName, getPopupContainer } from '@vben/utils';
import { FilterOutlined, RedoOutlined } from '@ant-design/icons-vue';
import {
Empty,
Form,
FormItem,
Input,
InputSearch,
Popover,
Spin,
Tooltip,
TreeSelect,
} from 'ant-design-vue';
import { cloneDeep, debounce } from 'lodash-es';
import { categoryTree } from '#/api/workflow/category';
import { pageByTaskWait } from '#/api/workflow/task';
import { ApprovalCard, ApprovalPanel, CopyComponent } from '../components';
import { bottomOffset } from './constant';
const emptyImage = Empty.PRESENTED_IMAGE_SIMPLE;
const taskList = ref<(TaskInfo & { active: boolean })[]>([]);
const taskTotal = ref(0);
const page = ref(1);
const loading = ref(false);
const defaultFormData = {
flowName: '', // 流程定义名称
nodeName: '', // 任务名称
flowCode: '', // 流程定义编码
createByIds: [] as string[], // 创建人
category: null as null | number, // 流程分类
};
const formData = ref(cloneDeep(defaultFormData));
/**
* 是否已经加载全部数据 即 taskList.length === taskTotal
*/
const isLoadComplete = computed(
() => taskList.value.length === taskTotal.value,
);
// 卡片父容器的ref
const cardContainerRef = useTemplateRef('cardContainerRef');
/**
* @param resetFields 是否清空查询参数
*/
async function reload(resetFields: boolean = false) {
// 需要先滚动到顶部
cardContainerRef.value?.scroll({ top: 0, behavior: 'auto' });
page.value = 1;
currentTask.value = undefined;
taskTotal.value = 0;
lastSelectId.value = '';
if (resetFields) {
formData.value = cloneDeep(defaultFormData);
selectedUserList.value = [];
}
loading.value = true;
const resp = await pageByTaskWait({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value = resp.rows.map((item) => ({ ...item, active: false }));
taskTotal.value = resp.total;
loading.value = false;
// 默认选中第一个
if (taskList.value.length > 0) {
const firstTask = taskList.value[0]!;
currentTask.value = firstTask;
handleCardClick(firstTask);
}
}
onMounted(reload);
const handleScroll = debounce(async (e: Event) => {
if (!e.target) {
return;
}
// e.target.scrollTop 是元素顶部到当前可视区域顶部的距离,即已滚动的高度。
// e.target.clientHeight 是元素的可视高度。
// e.target.scrollHeight 是元素的总高度。
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLElement;
// 判断是否滚动到底部
const isBottom = scrollTop + clientHeight >= scrollHeight - bottomOffset;
// 滚动到底部且没有加载完成
if (isBottom && !isLoadComplete.value) {
loading.value = true;
page.value += 1;
const resp = await pageByTaskWait({
pageSize: 10,
pageNum: page.value,
...formData.value,
});
taskList.value.push(
...resp.rows.map((item) => ({ ...item, active: false })),
);
loading.value = false;
}
}, 200);
const lastSelectId = ref('');
const currentTask = ref<TaskInfo>();
async function handleCardClick(item: TaskInfo) {
const { id } = item;
// 点击的是同一个
if (lastSelectId.value === id) {
return;
}
currentTask.value = item;
// 反选状态 & 如果已经点击了 不变 & 保持只能有一个选中
taskList.value.forEach((item) => {
item.active = item.id === id;
});
lastSelectId.value = id;
}
const { refreshTab } = useTabs();
// 由于失去焦点浮层会消失 使用v-model选择人员完毕后强制显示
const popoverOpen = ref(false);
const selectedUserList = ref<User[]>([]);
function handleFinish(userList: User[]) {
popoverOpen.value = true;
selectedUserList.value = userList;
formData.value.createByIds = userList.map((item) => item.userId);
}
const treeData = ref<any[]>([]);
onMounted(async () => {
// menu
const tree = await categoryTree();
addFullName(tree, 'label', ' / ');
treeData.value = tree;
});
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-2">
<div
class="bg-background relative flex h-full min-w-[320px] max-w-[320px] flex-col rounded-lg"
>
<!-- 搜索条件 -->
<div
class="bg-background z-100 sticky left-0 top-0 w-full rounded-t-lg border-b-[1px] border-solid p-2"
>
<div class="flex items-center gap-1">
<InputSearch
v-model:value="formData.flowName"
placeholder="流程名称搜索"
@search="reload(false)"
/>
<Tooltip placement="top" title="重置">
<a-button @click="reload(true)">
<RedoOutlined />
</a-button>
</Tooltip>
<Popover
v-model:open="popoverOpen"
:get-popup-container="getPopupContainer"
placement="rightTop"
trigger="click"
>
<template #title>
<div class="w-full border-b pb-[12px] text-[16px]">搜索</div>
</template>
<template #content>
<Form
:colon="false"
:label-col="{ span: 6 }"
:model="formData"
autocomplete="off"
class="w-[300px]"
@finish="() => reload(false)"
>
<FormItem label="申请人">
<!-- 弹窗关闭后仍然显示表单浮层 -->
<CopyComponent
v-model:user-list="selectedUserList"
@cancel="() => (popoverOpen = true)"
@finish="handleFinish"
/>
</FormItem>
<FormItem label="流程分类">
<TreeSelect
v-model:value="formData.category"
:allow-clear="true"
:field-names="{ label: 'label', value: 'id' }"
:get-popup-container="getPopupContainer"
:tree-data="treeData"
:tree-default-expand-all="true"
:tree-line="{ showLeafIcon: false }"
placeholder="请选择"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
/>
</FormItem>
<FormItem label="任务名称">
<Input
v-model:value="formData.nodeName"
placeholder="请输入"
/>
</FormItem>
<FormItem label="流程编码">
<Input
v-model:value="formData.flowCode"
placeholder="请输入"
/>
</FormItem>
<FormItem>
<div class="flex">
<a-button block html-type="submit" type="primary">
搜索
</a-button>
<a-button block class="ml-2" @click="reload(true)">
重置
</a-button>
</div>
</FormItem>
</Form>
</template>
<a-button>
<FilterOutlined />
</a-button>
</Popover>
</div>
</div>
<div
ref="cardContainerRef"
class="thin-scrollbar flex flex-1 flex-col gap-2 overflow-y-auto py-3"
@scroll="handleScroll"
>
<template v-if="taskList.length > 0">
<ApprovalCard
v-for="item in taskList"
:key="item.id"
:info="item"
class="mx-2"
@click="handleCardClick(item)"
/>
</template>
<Empty v-else :image="emptyImage" />
<div
v-if="isLoadComplete && taskList.length > 0"
class="flex items-center justify-center text-[14px] opacity-50"
>
没有更多数据了
</div>
<!-- 遮罩loading层 -->
<div
v-if="loading"
class="absolute left-0 top-0 flex h-full w-full items-center justify-center bg-[rgba(0,0,0,0.1)]"
>
<Spin tip="加载中..." />
</div>
</div>
<!-- total显示 -->
<div
class="bg-background sticky bottom-0 w-full rounded-b-lg border-t-[1px] py-2"
>
<div class="flex items-center justify-center">
{{ taskTotal }} 条记录
</div>
</div>
</div>
<ApprovalPanel :task="currentTask" type="approve" @reload="refreshTab" />
</div>
</Page>
</template>
<style lang="scss" scoped>
.thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
}
}
:deep(.ant-card-body) {
@apply thin-scrollbar;
}
</style>

View File

@@ -111,11 +111,12 @@ async function handleConfirm() {
const data = cloneDeep(await formApi.getValues())
// 通道信息
const filteredChannels = dynamicValidateForm.floor
.filter(item => !(item.out.length === 0 && item.in.length === 0))
.filter(item => !(item.outUp.length === 0 && item.outDown.length === 0 && item.in.length === 0))
.map(item => ({
floorId: item.id,
inChannel: item.in,
outChannel: item.out
upChannel: item.outUp,
downChannel: item.outDown,
}))
data.channels = filteredChannels
@@ -223,7 +224,8 @@ async function setupCommunitySelect() {
}
interface floor {
out: string
outUp: string
outDown: string
in: string
num: string | number
id: string | number
@@ -238,7 +240,8 @@ async function handleGetFloor(unitId: string | number) {
floorList.value = []
res.forEach((item) => {
floorList.value.push({
out: '',
outUp: '',
outDown: '',
in: '',
num: item.floorNumber,
id: item.id,
@@ -268,10 +271,14 @@ async function handleClosed() {
<Form :model="dynamicValidateForm" layout="inline">
<Space v-for="(floor, index) in dynamicValidateForm.floor" :key="floor.id"
style="display: flex; margin-bottom: 8px" align="baseline">
<FormItem :label="'楼层' + (floor.num)" :name="['floor', index, 'out']">
<Input v-model:value="floor.out" placeholder="外部按键通道" />
<span>{{ "楼层"+floor.num }}</span>
<FormItem label="上键通道" :name="['floor', index, 'outUp']">
<Input v-model:value="floor.outUp" placeholder="外部按键通道" />
</FormItem>
<FormItem :name="['floor', index, 'in']">
<FormItem label="下键通道" :name="['floor', index, 'outDown']">
<Input v-model:value="floor.outDown" placeholder="内部按键通道" />
</FormItem>
<FormItem label="楼层通道" :name="['floor', index, 'in']">
<Input v-model:value="floor.in" placeholder="内部按键通道" />
</FormItem>
</Space>