@@ -21,3 +21,6 @@ VITE_INJECT_APP_LOADING=true
|
|||||||
|
|
||||||
# 打包后是否生成dist.zip
|
# 打包后是否生成dist.zip
|
||||||
VITE_ARCHIVER=true
|
VITE_ARCHIVER=true
|
||||||
|
|
||||||
|
# 验证码的开关
|
||||||
|
VITE_APP_CAPTCHA_ENABLE=true
|
||||||
@@ -3,39 +3,78 @@ import type { PageParam, PageResult } from '@vben/request';
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace IotDeviceApi {
|
export namespace IotDeviceApi {
|
||||||
// TODO @haohao:需要跟后端对齐,必要的 ReqVO、RespVO
|
/** 设备新增/修改 Request VO */
|
||||||
/** 设备 */
|
// TODO @haohao:可以降低一些 VO 哈:DeviceSaveReqVO、DeviceRespVO 合并成 Device 就好,类似别的模块
|
||||||
export interface Device {
|
export interface DeviceSaveReqVO {
|
||||||
id?: number; // 设备 ID,主键,自增
|
id?: number; // 设备编号
|
||||||
deviceName: string; // 设备名称
|
deviceName: string; // 设备名称
|
||||||
|
nickname?: string; // 备注名称
|
||||||
|
serialNumber?: string; // 设备序列号
|
||||||
|
picUrl?: string; // 设备图片
|
||||||
|
groupIds?: number[]; // 设备分组编号数组
|
||||||
|
productId: number; // 产品编号(必填)
|
||||||
|
gatewayId?: number; // 网关设备 ID
|
||||||
|
config?: string; // 设备配置
|
||||||
|
locationType: number; // 定位类型(必填)
|
||||||
|
latitude?: number; // 设备位置的纬度
|
||||||
|
longitude?: number; // 设备位置的经度
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备更新分组 Request VO */
|
||||||
|
export interface DeviceUpdateGroupReqVO {
|
||||||
|
ids: number[]; // 设备编号列表(必填)
|
||||||
|
groupIds: number[]; // 分组编号列表(必填)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备分页 Request VO */
|
||||||
|
// TODO @haohao:可以不用 DevicePageReqVO,直接 PageParam 即可,简洁一点。这里的强类型,收益不大;
|
||||||
|
export interface DevicePageReqVO extends PageParam {
|
||||||
|
deviceName?: string; // 设备名称
|
||||||
|
nickname?: string; // 备注名称
|
||||||
|
productId?: number; // 产品编号
|
||||||
|
deviceType?: number; // 设备类型
|
||||||
|
status?: number; // 设备状态
|
||||||
|
groupId?: number; // 设备分组编号
|
||||||
|
gatewayId?: number; // 网关设备 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备 Response VO */
|
||||||
|
export interface DeviceRespVO {
|
||||||
|
id: number; // 设备编号
|
||||||
|
deviceName: string; // 设备名称
|
||||||
|
nickname?: string; // 设备备注名称
|
||||||
|
serialNumber?: string; // 设备序列号
|
||||||
|
picUrl?: string; // 设备图片
|
||||||
|
groupIds?: number[]; // 设备分组编号数组
|
||||||
productId: number; // 产品编号
|
productId: number; // 产品编号
|
||||||
productKey?: string; // 产品标识
|
productKey?: string; // 产品标识
|
||||||
deviceType?: number; // 设备类型
|
deviceType?: number; // 设备类型
|
||||||
nickname?: string; // 设备备注名称
|
|
||||||
gatewayId?: number; // 网关设备 ID
|
gatewayId?: number; // 网关设备 ID
|
||||||
state?: number; // 设备状态
|
state?: number; // 设备状态
|
||||||
status?: number; // 设备状态(兼容字段)
|
|
||||||
onlineTime?: Date; // 最后上线时间
|
onlineTime?: Date; // 最后上线时间
|
||||||
offlineTime?: Date; // 最后离线时间
|
offlineTime?: Date; // 最后离线时间
|
||||||
activeTime?: Date; // 设备激活时间
|
activeTime?: Date; // 设备激活时间
|
||||||
createTime?: Date; // 创建时间
|
deviceSecret?: string; // 设备密钥,用于设备认证
|
||||||
ip?: string; // 设备的 IP 地址
|
authType?: string; // 认证类型(如一机一密、动态注册)
|
||||||
firmwareVersion?: string; // 设备的固件版本
|
config?: string; // 设备配置
|
||||||
deviceSecret?: string; // 设备密钥,用于设备认证,需安全存储
|
locationType?: number; // 定位方式
|
||||||
mqttClientId?: string; // MQTT 客户端 ID
|
|
||||||
mqttUsername?: string; // MQTT 用户名
|
|
||||||
mqttPassword?: string; // MQTT 密码
|
|
||||||
authType?: string; // 认证类型
|
|
||||||
locationType?: number; // 定位类型
|
|
||||||
latitude?: number; // 设备位置的纬度
|
latitude?: number; // 设备位置的纬度
|
||||||
longitude?: number; // 设备位置的经度
|
longitude?: number; // 设备位置的经度
|
||||||
areaId?: number; // 地区编码
|
createTime?: Date; // 创建时间
|
||||||
address?: string; // 设备详细地址
|
}
|
||||||
serialNumber?: string; // 设备序列号
|
|
||||||
config?: string; // 设备配置
|
/** 设备认证信息 Response VO */
|
||||||
groupIds?: number[]; // 添加分组 ID
|
export interface DeviceAuthInfoRespVO {
|
||||||
picUrl?: string; // 设备图片
|
clientId: string; // 客户端 ID
|
||||||
location?: string; // 位置信息(格式:经度,纬度)
|
username: string; // 用户名
|
||||||
|
password: string; // 密码
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备导入 Response VO */
|
||||||
|
export interface DeviceImportRespVO {
|
||||||
|
createDeviceNames?: string[]; // 创建成功的设备名称列表
|
||||||
|
updateDeviceNames?: string[]; // 更新成功的设备名称列表
|
||||||
|
failureDeviceNames?: Record<string, string>; // 失败的设备名称及原因
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IoT 设备属性详细 VO */
|
/** IoT 设备属性详细 VO */
|
||||||
@@ -56,30 +95,17 @@ export namespace IotDeviceApi {
|
|||||||
updateTime: Date; // 更新时间
|
updateTime: Date; // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设备认证参数 VO */
|
|
||||||
export interface DeviceAuthInfo {
|
|
||||||
clientId: string; // 客户端 ID
|
|
||||||
username: string; // 用户名
|
|
||||||
password: string; // 密码
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 设备发送消息 Request VO */
|
/** 设备发送消息 Request VO */
|
||||||
export interface DeviceMessageSendReq {
|
export interface DeviceMessageSendReq {
|
||||||
deviceId: number; // 设备编号
|
deviceId: number; // 设备编号
|
||||||
method: string; // 请求方法
|
method: string; // 请求方法
|
||||||
params?: any; // 请求参数
|
params?: any; // 请求参数
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设备分组更新请求 */
|
|
||||||
export interface DeviceGroupUpdateReq {
|
|
||||||
ids: number[]; // 设备 ID 列表
|
|
||||||
groupIds: number[]; // 分组 ID 列表
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询设备分页 */
|
/** 查询设备分页 */
|
||||||
export function getDevicePage(params: PageParam) {
|
export function getDevicePage(params: IotDeviceApi.DevicePageReqVO) {
|
||||||
return requestClient.get<PageResult<IotDeviceApi.Device>>(
|
return requestClient.get<PageResult<IotDeviceApi.DeviceRespVO>>(
|
||||||
'/iot/device/page',
|
'/iot/device/page',
|
||||||
{ params },
|
{ params },
|
||||||
);
|
);
|
||||||
@@ -87,38 +113,40 @@ export function getDevicePage(params: PageParam) {
|
|||||||
|
|
||||||
/** 查询设备详情 */
|
/** 查询设备详情 */
|
||||||
export function getDevice(id: number) {
|
export function getDevice(id: number) {
|
||||||
return requestClient.get<IotDeviceApi.Device>(`/iot/device/get?id=${id}`);
|
return requestClient.get<IotDeviceApi.DeviceRespVO>(
|
||||||
|
`/iot/device/get?id=${id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增设备 */
|
/** 新增设备 */
|
||||||
export function createDevice(data: IotDeviceApi.Device) {
|
export function createDevice(data: IotDeviceApi.DeviceSaveReqVO) {
|
||||||
return requestClient.post('/iot/device/create', data);
|
return requestClient.post<number>('/iot/device/create', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改设备 */
|
/** 修改设备 */
|
||||||
export function updateDevice(data: IotDeviceApi.Device) {
|
export function updateDevice(data: IotDeviceApi.DeviceSaveReqVO) {
|
||||||
return requestClient.put('/iot/device/update', data);
|
return requestClient.put<boolean>('/iot/device/update', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改设备分组 */
|
/** 修改设备分组 */
|
||||||
export function updateDeviceGroup(data: IotDeviceApi.DeviceGroupUpdateReq) {
|
export function updateDeviceGroup(data: IotDeviceApi.DeviceUpdateGroupReqVO) {
|
||||||
return requestClient.put('/iot/device/update-group', data);
|
return requestClient.put<boolean>('/iot/device/update-group', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除单个设备 */
|
/** 删除单个设备 */
|
||||||
export function deleteDevice(id: number) {
|
export function deleteDevice(id: number) {
|
||||||
return requestClient.delete(`/iot/device/delete?id=${id}`);
|
return requestClient.delete<boolean>(`/iot/device/delete?id=${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除多个设备 */
|
/** 删除多个设备 */
|
||||||
export function deleteDeviceList(ids: number[]) {
|
export function deleteDeviceList(ids: number[]) {
|
||||||
return requestClient.delete('/iot/device/delete-list', {
|
return requestClient.delete<boolean>('/iot/device/delete-list', {
|
||||||
params: { ids: ids.join(',') },
|
params: { ids: ids.join(',') },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出设备 */
|
/** 导出设备 */
|
||||||
export function exportDeviceExcel(params: any) {
|
export function exportDeviceExcel(params: IotDeviceApi.DevicePageReqVO) {
|
||||||
return requestClient.download('/iot/device/export-excel', { params });
|
return requestClient.download('/iot/device/export-excel', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,16 +157,22 @@ export function getDeviceCount(productId: number) {
|
|||||||
|
|
||||||
/** 获取设备的精简信息列表 */
|
/** 获取设备的精简信息列表 */
|
||||||
export function getSimpleDeviceList(deviceType?: number, productId?: number) {
|
export function getSimpleDeviceList(deviceType?: number, productId?: number) {
|
||||||
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', {
|
return requestClient.get<IotDeviceApi.DeviceRespVO[]>(
|
||||||
params: { deviceType, productId },
|
'/iot/device/simple-list',
|
||||||
});
|
{
|
||||||
|
params: { deviceType, productId },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据产品编号,获取设备的精简信息列表 */
|
/** 根据产品编号,获取设备的精简信息列表 */
|
||||||
export function getDeviceListByProductId(productId: number) {
|
export function getDeviceListByProductId(productId: number) {
|
||||||
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', {
|
return requestClient.get<IotDeviceApi.DeviceRespVO[]>(
|
||||||
params: { productId },
|
'/iot/device/simple-list',
|
||||||
});
|
{
|
||||||
|
params: { productId },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取导入模板 */
|
/** 获取导入模板 */
|
||||||
@@ -148,10 +182,13 @@ export function importDeviceTemplate() {
|
|||||||
|
|
||||||
/** 导入设备 */
|
/** 导入设备 */
|
||||||
export function importDevice(file: File, updateSupport: boolean) {
|
export function importDevice(file: File, updateSupport: boolean) {
|
||||||
return requestClient.upload('/iot/device/import', {
|
return requestClient.upload<IotDeviceApi.DeviceImportRespVO>(
|
||||||
file,
|
'/iot/device/import',
|
||||||
updateSupport,
|
{
|
||||||
});
|
file,
|
||||||
|
updateSupport,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取设备属性最新数据 */
|
/** 获取设备属性最新数据 */
|
||||||
@@ -172,7 +209,7 @@ export function getHistoryDevicePropertyList(params: any) {
|
|||||||
|
|
||||||
/** 获取设备认证信息 */
|
/** 获取设备认证信息 */
|
||||||
export function getDeviceAuthInfo(id: number) {
|
export function getDeviceAuthInfo(id: number) {
|
||||||
return requestClient.get<IotDeviceApi.DeviceAuthInfo>(
|
return requestClient.get<IotDeviceApi.DeviceAuthInfoRespVO>(
|
||||||
'/iot/device/get-auth-info',
|
'/iot/device/get-auth-info',
|
||||||
{ params: { id } },
|
{ params: { id } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,14 +43,6 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading(通过 handleReady)
|
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading(通过 handleReady)
|
||||||
modalLoading(true);
|
modalLoading(true);
|
||||||
const img = new Image();
|
|
||||||
img.src = src.value;
|
|
||||||
img.addEventListener('load', () => {
|
|
||||||
modalLoading(false);
|
|
||||||
});
|
|
||||||
img.addEventListener('error', () => {
|
|
||||||
modalLoading(false);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// 关闭时,清空右侧预览
|
// 关闭时,清空右侧预览
|
||||||
previewSource.value = '';
|
previewSource.value = '';
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ export function useImagesUpload() {
|
|||||||
default: 5,
|
default: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup(props) {
|
||||||
// TODO: @puhui999:@dhb52 其实还是靠 props 默认参数起作用,没能从 formCreate 传递
|
return () => (
|
||||||
return (props: { maxNumber?: number; multiple?: boolean }) => (
|
|
||||||
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const apiSelectRule = [
|
|||||||
field: 'data',
|
field: 'data',
|
||||||
title: '请求参数 JSON 格式',
|
title: '请求参数 JSON 格式',
|
||||||
props: {
|
props: {
|
||||||
autoSize: true,
|
autoSize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
placeholder: '{"type": 1}',
|
placeholder: '{"type": 1}',
|
||||||
},
|
},
|
||||||
@@ -155,7 +155,7 @@ const apiSelectRule = [
|
|||||||
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
||||||
(data: any)=>{ label: string; value: any }[]`,
|
(data: any)=>{ label: string; value: any }[]`,
|
||||||
props: {
|
props: {
|
||||||
autoSize: true,
|
autoSize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||||
rows: { minRows: 2, maxRows: 6 },
|
rows: { minRows: 2, maxRows: 6 },
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
placeholder: `
|
placeholder: `
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function useDictSelectRule() {
|
|||||||
title: label,
|
title: label,
|
||||||
info: '',
|
info: '',
|
||||||
$required: false,
|
$required: false,
|
||||||
modelField: 'value',
|
modelField: 'value', // 特殊:ele 里是 model-value,antd 里是 value
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
props(_: any, { t }: any) {
|
props(_: any, { t }: any) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// TODO @xingyu:要不要改成 yudao-ui-admin-vue3/src/components/OperateLogV2/src/OperateLogV2.vue 这种;一行:时间、userType、userName、action
|
|
||||||
import type { OperateLogProps } from './typing';
|
import type { OperateLogProps } from './typing';
|
||||||
|
|
||||||
import { DICT_TYPE } from '@vben/constants';
|
import { DICT_TYPE } from '@vben/constants';
|
||||||
@@ -14,37 +13,46 @@ withDefaults(defineProps<OperateLogProps>(), {
|
|||||||
logList: () => [],
|
logList: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 获得 userType 颜色 */
|
||||||
function getUserTypeColor(userType: number) {
|
function getUserTypeColor(userType: number) {
|
||||||
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
||||||
if (dict && dict.colorType) {
|
switch (dict?.colorType) {
|
||||||
return `hsl(var(--${dict.colorType}))`;
|
case 'danger': {
|
||||||
|
return '#F56C6C';
|
||||||
|
}
|
||||||
|
case 'info': {
|
||||||
|
return '#909399';
|
||||||
|
}
|
||||||
|
case 'success': {
|
||||||
|
return '#67C23A';
|
||||||
|
}
|
||||||
|
case 'warning': {
|
||||||
|
return '#E6A23C';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'hsl(var(--primary))';
|
return '#409EFF';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="pt-5">
|
||||||
<Timeline>
|
<Timeline>
|
||||||
<Timeline.Item
|
<Timeline.Item v-for="log in logList" :key="log.id">
|
||||||
v-for="log in logList"
|
|
||||||
:key="log.id"
|
|
||||||
:color="getUserTypeColor(log.userType)"
|
|
||||||
>
|
|
||||||
<template #dot>
|
<template #dot>
|
||||||
<p
|
<span
|
||||||
:style="{ backgroundColor: getUserTypeColor(log.userType) }"
|
:style="{ backgroundColor: getUserTypeColor(log.userType) }"
|
||||||
class="absolute left-1 top-0 flex h-5 w-5 items-center justify-center rounded-full text-xs text-white"
|
class="flex h-5 w-5 items-center justify-center rounded-full text-[10px] text-white"
|
||||||
>
|
>
|
||||||
{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
|
{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
|
||||||
</p>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<p class="ml-2">{{ formatDateTime(log.createTime) }}</p>
|
<div class="ml-2 flex flex-wrap items-center gap-2 leading-[22px]">
|
||||||
<p class="ml-2 mt-2">
|
<span class="w-[140px] shrink-0 text-[13px] text-gray-400">
|
||||||
<Tag :color="getUserTypeColor(log.userType)">
|
{{ formatDateTime(log.createTime) }}
|
||||||
{{ log.userName }}
|
</span>
|
||||||
</Tag>
|
<Tag color="success" class="!mr-0">{{ log.userName }}</Tag>
|
||||||
{{ log.action }}
|
<span>{{ log.action }}</span>
|
||||||
</p>
|
</div>
|
||||||
</Timeline.Item>
|
</Timeline.Item>
|
||||||
</Timeline>
|
</Timeline>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import type { SystemUserProfileApi } from '#/api/system/user/profile';
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui';
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import { Card, Tabs } from 'ant-design-vue';
|
import { Card, Tabs } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getAuthPermissionInfoApi } from '#/api';
|
||||||
import { getUserProfile } from '#/api/system/user/profile';
|
import { getUserProfile } from '#/api/system/user/profile';
|
||||||
import { useAuthStore } from '#/store';
|
|
||||||
|
|
||||||
import BaseInfo from './modules/base-info.vue';
|
import BaseInfo from './modules/base-info.vue';
|
||||||
import ProfileUser from './modules/profile-user.vue';
|
import ProfileUser from './modules/profile-user.vue';
|
||||||
import ResetPwd from './modules/reset-pwd.vue';
|
import ResetPwd from './modules/reset-pwd.vue';
|
||||||
import UserSocial from './modules/user-social.vue';
|
import UserSocial from './modules/user-social.vue';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const userStore = useUserStore();
|
||||||
const activeName = ref('basicInfo');
|
const activeName = ref('basicInfo');
|
||||||
|
|
||||||
/** 加载个人信息 */
|
/** 加载个人信息 */
|
||||||
@@ -30,7 +31,8 @@ async function refreshProfile() {
|
|||||||
await loadProfile();
|
await loadProfile();
|
||||||
|
|
||||||
// 更新 store
|
// 更新 store
|
||||||
await authStore.fetchUserInfo();
|
const authPermissionInfo = await getAuthPermissionInfoApi();
|
||||||
|
userStore.setUserInfo(authPermissionInfo.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化 */
|
/** 初始化 */
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
toolbarConfig: {
|
toolbarConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 解绑账号 */
|
/** 解绑账号 */
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import ProcessListenerSelectModal from '#/views/bpm/processListener/components/process-listener-select-modal.vue';
|
import { ProcessListenerSelectModal } from '#/views/bpm/processListener/components';
|
||||||
|
|
||||||
import { createListenerObject, updateElementExtensions } from '../../utils';
|
import { createListenerObject, updateElementExtensions } from '../../utils';
|
||||||
import ListenerFieldModal from './ListenerFieldModal.vue';
|
import ListenerFieldModal from './ListenerFieldModal.vue';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import ProcessListenerSelectModal from '#/views/bpm/processListener/components/process-listener-select-modal.vue';
|
import { ProcessListenerSelectModal } from '#/views/bpm/processListener/components';
|
||||||
|
|
||||||
import { createListenerObject, updateElementExtensions } from '../../utils';
|
import { createListenerObject, updateElementExtensions } from '../../utils';
|
||||||
import ListenerFieldModal from './ListenerFieldModal.vue';
|
import ListenerFieldModal from './ListenerFieldModal.vue';
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
MULTI_LEVEL_DEPT,
|
MULTI_LEVEL_DEPT,
|
||||||
} from '#/views/bpm/components/simple-process-design/consts';
|
} from '#/views/bpm/components/simple-process-design/consts';
|
||||||
import { useFormFieldsPermission } from '#/views/bpm/components/simple-process-design/helpers';
|
import { useFormFieldsPermission } from '#/views/bpm/components/simple-process-design/helpers';
|
||||||
import ProcessExpressionSelectModal from '#/views/bpm/processExpression/components/process-expression-select-modal.vue';
|
import { ProcessExpressionSelectModal } from '#/views/bpm/processExpression/components';
|
||||||
|
|
||||||
defineOptions({ name: 'UserTask' });
|
defineOptions({ name: 'UserTask' });
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
|
|
||||||
import { getForm } from '#/api/bpm/form';
|
import { getForm } from '#/api/bpm/form';
|
||||||
import { getModelList } from '#/api/bpm/model';
|
import { getModelList } from '#/api/bpm/model';
|
||||||
|
import { parseFormFields } from '#/components/form-create';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CHILD_PROCESS_MULTI_INSTANCE_SOURCE_TYPE,
|
CHILD_PROCESS_MULTI_INSTANCE_SOURCE_TYPE,
|
||||||
@@ -42,12 +43,7 @@ import {
|
|||||||
TIME_UNIT_TYPES,
|
TIME_UNIT_TYPES,
|
||||||
TimeUnitType,
|
TimeUnitType,
|
||||||
} from '../../consts';
|
} from '../../consts';
|
||||||
import {
|
import { useFormFields, useNodeName, useWatchNode } from '../../helpers';
|
||||||
parseFormFields,
|
|
||||||
useFormFields,
|
|
||||||
useNodeName,
|
|
||||||
useWatchNode,
|
|
||||||
} from '../../helpers';
|
|
||||||
import { convertTimeUnit } from './utils';
|
import { convertTimeUnit } from './utils';
|
||||||
|
|
||||||
defineOptions({ name: 'ChildProcessNodeConfig' });
|
defineOptions({ name: 'ChildProcessNodeConfig' });
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function changeNodeName() {
|
|||||||
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Drawer class="w-1/3">
|
<Drawer class="w-2/5">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Drawer class="w-1/3">
|
<Drawer class="w-2/5">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="config-header">
|
<div class="config-header">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import type { ComponentPublicInstance, Ref } from 'vue';
|
|||||||
import type { ButtonSetting, SimpleFlowNode } from '../../consts';
|
import type { ButtonSetting, SimpleFlowNode } from '../../consts';
|
||||||
import type { UserTaskFormType } from '../../helpers';
|
import type { UserTaskFormType } from '../../helpers';
|
||||||
|
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
import { computed, nextTick, onMounted, reactive, ref, watchEffect } from 'vue';
|
||||||
|
|
||||||
import { useVbenDrawer } from '@vben/common-ui';
|
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||||
import {
|
import {
|
||||||
BpmModelFormType,
|
BpmModelFormType,
|
||||||
BpmNodeTypeEnum,
|
BpmNodeTypeEnum,
|
||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
TypographyText,
|
TypographyText,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ProcessExpressionSelectModal } from '#/views/bpm/processExpression/components';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
APPROVE_METHODS,
|
APPROVE_METHODS,
|
||||||
APPROVE_TYPE,
|
APPROVE_TYPE,
|
||||||
@@ -112,10 +114,20 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [ExpressionSelectModal, expressionSelectModalApi] = useVbenModal({
|
||||||
|
connectedComponent: ProcessExpressionSelectModal,
|
||||||
|
destroyOnClose: true,
|
||||||
|
showConfirmButton: false,
|
||||||
|
});
|
||||||
|
|
||||||
// 节点名称配置
|
// 节点名称配置
|
||||||
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
|
const { nodeName, showInput, clickIcon, changeNodeName, inputRef } =
|
||||||
useNodeName(BpmNodeTypeEnum.USER_TASK_NODE);
|
useNodeName(BpmNodeTypeEnum.USER_TASK_NODE);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
void inputRef.value;
|
||||||
|
});
|
||||||
|
|
||||||
// 激活的 Tab 标签页
|
// 激活的 Tab 标签页
|
||||||
const activeTabName = ref('user');
|
const activeTabName = ref('user');
|
||||||
|
|
||||||
@@ -218,9 +230,18 @@ function changeCandidateStrategy() {
|
|||||||
configForm.value.deptLevel = 1;
|
configForm.value.deptLevel = 1;
|
||||||
configForm.value.formUser = '';
|
configForm.value.formUser = '';
|
||||||
configForm.value.formDept = '';
|
configForm.value.formDept = '';
|
||||||
|
configForm.value.expression = '';
|
||||||
configForm.value.approveMethod = ApproveMethodType.SEQUENTIAL_APPROVE;
|
configForm.value.approveMethod = ApproveMethodType.SEQUENTIAL_APPROVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openExpressionSelect() {
|
||||||
|
expressionSelectModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExpressionSelected(row: any) {
|
||||||
|
configForm.value.expression = row?.expression ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
/** 审批方式改变 */
|
/** 审批方式改变 */
|
||||||
function approveMethodChanged() {
|
function approveMethodChanged() {
|
||||||
configForm.value.rejectHandlerType = RejectHandlerType.FINISH_PROCESS;
|
configForm.value.rejectHandlerType = RejectHandlerType.FINISH_PROCESS;
|
||||||
@@ -843,7 +864,6 @@ onMounted(() => {
|
|||||||
</SelectOption>
|
</SelectOption>
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<!-- TODO @jason:后续要支持选择已经存好的表达式 -->
|
|
||||||
<FormItem
|
<FormItem
|
||||||
v-if="
|
v-if="
|
||||||
configForm.candidateStrategy === CandidateStrategy.EXPRESSION
|
configForm.candidateStrategy === CandidateStrategy.EXPRESSION
|
||||||
@@ -851,7 +871,15 @@ onMounted(() => {
|
|||||||
label="流程表达式"
|
label="流程表达式"
|
||||||
name="expression"
|
name="expression"
|
||||||
>
|
>
|
||||||
<Textarea v-model:value="configForm.expression" allow-clear />
|
<div class="flex gap-2">
|
||||||
|
<Textarea v-model:value="configForm.expression" :rows="2" />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button type="primary" @click="openExpressionSelect">
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
<Button @click="configForm.expression = ''">清空</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<!-- 多人审批/办理 方式 -->
|
<!-- 多人审批/办理 方式 -->
|
||||||
<FormItem :label="`多人${nodeTypeName}方式`" name="approveMethod">
|
<FormItem :label="`多人${nodeTypeName}方式`" name="approveMethod">
|
||||||
@@ -1266,4 +1294,6 @@ onMounted(() => {
|
|||||||
</TabPane>
|
</TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<ExpressionSelectModal @select="handleExpressionSelected" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
ProcessVariableEnum,
|
ProcessVariableEnum,
|
||||||
} from '@vben/constants';
|
} from '@vben/constants';
|
||||||
|
|
||||||
|
import { parseFormFields } from '#/components/form-create';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApproveMethodType,
|
ApproveMethodType,
|
||||||
AssignEmptyHandlerType,
|
AssignEmptyHandlerType,
|
||||||
@@ -56,49 +58,6 @@ function parseFormCreateFields(formFields?: string[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析表单组件的 field, title 等字段(递归,如果组件包含子组件)
|
|
||||||
*
|
|
||||||
* @param rule 组件的生成规则 https://www.form-create.com/v3/guide/rule
|
|
||||||
* @param fields 解析后表单组件字段
|
|
||||||
* @param parentTitle 如果是子表单,子表单的标题,默认为空
|
|
||||||
*/
|
|
||||||
export const parseFormFields = (
|
|
||||||
rule: Record<string, any>,
|
|
||||||
fields: Array<Record<string, any>> = [],
|
|
||||||
parentTitle: string = '',
|
|
||||||
) => {
|
|
||||||
const { type, field, $required, title: tempTitle, children } = rule;
|
|
||||||
if (field && tempTitle) {
|
|
||||||
let title = tempTitle;
|
|
||||||
if (parentTitle) {
|
|
||||||
title = `${parentTitle}.${tempTitle}`;
|
|
||||||
}
|
|
||||||
let required = false;
|
|
||||||
if ($required) {
|
|
||||||
required = true;
|
|
||||||
}
|
|
||||||
fields.push({
|
|
||||||
field,
|
|
||||||
title,
|
|
||||||
type,
|
|
||||||
required,
|
|
||||||
});
|
|
||||||
// TODO 子表单 需要处理子表单字段
|
|
||||||
// if (type === 'group' && rule.props?.rule && Array.isArray(rule.props.rule)) {
|
|
||||||
// // 解析子表单的字段
|
|
||||||
// rule.props.rule.forEach((item) => {
|
|
||||||
// parseFields(item, fieldsPermission, title)
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
if (children && Array.isArray(children)) {
|
|
||||||
children.forEach((rule) => {
|
|
||||||
parseFormFields(rule, fields);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 表单数据权限配置,用于发起人节点 、审批节点、抄送节点
|
* @description 表单数据权限配置,用于发起人节点 、审批节点、抄送节点
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ export { default as SimpleProcessDesigner } from './components/simple-process-de
|
|||||||
export { default as SimpleProcessViewer } from './components/simple-process-viewer.vue';
|
export { default as SimpleProcessViewer } from './components/simple-process-viewer.vue';
|
||||||
|
|
||||||
export type { SimpleFlowNode } from './consts';
|
export type { SimpleFlowNode } from './consts';
|
||||||
|
|
||||||
export { parseFormFields } from './helpers';
|
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 第四步:更多设置 -->
|
<!-- 第四步:更多设置 -->
|
||||||
<div v-if="currentStep === 3" class="mx-auto w-4/6">
|
<div v-show="currentStep === 3" class="mx-auto w-4/6">
|
||||||
<ExtraSetting v-model="formData" ref="extraSettingRef" />
|
<ExtraSetting v-model="formData" ref="extraSettingRef" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ defineProps<{
|
|||||||
const emit = defineEmits(['success', 'init-finished']);
|
const emit = defineEmits(['success', 'init-finished']);
|
||||||
|
|
||||||
const formFields = ref<string[]>([]); // 表单信息
|
const formFields = ref<string[]>([]); // 表单信息
|
||||||
const formType = ref(BpmModelFormType.NORMAL); // 表单类型,暂仅限流程表单 TODO @jason:是不是已经支持 业务表单 了?
|
const formType = ref(BpmModelFormType.NORMAL); // 表单类型
|
||||||
provide('formFields', formFields);
|
provide('formFields', formFields);
|
||||||
provide('formType', formType);
|
provide('formType', formType);
|
||||||
|
|
||||||
@@ -40,7 +40,6 @@ const xmlString = inject('processData') as Ref; // 注入流程数据
|
|||||||
const modelData = inject('modelData') as Ref; // 注入模型数据
|
const modelData = inject('modelData') as Ref; // 注入模型数据
|
||||||
|
|
||||||
const modeler = shallowRef(); // BPMN Modeler
|
const modeler = shallowRef(); // BPMN Modeler
|
||||||
const processDesigner = ref();
|
|
||||||
const controlForm = ref({
|
const controlForm = ref({
|
||||||
simulation: true,
|
simulation: true,
|
||||||
labelEditing: false,
|
labelEditing: false,
|
||||||
@@ -102,7 +101,6 @@ onBeforeUnmount(() => {
|
|||||||
:value="xmlString"
|
:value="xmlString"
|
||||||
v-bind="controlForm"
|
v-bind="controlForm"
|
||||||
keyboard
|
keyboard
|
||||||
ref="processDesigner"
|
|
||||||
@init-finished="initModeler"
|
@init-finished="initModeler"
|
||||||
:additional-model="controlForm.additionalModel"
|
:additional-model="controlForm.additionalModel"
|
||||||
:model="model"
|
:model="model"
|
||||||
|
|||||||
@@ -29,10 +29,8 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { getForm } from '#/api/bpm/form';
|
import { getForm } from '#/api/bpm/form';
|
||||||
import {
|
import { parseFormFields } from '#/components/form-create';
|
||||||
HttpRequestSetting,
|
import { HttpRequestSetting } from '#/views/bpm/components/simple-process-design';
|
||||||
parseFormFields,
|
|
||||||
} from '#/views/bpm/components/simple-process-design';
|
|
||||||
|
|
||||||
import PrintTemplate from './custom-print-template.vue';
|
import PrintTemplate from './custom-print-template.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -464,7 +464,6 @@ function handleRenameSuccess() {
|
|||||||
>
|
>
|
||||||
<div class="flex h-12 items-center">
|
<div class="flex h-12 items-center">
|
||||||
<!-- 头部:分类名 -->
|
<!-- 头部:分类名 -->
|
||||||
<!-- TODO @jason:2)拖动后,直接请求排序,不用有个【保存】;排序模型分类,和排序分类里的模型,交互有点不同哈。@芋艿 好像 yudao-ui-admin-vue3 交互也是这样的,需要改吗? -->
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Tooltip v-if="isCategorySorting" title="拖动排序">
|
<Tooltip v-if="isCategorySorting" title="拖动排序">
|
||||||
<!-- drag-handle 标识可以拖动,不能删掉 -->
|
<!-- drag-handle 标识可以拖动,不能删掉 -->
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { BpmCandidateStrategyEnum, BpmNodeIdEnum } from '@vben/constants';
|
|||||||
import { useTabs } from '@vben/hooks';
|
import { useTabs } from '@vben/hooks';
|
||||||
import { IconifyIcon } from '@vben/icons';
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
import { Button, Card, message, Space } from 'ant-design-vue';
|
import { Button, Card, Col, message, Row, Space } from 'ant-design-vue';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { getProcessDefinition } from '#/api/bpm/definition';
|
import { getProcessDefinition } from '#/api/bpm/definition';
|
||||||
@@ -26,6 +26,7 @@ const { closeCurrentTab } = useTabs();
|
|||||||
const { query } = useRoute();
|
const { query } = useRoute();
|
||||||
|
|
||||||
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||||
|
const processTimeLineLoading = ref(false); // 审批流的加载中
|
||||||
|
|
||||||
const processDefineKey = 'oa_leave'; // 流程定义 Key
|
const processDefineKey = 'oa_leave'; // 流程定义 Key
|
||||||
const startUserSelectTasks = ref<any>([]); // 发起人需要选择审批人的用户任务列表
|
const startUserSelectTasks = ref<any>([]); // 发起人需要选择审批人的用户任务列表
|
||||||
@@ -91,10 +92,8 @@ async function onSubmit() {
|
|||||||
? updateLeave(submitData)
|
? updateLeave(submitData)
|
||||||
: createLeave(submitData));
|
: createLeave(submitData));
|
||||||
// 关闭并提示
|
// 关闭并提示
|
||||||
message.success({
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
content: $t('ui.actionMessage.operationSuccess'),
|
await closeCurrentTab();
|
||||||
key: 'action_process_msg',
|
|
||||||
});
|
|
||||||
await router.push({
|
await router.push({
|
||||||
name: 'BpmOALeave',
|
name: 'BpmOALeave',
|
||||||
});
|
});
|
||||||
@@ -119,38 +118,43 @@ function onBack() {
|
|||||||
|
|
||||||
/** 审批相关:获取审批详情 */
|
/** 审批相关:获取审批详情 */
|
||||||
async function getApprovalDetail() {
|
async function getApprovalDetail() {
|
||||||
const data = await getApprovalDetailApi({
|
processTimeLineLoading.value = true;
|
||||||
processDefinitionId: processDefinitionId.value,
|
try {
|
||||||
// TODO 小北:可以支持 processDefinitionKey 查询
|
const data = await getApprovalDetailApi({
|
||||||
activityId: BpmNodeIdEnum.START_USER_NODE_ID,
|
processDefinitionId: processDefinitionId.value,
|
||||||
processVariablesStr: JSON.stringify({
|
// TODO 小北:可以支持 processDefinitionKey 查询
|
||||||
day: dayjs(formData.value?.startTime).diff(
|
activityId: BpmNodeIdEnum.START_USER_NODE_ID,
|
||||||
dayjs(formData.value?.endTime),
|
processVariablesStr: JSON.stringify({
|
||||||
'day',
|
day: dayjs(formData.value?.startTime).diff(
|
||||||
),
|
dayjs(formData.value?.endTime),
|
||||||
}), // 解决 GET 无法传递对象的问题,后端 String 再转 JSON
|
'day',
|
||||||
});
|
),
|
||||||
if (!data) {
|
}), // 解决 GET 无法传递对象的问题,后端 String 再转 JSON
|
||||||
message.error('查询不到审批详情信息!');
|
});
|
||||||
return;
|
if (!data) {
|
||||||
}
|
message.error('查询不到审批详情信息!');
|
||||||
// 获取审批节点,显示 Timeline 的数据
|
return;
|
||||||
activityNodes.value = data.activityNodes;
|
|
||||||
|
|
||||||
// 获取发起人自选的任务
|
|
||||||
startUserSelectTasks.value = data.activityNodes?.filter(
|
|
||||||
(node: BpmProcessInstanceApi.ApprovalNodeInfo) =>
|
|
||||||
BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
|
|
||||||
);
|
|
||||||
// 恢复之前的选择审批人
|
|
||||||
if (startUserSelectTasks.value?.length > 0) {
|
|
||||||
for (const node of startUserSelectTasks.value) {
|
|
||||||
startUserSelectAssignees.value[node.id] =
|
|
||||||
tempStartUserSelectAssignees.value[node.id] &&
|
|
||||||
tempStartUserSelectAssignees.value[node.id].length > 0
|
|
||||||
? tempStartUserSelectAssignees.value[node.id]
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
// 获取审批节点,显示 Timeline 的数据
|
||||||
|
activityNodes.value = data.activityNodes;
|
||||||
|
|
||||||
|
// 获取发起人自选的任务
|
||||||
|
startUserSelectTasks.value = data.activityNodes?.filter(
|
||||||
|
(node: BpmProcessInstanceApi.ApprovalNodeInfo) =>
|
||||||
|
BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
|
||||||
|
);
|
||||||
|
// 恢复之前的选择审批人
|
||||||
|
if (startUserSelectTasks.value?.length > 0) {
|
||||||
|
for (const node of startUserSelectTasks.value) {
|
||||||
|
startUserSelectAssignees.value[node.id] =
|
||||||
|
tempStartUserSelectAssignees.value[node.id] &&
|
||||||
|
tempStartUserSelectAssignees.value[node.id].length > 0
|
||||||
|
? tempStartUserSelectAssignees.value[node.id]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processTimeLineLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,30 +235,35 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page>
|
||||||
<div class="mx-auto w-[80vw] max-w-[920px]">
|
<Row :gutter="16">
|
||||||
<Card :title="getTitle" class="w-full">
|
<Col :span="16">
|
||||||
<template #extra>
|
<Card :title="getTitle" class="w-full" v-loading="formLoading">
|
||||||
<Button type="default" @click="onBack">
|
<template #extra>
|
||||||
<IconifyIcon icon="lucide:arrow-left" />
|
<Button type="default" @click="onBack">
|
||||||
返回
|
<IconifyIcon icon="lucide:arrow-left" />
|
||||||
</Button>
|
返回
|
||||||
</template>
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Form />
|
<Form />
|
||||||
</Card>
|
<template #actions>
|
||||||
|
<Space warp :size="12" class="w-full px-6">
|
||||||
<Card title="流程" class="mt-2 w-full">
|
<Button type="primary" @click="onSubmit" :loading="formLoading">
|
||||||
<ProcessInstanceTimeline
|
提交
|
||||||
:activity-nodes="activityNodes"
|
</Button>
|
||||||
:show-status-icon="false"
|
</Space>
|
||||||
@select-user-confirm="selectUserConfirm"
|
</template>
|
||||||
/>
|
</Card>
|
||||||
<template #actions>
|
</Col>
|
||||||
<Space warp :size="12" class="w-full px-6">
|
<Col :span="8">
|
||||||
<Button type="primary" @click="onSubmit"> 提交 </Button>
|
<Card title="流程" class="w-full" v-loading="processTimeLineLoading">
|
||||||
</Space>
|
<ProcessInstanceTimeline
|
||||||
</template>
|
:activity-nodes="activityNodes"
|
||||||
</Card>
|
:show-status-icon="false"
|
||||||
</div>
|
@select-user-confirm="selectUserConfirm"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
|
import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
|
||||||
|
|
||||||
import { h } from 'vue';
|
import { h, onActivated } from 'vue';
|
||||||
|
|
||||||
import { DocAlert, Page, prompt } from '@vben/common-ui';
|
import { DocAlert, Page, prompt } from '@vben/common-ui';
|
||||||
import { BpmProcessInstanceStatus } from '@vben/constants';
|
import { BpmProcessInstanceStatus } from '@vben/constants';
|
||||||
@@ -17,7 +17,6 @@ import { router } from '#/router';
|
|||||||
|
|
||||||
import { useGridColumns, useGridFormSchema } from './data';
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
|
||||||
// TODO @jason:这里是不是要迁移下?
|
|
||||||
/** 刷新表格 */
|
/** 刷新表格 */
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
gridApi.query();
|
gridApi.query();
|
||||||
@@ -127,6 +126,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
},
|
},
|
||||||
} as VxeTableGridOptions<BpmOALeaveApi.Leave>,
|
} as VxeTableGridOptions<BpmOALeaveApi.Leave>,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 激活时 */
|
||||||
|
onActivated(() => {
|
||||||
|
handleRefresh();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ProcessExpressionSelectModal } from './select-modal.vue';
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type {
|
||||||
|
VxeGridPropTypes,
|
||||||
|
VxeTableGridOptions,
|
||||||
|
} from '#/adapter/vxe-table';
|
||||||
import type { BpmProcessExpressionApi } from '#/api/bpm/processExpression';
|
import type { BpmProcessExpressionApi } from '#/api/bpm/processExpression';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
import { CommonStatusEnum } from '@vben/constants';
|
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||||
|
import { getDictOptions } from '@vben/hooks';
|
||||||
|
|
||||||
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { getProcessExpressionPage } from '#/api/bpm/processExpression';
|
import { getProcessExpressionPage } from '#/api/bpm/processExpression';
|
||||||
@@ -16,35 +19,23 @@ const emit = defineEmits<{
|
|||||||
select: [expression: BpmProcessExpressionApi.ProcessExpression];
|
select: [expression: BpmProcessExpressionApi.ProcessExpression];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// TODO @jason:这里是不是要迁移下?
|
|
||||||
// 查询参数
|
|
||||||
const queryParams = ref({
|
|
||||||
status: CommonStatusEnum.ENABLE,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 配置 VxeGrid
|
// 配置 VxeGrid
|
||||||
const [Grid] = useVbenVxeGrid({
|
const [Grid] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
gridOptions: {
|
gridOptions: {
|
||||||
columns: [
|
columns: useGridColumns(),
|
||||||
{ field: 'name', title: '名字', minWidth: 160 },
|
|
||||||
{ field: 'expression', title: '表达式', minWidth: 260 },
|
|
||||||
{
|
|
||||||
field: 'action',
|
|
||||||
title: '操作',
|
|
||||||
width: 120,
|
|
||||||
slots: { default: 'action' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showOverflow: true,
|
showOverflow: true,
|
||||||
minHeight: 300,
|
minHeight: 300,
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
// 查询表达式列表
|
// 查询表达式列表
|
||||||
query: async ({ page }) => {
|
query: async ({ page }, formValues) => {
|
||||||
return await getProcessExpressionPage({
|
return await getProcessExpressionPage({
|
||||||
pageNo: page.currentPage,
|
pageNo: page.currentPage,
|
||||||
pageSize: page.pageSize,
|
pageSize: page.pageSize,
|
||||||
status: queryParams.value.status,
|
...formValues,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -62,6 +53,7 @@ const [Grid] = useVbenVxeGrid({
|
|||||||
// 配置 Modal
|
// 配置 Modal
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
showConfirmButton: false,
|
showConfirmButton: false,
|
||||||
|
contentClass: 'bg-background-deep p-3',
|
||||||
destroyOnClose: true,
|
destroyOnClose: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +62,53 @@ function handleSelect(row: BpmProcessExpressionApi.ProcessExpression) {
|
|||||||
emit('select', row);
|
emit('select', row);
|
||||||
modalApi.close();
|
modalApi.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '名字',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入名字',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
component: 'Select',
|
||||||
|
defaultValue: CommonStatusEnum.ENABLE,
|
||||||
|
componentProps: {
|
||||||
|
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||||
|
placeholder: '请选择状态',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{ field: 'name', title: '名字', minWidth: 160 },
|
||||||
|
{ field: 'expression', title: '表达式', minWidth: 260 },
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
minWidth: 100,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'action',
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
slots: { default: 'action' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -168,9 +168,6 @@ async function initProcessInfo(row: any, formVariables?: any) {
|
|||||||
await router.push({
|
await router.push({
|
||||||
path: row.formCustomCreatePath,
|
path: row.formCustomCreatePath,
|
||||||
});
|
});
|
||||||
// 返回选择流程
|
|
||||||
// TODO @jason:这里为啥要有个 cancel 事件哈?目前看 vue3 + element-plus 貌似不需要呀;
|
|
||||||
emit('cancel');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import {
|
|||||||
cancelProcessInstanceByAdmin,
|
cancelProcessInstanceByAdmin,
|
||||||
getProcessInstanceManagerPage,
|
getProcessInstanceManagerPage,
|
||||||
} from '#/api/bpm/processInstance';
|
} from '#/api/bpm/processInstance';
|
||||||
// TODO @jason:现在 ele 和 antd 使用的 parseFormFields 路径不同;看看以哪个为主。ele 是 import { parseFormFields } from '#/components/form-create';
|
import { parseFormFields } from '#/components/form-create';
|
||||||
import { parseFormFields } from '#/views/bpm/components/simple-process-design';
|
|
||||||
|
|
||||||
import { useGridColumns, useGridFormSchema } from './data';
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
import { DICT_TYPE } from '@vben/constants';
|
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||||
|
import { getDictOptions } from '@vben/hooks';
|
||||||
|
|
||||||
/** 选择监听器弹窗的列表字段 */
|
/** 选择监听器弹窗的列表字段 */
|
||||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
return [
|
return [
|
||||||
{ field: 'name', title: '名字', minWidth: 120 },
|
{ field: 'name', title: '名字', minWidth: 160 },
|
||||||
{
|
{
|
||||||
field: 'type',
|
field: 'type',
|
||||||
title: '类型',
|
title: '类型',
|
||||||
minWidth: 200,
|
minWidth: 120,
|
||||||
cellRender: {
|
cellRender: {
|
||||||
name: 'CellDict',
|
name: 'CellDict',
|
||||||
props: { type: DICT_TYPE.BPM_PROCESS_LISTENER_TYPE },
|
props: { type: DICT_TYPE.BPM_PROCESS_LISTENER_TYPE },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ field: 'event', title: '事件', minWidth: 200 },
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
minWidth: 120,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ field: 'event', title: '事件', minWidth: 120 },
|
||||||
{
|
{
|
||||||
field: 'valueType',
|
field: 'valueType',
|
||||||
title: '值类型',
|
title: '值类型',
|
||||||
@@ -34,3 +45,29 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '名字',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入名字',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
component: 'Select',
|
||||||
|
defaultValue: CommonStatusEnum.ENABLE,
|
||||||
|
componentProps: {
|
||||||
|
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||||
|
placeholder: '请选择状态',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ProcessListenerSelectModal } from './select-modal.vue';
|
||||||
@@ -5,12 +5,11 @@ import type { BpmProcessListenerApi } from '#/api/bpm/processListener';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
import { CommonStatusEnum } from '@vben/constants';
|
|
||||||
|
|
||||||
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { getProcessListenerPage } from '#/api/bpm/processListener';
|
import { getProcessListenerPage } from '#/api/bpm/processListener';
|
||||||
|
|
||||||
import { useGridColumns } from './data';
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
|
||||||
defineOptions({ name: 'ProcessListenerSelectModal' });
|
defineOptions({ name: 'ProcessListenerSelectModal' });
|
||||||
|
|
||||||
@@ -18,27 +17,25 @@ const emit = defineEmits<{
|
|||||||
select: [listener: BpmProcessListenerApi.ProcessListener];
|
select: [listener: BpmProcessListenerApi.ProcessListener];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// TODO @jason:这里是不是要迁移下?
|
const listenerType = ref('');
|
||||||
// 查询参数
|
|
||||||
const queryParams = ref({
|
|
||||||
type: '',
|
|
||||||
status: CommonStatusEnum.ENABLE,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 配置 VxeGrid
|
// 配置 VxeGrid
|
||||||
const [Grid] = useVbenVxeGrid({
|
const [Grid] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
gridOptions: {
|
gridOptions: {
|
||||||
columns: useGridColumns(),
|
columns: useGridColumns(),
|
||||||
showOverflow: true,
|
showOverflow: true,
|
||||||
minHeight: 300,
|
minHeight: 300,
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
query: async ({ page }) => {
|
query: async ({ page }, formValues) => {
|
||||||
return await getProcessListenerPage({
|
return await getProcessListenerPage({
|
||||||
pageNo: page.currentPage,
|
pageNo: page.currentPage,
|
||||||
pageSize: page.pageSize,
|
pageSize: page.pageSize,
|
||||||
type: queryParams.value.type,
|
type: listenerType.value,
|
||||||
status: queryParams.value.status,
|
...formValues,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,14 +53,15 @@ const [Grid] = useVbenVxeGrid({
|
|||||||
// 配置 Modal
|
// 配置 Modal
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
showConfirmButton: false,
|
showConfirmButton: false,
|
||||||
|
contentClass: 'bg-background-deep p-3',
|
||||||
onOpenChange: async (isOpen: boolean) => {
|
onOpenChange: async (isOpen: boolean) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
queryParams.value.type = '';
|
listenerType.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = modalApi.getData<{ type: string }>();
|
const data = modalApi.getData<{ type: string }>();
|
||||||
if (data?.type) {
|
if (data?.type) {
|
||||||
queryParams.value.type = data.type;
|
listenerType.value = data.type;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destroyOnClose: true,
|
destroyOnClose: true,
|
||||||
@@ -61,6 +61,24 @@ const dictTypeOptions = ref<SystemDictTypeApi.DictType[]>([]); // 字典类型
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
dictTypeOptions.value = await getSimpleDictTypeList();
|
dictTypeOptions.value = await getSimpleDictTypeList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 字典类型过滤:支持 type 或 name,忽略大小写 */
|
||||||
|
function filterDictTypeOption(input: string, option: any) {
|
||||||
|
if (!option?.key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const searchValue = input.toLowerCase();
|
||||||
|
const dictType = dictTypeOptions.value.find(
|
||||||
|
(item) => item.type === option.key,
|
||||||
|
);
|
||||||
|
if (!dictType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
dictType.type.toLowerCase().includes(searchValue) ||
|
||||||
|
dictType.name.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -142,6 +160,7 @@ onMounted(async () => {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
allow-clear
|
allow-clear
|
||||||
show-search
|
show-search
|
||||||
|
:filter-option="filterDictTypeOption"
|
||||||
>
|
>
|
||||||
<Select.Option
|
<Select.Option
|
||||||
v-for="option in dictTypeOptions"
|
v-for="option in dictTypeOptions"
|
||||||
|
|||||||
@@ -26,8 +26,17 @@ const formData = reactive<InfraCodegenApi.CodegenCreateListReqVO>({
|
|||||||
tableNames: [], // 已选择的表列表
|
tableNames: [], // 已选择的表列表
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 处理选择变化 */
|
||||||
|
function handleCheckboxChange({
|
||||||
|
records,
|
||||||
|
}: {
|
||||||
|
records: InfraCodegenApi.DatabaseTable[];
|
||||||
|
}) {
|
||||||
|
formData.tableNames = records.map((item) => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
/** 表格实例 */
|
/** 表格实例 */
|
||||||
const [Grid] = useVbenVxeGrid({
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
formOptions: {
|
formOptions: {
|
||||||
schema: useImportTableFormSchema(),
|
schema: useImportTableFormSchema(),
|
||||||
submitOnChange: true,
|
submitOnChange: true,
|
||||||
@@ -67,13 +76,8 @@ const [Grid] = useVbenVxeGrid({
|
|||||||
},
|
},
|
||||||
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
|
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
|
||||||
gridEvents: {
|
gridEvents: {
|
||||||
checkboxChange: ({
|
checkboxChange: handleCheckboxChange,
|
||||||
records,
|
checkboxAll: handleCheckboxChange,
|
||||||
}: {
|
|
||||||
records: InfraCodegenApi.DatabaseTable[];
|
|
||||||
}) => {
|
|
||||||
formData.tableNames = records.map((item) => item.name);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +85,13 @@ const [Grid] = useVbenVxeGrid({
|
|||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
title: '导入表',
|
title: '导入表',
|
||||||
class: 'w-1/2',
|
class: 'w-1/2',
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
// 关闭时清空选择状态
|
||||||
|
formData.tableNames = [];
|
||||||
|
await gridApi.grid?.clearCheckboxRow();
|
||||||
|
}
|
||||||
|
},
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
// 1.1 获取表单值
|
// 1.1 获取表单值
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { getSimpleDeviceList } from '#/api/iot/device/device';
|
|||||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||||
|
|
||||||
/** 新增/修改的表单 */
|
/** 基础表单字段 */
|
||||||
export function useFormSchema(): VbenFormSchema[] {
|
export function useBasicFormSchema(): VbenFormSchema[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
@@ -36,6 +36,14 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'deviceType',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: [''],
|
||||||
|
show: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'deviceName',
|
fieldName: 'deviceName',
|
||||||
label: 'DeviceName',
|
label: 'DeviceName',
|
||||||
@@ -62,7 +70,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
component: 'ApiSelect',
|
component: 'ApiSelect',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
api: () => getSimpleDeviceList(DeviceTypeEnum.GATEWAY),
|
api: () => getSimpleDeviceList(DeviceTypeEnum.GATEWAY),
|
||||||
labelField: 'nickname',
|
labelField: 'deviceName',
|
||||||
valueField: 'id',
|
valueField: 'id',
|
||||||
placeholder: '子设备可选择父设备',
|
placeholder: '子设备可选择父设备',
|
||||||
},
|
},
|
||||||
@@ -71,6 +79,12 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
|
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高级设置表单字段(更多设置) */
|
||||||
|
export function useAdvancedFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
fieldName: 'nickname',
|
fieldName: 'nickname',
|
||||||
label: '备注名称',
|
label: '备注名称',
|
||||||
@@ -89,6 +103,11 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
.optional()
|
.optional()
|
||||||
.or(z.literal('')),
|
.or(z.literal('')),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'picUrl',
|
||||||
|
label: '设备图片',
|
||||||
|
component: 'ImageUpload',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'groupIds',
|
fieldName: 'groupIds',
|
||||||
label: '设备分组',
|
label: '设备分组',
|
||||||
@@ -278,6 +297,14 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||||||
title: '备注名称',
|
title: '备注名称',
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'picUrl',
|
||||||
|
title: '设备图片',
|
||||||
|
width: 100,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellImage',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'productId',
|
field: 'productId',
|
||||||
title: '所属产品',
|
title: '所属产品',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const router = useRouter();
|
|||||||
const id = Number(route.params.id);
|
const id = Number(route.params.id);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
|
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
|
||||||
const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
|
const device = ref<IotDeviceApi.DeviceRespVO>({} as IotDeviceApi.DeviceRespVO);
|
||||||
const activeTab = ref('info');
|
const activeTab = ref('info');
|
||||||
const thingModelList = ref<ThingModelData[]>([]);
|
const thingModelList = ref<ThingModelData[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
|
|||||||
defineOptions({ name: 'DeviceDetailConfig' });
|
defineOptions({ name: 'DeviceDetailConfig' });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
device: IotDeviceApi.Device;
|
device: IotDeviceApi.DeviceRespVO;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -114,7 +114,7 @@ async function updateDeviceConfig() {
|
|||||||
await updateDevice({
|
await updateDevice({
|
||||||
id: props.device.id,
|
id: props.device.id,
|
||||||
config: JSON.stringify(config.value),
|
config: JSON.stringify(config.value),
|
||||||
} as IotDeviceApi.Device);
|
} as IotDeviceApi.DeviceSaveReqVO);
|
||||||
message.success({ content: '更新成功!' });
|
message.success({ content: '更新成功!' });
|
||||||
// 触发 success 事件
|
// 触发 success 事件
|
||||||
emit('success');
|
emit('success');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import DeviceForm from '../../modules/form.vue';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
product: IotProductApi.Product;
|
product: IotProductApi.Product;
|
||||||
device: IotDeviceApi.Device;
|
device: IotDeviceApi.DeviceRespVO;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ function goToProductDetail(productId: number | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 打开编辑表单 */
|
/** 打开编辑表单 */
|
||||||
function openEditForm(row: IotDeviceApi.Device) {
|
function openEditForm(row: IotDeviceApi.DeviceRespVO) {
|
||||||
formModalApi.setData(row).open();
|
formModalApi.setData(row).open();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { getDeviceAuthInfo } from '#/api/iot/device/device';
|
|||||||
import { DictTag } from '#/components/dict-tag';
|
import { DictTag } from '#/components/dict-tag';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: IotDeviceApi.Device;
|
device: IotDeviceApi.DeviceRespVO;
|
||||||
product: IotProductApi.Product;
|
product: IotProductApi.Product;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ const props = defineProps<Props>();
|
|||||||
|
|
||||||
const authDialogVisible = ref(false);
|
const authDialogVisible = ref(false);
|
||||||
const authPasswordVisible = ref(false);
|
const authPasswordVisible = ref(false);
|
||||||
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
|
const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
|
||||||
{} as IotDeviceApi.DeviceAuthInfo,
|
{} as IotDeviceApi.DeviceAuthInfoRespVO,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 控制地图显示的标志 */
|
/** 控制地图显示的标志 */
|
||||||
@@ -75,8 +75,7 @@ function handleAuthInfoDialogClose() {
|
|||||||
<Card class="h-full">
|
<Card class="h-full">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<!-- TODO @haohao:图标尽量使用中立的,这样 ep 版本呢好迁移 -->
|
<IconifyIcon class="mr-2 text-primary" icon="lucide:info" />
|
||||||
<IconifyIcon class="mr-2 text-primary" icon="ep:info-filled" />
|
|
||||||
<span>设备信息</span>
|
<span>设备信息</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -142,8 +141,7 @@ function handleAuthInfoDialogClose() {
|
|||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<!-- TODO @haohao:图标尽量使用中立的,这样 ep 版本呢好迁移 -->
|
<IconifyIcon class="mr-2 text-primary" icon="lucide:map-pin" />
|
||||||
<IconifyIcon class="mr-2 text-primary" icon="ep:location" />
|
|
||||||
<span>设备位置</span>
|
<span>设备位置</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
@@ -162,8 +160,7 @@ function handleAuthInfoDialogClose() {
|
|||||||
v-else
|
v-else
|
||||||
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
|
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
|
||||||
>
|
>
|
||||||
<!-- TODO @haohao:图标尽量使用中立的,这样 ep 版本呢好迁移 -->
|
<IconifyIcon class="mr-2" icon="lucide:alert-triangle" />
|
||||||
<IconifyIcon class="mr-2" icon="ep:warning" />
|
|
||||||
<span>暂无位置信息</span>
|
<span>暂无位置信息</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import DataDefinition from '../../../../thingmodel/modules/components/data-defin
|
|||||||
import DeviceDetailsMessage from './message.vue';
|
import DeviceDetailsMessage from './message.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
device: IotDeviceApi.Device;
|
device: IotDeviceApi.DeviceRespVO;
|
||||||
product: IotProductApi.Product;
|
product: IotProductApi.Product;
|
||||||
thingModelList: ThingModelData[];
|
thingModelList: ThingModelData[];
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -1,45 +1,216 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
import type { IotProductApi } from '#/api/iot/product/product';
|
||||||
|
|
||||||
import { Card, Empty } from 'ant-design-vue';
|
import { onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
// TODO @haohao:这里要实现一把么?
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
|
||||||
|
import { getDictOptions } from '@vben/hooks';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { Button, Input, Select, Space } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getDevicePage } from '#/api/iot/device/device';
|
||||||
|
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deviceId: number;
|
deviceId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const loading = ref(false);
|
const products = ref<IotProductApi.Product[]>([]); // 产品列表
|
||||||
const subDevices = ref<any[]>([]);
|
const queryParams = reactive({
|
||||||
|
deviceName: '',
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
}); // 查询参数
|
||||||
|
|
||||||
/** 获取子设备列表 */
|
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
async function getSubDeviceList() {
|
return [
|
||||||
loading.value = true;
|
{
|
||||||
try {
|
field: 'deviceName',
|
||||||
// TODO: 实现获取子设备列表的API调用
|
title: 'DeviceName',
|
||||||
// const data = await getSubDevicesByGatewayId(deviceId);
|
minWidth: 150,
|
||||||
// subDevices.value = data || [];
|
},
|
||||||
subDevices.value = [];
|
{
|
||||||
} catch (error) {
|
field: 'nickname',
|
||||||
console.error('获取子设备列表失败:', error);
|
title: '备注名称',
|
||||||
} finally {
|
minWidth: 120,
|
||||||
loading.value = false;
|
},
|
||||||
}
|
{
|
||||||
|
field: 'productId',
|
||||||
|
title: '所属产品',
|
||||||
|
minWidth: 120,
|
||||||
|
slots: { default: 'product' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'state',
|
||||||
|
title: '设备状态',
|
||||||
|
minWidth: 100,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'onlineTime',
|
||||||
|
title: '最后上线时间',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 150,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
|
||||||
getSubDeviceList();
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
page: { currentPage: number; pageSize: number };
|
||||||
|
}) => {
|
||||||
|
if (!props.deviceId) {
|
||||||
|
return { list: [], total: 0 };
|
||||||
|
}
|
||||||
|
return await getDevicePage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
gatewayId: props.deviceId,
|
||||||
|
deviceType: DeviceTypeEnum.GATEWAY_SUB,
|
||||||
|
deviceName: queryParams.deviceName || undefined,
|
||||||
|
status: queryParams.status,
|
||||||
|
} as IotDeviceApi.DevicePageReqVO);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: false,
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 搜索操作 */
|
||||||
|
function handleQuery() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置搜索 */
|
||||||
|
function resetQuery() {
|
||||||
|
queryParams.deviceName = '';
|
||||||
|
queryParams.status = undefined;
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品名称 */
|
||||||
|
function getProductName(productId: number) {
|
||||||
|
const product = products.value.find((p) => p.id === productId);
|
||||||
|
return product?.name || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看详情 */
|
||||||
|
function openDetail(id: number) {
|
||||||
|
router.push({ name: 'IoTDeviceDetail', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听设备ID变化 */
|
||||||
|
watch(
|
||||||
|
() => props.deviceId,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(async () => {
|
||||||
|
// 获取产品列表
|
||||||
|
products.value = await getSimpleProductList();
|
||||||
|
|
||||||
|
// 如果设备ID存在,则查询列表
|
||||||
|
if (props.deviceId) {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card :loading="loading" title="子设备管理">
|
<Page auto-content-height>
|
||||||
<Empty
|
<!-- 搜索区域 -->
|
||||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
<!-- TODO @haohao:这个 search 能不能融合到 Grid 里; -->
|
||||||
description="暂无子设备数据,此功能待实现"
|
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||||
/>
|
<Input
|
||||||
<!-- TODO: 实现子设备列表展示和管理功能 -->
|
v-model:value="queryParams.deviceName"
|
||||||
</Card>
|
placeholder="请输入设备名称"
|
||||||
|
style="width: 200px"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleQuery"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model:value="queryParams.status"
|
||||||
|
allow-clear
|
||||||
|
placeholder="请选择设备状态"
|
||||||
|
style="width: 160px"
|
||||||
|
>
|
||||||
|
<Select.Option
|
||||||
|
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
|
||||||
|
:key="dict.value"
|
||||||
|
:value="dict.value"
|
||||||
|
>
|
||||||
|
{{ dict.label }}
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" @click="handleQuery">
|
||||||
|
<IconifyIcon icon="ep:search" class="mr-5px" />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button @click="resetQuery">
|
||||||
|
<IconifyIcon icon="ep:refresh-right" class="mr-5px" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子设备列表 -->
|
||||||
|
<Grid>
|
||||||
|
<template #product="{ row }">
|
||||||
|
{{ getProductName(row.productId) }}
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '查看详情',
|
||||||
|
type: 'link',
|
||||||
|
icon: ACTION_ICON.VIEW,
|
||||||
|
onClick: openDetail.bind(null, row.id!),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||||
import type { IotProductApi } from '#/api/iot/product/product';
|
import type { IotProductApi } from '#/api/iot/product/product';
|
||||||
@@ -51,6 +50,9 @@ const viewMode = ref<'card' | 'list'>('card');
|
|||||||
const cardViewRef = ref();
|
const cardViewRef = ref();
|
||||||
const checkedIds = ref<number[]>([]);
|
const checkedIds = ref<number[]>([]);
|
||||||
|
|
||||||
|
/** 判断是否为列表视图 */
|
||||||
|
const isListView = () => viewMode.value === 'list';
|
||||||
|
|
||||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||||
connectedComponent: DeviceForm,
|
connectedComponent: DeviceForm,
|
||||||
destroyOnClose: true,
|
destroyOnClose: true,
|
||||||
@@ -66,13 +68,13 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
|
|||||||
destroyOnClose: true,
|
destroyOnClose: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryParams = ref({
|
const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
|
||||||
deviceName: '',
|
deviceName: '',
|
||||||
nickname: '',
|
nickname: '',
|
||||||
productId: undefined as number | undefined,
|
productId: undefined,
|
||||||
deviceType: undefined as number | undefined,
|
deviceType: undefined,
|
||||||
status: undefined as number | undefined,
|
status: undefined,
|
||||||
groupId: undefined as number | undefined,
|
groupId: undefined,
|
||||||
}); // 搜索参数
|
}); // 搜索参数
|
||||||
|
|
||||||
/** 搜索 */
|
/** 搜索 */
|
||||||
@@ -112,7 +114,11 @@ async function handleViewModeChange(mode: 'card' | 'list') {
|
|||||||
|
|
||||||
/** 导出表格 */
|
/** 导出表格 */
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
const data = await exportDeviceExcel(queryParams.value);
|
const data = await exportDeviceExcel({
|
||||||
|
...queryParams.value,
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 999_999,
|
||||||
|
} as IotDeviceApi.DevicePageReqVO);
|
||||||
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
|
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +147,12 @@ function handleCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑设备 */
|
/** 编辑设备 */
|
||||||
function handleEdit(row: IotDeviceApi.Device) {
|
function handleEdit(row: IotDeviceApi.DeviceRespVO) {
|
||||||
deviceFormModalApi.setData(row).open();
|
deviceFormModalApi.setData(row).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除设备 */
|
/** 删除设备 */
|
||||||
async function handleDelete(row: IotDeviceApi.Device) {
|
async function handleDelete(row: IotDeviceApi.DeviceRespVO) {
|
||||||
const hideLoading = message.loading({
|
const hideLoading = message.loading({
|
||||||
content: $t('ui.actionMessage.deleting', [row.deviceName]),
|
content: $t('ui.actionMessage.deleting', [row.deviceName]),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
@@ -197,12 +203,12 @@ function handleImport() {
|
|||||||
function handleRowCheckboxChange({
|
function handleRowCheckboxChange({
|
||||||
records,
|
records,
|
||||||
}: {
|
}: {
|
||||||
records: IotDeviceApi.Device[];
|
records: IotDeviceApi.DeviceRespVO[];
|
||||||
}) {
|
}) {
|
||||||
checkedIds.value = records.map((item) => item.id!);
|
checkedIds.value = records.map((item) => item.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [Grid, gridApi] = useVbenVxeGrid({
|
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
|
||||||
gridOptions: {
|
gridOptions: {
|
||||||
checkboxConfig: {
|
checkboxConfig: {
|
||||||
highlight: true,
|
highlight: true,
|
||||||
@@ -213,12 +219,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
keepSource: true,
|
keepSource: true,
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
query: async ({ page }) => {
|
query: async ({
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
page: { currentPage: number; pageSize: number };
|
||||||
|
}) => {
|
||||||
return await getDevicePage({
|
return await getDevicePage({
|
||||||
pageNo: page.currentPage,
|
pageNo: page.currentPage,
|
||||||
pageSize: page.pageSize,
|
pageSize: page.pageSize,
|
||||||
...queryParams.value,
|
...queryParams.value,
|
||||||
});
|
} as IotDeviceApi.DevicePageReqVO);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -230,7 +240,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
refresh: true,
|
refresh: true,
|
||||||
search: true,
|
search: true,
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions<IotDeviceApi.Device>,
|
},
|
||||||
gridEvents: {
|
gridEvents: {
|
||||||
checkboxAll: handleRowCheckboxChange,
|
checkboxAll: handleRowCheckboxChange,
|
||||||
checkboxChange: handleRowCheckboxChange,
|
checkboxChange: handleRowCheckboxChange,
|
||||||
@@ -388,7 +398,7 @@ onMounted(async () => {
|
|||||||
type: 'primary',
|
type: 'primary',
|
||||||
icon: 'ant-design:folder-add-outlined',
|
icon: 'ant-design:folder-add-outlined',
|
||||||
auth: ['iot:device:update'],
|
auth: ['iot:device:update'],
|
||||||
ifShow: () => viewMode === 'list',
|
ifShow: isListView,
|
||||||
disabled: isEmpty(checkedIds),
|
disabled: isEmpty(checkedIds),
|
||||||
onClick: handleAddToGroup,
|
onClick: handleAddToGroup,
|
||||||
},
|
},
|
||||||
@@ -398,13 +408,12 @@ onMounted(async () => {
|
|||||||
danger: true,
|
danger: true,
|
||||||
icon: ACTION_ICON.DELETE,
|
icon: ACTION_ICON.DELETE,
|
||||||
auth: ['iot:device:delete'],
|
auth: ['iot:device:delete'],
|
||||||
ifShow: () => viewMode === 'list',
|
ifShow: isListView,
|
||||||
disabled: isEmpty(checkedIds),
|
disabled: isEmpty(checkedIds),
|
||||||
onClick: handleDeleteBatch,
|
onClick: handleDeleteBatch,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 视图切换 -->
|
<!-- 视图切换 -->
|
||||||
<Space :size="4">
|
<Space :size="4">
|
||||||
<Button
|
<Button
|
||||||
@@ -423,8 +432,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- 列表视图 -->
|
||||||
<Grid table-title="设备列表" v-show="viewMode === 'list'">
|
<Grid table-title="设备列表" v-show="viewMode === 'list'">
|
||||||
<!-- 所属产品列 -->
|
|
||||||
<template #product="{ row }">
|
<template #product="{ row }">
|
||||||
<a
|
<a
|
||||||
class="cursor-pointer text-primary"
|
class="cursor-pointer text-primary"
|
||||||
@@ -433,8 +442,6 @@ onMounted(async () => {
|
|||||||
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
|
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 所属分组列 -->
|
|
||||||
<template #groups="{ row }">
|
<template #groups="{ row }">
|
||||||
<template v-if="row.groupIds?.length">
|
<template v-if="row.groupIds?.length">
|
||||||
<Tag
|
<Tag
|
||||||
@@ -448,8 +455,6 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<TableAction
|
<TableAction
|
||||||
:actions="[
|
:actions="[
|
||||||
@@ -490,7 +495,14 @@ onMounted(async () => {
|
|||||||
ref="cardViewRef"
|
ref="cardViewRef"
|
||||||
:products="products"
|
:products="products"
|
||||||
:device-groups="deviceGroups"
|
:device-groups="deviceGroups"
|
||||||
:search-params="queryParams"
|
:search-params="{
|
||||||
|
deviceName: queryParams.deviceName || '',
|
||||||
|
nickname: queryParams.nickname || '',
|
||||||
|
productId: queryParams.productId,
|
||||||
|
deviceType: queryParams.deviceType,
|
||||||
|
status: queryParams.status,
|
||||||
|
groupId: queryParams.groupId,
|
||||||
|
}"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { DICT_TYPE } from '@vben/constants';
|
import { DICT_TYPE } from '@vben/constants';
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Empty,
|
Empty,
|
||||||
|
Image,
|
||||||
Pagination,
|
Pagination,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
@@ -43,9 +46,9 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const list = ref<any[]>([]);
|
const list = ref<IotDeviceApi.DeviceRespVO[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const queryParams = ref({
|
const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
|
||||||
pageNo: 1,
|
pageNo: 1,
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
});
|
});
|
||||||
@@ -63,7 +66,7 @@ async function getList() {
|
|||||||
const data = await getDevicePage({
|
const data = await getDevicePage({
|
||||||
...queryParams.value,
|
...queryParams.value,
|
||||||
...props.searchParams,
|
...props.searchParams,
|
||||||
});
|
} as IotDeviceApi.DevicePageReqVO);
|
||||||
list.value = data.list || [];
|
list.value = data.list || [];
|
||||||
total.value = data.total || 0;
|
total.value = data.total || 0;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -128,8 +131,8 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<div class="mb-3">
|
<div class="mb-3 flex items-start">
|
||||||
<div class="info-list">
|
<div class="info-list flex-1">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">所属产品</span>
|
<span class="info-label">所属产品</span>
|
||||||
<a
|
<a
|
||||||
@@ -154,13 +157,27 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Deviceid</span>
|
<span class="info-label">Deviceid</span>
|
||||||
<Tooltip :title="item.Deviceid || item.id" placement="top">
|
<Tooltip :title="String(item.id)" placement="top">
|
||||||
<span class="info-value device-id cursor-pointer">
|
<span class="info-value device-id cursor-pointer">
|
||||||
{{ item.Deviceid || item.id }}
|
{{ item.id }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 设备图片 -->
|
||||||
|
<div class="device-image">
|
||||||
|
<Image
|
||||||
|
v-if="item.picUrl"
|
||||||
|
:src="item.picUrl"
|
||||||
|
:preview="true"
|
||||||
|
class="size-full rounded object-cover"
|
||||||
|
/>
|
||||||
|
<IconifyIcon
|
||||||
|
v-else
|
||||||
|
icon="lucide:image"
|
||||||
|
class="text-2xl opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 按钮组 -->
|
<!-- 按钮组 -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
@@ -263,6 +280,19 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设备图片
|
||||||
|
.device-image {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
color: #1890ff;
|
||||||
|
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
// 信息列表
|
// 信息列表
|
||||||
.info-list {
|
.info-list {
|
||||||
.info-item {
|
.info-item {
|
||||||
@@ -385,6 +415,11 @@ html.dark {
|
|||||||
color: rgb(255 255 255 / 75%);
|
color: rgb(255 255 255 / 75%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-image {
|
||||||
|
color: #69c0ff;
|
||||||
|
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
import type { IotProductApi } from '#/api/iot/product/product';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { Collapse, message } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
|
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
|
||||||
|
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
|
||||||
|
|
||||||
defineOptions({ name: 'IoTDeviceForm' });
|
defineOptions({ name: 'IoTDeviceForm' });
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
const formData = ref<IotDeviceApi.Device>();
|
const formData = ref<IotDeviceApi.DeviceRespVO>();
|
||||||
|
const products = ref<IotProductApi.Product[]>([]);
|
||||||
|
const activeKey = ref<string[]>([]);
|
||||||
|
|
||||||
const getTitle = computed(() => {
|
const getTitle = computed(() => {
|
||||||
return formData.value?.id
|
return formData.value?.id
|
||||||
? $t('ui.actionTitle.edit', ['设备'])
|
? $t('ui.actionTitle.edit', ['设备'])
|
||||||
@@ -31,10 +36,54 @@ const [Form, formApi] = useVbenForm({
|
|||||||
},
|
},
|
||||||
wrapperClass: 'grid-cols-1',
|
wrapperClass: 'grid-cols-1',
|
||||||
layout: 'horizontal',
|
layout: 'horizontal',
|
||||||
schema: useFormSchema(),
|
schema: useBasicFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
handleValuesChange: async (values, changedFields) => {
|
||||||
|
// 当产品 ProductId 变化时,自动设置设备类型
|
||||||
|
if (changedFields.includes('productId')) {
|
||||||
|
const productId = values.productId;
|
||||||
|
if (!productId) {
|
||||||
|
await formApi.setFieldValue('deviceType', undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 从产品列表中查找产品
|
||||||
|
const product = products.value.find((p) => p.id === productId);
|
||||||
|
if (product?.deviceType !== undefined) {
|
||||||
|
await formApi.setFieldValue('deviceType', product.deviceType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [AdvancedForm, advancedFormApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wrapperClass: 'grid-cols-1',
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useAdvancedFormSchema(),
|
||||||
showDefaultActions: false,
|
showDefaultActions: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 获取高级表单的值(如果表单未挂载,则从 formData 中获取) */
|
||||||
|
async function getAdvancedFormValues() {
|
||||||
|
if (advancedFormApi.isMounted) {
|
||||||
|
return await advancedFormApi.getValues();
|
||||||
|
}
|
||||||
|
// 表单未挂载(折叠状态),从 formData 中获取
|
||||||
|
return {
|
||||||
|
nickname: formData.value?.nickname,
|
||||||
|
picUrl: formData.value?.picUrl,
|
||||||
|
groupIds: formData.value?.groupIds,
|
||||||
|
serialNumber: formData.value?.serialNumber,
|
||||||
|
locationType: formData.value?.locationType,
|
||||||
|
longitude: formData.value?.longitude,
|
||||||
|
latitude: formData.value?.latitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
@@ -42,8 +91,13 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
// 提交表单
|
// 合并两个表单的值(字段不冲突,可直接合并)
|
||||||
const data = (await formApi.getValues()) as IotDeviceApi.Device;
|
const basicValues = await formApi.getValues();
|
||||||
|
const advancedValues = await getAdvancedFormValues();
|
||||||
|
const data = {
|
||||||
|
...basicValues,
|
||||||
|
...advancedValues,
|
||||||
|
} as IotDeviceApi.DeviceSaveReqVO;
|
||||||
try {
|
try {
|
||||||
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
||||||
// 关闭并提示
|
// 关闭并提示
|
||||||
@@ -57,11 +111,15 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
formData.value = undefined;
|
formData.value = undefined;
|
||||||
|
activeKey.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const data = modalApi.getData<IotDeviceApi.Device>();
|
const data = modalApi.getData<IotDeviceApi.DeviceRespVO>();
|
||||||
if (!data || !data.id) {
|
if (!data || !data.id) {
|
||||||
|
// 新增:确保 Collapse 折叠
|
||||||
|
// TODO @haohao:是不是 activeKey 在上面的 112 到 115 就已经处理了哈;
|
||||||
|
activeKey.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 编辑模式:加载数据
|
// 编辑模式:加载数据
|
||||||
@@ -69,15 +127,44 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
try {
|
try {
|
||||||
formData.value = await getDevice(data.id);
|
formData.value = await getDevice(data.id);
|
||||||
await formApi.setValues(formData.value);
|
await formApi.setValues(formData.value);
|
||||||
|
// 如果存在高级字段数据,自动展开 Collapse
|
||||||
|
// TODO @haohao:默认不用展开哈;
|
||||||
|
if (
|
||||||
|
formData.value?.nickname ||
|
||||||
|
formData.value?.picUrl ||
|
||||||
|
formData.value?.groupIds?.length ||
|
||||||
|
formData.value?.serialNumber ||
|
||||||
|
formData.value?.locationType !== undefined
|
||||||
|
) {
|
||||||
|
activeKey.value = ['advanced'];
|
||||||
|
// 等待 Collapse 展开后表单挂载
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
if (advancedFormApi.isMounted) {
|
||||||
|
await advancedFormApi.setValues(formData.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 初始化产品列表 */
|
||||||
|
onMounted(async () => {
|
||||||
|
products.value = await getSimpleProductList();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="getTitle" class="w-2/5">
|
<Modal :title="getTitle" class="w-2/5">
|
||||||
<Form class="mx-4" />
|
<div class="mx-4">
|
||||||
|
<Form />
|
||||||
|
<Collapse v-model:active-key="activeKey" class="mt-4">
|
||||||
|
<Collapse.Panel key="advanced" header="更多设置">
|
||||||
|
<AdvancedForm />
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
@@ -41,7 +43,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
await updateDeviceGroup({
|
await updateDeviceGroup({
|
||||||
ids: deviceIds.value,
|
ids: deviceIds.value,
|
||||||
groupIds: data.groupIds as number[],
|
groupIds: data.groupIds as number[],
|
||||||
});
|
} as IotDeviceApi.DeviceUpdateGroupReqVO);
|
||||||
// 关闭并提示
|
// 关闭并提示
|
||||||
await modalApi.close();
|
await modalApi.close();
|
||||||
emit('success');
|
emit('success');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
||||||
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||||
@@ -38,7 +39,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
try {
|
try {
|
||||||
const result = await importDevice(data.file, data.updateSupport);
|
const result = await importDevice(data.file, data.updateSupport);
|
||||||
// 处理导入结果提示
|
// 处理导入结果提示
|
||||||
const importData = result.data || result;
|
const importData = result as IotDeviceApi.DeviceImportRespVO;
|
||||||
if (importData) {
|
if (importData) {
|
||||||
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
|
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
|
||||||
if (importData.createDeviceNames?.length) {
|
if (importData.createDeviceNames?.length) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
import type { DeviceRespVO, IotDeviceApi } from '#/api/iot/device/device';
|
||||||
import type { OtaTask } from '#/api/iot/ota/task';
|
import type { OtaTask } from '#/api/iot/ota/task';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
@@ -57,7 +57,7 @@ const formRules = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const devices = ref<IotDeviceApi.Device[]>([]);
|
const devices = ref<IotDeviceApi.DeviceRespVO[]>([]);
|
||||||
|
|
||||||
/** 设备选项 */
|
/** 设备选项 */
|
||||||
const deviceOptions = computed(() => {
|
const deviceOptions = computed(() => {
|
||||||
|
|||||||
@@ -236,6 +236,15 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
|||||||
name: 'CellImage',
|
name: 'CellImage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '产品状态',
|
||||||
|
minWidth: 100,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.IOT_PRODUCT_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'createTime',
|
field: 'createTime',
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ onMounted(() => {
|
|||||||
<div class="ml-3 min-w-0 flex-1">
|
<div class="ml-3 min-w-0 flex-1">
|
||||||
<div class="product-title">{{ item.name }}</div>
|
<div class="product-title">{{ item.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DictTag
|
||||||
|
:type="DICT_TYPE.IOT_PRODUCT_STATUS"
|
||||||
|
:value="item.status"
|
||||||
|
class="status-tag"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<div class="mb-3 flex items-start">
|
<div class="mb-3 flex items-start">
|
||||||
@@ -264,6 +269,11 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态标签
|
||||||
|
.status-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
// 信息列表
|
// 信息列表
|
||||||
.info-list {
|
.info-list {
|
||||||
.info-item {
|
.info-item {
|
||||||
|
|||||||
@@ -96,14 +96,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// TODO: @puhui999:商品关联
|
|
||||||
fieldName: 'spuId',
|
fieldName: 'spuId',
|
||||||
label: '商品关联',
|
label: '商品关联',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
formItemClass: 'col-span-2',
|
formItemClass: 'col-span-2',
|
||||||
componentProps: {
|
|
||||||
placeholder: '请输入商品 SPU 编号',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'sort',
|
fieldName: 'sort',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
updateArticle,
|
updateArticle,
|
||||||
} from '#/api/mall/promotion/article';
|
} from '#/api/mall/promotion/article';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
|
// 同步商品选择到表单,确保验证时能获取到值
|
||||||
|
if (formData.value?.spuId) {
|
||||||
|
await formApi.setFieldValue('spuId', formData.value.spuId);
|
||||||
|
}
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
@@ -82,6 +87,11 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="getTitle" class="w-2/5">
|
<Modal :title="getTitle" class="w-2/5">
|
||||||
<Form class="mx-4" />
|
<Form class="mx-4">
|
||||||
|
<!-- 自定义插槽:商品选择 -->
|
||||||
|
<template #spuId>
|
||||||
|
<SpuShowcase v-model="formData!.spuId" :limit="1" />
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
|
// 同步商品/分类选择到表单,确保验证时能获取到值
|
||||||
|
if (formData.value.productSpuIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productSpuIds',
|
||||||
|
formData.value.productSpuIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (formData.value.productCategoryIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productCategoryIds',
|
||||||
|
formData.value.productCategoryIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
fieldName: 'spuIds',
|
fieldName: 'spuIds',
|
||||||
label: '活动商品',
|
label: '活动商品',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
rules: 'required',
|
|
||||||
formItemClass: 'col-span-2',
|
formItemClass: 'col-span-2',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,30 +1,48 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
|
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
|
||||||
|
import type {
|
||||||
|
PropertyAndValues,
|
||||||
|
RuleConfig,
|
||||||
|
SpuProperty,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { PromotionDiscountTypeEnum } from '@vben/constants';
|
||||||
|
import {
|
||||||
|
cloneDeep,
|
||||||
|
convertToInteger,
|
||||||
|
erpCalculatePercentage,
|
||||||
|
formatToFraction,
|
||||||
|
yuanToFen,
|
||||||
|
} from '@vben/utils';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { Button, InputNumber, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { VxeColumn } from '#/adapter/vxe-table';
|
||||||
|
import { getSpuDetailList } from '#/api/mall/product/spu';
|
||||||
import {
|
import {
|
||||||
createDiscountActivity,
|
createDiscountActivity,
|
||||||
getDiscountActivity,
|
getDiscountActivity,
|
||||||
updateDiscountActivity,
|
updateDiscountActivity,
|
||||||
} from '#/api/mall/promotion/discount/discountActivity';
|
} from '#/api/mall/promotion/discount/discountActivity';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
import {
|
||||||
|
getPropertyList,
|
||||||
|
SpuAndSkuList,
|
||||||
|
SpuSkuSelect,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
defineOptions({ name: 'DiscountActivityForm' });
|
defineOptions({ name: 'DiscountActivityForm' });
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
const formData = ref<
|
|
||||||
Partial<MallDiscountActivityApi.DiscountActivity> & {
|
// ================= 表单相关 =================
|
||||||
spuIds?: number[];
|
const formData = ref<Partial<MallDiscountActivityApi.DiscountActivity>>({});
|
||||||
}
|
|
||||||
>({});
|
|
||||||
const getTitle = computed(() => {
|
const getTitle = computed(() => {
|
||||||
return formData.value?.id
|
return formData.value?.id
|
||||||
? $t('ui.actionTitle.edit', ['限时折扣活动'])
|
? $t('ui.actionTitle.edit', ['限时折扣活动'])
|
||||||
@@ -44,23 +62,195 @@ const [Form, formApi] = useVbenForm({
|
|||||||
showDefaultActions: false,
|
showDefaultActions: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================= 商品选择相关 =================
|
||||||
|
/** SKU 扩展类型 */
|
||||||
|
interface SkuExtension extends MallSpuApi.Sku {
|
||||||
|
productConfig: MallDiscountActivityApi.DiscountProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SPU 扩展类型 */
|
||||||
|
interface SpuExtension extends MallSpuApi.Spu {
|
||||||
|
skus?: SkuExtension[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const spuSelectRef = ref(); // 商品选择组件 Ref
|
||||||
|
const spuAndSkuListRef = ref(); // SKU 列表组件 Ref
|
||||||
|
const spuList = ref<SpuExtension[]>([]); // 选择的 SPU 列表
|
||||||
|
const spuPropertyList = ref<SpuProperty<SpuExtension>[]>([]); // SPU 属性列表
|
||||||
|
const spuIdList = ref<number[]>([]); // 已选择的 SPU ID 列表
|
||||||
|
|
||||||
|
/** SKU 校验规则配置 */
|
||||||
|
const ruleConfig: RuleConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'productConfig.discountPrice',
|
||||||
|
rule: (arg) => arg > 0,
|
||||||
|
message: '商品优惠金额不能为 0 !!!',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 打开商品选择弹窗 */
|
||||||
|
function openSpuSelect() {
|
||||||
|
spuSelectRef.value?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择商品后的回调 */
|
||||||
|
function handleSpuSelected(spuId: number, skuIds?: number[]) {
|
||||||
|
getSpuDetails(spuId, skuIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 SPU 详情 */
|
||||||
|
async function getSpuDetails(
|
||||||
|
spuId: number,
|
||||||
|
skuIdArr?: number[],
|
||||||
|
products?: MallDiscountActivityApi.DiscountProduct[],
|
||||||
|
type?: string,
|
||||||
|
) {
|
||||||
|
// 如果已经包含该 SPU 则跳过
|
||||||
|
if (spuIdList.value.includes(spuId)) {
|
||||||
|
if (type !== 'load') {
|
||||||
|
message.error('数据重复选择!');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spuIdList.value.push(spuId);
|
||||||
|
|
||||||
|
const res = (await getSpuDetailList([spuId])) as SpuExtension[];
|
||||||
|
if (res.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spu = res[0]!;
|
||||||
|
// 筛选 SKU
|
||||||
|
const selectSkus =
|
||||||
|
skuIdArr === undefined
|
||||||
|
? spu.skus
|
||||||
|
: spu.skus?.filter((sku) => skuIdArr.includes(sku.id!));
|
||||||
|
|
||||||
|
// 为每个 SKU 添加折扣配置
|
||||||
|
selectSkus?.forEach((sku) => {
|
||||||
|
let config: MallDiscountActivityApi.DiscountProduct = {
|
||||||
|
skuId: sku.id!,
|
||||||
|
spuId: spu.id!,
|
||||||
|
discountType: 1,
|
||||||
|
discountPercent: 0,
|
||||||
|
discountPrice: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑时,使用已有的配置
|
||||||
|
if (products !== undefined) {
|
||||||
|
const product = products.find((item) => item.skuId === sku.id);
|
||||||
|
if (product) {
|
||||||
|
// 转换为元显示
|
||||||
|
config = {
|
||||||
|
...product,
|
||||||
|
discountPercent: Number(formatToFraction(product.discountPercent)),
|
||||||
|
discountPrice: Number(formatToFraction(product.discountPrice)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(sku as SkuExtension).productConfig = config;
|
||||||
|
});
|
||||||
|
|
||||||
|
spu.skus = selectSkus as SkuExtension[];
|
||||||
|
spuPropertyList.value.push({
|
||||||
|
spuId: spu.id!,
|
||||||
|
spuDetail: spu,
|
||||||
|
propertyList: getPropertyList(spu) as PropertyAndValues[],
|
||||||
|
});
|
||||||
|
spuList.value.push(spu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 SPU */
|
||||||
|
function handleDeleteSpu(spuId: number) {
|
||||||
|
const spuIndex = spuIdList.value.indexOf(spuId);
|
||||||
|
if (spuIndex !== -1) {
|
||||||
|
spuIdList.value.splice(spuIndex, 1);
|
||||||
|
}
|
||||||
|
const propertyIndex = spuPropertyList.value.findIndex(
|
||||||
|
(item) => item.spuId === spuId,
|
||||||
|
);
|
||||||
|
if (propertyIndex !== -1) {
|
||||||
|
spuPropertyList.value.splice(propertyIndex, 1);
|
||||||
|
}
|
||||||
|
const listIndex = spuList.value.findIndex((item) => item.id === spuId);
|
||||||
|
if (listIndex !== -1) {
|
||||||
|
spuList.value.splice(listIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 SKU 优惠金额变动 */
|
||||||
|
function handleSkuDiscountPriceChange(row: SkuExtension) {
|
||||||
|
if (row.productConfig.discountPrice <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 设置优惠类型:满减
|
||||||
|
row.productConfig.discountType = PromotionDiscountTypeEnum.PRICE.type;
|
||||||
|
// 计算折扣百分比
|
||||||
|
const price = typeof row.price === 'number' ? row.price : Number(row.price);
|
||||||
|
const percent = erpCalculatePercentage(
|
||||||
|
price - yuanToFen(row.productConfig.discountPrice),
|
||||||
|
price,
|
||||||
|
);
|
||||||
|
row.productConfig.discountPercent =
|
||||||
|
typeof percent === 'number' ? percent : Number(percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 SKU 折扣百分比变动 */
|
||||||
|
function handleSkuDiscountPercentChange(row: SkuExtension) {
|
||||||
|
if (
|
||||||
|
row.productConfig.discountPercent <= 0 ||
|
||||||
|
row.productConfig.discountPercent >= 100
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 设置优惠类型:折扣
|
||||||
|
row.productConfig.discountType = PromotionDiscountTypeEnum.PERCENT.type;
|
||||||
|
// 计算优惠金额
|
||||||
|
const price = typeof row.price === 'number' ? row.price : Number(row.price);
|
||||||
|
row.productConfig.discountPrice = Number(
|
||||||
|
formatToFraction(price - price * (row.productConfig.discountPercent / 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置表单 */
|
||||||
|
async function resetForm() {
|
||||||
|
spuList.value = [];
|
||||||
|
spuPropertyList.value = [];
|
||||||
|
spuIdList.value = [];
|
||||||
|
formData.value = {};
|
||||||
|
await nextTick();
|
||||||
|
await formApi.resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 弹窗相关 =================
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 校验是否选择了商品
|
||||||
|
if (spuList.value.length === 0) {
|
||||||
|
message.warning('请选择活动商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const data =
|
|
||||||
(await formApi.getValues()) as MallDiscountActivityApi.DiscountActivity;
|
|
||||||
|
|
||||||
// 确保必要的默认值
|
|
||||||
if (!data.products) {
|
|
||||||
data.products = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 获取折扣商品配置
|
||||||
|
const products = cloneDeep(
|
||||||
|
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
|
||||||
|
) as MallDiscountActivityApi.DiscountProduct[];
|
||||||
|
// 转换金额为分
|
||||||
|
products.forEach((item) => {
|
||||||
|
item.discountPercent = convertToInteger(item.discountPercent);
|
||||||
|
item.discountPrice = convertToInteger(item.discountPrice);
|
||||||
|
});
|
||||||
|
const data = cloneDeep(
|
||||||
|
await formApi.getValues(),
|
||||||
|
) as MallDiscountActivityApi.DiscountActivity;
|
||||||
|
data.products = products;
|
||||||
|
// 提交请求
|
||||||
await (formData.value?.id
|
await (formData.value?.id
|
||||||
? updateDiscountActivity(data)
|
? updateDiscountActivity(data)
|
||||||
: createDiscountActivity(data));
|
: createDiscountActivity(data));
|
||||||
@@ -74,7 +264,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
},
|
},
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
formData.value = {};
|
await resetForm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 加载数据
|
// 加载数据
|
||||||
@@ -84,9 +274,30 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
}
|
}
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
formData.value = await getDiscountActivity(data.id);
|
const activityData = await getDiscountActivity(data.id);
|
||||||
|
formData.value = activityData;
|
||||||
|
// 加载商品详情
|
||||||
|
if (activityData.products && activityData.products.length > 0) {
|
||||||
|
// 按 spuId 分组
|
||||||
|
const spuProductsMap = new Map<
|
||||||
|
number,
|
||||||
|
MallDiscountActivityApi.DiscountProduct[]
|
||||||
|
>();
|
||||||
|
for (const product of activityData.products) {
|
||||||
|
const spuId = product.spuId;
|
||||||
|
if (!spuProductsMap.has(spuId)) {
|
||||||
|
spuProductsMap.set(spuId, []);
|
||||||
|
}
|
||||||
|
spuProductsMap.get(spuId)!.push(product);
|
||||||
|
}
|
||||||
|
// 加载每个 SPU 的详情
|
||||||
|
for (const [spuId, products] of spuProductsMap) {
|
||||||
|
const skuIdArr = products.map((p) => p.skuId);
|
||||||
|
await getSpuDetails(spuId, skuIdArr, products, 'load');
|
||||||
|
}
|
||||||
|
}
|
||||||
// 设置到 values
|
// 设置到 values
|
||||||
await formApi.setValues(formData.value);
|
await formApi.setValues(activityData);
|
||||||
} finally {
|
} finally {
|
||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
}
|
}
|
||||||
@@ -95,12 +306,59 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal class="w-3/5" :title="getTitle">
|
<Modal class="w-[70%]" :title="getTitle">
|
||||||
<Form>
|
<Form>
|
||||||
<!-- 自定义插槽:商品选择 -->
|
<!-- 自定义插槽:商品选择 -->
|
||||||
<template #spuIds>
|
<template #spuIds>
|
||||||
<SpuShowcase v-model="formData.spuIds" />
|
<div class="w-full">
|
||||||
|
<Button class="mb-4" @click="openSpuSelect">选择商品</Button>
|
||||||
|
<SpuAndSkuList
|
||||||
|
ref="spuAndSkuListRef"
|
||||||
|
:deletable="true"
|
||||||
|
:rule-config="ruleConfig"
|
||||||
|
:spu-list="spuList"
|
||||||
|
:spu-property-list-p="spuPropertyList"
|
||||||
|
@delete="handleDeleteSpu"
|
||||||
|
>
|
||||||
|
<!-- 扩展列:限时折扣活动特有配置 -->
|
||||||
|
<template #default>
|
||||||
|
<VxeColumn align="center" min-width="168" title="优惠金额(元)">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="row.productConfig.discountPrice"
|
||||||
|
:max="Number(formatToFraction(row.price))"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
@change="handleSkuDiscountPriceChange(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" min-width="168" title="折扣百分比(%)">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="row.productConfig.discountPercent"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
@change="handleSkuDiscountPercentChange(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
</SpuAndSkuList>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 商品选择弹窗 -->
|
||||||
|
<SpuSkuSelect
|
||||||
|
ref="spuSelectRef"
|
||||||
|
:is-select-sku="true"
|
||||||
|
@select="handleSpuSelected"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
componentProps: {
|
componentProps: {
|
||||||
showTime: true,
|
showTime: true,
|
||||||
format: 'YYYY-MM-DD HH:mm:ss',
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
valueFormat: 'x',
|
||||||
placeholder: [
|
placeholder: [
|
||||||
$t('utils.rangePicker.beginTime'),
|
$t('utils.rangePicker.beginTime'),
|
||||||
$t('utils.rangePicker.endTime'),
|
$t('utils.rangePicker.endTime'),
|
||||||
@@ -217,13 +218,15 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
// TODO @puhui999:1)新增时:一直报:“请输入优惠设置”;2)修改老数据,出现报“请求参数类型错误:50.00”;
|
|
||||||
{
|
{
|
||||||
fieldName: 'rules',
|
fieldName: 'rules',
|
||||||
label: '优惠设置',
|
label: '优惠设置',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
formItemClass: 'items-start',
|
formItemClass: 'items-start',
|
||||||
rules: 'required',
|
rules: z
|
||||||
|
.array(z.any())
|
||||||
|
.min(1, { message: '请添加至少一条优惠规则' })
|
||||||
|
.default([]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import {
|
|||||||
PromotionConditionTypeEnum,
|
PromotionConditionTypeEnum,
|
||||||
PromotionProductScopeEnum,
|
PromotionProductScopeEnum,
|
||||||
} from '@vben/constants';
|
} from '@vben/constants';
|
||||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +52,21 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
|
// 在验证前同步 formData 中的值到表单中
|
||||||
|
await formApi.setFieldValue('rules', formData.value.rules || []);
|
||||||
|
// 同步商品/分类选择到表单,确保验证时能获取到值
|
||||||
|
if (formData.value.productSpuIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productSpuIds',
|
||||||
|
formData.value.productSpuIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (formData.value.productCategoryIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productCategoryIds',
|
||||||
|
formData.value.productCategoryIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
@@ -61,18 +75,24 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
// 提交表单
|
// 提交表单
|
||||||
try {
|
try {
|
||||||
const values = await formApi.getValues();
|
const values = await formApi.getValues();
|
||||||
const data = { ...formData.value, ...values };
|
// 使用 formData.value 作为基础,确保 rules 来自 formData
|
||||||
|
const data = { ...values, ...formData.value };
|
||||||
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
|
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
|
||||||
data.startTime = data.startAndEndTime[0];
|
data.startTime = data.startAndEndTime[0];
|
||||||
data.endTime = data.startAndEndTime[1];
|
data.endTime = data.startAndEndTime[1];
|
||||||
delete data.startAndEndTime;
|
delete data.startAndEndTime;
|
||||||
}
|
}
|
||||||
data.rules?.forEach((item: any) => {
|
// 深拷贝 rules 避免修改原始数据
|
||||||
|
const rules = cloneDeep(
|
||||||
|
data.rules,
|
||||||
|
) as unknown as MallRewardActivityApi.RewardRule[];
|
||||||
|
rules.forEach((item: any) => {
|
||||||
item.discountPrice = convertToInteger(item.discountPrice || 0);
|
item.discountPrice = convertToInteger(item.discountPrice || 0);
|
||||||
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||||
item.limit = convertToInteger(item.limit || 0);
|
item.limit = convertToInteger(item.limit || 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
data.rules = rules;
|
||||||
await (data.id
|
await (data.id
|
||||||
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
||||||
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
||||||
@@ -98,8 +118,8 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
try {
|
try {
|
||||||
const result = await getReward(data.id);
|
const result = await getReward(data.id);
|
||||||
result.startAndEndTime = [
|
result.startAndEndTime = [
|
||||||
result.startTime ? dayjs(result.startTime) : undefined,
|
result.startTime ? String(result.startTime) : undefined,
|
||||||
result.endTime ? dayjs(result.endTime) : undefined,
|
result.endTime ? String(result.endTime) : undefined,
|
||||||
] as any[];
|
] as any[];
|
||||||
result.rules?.forEach((item: any) => {
|
result.rules?.forEach((item: any) => {
|
||||||
item.discountPrice = formatToFraction(item.discountPrice || 0);
|
item.discountPrice = formatToFraction(item.discountPrice || 0);
|
||||||
|
|||||||
@@ -217,6 +217,30 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
},
|
},
|
||||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'visible',
|
||||||
|
label: '显示状态',
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '显示', value: true },
|
||||||
|
{ label: '隐藏', value: false },
|
||||||
|
],
|
||||||
|
buttonStyle: 'solid',
|
||||||
|
optionType: 'button',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
defaultValue: true,
|
||||||
|
help: '选择隐藏时,路由将不会出现在侧边栏,但仍然可以访问',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['type'],
|
||||||
|
show: (values) => {
|
||||||
|
return [SystemMenuTypeEnum.DIR, SystemMenuTypeEnum.MENU].includes(
|
||||||
|
values.type,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'alwaysShow',
|
fieldName: 'alwaysShow',
|
||||||
label: '总是显示',
|
label: '总是显示',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export default defineConfig(async () => {
|
|||||||
application: {},
|
application: {},
|
||||||
vite: {
|
vite: {
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/admin-api': {
|
'/admin-api': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ VITE_INJECT_APP_LOADING=true
|
|||||||
VITE_ARCHIVER=true
|
VITE_ARCHIVER=true
|
||||||
|
|
||||||
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
|
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
|
||||||
|
|
||||||
|
# 验证码的开关
|
||||||
|
VITE_APP_CAPTCHA_ENABLE=true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CropperAvatarProps } from './typing';
|
|||||||
import { computed, ref, unref, watch, watchEffect } from 'vue';
|
import { computed, ref, unref, watch, watchEffect } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import { ElButton, ElMessage } from 'element-plus';
|
import { ElButton, ElMessage } from 'element-plus';
|
||||||
@@ -18,7 +19,7 @@ const props = withDefaults(defineProps<CropperAvatarProps>(), {
|
|||||||
width: 200,
|
width: 200,
|
||||||
value: '',
|
value: '',
|
||||||
showBtn: true,
|
showBtn: true,
|
||||||
btnProps: () => ({}),
|
btnProps: () => ({}) as any,
|
||||||
btnText: '',
|
btnText: '',
|
||||||
uploadApi: () => Promise.resolve(),
|
uploadApi: () => Promise.resolve(),
|
||||||
size: 5,
|
size: 5,
|
||||||
@@ -27,14 +28,10 @@ const props = withDefaults(defineProps<CropperAvatarProps>(), {
|
|||||||
const emit = defineEmits(['update:value', 'change']);
|
const emit = defineEmits(['update:value', 'change']);
|
||||||
|
|
||||||
const sourceValue = ref(props.value || '');
|
const sourceValue = ref(props.value || '');
|
||||||
// TODO @puhui999:这个有办法去掉么?
|
|
||||||
const prefixCls = 'cropper-avatar';
|
|
||||||
const [CropperModal, modalApi] = useVbenModal({
|
const [CropperModal, modalApi] = useVbenModal({
|
||||||
connectedComponent: cropperModal,
|
connectedComponent: cropperModal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getClass = computed(() => [prefixCls]);
|
|
||||||
|
|
||||||
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
|
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
|
||||||
|
|
||||||
const getIconWidth = computed(
|
const getIconWidth = computed(
|
||||||
@@ -74,34 +71,42 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- TODO @puhui999:html 部分,看看有没办法和 web-antd/src/components/cropper/cropper-avatar.vue 风格更接近 -->
|
|
||||||
<!-- 头像容器 -->
|
<!-- 头像容器 -->
|
||||||
<div :class="getClass" :style="getStyle">
|
<div class="inline-block text-center" :style="getStyle">
|
||||||
<!-- 图片包装器 -->
|
<!-- 图片包装器 -->
|
||||||
<div
|
<div
|
||||||
:class="`${prefixCls}-image-wrapper`"
|
class="group relative cursor-pointer overflow-hidden rounded-full border border-gray-200 bg-white"
|
||||||
:style="getImageWrapperStyle"
|
:style="getImageWrapperStyle"
|
||||||
@click="openModal"
|
@click="openModal"
|
||||||
>
|
>
|
||||||
<!-- 遮罩层 -->
|
<!-- 遮罩层 -->
|
||||||
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
|
<div
|
||||||
<span
|
class="duration-400 absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-40 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
:style="getImageWrapperStyle"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="lucide:cloud-upload"
|
||||||
|
class="m-auto text-gray-400"
|
||||||
:style="{
|
:style="{
|
||||||
...getImageWrapperStyle,
|
...getImageWrapperStyle,
|
||||||
width: `${getIconWidth}`,
|
width: getIconWidth,
|
||||||
height: `${getIconWidth}`,
|
height: getIconWidth,
|
||||||
lineHeight: `${getIconWidth}`,
|
lineHeight: getIconWidth,
|
||||||
}"
|
}"
|
||||||
class="icon-[ant-design--cloud-upload-outlined] text-[#d6d6d6]"
|
/>
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 头像图片 -->
|
<!-- 头像图片 -->
|
||||||
<img v-if="sourceValue" :src="sourceValue" alt="avatar" />
|
<img
|
||||||
|
v-if="sourceValue"
|
||||||
|
:src="sourceValue"
|
||||||
|
alt="avatar"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 上传按钮 -->
|
<!-- 上传按钮 -->
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="showBtn"
|
v-if="showBtn"
|
||||||
:class="`${prefixCls}-upload-btn`"
|
class="mx-auto mt-2"
|
||||||
@click="openModal"
|
@click="openModal"
|
||||||
v-bind="btnProps"
|
v-bind="btnProps"
|
||||||
>
|
>
|
||||||
@@ -116,50 +121,3 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
/* TODO @puhui999:要类似 web-antd/src/components/cropper/cropper-avatar.vue 减少 scss,通过 tindwind 么? */
|
|
||||||
.cropper-avatar {
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&-image-wrapper {
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-image-mask {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: inherit;
|
|
||||||
height: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgb(0 0 0 / 40%);
|
|
||||||
border: inherit;
|
|
||||||
border-radius: inherit;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.4s;
|
|
||||||
|
|
||||||
::v-deep(svg) {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-image-mask:hover {
|
|
||||||
opacity: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-upload-btn {
|
|
||||||
margin: 10px auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CropendResult, CropperModalProps, CropperType } from './typing';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
import { dataURLtoBlob, isFunction } from '@vben/utils';
|
import { dataURLtoBlob, isFunction } from '@vben/utils';
|
||||||
|
|
||||||
@@ -36,7 +37,6 @@ const cropper = ref<CropperType>();
|
|||||||
let scaleX = 1;
|
let scaleX = 1;
|
||||||
let scaleY = 1;
|
let scaleY = 1;
|
||||||
|
|
||||||
const prefixCls = 'cropper-am';
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
onConfirm: handleOk,
|
onConfirm: handleOk,
|
||||||
onOpenChange(isOpen) {
|
onOpenChange(isOpen) {
|
||||||
@@ -118,13 +118,15 @@ async function handleOk() {
|
|||||||
:confirm-text="$t('ui.cropper.okText')"
|
:confirm-text="$t('ui.cropper.okText')"
|
||||||
:fullscreen-button="false"
|
:fullscreen-button="false"
|
||||||
:title="$t('ui.cropper.modalTitle')"
|
:title="$t('ui.cropper.modalTitle')"
|
||||||
class="w-[800px]"
|
class="w-2/3"
|
||||||
>
|
>
|
||||||
<div :class="prefixCls">
|
<div class="flex h-96">
|
||||||
<!-- 左侧区域 -->
|
<!-- 左侧区域 -->
|
||||||
<div :class="`${prefixCls}-left`" class="w-full">
|
<div class="h-full w-3/5">
|
||||||
<!-- 裁剪器容器 -->
|
<!-- 裁剪器容器 -->
|
||||||
<div :class="`${prefixCls}-cropper`">
|
<div
|
||||||
|
class="relative h-[300px] bg-gradient-to-b from-neutral-50 to-neutral-200"
|
||||||
|
>
|
||||||
<CropperImage
|
<CropperImage
|
||||||
v-if="src"
|
v-if="src"
|
||||||
:circled="circled"
|
:circled="circled"
|
||||||
@@ -136,7 +138,7 @@ async function handleOk() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<div :class="`${prefixCls}-toolbar`">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
<ElUpload
|
<ElUpload
|
||||||
:before-upload="handleBeforeUpload"
|
:before-upload="handleBeforeUpload"
|
||||||
:file-list="[]"
|
:file-list="[]"
|
||||||
@@ -149,7 +151,7 @@ async function handleOk() {
|
|||||||
<ElButton size="small" type="primary">
|
<ElButton size="small" type="primary">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="icon-[ant-design--upload-outlined]"></span>
|
<IconifyIcon icon="lucide:upload" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -165,7 +167,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="icon-[ant-design--reload-outlined]"></span>
|
<IconifyIcon icon="lucide:rotate-ccw" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -182,9 +184,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span
|
<IconifyIcon icon="ant-design:rotate-left-outlined" />
|
||||||
class="icon-[ant-design--rotate-left-outlined]"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -201,9 +201,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span
|
<IconifyIcon icon="ant-design:rotate-right-outlined" />
|
||||||
class="icon-[ant-design--rotate-right-outlined]"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -220,7 +218,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="icon-[vaadin--arrows-long-h]"></span>
|
<IconifyIcon icon="vaadin:arrows-long-h" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -237,7 +235,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="icon-[vaadin--arrows-long-v]"></span>
|
<IconifyIcon icon="vaadin:arrows-long-v" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -254,7 +252,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="icon-[ant-design--zoom-in-outlined]"></span>
|
<IconifyIcon icon="lucide:zoom-in" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -271,7 +269,7 @@ async function handleOk() {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<span class="icon-[ant-design--zoom-out-outlined]"></span>
|
<IconifyIcon icon="lucide:zoom-out" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -281,18 +279,23 @@ async function handleOk() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧区域 -->
|
<!-- 右侧区域 -->
|
||||||
<div :class="`${prefixCls}-right`">
|
<div class="h-full w-2/5">
|
||||||
<!-- 预览区域 -->
|
<!-- 预览区域 -->
|
||||||
<div :class="`${prefixCls}-preview`">
|
<div
|
||||||
|
class="mx-auto h-56 w-56 overflow-hidden rounded-full border border-gray-200"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="previewSource"
|
v-if="previewSource"
|
||||||
:alt="$t('ui.cropper.preview')"
|
:alt="$t('ui.cropper.preview')"
|
||||||
:src="previewSource"
|
:src="previewSource"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 头像组合预览 -->
|
<!-- 头像组合预览 -->
|
||||||
<template v-if="previewSource">
|
<template v-if="previewSource">
|
||||||
<div :class="`${prefixCls}-group`">
|
<div
|
||||||
|
class="mt-2 flex items-center justify-around border-t border-gray-200 pt-2"
|
||||||
|
>
|
||||||
<ElAvatar :src="previewSource" size="large" />
|
<ElAvatar :src="previewSource" size="large" />
|
||||||
<ElAvatar :size="48" :src="previewSource" />
|
<ElAvatar :size="48" :src="previewSource" />
|
||||||
<ElAvatar :size="64" :src="previewSource" />
|
<ElAvatar :size="64" :src="previewSource" />
|
||||||
@@ -303,77 +306,3 @@ async function handleOk() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
/* TODO @puhui999:要类似 web-antd/src/components/cropper/cropper-avatar.vue 减少 scss,通过 tindwind 么? */
|
|
||||||
.cropper-am {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&-left,
|
|
||||||
&-right {
|
|
||||||
height: 340px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-left {
|
|
||||||
width: 55%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-right {
|
|
||||||
width: 45%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-cropper {
|
|
||||||
height: 300px;
|
|
||||||
background: #eee;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(
|
|
||||||
45deg,
|
|
||||||
rgb(0 0 0 / 25%) 25%,
|
|
||||||
transparent 0,
|
|
||||||
transparent 75%,
|
|
||||||
rgb(0 0 0 / 25%) 0
|
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
45deg,
|
|
||||||
rgb(0 0 0 / 25%) 25%,
|
|
||||||
transparent 0,
|
|
||||||
transparent 75%,
|
|
||||||
rgb(0 0 0 / 25%) 0
|
|
||||||
);
|
|
||||||
background-position:
|
|
||||||
0 0,
|
|
||||||
12px 12px;
|
|
||||||
background-size: 24px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-preview {
|
|
||||||
width: 220px;
|
|
||||||
height: 220px;
|
|
||||||
margin: 0 auto;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding-top: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
border-top: 1px solid #eee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ const imgElRef = ref<ElRef<HTMLImageElement>>();
|
|||||||
const cropper = ref<Cropper | null>();
|
const cropper = ref<Cropper | null>();
|
||||||
const isReady = ref(false);
|
const isReady = ref(false);
|
||||||
|
|
||||||
// TODO @puhui999:这个有办法去掉么?
|
|
||||||
const prefixCls = 'cropper-image';
|
|
||||||
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
|
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
|
||||||
|
|
||||||
const getImageStyle = computed((): CSSProperties => {
|
const getImageStyle = computed((): CSSProperties => {
|
||||||
@@ -47,10 +45,9 @@ const getImageStyle = computed((): CSSProperties => {
|
|||||||
|
|
||||||
const getClass = computed(() => {
|
const getClass = computed(() => {
|
||||||
return [
|
return [
|
||||||
prefixCls,
|
|
||||||
attrs.class,
|
attrs.class,
|
||||||
{
|
{
|
||||||
[`${prefixCls}--circled`]: props.circled,
|
'cropper-image--circled': props.circled,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -158,6 +155,7 @@ function getRoundedCanvas() {
|
|||||||
:crossorigin="crossorigin"
|
:crossorigin="crossorigin"
|
||||||
:src="src"
|
:src="src"
|
||||||
:style="getImageStyle"
|
:style="getImageStyle"
|
||||||
|
class="h-auto max-w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ export function useImagesUpload() {
|
|||||||
default: 5,
|
default: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup(props) {
|
||||||
// TODO: @puhui999:@dhb52 其实还是靠 props 默认参数起作用,没能从 formCreate 传递
|
return () => (
|
||||||
return (props: { maxNumber?: number; multiple?: boolean }) => (
|
|
||||||
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
<ImageUpload maxNumber={props.maxNumber} multiple={props.multiple} />
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const apiSelectRule = [
|
|||||||
field: 'data',
|
field: 'data',
|
||||||
title: '请求参数 JSON 格式',
|
title: '请求参数 JSON 格式',
|
||||||
props: {
|
props: {
|
||||||
autosize: true, // TODO @puhui999:这里时 autoSize 还是 autosize 哈?和 antd 不同
|
autosize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
placeholder: '{"type": 1}',
|
placeholder: '{"type": 1}',
|
||||||
},
|
},
|
||||||
@@ -155,7 +155,7 @@ const apiSelectRule = [
|
|||||||
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
||||||
(data: any)=>{ label: string; value: any }[]`,
|
(data: any)=>{ label: string; value: any }[]`,
|
||||||
props: {
|
props: {
|
||||||
autosize: true, // TODO @puhui999:这里时 autoSize 还是 autosize 哈?和 antd 不同
|
autosize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||||
rows: { minRows: 2, maxRows: 6 },
|
rows: { minRows: 2, maxRows: 6 },
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
placeholder: `
|
placeholder: `
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function useDictSelectRule() {
|
|||||||
title: label,
|
title: label,
|
||||||
info: '',
|
info: '',
|
||||||
$required: false,
|
$required: false,
|
||||||
// TODO @puhui999:vben 版本里,这里有个 modelField: 'value', 需要添加么?
|
modelField: 'model-value', // 特殊:ele 里是 model-value,antd 里是 value
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
props(_: any, { t }: any) {
|
props(_: any, { t }: any) {
|
||||||
|
|||||||
@@ -13,37 +13,46 @@ withDefaults(defineProps<OperateLogProps>(), {
|
|||||||
logList: () => [],
|
logList: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 获得 userType 颜色 */
|
||||||
function getUserTypeColor(userType: number) {
|
function getUserTypeColor(userType: number) {
|
||||||
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
||||||
if (dict && dict.colorType) {
|
switch (dict?.colorType) {
|
||||||
return `hsl(var(--${dict.colorType}))`;
|
case 'danger': {
|
||||||
|
return '#F56C6C';
|
||||||
|
}
|
||||||
|
case 'info': {
|
||||||
|
return '#909399';
|
||||||
|
}
|
||||||
|
case 'success': {
|
||||||
|
return '#67C23A';
|
||||||
|
}
|
||||||
|
case 'warning': {
|
||||||
|
return '#E6A23C';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'hsl(var(--primary))';
|
return '#409EFF';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="pt-5">
|
||||||
<ElTimeline>
|
<ElTimeline>
|
||||||
<ElTimelineItem
|
<ElTimelineItem v-for="log in logList" :key="log.id" class="!pb-3">
|
||||||
v-for="log in logList"
|
|
||||||
:key="log.id"
|
|
||||||
:color="getUserTypeColor(log.userType)"
|
|
||||||
>
|
|
||||||
<template #dot>
|
<template #dot>
|
||||||
<p
|
<span
|
||||||
:style="{ backgroundColor: getUserTypeColor(log.userType) }"
|
:style="{ backgroundColor: getUserTypeColor(log.userType) }"
|
||||||
class="absolute left-1 top-0 flex h-5 w-5 items-center justify-center rounded-full text-xs text-white"
|
class="-ml-[2px] -mt-[1px] flex h-5 w-5 items-center justify-center rounded-full text-[10px] text-white"
|
||||||
>
|
>
|
||||||
{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
|
{{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
|
||||||
</p>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<p class="ml-2">{{ formatDateTime(log.createTime) }}</p>
|
<div class="ml-2 flex flex-wrap items-center gap-2 leading-[22px]">
|
||||||
<p class="ml-2 mt-2">
|
<span class="w-[130px] shrink-0 text-[13px] text-gray-400">
|
||||||
<ElTag :color="getUserTypeColor(log.userType)">
|
{{ formatDateTime(log.createTime) }}
|
||||||
{{ log.userName }}
|
</span>
|
||||||
</ElTag>
|
<ElTag type="success" class="!mr-0">{{ log.userName }}</ElTag>
|
||||||
{{ log.action }}
|
<span>{{ log.action }}</span>
|
||||||
</p>
|
</div>
|
||||||
</ElTimelineItem>
|
</ElTimelineItem>
|
||||||
</ElTimeline>
|
</ElTimeline>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
45
apps/web-ele/src/router/routes/modules/leave.ts
Normal file
45
apps/web-ele/src/router/routes/modules/leave.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
// OA 请假相关路由配置
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/bpm/oa',
|
||||||
|
name: 'OALeave',
|
||||||
|
meta: {
|
||||||
|
title: 'OA请假',
|
||||||
|
hideInMenu: true,
|
||||||
|
redirect: '/bpm/oa/leave/index',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'leave',
|
||||||
|
name: 'OALeaveIndex',
|
||||||
|
component: () => import('#/views/bpm/oa/leave/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '请假列表',
|
||||||
|
activePath: '/bpm/oa/leave',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'leave/create',
|
||||||
|
name: 'OALeaveCreate',
|
||||||
|
component: () => import('#/views/bpm/oa/leave/create.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '创建请假',
|
||||||
|
activePath: '/bpm/oa/leave',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'leave/detail',
|
||||||
|
name: 'OALeaveDetail',
|
||||||
|
component: () => import('#/views/bpm/oa/leave/detail.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '请假详情',
|
||||||
|
activePath: '/bpm/oa/leave',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
interface TreeHelperConfig {
|
|
||||||
id: string;
|
|
||||||
children: string;
|
|
||||||
pid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: TreeHelperConfig = {
|
|
||||||
id: 'id',
|
|
||||||
children: 'children',
|
|
||||||
pid: 'pid',
|
|
||||||
};
|
|
||||||
export const defaultProps = {
|
|
||||||
children: 'children',
|
|
||||||
label: 'name',
|
|
||||||
value: 'id',
|
|
||||||
isLeaf: 'leaf',
|
|
||||||
emitPath: false, // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfig = (config: Partial<TreeHelperConfig>) =>
|
|
||||||
Object.assign({}, DEFAULT_CONFIG, config);
|
|
||||||
|
|
||||||
// tree from list
|
|
||||||
export const listToTree = <T = any>(
|
|
||||||
list: any[],
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): T[] => {
|
|
||||||
const conf = getConfig(config) as TreeHelperConfig;
|
|
||||||
const nodeMap = new Map();
|
|
||||||
const result: T[] = [];
|
|
||||||
const { id, children, pid } = conf;
|
|
||||||
|
|
||||||
for (const node of list) {
|
|
||||||
node[children] = node[children] || [];
|
|
||||||
nodeMap.set(node[id], node);
|
|
||||||
}
|
|
||||||
for (const node of list) {
|
|
||||||
const parent = nodeMap.get(node[pid]);
|
|
||||||
(parent ? parent.children : result).push(node);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const treeToList = <T = any>(
|
|
||||||
tree: any,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): T => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const { children } = config;
|
|
||||||
const result: any = [...tree];
|
|
||||||
for (let i = 0; i < result.length; i++) {
|
|
||||||
const childNodes = result[i][children];
|
|
||||||
if (!childNodes) continue;
|
|
||||||
result.splice(i + 1, 0, ...childNodes);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findNode = <T = any>(
|
|
||||||
tree: any,
|
|
||||||
func: Fn,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): null | T => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const { children } = config;
|
|
||||||
const list = [...tree];
|
|
||||||
for (const node of list) {
|
|
||||||
if (func(node)) return node;
|
|
||||||
const childNodes = node[children];
|
|
||||||
if (childNodes) {
|
|
||||||
list.push(...childNodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findNodeAll = <T = any>(
|
|
||||||
tree: any,
|
|
||||||
func: Fn,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): T[] => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const { children } = config;
|
|
||||||
const list = [...tree];
|
|
||||||
const result: T[] = [];
|
|
||||||
for (const node of list) {
|
|
||||||
func(node) && result.push(node);
|
|
||||||
const childNodes = node[children];
|
|
||||||
if (childNodes) {
|
|
||||||
list.push(...childNodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findPath = <T = any>(
|
|
||||||
tree: any,
|
|
||||||
func: Fn,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): null | T | T[] => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const path: T[] = [];
|
|
||||||
const list = [...tree];
|
|
||||||
const visitedSet = new Set();
|
|
||||||
const { children } = config;
|
|
||||||
while (list.length > 0) {
|
|
||||||
const node = list[0];
|
|
||||||
if (visitedSet.has(node)) {
|
|
||||||
path.pop();
|
|
||||||
list.shift();
|
|
||||||
} else {
|
|
||||||
visitedSet.add(node);
|
|
||||||
const childNodes = node[children];
|
|
||||||
if (childNodes) {
|
|
||||||
list.unshift(...childNodes);
|
|
||||||
}
|
|
||||||
path.push(node);
|
|
||||||
if (func(node)) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findPathAll = (
|
|
||||||
tree: any,
|
|
||||||
func: Fn,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
) => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const path: any[] = [];
|
|
||||||
const list = [...tree];
|
|
||||||
const result: any[] = [];
|
|
||||||
const visitedSet = new Set();
|
|
||||||
const { children } = config;
|
|
||||||
while (list.length > 0) {
|
|
||||||
const node = list[0];
|
|
||||||
if (visitedSet.has(node)) {
|
|
||||||
path.pop();
|
|
||||||
list.shift();
|
|
||||||
} else {
|
|
||||||
visitedSet.add(node);
|
|
||||||
const childNodes = node[children];
|
|
||||||
if (childNodes) {
|
|
||||||
list.unshift(...childNodes);
|
|
||||||
}
|
|
||||||
path.push(node);
|
|
||||||
func(node) && result.push([...path]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filter = <T = any>(
|
|
||||||
tree: T[],
|
|
||||||
func: (n: T) => boolean,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): T[] => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const children = config.children as string;
|
|
||||||
|
|
||||||
function listFilter(list: T[]) {
|
|
||||||
return list
|
|
||||||
.map((node: any) => ({ ...node }))
|
|
||||||
.filter((node) => {
|
|
||||||
node[children] = node[children] && listFilter(node[children]);
|
|
||||||
return func(node) || node[children]?.length > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return listFilter(tree);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const forEach = <T = any>(
|
|
||||||
tree: T[],
|
|
||||||
func: (n: T) => any,
|
|
||||||
config: Partial<TreeHelperConfig> = {},
|
|
||||||
): void => {
|
|
||||||
config = getConfig(config);
|
|
||||||
const list: any[] = [...tree];
|
|
||||||
const { children } = config;
|
|
||||||
for (let i = 0; i < list.length; i++) {
|
|
||||||
// func 返回true就终止遍历,避免大量节点场景下无意义循环,引起浏览器卡顿
|
|
||||||
if (func(list[i])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
children &&
|
|
||||||
list[i][children] &&
|
|
||||||
list.splice(i + 1, 0, ...list[i][children]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description: Extract tree specified structure
|
|
||||||
*/
|
|
||||||
export const treeMap = <T = any>(
|
|
||||||
treeData: T[],
|
|
||||||
opt: { children?: string; conversion: Fn },
|
|
||||||
): T[] => {
|
|
||||||
return treeData.map((item) => treeMapEach(item, opt));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description: Extract tree specified structure
|
|
||||||
*/
|
|
||||||
export const treeMapEach = (
|
|
||||||
data: any,
|
|
||||||
{ children = 'children', conversion }: { children?: string; conversion: Fn },
|
|
||||||
) => {
|
|
||||||
const haveChildren =
|
|
||||||
Array.isArray(data[children]) && data[children].length > 0;
|
|
||||||
const conversionData = conversion(data) || {};
|
|
||||||
return haveChildren
|
|
||||||
? {
|
|
||||||
...conversionData,
|
|
||||||
[children]: data[children].map((i: number) =>
|
|
||||||
treeMapEach(i, {
|
|
||||||
children,
|
|
||||||
conversion,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...conversionData,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 递归遍历树结构
|
|
||||||
* @param treeDatas 树
|
|
||||||
* @param callBack 回调
|
|
||||||
* @param parentNode 父节点
|
|
||||||
*/
|
|
||||||
export const eachTree = (treeDatas: any[], callBack: Fn, parentNode = {}) => {
|
|
||||||
treeDatas.forEach((element) => {
|
|
||||||
const newNode = callBack(element, parentNode) || element;
|
|
||||||
if (element.children) {
|
|
||||||
eachTree(element.children, callBack, newNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构造树型结构数据
|
|
||||||
* @param {*} data 数据源
|
|
||||||
* @param {*} id id字段 默认 'id'
|
|
||||||
* @param {*} parentId 父节点字段 默认 'parentId'
|
|
||||||
* @param {*} children 孩子节点字段 默认 'children'
|
|
||||||
*/
|
|
||||||
export const handleTree = (
|
|
||||||
data: any[],
|
|
||||||
id?: string,
|
|
||||||
parentId?: string,
|
|
||||||
children?: string,
|
|
||||||
) => {
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
console.warn('data must be an array');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const config = {
|
|
||||||
id: id || 'id',
|
|
||||||
parentId: parentId || 'parentId',
|
|
||||||
childrenList: children || 'children',
|
|
||||||
};
|
|
||||||
|
|
||||||
const childrenListMap = {};
|
|
||||||
const nodeIds = {};
|
|
||||||
const tree: any[] = [];
|
|
||||||
|
|
||||||
for (const d of data) {
|
|
||||||
const parentId = d[config.parentId];
|
|
||||||
if (
|
|
||||||
childrenListMap[parentId] === null ||
|
|
||||||
childrenListMap[parentId] === undefined
|
|
||||||
) {
|
|
||||||
childrenListMap[parentId] = [];
|
|
||||||
}
|
|
||||||
nodeIds[d[config.id]] = d;
|
|
||||||
childrenListMap[parentId].push(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const d of data) {
|
|
||||||
const parentId = d[config.parentId];
|
|
||||||
if (nodeIds[parentId] === null || nodeIds[parentId] === undefined) {
|
|
||||||
tree.push(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const t of tree) {
|
|
||||||
adaptToChildrenList(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function adaptToChildrenList(o) {
|
|
||||||
if (childrenListMap[o[config.id]] !== null) {
|
|
||||||
o[config.childrenList] = childrenListMap[o[config.id]];
|
|
||||||
}
|
|
||||||
if (o[config.childrenList]) {
|
|
||||||
for (const c of o[config.childrenList]) {
|
|
||||||
adaptToChildrenList(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tree;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构造树型结构数据
|
|
||||||
* @param {*} data 数据源
|
|
||||||
* @param {*} id id字段 默认 'id'
|
|
||||||
* @param {*} parentId 父节点字段 默认 'parentId'
|
|
||||||
* @param {*} children 孩子节点字段 默认 'children'
|
|
||||||
* @param {*} rootId 根Id 默认 0
|
|
||||||
*/
|
|
||||||
// @ts-ignore: 遗留函数,保持原有逻辑不变
|
|
||||||
export const handleTree2 = (data, id, parentId, children, rootId) => {
|
|
||||||
id = id || 'id';
|
|
||||||
parentId = parentId || 'parentId';
|
|
||||||
// children = children || 'children'
|
|
||||||
rootId =
|
|
||||||
rootId ||
|
|
||||||
Math.min(
|
|
||||||
...data.map((item) => {
|
|
||||||
return item[parentId];
|
|
||||||
}),
|
|
||||||
) ||
|
|
||||||
0;
|
|
||||||
// 对源数据深度克隆
|
|
||||||
const cloneData = structuredClone(data);
|
|
||||||
// 循环所有项
|
|
||||||
const treeData = cloneData.filter((father) => {
|
|
||||||
const branchArr = cloneData.filter((child) => {
|
|
||||||
// 返回每一项的子级数组
|
|
||||||
return father[id] === child[parentId];
|
|
||||||
});
|
|
||||||
branchArr.length > 0 ? (father.children = branchArr) : '';
|
|
||||||
// 返回第一层
|
|
||||||
return father[parentId] === rootId;
|
|
||||||
});
|
|
||||||
return treeData === '' ? data : treeData;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验选中的节点,是否为指定 level
|
|
||||||
*
|
|
||||||
* @param tree 要操作的树结构数据
|
|
||||||
* @param nodeId 需要判断在什么层级的数据
|
|
||||||
* @param level 检查的级别, 默认检查到二级
|
|
||||||
* @return true 是;false 否
|
|
||||||
*/
|
|
||||||
export const checkSelectedNode = (
|
|
||||||
tree: any[],
|
|
||||||
nodeId: any,
|
|
||||||
level = 2,
|
|
||||||
): boolean => {
|
|
||||||
if (tree === undefined || !Array.isArray(tree) || tree.length === 0) {
|
|
||||||
console.warn('tree must be an array');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验是否是一级节点
|
|
||||||
if (tree.some((item) => item.id === nodeId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归计数
|
|
||||||
let count = 1;
|
|
||||||
|
|
||||||
// 深层次校验
|
|
||||||
function performAThoroughValidation(arr: any[]): boolean {
|
|
||||||
count += 1;
|
|
||||||
for (const item of arr) {
|
|
||||||
if (item.id === nodeId) {
|
|
||||||
return true;
|
|
||||||
} else if (
|
|
||||||
item.children !== undefined &&
|
|
||||||
item.children.length > 0 &&
|
|
||||||
performAThoroughValidation(item.children)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of tree) {
|
|
||||||
count = 1;
|
|
||||||
if (
|
|
||||||
performAThoroughValidation(item.children) && // 找到后对比是否是期望的层级
|
|
||||||
count >= level
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取节点的完整结构
|
|
||||||
* @param tree 树数据
|
|
||||||
* @param nodeId 节点 id
|
|
||||||
*/
|
|
||||||
export const treeToString = (tree: any[], nodeId) => {
|
|
||||||
if (tree === undefined || !Array.isArray(tree) || tree.length === 0) {
|
|
||||||
console.warn('tree must be an array');
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// 校验是否是一级节点
|
|
||||||
const node = tree.find((item) => item.id === nodeId);
|
|
||||||
if (node !== undefined) {
|
|
||||||
return node.name;
|
|
||||||
}
|
|
||||||
let str = '';
|
|
||||||
|
|
||||||
function performAThoroughValidation(arr) {
|
|
||||||
if (arr === undefined || !Array.isArray(arr) || arr.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (const item of arr) {
|
|
||||||
if (item.id === nodeId) {
|
|
||||||
str += ` / ${item.name}`;
|
|
||||||
return true;
|
|
||||||
} else if (item.children !== undefined && item.children.length > 0) {
|
|
||||||
str += ` / ${item.name}`;
|
|
||||||
if (performAThoroughValidation(item.children)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of tree) {
|
|
||||||
str = `${item.name}`;
|
|
||||||
if (performAThoroughValidation(item.children)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
@@ -197,7 +197,7 @@ async function handleSubmit(values: Recordable<any>) {
|
|||||||
await smsResetPassword({ mobile, code, password });
|
await smsResetPassword({ mobile, code, password });
|
||||||
ElMessage.success($t('authentication.resetPasswordSuccess'));
|
ElMessage.success($t('authentication.resetPasswordSuccess'));
|
||||||
// 重置成功后跳转到首页
|
// 重置成功后跳转到首页
|
||||||
router.push('/');
|
await router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重置密码失败:', error);
|
console.error('重置密码失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import type { SystemUserProfileApi } from '#/api/system/user/profile';
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui';
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
|
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
|
||||||
|
|
||||||
|
import { getAuthPermissionInfoApi } from '#/api';
|
||||||
import { getUserProfile } from '#/api/system/user/profile';
|
import { getUserProfile } from '#/api/system/user/profile';
|
||||||
import { useAuthStore } from '#/store';
|
|
||||||
|
|
||||||
import BaseInfo from './modules/base-info.vue';
|
import BaseInfo from './modules/base-info.vue';
|
||||||
import ProfileUser from './modules/profile-user.vue';
|
import ProfileUser from './modules/profile-user.vue';
|
||||||
import ResetPwd from './modules/reset-pwd.vue';
|
import ResetPwd from './modules/reset-pwd.vue';
|
||||||
import UserSocial from './modules/user-social.vue';
|
import UserSocial from './modules/user-social.vue';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const userStore = useUserStore();
|
||||||
const activeName = ref('basicInfo');
|
const activeName = ref('basicInfo');
|
||||||
|
|
||||||
/** 加载个人信息 */
|
/** 加载个人信息 */
|
||||||
@@ -30,7 +31,8 @@ async function refreshProfile() {
|
|||||||
await loadProfile();
|
await loadProfile();
|
||||||
|
|
||||||
// 更新 store
|
// 更新 store
|
||||||
await authStore.fetchUserInfo();
|
const authPermissionInfo = await getAuthPermissionInfoApi();
|
||||||
|
userStore.setUserInfo(authPermissionInfo.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化 */
|
/** 初始化 */
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const emit = defineEmits<{
|
|||||||
(e: 'success'): void;
|
(e: 'success'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// TODO @puhui999:展示貌似不太对;应该是左右,不是上下哈
|
|
||||||
const [Form, formApi] = useVbenForm({
|
const [Form, formApi] = useVbenForm({
|
||||||
commonConfig: {
|
commonConfig: {
|
||||||
labelWidth: 70,
|
labelWidth: 70,
|
||||||
@@ -101,7 +100,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-16px md:w-full lg:w-1/2 2xl:w-2/5">
|
<div class="mt-4 md:w-full lg:w-1/2 2xl:w-2/5">
|
||||||
<Form />
|
<Form />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const avatar = computed(
|
|||||||
() => props.profile?.avatar || preferences.app.defaultAvatar,
|
() => props.profile?.avatar || preferences.app.defaultAvatar,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO @puhui999:头像上传没跑通
|
|
||||||
async function handelUpload({
|
async function handelUpload({
|
||||||
file,
|
file,
|
||||||
filename,
|
filename,
|
||||||
@@ -57,8 +56,8 @@ async function handelUpload({
|
|||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<ElDescriptions :column="2">
|
<ElDescriptions :column="2" border>
|
||||||
<ElDescriptionsItem>
|
<ElDescriptionsItem label="用户账号">
|
||||||
<template #label>
|
<template #label>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
|
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
|
||||||
@@ -116,7 +115,11 @@ async function handelUpload({
|
|||||||
所属岗位
|
所属岗位
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
{{ profile.posts.map((post) => post.name).join(',') }}
|
{{
|
||||||
|
profile.posts && profile.posts.length > 0
|
||||||
|
? profile.posts.map((post) => post.name).join(',')
|
||||||
|
: '-'
|
||||||
|
}}
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
<ElDescriptionsItem>
|
<ElDescriptionsItem>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ async function handleSubmit(values: Recordable<any>) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-[16px] md:w-full lg:w-1/2 2xl:w-2/5">
|
<div class="mt-4 md:w-full lg:w-1/2 2xl:w-2/5">
|
||||||
<Form />
|
<Form />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
toolbarConfig: {
|
toolbarConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 解绑账号 */
|
/** 解绑账号 */
|
||||||
@@ -167,19 +167,16 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<ElCard v-for="item in allBindList" :key="item.type" class="!mb-2">
|
<ElCard v-for="item in allBindList" :key="item.type" class="!mb-2">
|
||||||
<div class="flex w-full items-center gap-4">
|
<div class="flex w-full items-center gap-4">
|
||||||
<!-- TODO @puhui999:图片大小不太对 -->
|
|
||||||
<ElImage
|
<ElImage
|
||||||
:src="item.img"
|
:src="item.img"
|
||||||
:width="40"
|
style="width: 40px; height: 40px"
|
||||||
:height="40"
|
|
||||||
:alt="item.title"
|
:alt="item.title"
|
||||||
:preview="false"
|
:preview-disabled="true"
|
||||||
|
fit="contain"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-1 items-center justify-between">
|
<div class="flex flex-1 items-center justify-between">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h4
|
<h4 class="mb-1 text-sm text-black/85 dark:text-white/85">
|
||||||
class="mb-[4px] text-[14px] text-black/85 dark:text-white/85"
|
|
||||||
>
|
|
||||||
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
|
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
|
||||||
</h4>
|
</h4>
|
||||||
<span class="text-black/45 dark:text-white/45">
|
<span class="text-black/45 dark:text-white/45">
|
||||||
@@ -187,9 +184,9 @@ onMounted(() => {
|
|||||||
{{ item.socialUser?.nickname || item.socialUser?.openid }}
|
{{ item.socialUser?.nickname || item.socialUser?.openid }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
绑定{{
|
绑定
|
||||||
getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type)
|
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
|
||||||
}}账号
|
账号
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ function changeNodeName() {
|
|||||||
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<!-- TODO @jason:antd 是 1/3,这里要统一么? -->
|
|
||||||
<Drawer class="w-2/5">
|
<Drawer class="w-2/5">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ defineOptions({
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
// TODO @jason:这里 required: false,
|
required: false,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,13 +54,12 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<ElFormItem label="请求头">
|
<ElFormItem label="请求头" label-position="top">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in props.header"
|
v-for="(item, index) in props.header"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="mb-2 flex items-center gap-2"
|
class="mb-2 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<!-- 参数名 -->
|
|
||||||
<div class="w-[26%] min-w-32 shrink-0">
|
<div class="w-[26%] min-w-32 shrink-0">
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:prop="`${bind}.header.${index}.key`"
|
:prop="`${bind}.header.${index}.key`"
|
||||||
@@ -73,8 +72,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
<ElInput placeholder="参数名不能为空" v-model="item.key" />
|
<ElInput placeholder="参数名不能为空" v-model="item.key" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 类型选择 -->
|
|
||||||
<div class="w-[24%] min-w-11 shrink-0">
|
<div class="w-[24%] min-w-11 shrink-0">
|
||||||
<ElFormItem class="w-full">
|
<ElFormItem class="w-full">
|
||||||
<ElSelect v-model="item.type">
|
<ElSelect v-model="item.type">
|
||||||
@@ -87,8 +84,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 参数值 -->
|
|
||||||
<div class="w-[42%] flex-1">
|
<div class="w-[42%] flex-1">
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:prop="`${bind}.header.${index}.value`"
|
:prop="`${bind}.header.${index}.value`"
|
||||||
@@ -125,8 +120,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
|
||||||
<div class="flex w-[8%] shrink-0 items-center">
|
<div class="flex w-[8%] shrink-0 items-center">
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
class="size-4 cursor-pointer text-red-500"
|
class="size-4 cursor-pointer text-red-500"
|
||||||
@@ -144,13 +137,12 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
添加一行
|
添加一行
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
<ElFormItem label="请求体">
|
<ElFormItem label="请求体" label-position="top">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in props.body"
|
v-for="(item, index) in props.body"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="mb-2 flex items-center gap-2"
|
class="mb-2 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<!-- 参数名 -->
|
|
||||||
<div class="w-[26%] min-w-32 shrink-0">
|
<div class="w-[26%] min-w-32 shrink-0">
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:prop="`${bind}.body.${index}.key`"
|
:prop="`${bind}.body.${index}.key`"
|
||||||
@@ -163,8 +155,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
<ElInput placeholder="参数名" v-model="item.key" />
|
<ElInput placeholder="参数名" v-model="item.key" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 类型选择 -->
|
|
||||||
<div class="w-[24%] min-w-11 shrink-0">
|
<div class="w-[24%] min-w-11 shrink-0">
|
||||||
<ElFormItem>
|
<ElFormItem>
|
||||||
<ElSelect v-model="item.type">
|
<ElSelect v-model="item.type">
|
||||||
@@ -177,8 +167,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 参数值 -->
|
|
||||||
<div class="w-[42%] flex-1">
|
<div class="w-[42%] flex-1">
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:prop="`${bind}.body.${index}.value`"
|
:prop="`${bind}.body.${index}.value`"
|
||||||
@@ -215,8 +203,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
|
||||||
<div class="flex w-[8%] shrink-0 items-center">
|
<div class="flex w-[8%] shrink-0 items-center">
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
class="size-4 cursor-pointer text-red-500"
|
class="size-4 cursor-pointer text-red-500"
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function deleteHttpResponseSetting(
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<ElFormItem>
|
<ElFormItem label-position="top">
|
||||||
<ElAlert
|
<ElAlert
|
||||||
title="仅支持 POST 请求,以请求体方式接收参数"
|
title="仅支持 POST 请求,以请求体方式接收参数"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -75,6 +75,7 @@ function deleteHttpResponseSetting(
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<!-- 请求地址-->
|
<!-- 请求地址-->
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
|
label-position="top"
|
||||||
label="请求地址"
|
label="请求地址"
|
||||||
:prop="`${formItemPrefix}.url`"
|
:prop="`${formItemPrefix}.url`"
|
||||||
:rules="{
|
:rules="{
|
||||||
@@ -93,7 +94,7 @@ function deleteHttpResponseSetting(
|
|||||||
/>
|
/>
|
||||||
<!-- 返回值设置-->
|
<!-- 返回值设置-->
|
||||||
<div v-if="responseEnable">
|
<div v-if="responseEnable">
|
||||||
<ElFormItem label="返回值">
|
<ElFormItem label="返回值" label-position="top">
|
||||||
<ElAlert
|
<ElAlert
|
||||||
title="通过请求返回值, 可以修改流程表单的值"
|
title="通过请求返回值, 可以修改流程表单的值"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -101,6 +102,7 @@ function deleteHttpResponseSetting(
|
|||||||
:closable="false"
|
:closable="false"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<!-- todo jason 更多设置排版需要优化一下-->
|
||||||
<ElRow
|
<ElRow
|
||||||
:gutter="8"
|
:gutter="8"
|
||||||
v-for="(item, index) in setting.response"
|
v-for="(item, index) in setting.response"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormRules } from 'element-plus';
|
import type { FormRules } from 'element-plus';
|
||||||
// SelectValue type removed - use string | number | boolean directly
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FormTriggerSetting,
|
FormTriggerSetting,
|
||||||
@@ -383,7 +382,6 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<!-- TODO @jason:antd 这里是 1/3,需要保持一致么? -->
|
|
||||||
<Drawer class="w-2/5">
|
<Drawer class="w-2/5">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="config-header">
|
<div class="config-header">
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 第四步:更多设置 -->
|
<!-- 第四步:更多设置 -->
|
||||||
<div v-if="currentStep === 3" class="mx-auto w-4/6">
|
<div v-show="currentStep === 3" class="mx-auto w-4/6">
|
||||||
<ExtraSetting v-model="formData" ref="extraSettingRef" />
|
<ExtraSetting v-model="formData" ref="extraSettingRef" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ onBeforeUnmount(() => {
|
|||||||
<Modal class="w-3/4" title="自定义模板">
|
<Modal class="w-3/4" title="自定义模板">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<ElAlert
|
<ElAlert
|
||||||
message="输入 @ 可选择插入流程选项和表单选项"
|
title="输入 @ 可选择插入流程选项和表单选项"
|
||||||
type="info"
|
type="primary"
|
||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,12 +30,8 @@ import {
|
|||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { getForm } from '#/api/bpm/form';
|
import { getForm } from '#/api/bpm/form';
|
||||||
// TODO @jason:这里要迁移下么?
|
|
||||||
// import {
|
|
||||||
// HttpRequestSetting,
|
|
||||||
// parseFormFields,
|
|
||||||
// } from '#/views/bpm/components/simple-process-design';
|
|
||||||
import { parseFormFields } from '#/components/form-create';
|
import { parseFormFields } from '#/components/form-create';
|
||||||
|
import { HttpRequestSetting } from '#/views/bpm/components/simple-process-design';
|
||||||
|
|
||||||
import PrintTemplate from './custom-print-template.vue';
|
import PrintTemplate from './custom-print-template.vue';
|
||||||
|
|
||||||
@@ -514,16 +510,17 @@ defineExpose({ initData, validate });
|
|||||||
</div>
|
</div>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
</ElRow>
|
</ElRow>
|
||||||
<ElRow v-if="processBeforeTriggerEnable">
|
|
||||||
<ElCol :span="24" class="mt-6">
|
|
||||||
<!-- <HttpRequestSetting
|
|
||||||
v-model:setting="modelData.processBeforeTriggerSetting"
|
|
||||||
:response-enable="true"
|
|
||||||
form-item-prefix="processBeforeTriggerSetting"
|
|
||||||
/> -->
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElRow v-if="processBeforeTriggerEnable">
|
||||||
|
<ElCol :span="2" />
|
||||||
|
<ElCol :span="22" class="mt-2">
|
||||||
|
<HttpRequestSetting
|
||||||
|
v-model:setting="modelData.processBeforeTriggerSetting"
|
||||||
|
:response-enable="true"
|
||||||
|
form-item-prefix="processBeforeTriggerSetting"
|
||||||
|
/>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
<ElFormItem class="mb-5" label="流程后置通知">
|
<ElFormItem class="mb-5" label="流程后置通知">
|
||||||
<ElRow class="mt-1">
|
<ElRow class="mt-1">
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
@@ -536,16 +533,17 @@ defineExpose({ initData, validate });
|
|||||||
</div>
|
</div>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
</ElRow>
|
</ElRow>
|
||||||
<ElRow v-if="processAfterTriggerEnable" class="mt-2">
|
|
||||||
<ElCol :span="24">
|
|
||||||
<HttpRequestSetting
|
|
||||||
v-model:setting="modelData.processAfterTriggerSetting"
|
|
||||||
:response-enable="true"
|
|
||||||
form-item-prefix="processAfterTriggerSetting"
|
|
||||||
/>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElRow v-if="processAfterTriggerEnable" class="mt-1">
|
||||||
|
<ElCol :span="2" />
|
||||||
|
<ElCol :span="22">
|
||||||
|
<HttpRequestSetting
|
||||||
|
v-model:setting="modelData.processAfterTriggerSetting"
|
||||||
|
:response-enable="true"
|
||||||
|
form-item-prefix="processAfterTriggerSetting"
|
||||||
|
/>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
<ElFormItem class="mb-5" label="任务前置通知">
|
<ElFormItem class="mb-5" label="任务前置通知">
|
||||||
<ElRow class="mt-1">
|
<ElRow class="mt-1">
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
@@ -558,16 +556,17 @@ defineExpose({ initData, validate });
|
|||||||
</div>
|
</div>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
</ElRow>
|
</ElRow>
|
||||||
<ElRow v-if="taskBeforeTriggerEnable" class="mt-2">
|
|
||||||
<ElCol :span="24">
|
|
||||||
<HttpRequestSetting
|
|
||||||
v-model:setting="modelData.taskBeforeTriggerSetting"
|
|
||||||
:response-enable="true"
|
|
||||||
form-item-prefix="taskBeforeTriggerSetting"
|
|
||||||
/>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElRow v-if="taskBeforeTriggerEnable" class="mt-1">
|
||||||
|
<ElCol :span="2" />
|
||||||
|
<ElCol :span="22">
|
||||||
|
<HttpRequestSetting
|
||||||
|
v-model:setting="modelData.taskBeforeTriggerSetting"
|
||||||
|
:response-enable="true"
|
||||||
|
form-item-prefix="taskBeforeTriggerSetting"
|
||||||
|
/>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
<ElFormItem class="mb-5" label="任务后置通知">
|
<ElFormItem class="mb-5" label="任务后置通知">
|
||||||
<ElRow class="mt-1">
|
<ElRow class="mt-1">
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
@@ -580,16 +579,17 @@ defineExpose({ initData, validate });
|
|||||||
</div>
|
</div>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
</ElRow>
|
</ElRow>
|
||||||
<ElRow v-if="taskAfterTriggerEnable" class="mt-2">
|
|
||||||
<ElCol :span="24">
|
|
||||||
<HttpRequestSetting
|
|
||||||
v-model:setting="modelData.taskAfterTriggerSetting"
|
|
||||||
:response-enable="true"
|
|
||||||
form-item-prefix="taskAfterTriggerSetting"
|
|
||||||
/>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElRow v-if="taskAfterTriggerEnable" class="mt-1">
|
||||||
|
<ElCol :span="2" />
|
||||||
|
<ElCol :span="22">
|
||||||
|
<HttpRequestSetting
|
||||||
|
v-model:setting="modelData.taskAfterTriggerSetting"
|
||||||
|
:response-enable="true"
|
||||||
|
form-item-prefix="taskAfterTriggerSetting"
|
||||||
|
/>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
<ElFormItem class="mb-5" label="自定义打印模板">
|
<ElFormItem class="mb-5" label="自定义打印模板">
|
||||||
<div class="flex w-full flex-col">
|
<div class="flex w-full flex-col">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
282
apps/web-ele/src/views/bpm/oa/leave/create.vue
Normal file
282
apps/web-ele/src/views/bpm/oa/leave/create.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
|
||||||
|
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
|
||||||
|
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import { confirm, Page, useVbenForm } from '@vben/common-ui';
|
||||||
|
import { BpmCandidateStrategyEnum, BpmNodeIdEnum } from '@vben/constants';
|
||||||
|
import { useTabs } from '@vben/hooks';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElCard,
|
||||||
|
ElCol,
|
||||||
|
ElMessage,
|
||||||
|
ElRow,
|
||||||
|
ElSpace,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { getProcessDefinition } from '#/api/bpm/definition';
|
||||||
|
import { createLeave, getLeave, updateLeave } from '#/api/bpm/oa/leave';
|
||||||
|
import { getApprovalDetail as getApprovalDetailApi } from '#/api/bpm/processInstance';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { router } from '#/router';
|
||||||
|
import ProcessInstanceTimeline from '#/views/bpm/processInstance/detail/modules/time-line.vue';
|
||||||
|
|
||||||
|
import { useFormSchema } from './data';
|
||||||
|
|
||||||
|
const { closeCurrentTab } = useTabs();
|
||||||
|
const { query } = useRoute();
|
||||||
|
|
||||||
|
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||||
|
const processTimeLineLoading = ref(false); // 审批流的加载中
|
||||||
|
|
||||||
|
const processDefineKey = 'oa_leave'; // 流程定义 Key
|
||||||
|
const startUserSelectTasks = ref<any>([]); // 发起人需要选择审批人的用户任务列表
|
||||||
|
const startUserSelectAssignees = ref<any>({}); // 发起人选择审批人的数据
|
||||||
|
const tempStartUserSelectAssignees = ref<any>({}); // 历史发起人选择审批人的数据,用于每次表单变更时,临时保存
|
||||||
|
const activityNodes = ref<BpmProcessInstanceApi.ApprovalNodeInfo[]>([]); // 审批节点信息
|
||||||
|
const processDefinitionId = ref('');
|
||||||
|
|
||||||
|
const formData = ref<BpmOALeaveApi.Leave>();
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
return formData.value?.id
|
||||||
|
? '重新发起请假'
|
||||||
|
: $t('ui.actionTitle.create', ['请假']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 100,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 提交申请 */
|
||||||
|
async function onSubmit() {
|
||||||
|
// 1.1 表单校验
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1.2 审批相关:校验指定审批人
|
||||||
|
if (startUserSelectTasks.value?.length > 0) {
|
||||||
|
for (const userTask of startUserSelectTasks.value) {
|
||||||
|
if (
|
||||||
|
Array.isArray(startUserSelectAssignees.value[userTask.id]) &&
|
||||||
|
startUserSelectAssignees.value[userTask.id].length === 0
|
||||||
|
) {
|
||||||
|
return ElMessage.warning(`请选择${userTask.name}的审批人`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const data = (await formApi.getValues()) as BpmOALeaveApi.Leave;
|
||||||
|
// 审批相关:设置指定审批人
|
||||||
|
if (startUserSelectTasks.value?.length > 0) {
|
||||||
|
data.startUserSelectAssignees = startUserSelectAssignees.value;
|
||||||
|
}
|
||||||
|
// 格式化开始时间和结束时间的值
|
||||||
|
const submitData: BpmOALeaveApi.Leave = {
|
||||||
|
...data,
|
||||||
|
startTime: Number(data.startTime),
|
||||||
|
endTime: Number(data.endTime),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
formLoading.value = true;
|
||||||
|
await (formData.value?.id
|
||||||
|
? updateLeave(submitData)
|
||||||
|
: createLeave(submitData));
|
||||||
|
// 关闭并提示
|
||||||
|
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
await closeCurrentTab();
|
||||||
|
await router.push({
|
||||||
|
name: 'BpmOALeave',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回上一页 */
|
||||||
|
function onBack() {
|
||||||
|
confirm({
|
||||||
|
content: '确定要返回上一页吗?请先保存您填写的信息!',
|
||||||
|
icon: 'warning',
|
||||||
|
beforeClose({ isConfirm }) {
|
||||||
|
if (isConfirm) {
|
||||||
|
closeCurrentTab();
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批相关:获取审批详情 */
|
||||||
|
async function getApprovalDetail() {
|
||||||
|
processTimeLineLoading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await getApprovalDetailApi({
|
||||||
|
processDefinitionId: processDefinitionId.value,
|
||||||
|
// TODO 小北:可以支持 processDefinitionKey 查询
|
||||||
|
activityId: BpmNodeIdEnum.START_USER_NODE_ID,
|
||||||
|
processVariablesStr: JSON.stringify({
|
||||||
|
day: dayjs(formData.value?.startTime).diff(
|
||||||
|
dayjs(formData.value?.endTime),
|
||||||
|
'day',
|
||||||
|
),
|
||||||
|
}), // 解决 GET 无法传递对象的问题,后端 String 再转 JSON
|
||||||
|
});
|
||||||
|
if (!data) {
|
||||||
|
ElMessage.error('查询不到审批详情信息!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取审批节点,显示 Timeline 的数据
|
||||||
|
activityNodes.value = data.activityNodes;
|
||||||
|
|
||||||
|
// 获取发起人自选的任务
|
||||||
|
startUserSelectTasks.value = data.activityNodes?.filter(
|
||||||
|
(node: BpmProcessInstanceApi.ApprovalNodeInfo) =>
|
||||||
|
BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
|
||||||
|
);
|
||||||
|
// 恢复之前的选择审批人
|
||||||
|
if (startUserSelectTasks.value?.length > 0) {
|
||||||
|
for (const node of startUserSelectTasks.value) {
|
||||||
|
startUserSelectAssignees.value[node.id] =
|
||||||
|
tempStartUserSelectAssignees.value[node.id] &&
|
||||||
|
tempStartUserSelectAssignees.value[node.id].length > 0
|
||||||
|
? tempStartUserSelectAssignees.value[node.id]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processTimeLineLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批相关:选择发起人 */
|
||||||
|
function selectUserConfirm(id: string, userList: any[]) {
|
||||||
|
startUserSelectAssignees.value[id] = userList?.map((item: any) => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取请假数据,用于重新发起时自动填充 */
|
||||||
|
async function getDetail(id: number) {
|
||||||
|
try {
|
||||||
|
formLoading.value = true;
|
||||||
|
const data = await getLeave(id);
|
||||||
|
if (!data) {
|
||||||
|
ElMessage.error('重新发起请假失败,原因:请假数据不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.value = {
|
||||||
|
...formData.value,
|
||||||
|
id: data.id,
|
||||||
|
type: data.type,
|
||||||
|
reason: data.reason,
|
||||||
|
startTime: data.startTime,
|
||||||
|
endTime: data.endTime,
|
||||||
|
} as BpmOALeaveApi.Leave;
|
||||||
|
await formApi.setValues({
|
||||||
|
type: data.type,
|
||||||
|
reason: data.reason,
|
||||||
|
startTime: data.startTime,
|
||||||
|
endTime: data.endTime,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批相关:预测流程节点会因为输入的参数值而产生新的预测结果值,所以需重新预测一次, formData.value可改成实际业务中的特定字段 */
|
||||||
|
watch(
|
||||||
|
formData.value as object,
|
||||||
|
(newValue, oldValue) => {
|
||||||
|
if (!oldValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newValue && Object.keys(newValue).length > 0) {
|
||||||
|
// 记录之前的节点审批人
|
||||||
|
tempStartUserSelectAssignees.value = startUserSelectAssignees.value;
|
||||||
|
startUserSelectAssignees.value = {};
|
||||||
|
// 加载最新的审批详情,主要用于节点预测
|
||||||
|
getApprovalDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(async () => {
|
||||||
|
const processDefinitionDetail: any = await getProcessDefinition(
|
||||||
|
undefined,
|
||||||
|
processDefineKey,
|
||||||
|
);
|
||||||
|
if (!processDefinitionDetail) {
|
||||||
|
ElMessage.error('OA 请假的流程模型未配置,请检查!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processDefinitionId.value = processDefinitionDetail.id;
|
||||||
|
startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks;
|
||||||
|
|
||||||
|
// 如果是重新发起,则加载请假数据
|
||||||
|
if (query.id) {
|
||||||
|
await getDetail(Number(query.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await getApprovalDetail();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page>
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="16">
|
||||||
|
<ElCard v-loading="formLoading">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{ getTitle }}</span>
|
||||||
|
<ElButton @click="onBack">
|
||||||
|
<IconifyIcon icon="lucide:arrow-left" />
|
||||||
|
返回
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Form />
|
||||||
|
<template #footer>
|
||||||
|
<ElSpace wrap :size="12" class="w-full px-6">
|
||||||
|
<ElButton type="primary" @click="onSubmit" :loading="formLoading">
|
||||||
|
提交
|
||||||
|
</ElButton>
|
||||||
|
</ElSpace>
|
||||||
|
</template>
|
||||||
|
</ElCard>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="8">
|
||||||
|
<ElCard v-loading="processTimeLineLoading">
|
||||||
|
<template #header>
|
||||||
|
<span>流程</span>
|
||||||
|
</template>
|
||||||
|
<ProcessInstanceTimeline
|
||||||
|
:activity-nodes="activityNodes"
|
||||||
|
:show-status-icon="false"
|
||||||
|
@select-user-confirm="selectUserConfirm"
|
||||||
|
/>
|
||||||
|
</ElCard>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
205
apps/web-ele/src/views/bpm/oa/leave/data.ts
Normal file
205
apps/web-ele/src/views/bpm/oa/leave/data.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { DescriptionItemSchema } from '#/components/description';
|
||||||
|
|
||||||
|
import { h } from 'vue';
|
||||||
|
|
||||||
|
import { DICT_TYPE } from '@vben/constants';
|
||||||
|
import { getDictOptions } from '@vben/hooks';
|
||||||
|
import { formatDate } from '@vben/utils';
|
||||||
|
|
||||||
|
import { DictTag } from '#/components/dict-tag';
|
||||||
|
import { getRangePickerDefaultProps } from '#/utils';
|
||||||
|
|
||||||
|
/** 新增/修改的表单 */
|
||||||
|
export function useFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'id',
|
||||||
|
component: 'Input',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: [''],
|
||||||
|
show: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'type',
|
||||||
|
label: '请假类型',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择请假类型',
|
||||||
|
options: getDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE, 'number'),
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
fieldName: 'startTime',
|
||||||
|
label: '开始时间',
|
||||||
|
component: 'DatePicker',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择开始时间',
|
||||||
|
showTime: true,
|
||||||
|
valueFormat: 'x',
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'endTime',
|
||||||
|
label: '结束时间',
|
||||||
|
component: 'DatePicker',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择结束时间',
|
||||||
|
showTime: true,
|
||||||
|
valueFormat: 'x',
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'reason',
|
||||||
|
label: '原因',
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入原因',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'type',
|
||||||
|
label: '请假类型',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择请假类型',
|
||||||
|
options: getDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE, 'number'),
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'createTime',
|
||||||
|
label: '创建时间',
|
||||||
|
component: 'RangePicker',
|
||||||
|
componentProps: {
|
||||||
|
...getRangePickerDefaultProps(),
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '审批结果',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择审批结果',
|
||||||
|
allowClear: true,
|
||||||
|
options: getDictOptions(
|
||||||
|
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
|
||||||
|
'number',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'reason',
|
||||||
|
label: '原因',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入原因',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
field: 'id',
|
||||||
|
title: '申请编号',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
minWidth: 100,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'startTime',
|
||||||
|
title: '开始时间',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: 'formatDate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'endTime',
|
||||||
|
title: '结束时间',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: 'formatDate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'type',
|
||||||
|
title: '请假类型',
|
||||||
|
minWidth: 100,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.BPM_OA_LEAVE_TYPE },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'reason',
|
||||||
|
title: '原因',
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '申请时间',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 240,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情 */
|
||||||
|
export function useDetailFormSchema(): DescriptionItemSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '请假类型',
|
||||||
|
field: 'type',
|
||||||
|
render: (val) =>
|
||||||
|
h(DictTag, {
|
||||||
|
type: DICT_TYPE.BPM_OA_LEAVE_TYPE,
|
||||||
|
value: val,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '开始时间',
|
||||||
|
field: 'startTime',
|
||||||
|
render: (val) => formatDate(val) as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '结束时间',
|
||||||
|
field: 'endTime',
|
||||||
|
render: (val) => formatDate(val) as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '原因',
|
||||||
|
field: 'reason',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
50
apps/web-ele/src/views/bpm/oa/leave/detail.vue
Normal file
50
apps/web-ele/src/views/bpm/oa/leave/detail.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
|
||||||
|
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import { ContentWrap } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { getLeave } from '#/api/bpm/oa/leave';
|
||||||
|
import { useDescription } from '#/components/description';
|
||||||
|
|
||||||
|
import { useDetailFormSchema } from './data';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { query } = useRoute();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const formData = ref<BpmOALeaveApi.Leave>();
|
||||||
|
const queryId = computed(() => query.id as string);
|
||||||
|
|
||||||
|
const [Descriptions] = useDescription({
|
||||||
|
border: true,
|
||||||
|
column: 1,
|
||||||
|
schema: useDetailFormSchema(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 获取详情数据 */
|
||||||
|
async function getDetailData() {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
formData.value = await getLeave(Number(props.id || queryId.value));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(() => {
|
||||||
|
getDetailData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContentWrap class="m-2" v-loading="loading" element-loading-text="加载中...">
|
||||||
|
<Descriptions :data="formData" class="mx-4" />
|
||||||
|
</ContentWrap>
|
||||||
|
</template>
|
||||||
196
apps/web-ele/src/views/bpm/oa/leave/index.vue
Normal file
196
apps/web-ele/src/views/bpm/oa/leave/index.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
|
||||||
|
|
||||||
|
import { onActivated } from 'vue';
|
||||||
|
|
||||||
|
import { DocAlert, Page, prompt } from '@vben/common-ui';
|
||||||
|
import { BpmProcessInstanceStatus } from '@vben/constants';
|
||||||
|
|
||||||
|
import { ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getLeavePage } from '#/api/bpm/oa/leave';
|
||||||
|
import { cancelProcessInstanceByStartUser } from '#/api/bpm/processInstance';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { router } from '#/router';
|
||||||
|
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建请假 */
|
||||||
|
function handleCreate() {
|
||||||
|
router.push({
|
||||||
|
name: 'OALeaveCreate',
|
||||||
|
query: {
|
||||||
|
formType: 'create',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新发起请假 */
|
||||||
|
function handleReCreate(row: BpmOALeaveApi.Leave) {
|
||||||
|
router.push({
|
||||||
|
name: 'OALeaveCreate',
|
||||||
|
query: {
|
||||||
|
id: row.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消请假 */
|
||||||
|
function handleCancel(row: BpmOALeaveApi.Leave) {
|
||||||
|
prompt({
|
||||||
|
title: '取消流程',
|
||||||
|
content: '请输入取消原因',
|
||||||
|
component: ElInput,
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入取消原因',
|
||||||
|
clearable: true,
|
||||||
|
type: 'textarea',
|
||||||
|
rows: 2,
|
||||||
|
},
|
||||||
|
modelPropName: 'modelValue',
|
||||||
|
async beforeClose(scope) {
|
||||||
|
if (!scope.isConfirm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scope.value) {
|
||||||
|
ElMessage.error('请输入取消原因');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hideLoading = ElMessage({
|
||||||
|
type: 'info',
|
||||||
|
message: '正在取消中...',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await cancelProcessInstanceByStartUser(row.id, scope.value);
|
||||||
|
ElMessage.success('取消成功');
|
||||||
|
handleRefresh();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
hideLoading.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看请假详情 */
|
||||||
|
function handleDetail(row: BpmOALeaveApi.Leave) {
|
||||||
|
router.push({
|
||||||
|
name: 'OALeaveDetail',
|
||||||
|
query: { id: row.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批进度 */
|
||||||
|
function handleProgress(row: BpmOALeaveApi.Leave) {
|
||||||
|
router.push({
|
||||||
|
name: 'BpmProcessInstanceDetail',
|
||||||
|
query: { id: row.processInstanceId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getLeavePage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<BpmOALeaveApi.Leave>,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 激活时 */
|
||||||
|
onActivated(() => {
|
||||||
|
handleRefresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="审批接入(业务表单)"
|
||||||
|
url="https://doc.iocoder.cn/bpm/use-business-form/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Grid table-title="请假列表">
|
||||||
|
<template #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '发起请假',
|
||||||
|
type: 'primary',
|
||||||
|
icon: ACTION_ICON.ADD,
|
||||||
|
onClick: handleCreate,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: $t('common.detail'),
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.VIEW,
|
||||||
|
onClick: handleDetail.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '审批进度',
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.VIEW,
|
||||||
|
onClick: handleProgress.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '取消',
|
||||||
|
type: 'danger',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
ifShow: row.status === BpmProcessInstanceStatus.RUNNING,
|
||||||
|
onClick: handleCancel.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '重新发起',
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.ADD,
|
||||||
|
ifShow: row.status !== BpmProcessInstanceStatus.RUNNING,
|
||||||
|
onClick: handleReCreate.bind(null, row),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
// TODO @jason:这个貌似暂时还没迁移的样子?
|
import { MyProcessViewer } from '#/views/bpm/components/bpmn-process-designer/package';
|
||||||
// import { MyProcessViewer } from '#/views/bpm/components/bpmn-process-designer/package';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ProcessInstanceBpmnViewer' });
|
defineOptions({ name: 'ProcessInstanceBpmnViewer' });
|
||||||
|
|
||||||
@@ -50,11 +49,11 @@ watch(
|
|||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
class="h-full w-full overflow-auto rounded-lg border border-gray-200 bg-white p-4"
|
class="h-full w-full overflow-auto rounded-lg border border-gray-200 bg-white p-4"
|
||||||
>
|
>
|
||||||
<!-- <MyProcessViewer
|
<MyProcessViewer
|
||||||
key="processViewer"
|
key="processViewer"
|
||||||
:xml="view.bpmnXml"
|
:xml="view.bpmnXml"
|
||||||
:view="view"
|
:view="view"
|
||||||
class="h-full min-h-[500px] w-full"
|
class="h-full min-h-[500px] w-full"
|
||||||
/> -->
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -275,8 +275,7 @@ async function openPopover(type: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Object.keys(popOverVisible.value).forEach((item) => {
|
Object.keys(popOverVisible.value).forEach((item) => {
|
||||||
// TODO @jason:这里是不是保持和 antd 一致?
|
if (popOverVisible.value[item]) popOverVisible.value[item] = item === type;
|
||||||
popOverVisible.value[item] = item === type;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { InfraCodegenApi } from '#/api/infra/codegen';
|
import type { InfraCodegenApi } from '#/api/infra/codegen';
|
||||||
import type { SystemDictTypeApi } from '#/api/system/dict/type';
|
import type { SystemDictTypeApi } from '#/api/system/dict/type';
|
||||||
|
|
||||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { ElCheckbox, ElInput, ElOption, ElSelect } from 'element-plus';
|
import { ElCheckbox, ElInput, ElOption, ElSelect } from 'element-plus';
|
||||||
|
|
||||||
@@ -61,6 +61,25 @@ const dictTypeOptions = ref<SystemDictTypeApi.DictType[]>([]); // 字典类型
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
dictTypeOptions.value = await getSimpleDictTypeList();
|
dictTypeOptions.value = await getSimpleDictTypeList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 字典类型过滤方法 */
|
||||||
|
const dictTypeQuery = ref('');
|
||||||
|
function filterDictTypeMethod(query: string) {
|
||||||
|
dictTypeQuery.value = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 过滤后的字典类型选项:支持 type 或 name,忽略大小写 */
|
||||||
|
const filteredDictTypeOptions = computed(() => {
|
||||||
|
const query = dictTypeQuery.value.toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return dictTypeOptions.value;
|
||||||
|
}
|
||||||
|
return dictTypeOptions.value.filter(
|
||||||
|
(item) =>
|
||||||
|
item.type.toLowerCase().includes(query) ||
|
||||||
|
item.name.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -134,9 +153,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 字典类型 -->
|
<!-- 字典类型 -->
|
||||||
<template #dictType="{ row }">
|
<template #dictType="{ row }">
|
||||||
<ElSelect v-model="row.dictType" class="w-full" clearable filterable>
|
<ElSelect
|
||||||
|
v-model="row.dictType"
|
||||||
|
class="w-full"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
:filter-method="filterDictTypeMethod"
|
||||||
|
>
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="option in dictTypeOptions"
|
v-for="option in filteredDictTypeOptions"
|
||||||
:key="option.type"
|
:key="option.type"
|
||||||
:label="option.name"
|
:label="option.name"
|
||||||
:value="option.type"
|
:value="option.type"
|
||||||
|
|||||||
@@ -26,8 +26,17 @@ const formData = reactive<InfraCodegenApi.CodegenCreateListReqVO>({
|
|||||||
tableNames: [], // 已选择的表列表
|
tableNames: [], // 已选择的表列表
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 处理选择变化 */
|
||||||
|
function handleCheckboxChange({
|
||||||
|
records,
|
||||||
|
}: {
|
||||||
|
records: InfraCodegenApi.DatabaseTable[];
|
||||||
|
}) {
|
||||||
|
formData.tableNames = records.map((item) => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
/** 表格实例 */
|
/** 表格实例 */
|
||||||
const [Grid] = useVbenVxeGrid({
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
formOptions: {
|
formOptions: {
|
||||||
schema: useImportTableFormSchema(),
|
schema: useImportTableFormSchema(),
|
||||||
submitOnChange: true,
|
submitOnChange: true,
|
||||||
@@ -67,13 +76,8 @@ const [Grid] = useVbenVxeGrid({
|
|||||||
},
|
},
|
||||||
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
|
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
|
||||||
gridEvents: {
|
gridEvents: {
|
||||||
checkboxChange: ({
|
checkboxChange: handleCheckboxChange,
|
||||||
records,
|
checkboxAll: handleCheckboxChange,
|
||||||
}: {
|
|
||||||
records: InfraCodegenApi.DatabaseTable[];
|
|
||||||
}) => {
|
|
||||||
formData.tableNames = records.map((item) => item.name);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +85,13 @@ const [Grid] = useVbenVxeGrid({
|
|||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
title: '导入表',
|
title: '导入表',
|
||||||
class: 'w-1/2',
|
class: 'w-1/2',
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
// 关闭时清空选择状态
|
||||||
|
formData.tableNames = [];
|
||||||
|
await gridApi.grid?.clearCheckboxRow();
|
||||||
|
}
|
||||||
|
},
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
// 1.1 获取表单值
|
// 1.1 获取表单值
|
||||||
|
|||||||
@@ -490,16 +490,25 @@ defineExpose({
|
|||||||
@checkbox-all="handleSelectionChange"
|
@checkbox-all="handleSelectionChange"
|
||||||
>
|
>
|
||||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||||
<!-- TODO @puhui999:这里的宽度貌似有点问题,图片会寄出来; -->
|
<VxeColumn
|
||||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
align="center"
|
||||||
|
title="图片"
|
||||||
|
width="80"
|
||||||
|
min-width="80"
|
||||||
|
fixed="left"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElImage
|
<div class="flex items-center justify-center overflow-hidden">
|
||||||
v-if="row.picUrl"
|
<ElImage
|
||||||
:src="row.picUrl"
|
v-if="row.picUrl"
|
||||||
class="h-[50px] w-[50px] cursor-pointer"
|
:src="row.picUrl"
|
||||||
:preview-src-list="[row.picUrl]"
|
class="h-[50px] w-[50px] cursor-pointer"
|
||||||
fit="cover"
|
:preview-src-list="[row.picUrl]"
|
||||||
/>
|
:preview-teleported="true"
|
||||||
|
:z-index="3000"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VxeColumn>
|
</VxeColumn>
|
||||||
<template v-if="formData?.specType && !isBatch">
|
<template v-if="formData?.specType && !isBatch">
|
||||||
@@ -583,15 +592,24 @@ defineExpose({
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||||
<!-- TODO @puhui999:这里的宽度貌似有点问题,图片会寄出来; -->
|
<VxeColumn
|
||||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
align="center"
|
||||||
|
title="图片"
|
||||||
|
width="80"
|
||||||
|
min-width="80"
|
||||||
|
fixed="left"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElImage
|
<div class="flex items-center justify-center overflow-hidden">
|
||||||
:src="row.picUrl"
|
<ElImage
|
||||||
class="h-[60px] w-[60px] cursor-pointer"
|
:src="row.picUrl"
|
||||||
:preview-src-list="[row.picUrl]"
|
class="h-[60px] w-[60px] cursor-pointer"
|
||||||
fit="cover"
|
:preview-src-list="[row.picUrl]"
|
||||||
/>
|
:preview-teleported="true"
|
||||||
|
:z-index="3000"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VxeColumn>
|
</VxeColumn>
|
||||||
<template v-if="formData?.specType">
|
<template v-if="formData?.specType">
|
||||||
|
|||||||
@@ -130,12 +130,13 @@ watch(
|
|||||||
<VxeColumn field="id" align="center" title="商品编号" min-width="30" />
|
<VxeColumn field="id" align="center" title="商品编号" min-width="30" />
|
||||||
<VxeColumn title="商品图" min-width="80">
|
<VxeColumn title="商品图" min-width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- TODO @puhui999:它的 preview 貌似展示有点奇怪,不像 antd 是全屏的。。。 -->
|
|
||||||
<ElImage
|
<ElImage
|
||||||
v-if="row.picUrl"
|
v-if="row.picUrl"
|
||||||
:src="row.picUrl"
|
:src="row.picUrl"
|
||||||
class="h-[30px] w-[30px] cursor-pointer"
|
class="h-[30px] w-[30px] cursor-pointer"
|
||||||
:preview-src-list="[row.picUrl]"
|
:preview-src-list="[row.picUrl]"
|
||||||
|
:preview-teleported="true"
|
||||||
|
:z-index="3000"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ onMounted(async () => {
|
|||||||
<ElCard class="h-full w-full" v-loading="formLoading">
|
<ElCard class="h-full w-full" v-loading="formLoading">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- TODO @puhui999:这里有告警,需要修复下。 -->
|
<!-- @puhui999:idea 这边会有告警 -->
|
||||||
<ElTabs v-model="activeTabName" @tab-change="handleTabChange">
|
<ElTabs v-model="activeTabName" @tab-change="handleTabChange">
|
||||||
<ElTabPane label="基础设置" name="info" />
|
<ElTabPane label="基础设置" name="info" />
|
||||||
<ElTabPane label="价格库存" name="sku" />
|
<ElTabPane label="价格库存" name="sku" />
|
||||||
|
|||||||
@@ -92,14 +92,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// TODO: @puhui999:商品关联
|
|
||||||
fieldName: 'spuId',
|
fieldName: 'spuId',
|
||||||
label: '商品关联',
|
label: '商品关联',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
formItemClass: 'col-span-2',
|
formItemClass: 'col-span-2',
|
||||||
componentProps: {
|
|
||||||
placeholder: '请输入商品 SPU 编号',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'sort',
|
fieldName: 'sort',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
updateArticle,
|
updateArticle,
|
||||||
} from '#/api/mall/promotion/article';
|
} from '#/api/mall/promotion/article';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
|
// 同步商品选择到表单,确保验证时能获取到值
|
||||||
|
if (formData.value?.spuId) {
|
||||||
|
await formApi.setFieldValue('spuId', formData.value.spuId);
|
||||||
|
}
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
@@ -82,6 +87,11 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="getTitle" class="w-2/5">
|
<Modal :title="getTitle" class="w-2/5">
|
||||||
<Form class="mx-4" />
|
<Form class="mx-4">
|
||||||
|
<!-- 自定义插槽:商品选择 -->
|
||||||
|
<template #spuId>
|
||||||
|
<SpuShowcase v-model="formData!.spuId" :limit="1" />
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
|
// 同步商品/分类选择到表单,确保验证时能获取到值
|
||||||
|
if (formData.value.productSpuIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productSpuIds',
|
||||||
|
formData.value.productSpuIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (formData.value.productCategoryIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productCategoryIds',
|
||||||
|
formData.value.productCategoryIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
fieldName: 'spuIds',
|
fieldName: 'spuIds',
|
||||||
label: '活动商品',
|
label: '活动商品',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
rules: 'required',
|
|
||||||
formItemClass: 'col-span-2',
|
formItemClass: 'col-span-2',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,30 +1,48 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
|
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
|
||||||
|
import type {
|
||||||
|
PropertyAndValues,
|
||||||
|
RuleConfig,
|
||||||
|
SpuProperty,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { PromotionDiscountTypeEnum } from '@vben/constants';
|
||||||
|
import {
|
||||||
|
cloneDeep,
|
||||||
|
convertToInteger,
|
||||||
|
erpCalculatePercentage,
|
||||||
|
formatToFraction,
|
||||||
|
yuanToFen,
|
||||||
|
} from '@vben/utils';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElButton, ElInputNumber, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { VxeColumn } from '#/adapter/vxe-table';
|
||||||
|
import { getSpuDetailList } from '#/api/mall/product/spu';
|
||||||
import {
|
import {
|
||||||
createDiscountActivity,
|
createDiscountActivity,
|
||||||
getDiscountActivity,
|
getDiscountActivity,
|
||||||
updateDiscountActivity,
|
updateDiscountActivity,
|
||||||
} from '#/api/mall/promotion/discount/discountActivity';
|
} from '#/api/mall/promotion/discount/discountActivity';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
import {
|
||||||
|
getPropertyList,
|
||||||
|
SpuAndSkuList,
|
||||||
|
SpuSkuSelect,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
defineOptions({ name: 'DiscountActivityForm' });
|
defineOptions({ name: 'DiscountActivityForm' });
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
const formData = ref<
|
|
||||||
Partial<MallDiscountActivityApi.DiscountActivity> & {
|
// ================= 表单相关 =================
|
||||||
spuIds?: number[];
|
const formData = ref<Partial<MallDiscountActivityApi.DiscountActivity>>({});
|
||||||
}
|
|
||||||
>({});
|
|
||||||
const getTitle = computed(() => {
|
const getTitle = computed(() => {
|
||||||
return formData.value?.id
|
return formData.value?.id
|
||||||
? $t('ui.actionTitle.edit', ['限时折扣活动'])
|
? $t('ui.actionTitle.edit', ['限时折扣活动'])
|
||||||
@@ -44,24 +62,195 @@ const [Form, formApi] = useVbenForm({
|
|||||||
showDefaultActions: false,
|
showDefaultActions: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO @puhui999:antd 和 ele 里,修改时,商品都没展示。
|
// ================= 商品选择相关 =================
|
||||||
|
/** SKU 扩展类型 */
|
||||||
|
interface SkuExtension extends MallSpuApi.Sku {
|
||||||
|
productConfig: MallDiscountActivityApi.DiscountProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SPU 扩展类型 */
|
||||||
|
interface SpuExtension extends MallSpuApi.Spu {
|
||||||
|
skus?: SkuExtension[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const spuSelectRef = ref(); // 商品选择组件 Ref
|
||||||
|
const spuAndSkuListRef = ref(); // SKU 列表组件 Ref
|
||||||
|
const spuList = ref<SpuExtension[]>([]); // 选择的 SPU 列表
|
||||||
|
const spuPropertyList = ref<SpuProperty<SpuExtension>[]>([]); // SPU 属性列表
|
||||||
|
const spuIdList = ref<number[]>([]); // 已选择的 SPU ID 列表
|
||||||
|
|
||||||
|
/** SKU 校验规则配置 */
|
||||||
|
const ruleConfig: RuleConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'productConfig.discountPrice',
|
||||||
|
rule: (arg) => arg > 0,
|
||||||
|
message: '商品优惠金额不能为 0 !!!',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 打开商品选择弹窗 */
|
||||||
|
function openSpuSelect() {
|
||||||
|
spuSelectRef.value?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择商品后的回调 */
|
||||||
|
function handleSpuSelected(spuId: number, skuIds?: number[]) {
|
||||||
|
getSpuDetails(spuId, skuIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 SPU 详情 */
|
||||||
|
async function getSpuDetails(
|
||||||
|
spuId: number,
|
||||||
|
skuIdArr?: number[],
|
||||||
|
products?: MallDiscountActivityApi.DiscountProduct[],
|
||||||
|
type?: string,
|
||||||
|
) {
|
||||||
|
// 如果已经包含该 SPU 则跳过
|
||||||
|
if (spuIdList.value.includes(spuId)) {
|
||||||
|
if (type !== 'load') {
|
||||||
|
ElMessage.error('数据重复选择!');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spuIdList.value.push(spuId);
|
||||||
|
|
||||||
|
const res = (await getSpuDetailList([spuId])) as SpuExtension[];
|
||||||
|
if (res.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spu = res[0]!;
|
||||||
|
// 筛选 SKU
|
||||||
|
const selectSkus =
|
||||||
|
skuIdArr === undefined
|
||||||
|
? spu.skus
|
||||||
|
: spu.skus?.filter((sku) => skuIdArr.includes(sku.id!));
|
||||||
|
|
||||||
|
// 为每个 SKU 添加折扣配置
|
||||||
|
selectSkus?.forEach((sku) => {
|
||||||
|
let config: MallDiscountActivityApi.DiscountProduct = {
|
||||||
|
skuId: sku.id!,
|
||||||
|
spuId: spu.id!,
|
||||||
|
discountType: 1,
|
||||||
|
discountPercent: 0,
|
||||||
|
discountPrice: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑时,使用已有的配置
|
||||||
|
if (products !== undefined) {
|
||||||
|
const product = products.find((item) => item.skuId === sku.id);
|
||||||
|
if (product) {
|
||||||
|
// 转换为元显示
|
||||||
|
config = {
|
||||||
|
...product,
|
||||||
|
discountPercent: Number(formatToFraction(product.discountPercent)),
|
||||||
|
discountPrice: Number(formatToFraction(product.discountPrice)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(sku as SkuExtension).productConfig = config;
|
||||||
|
});
|
||||||
|
|
||||||
|
spu.skus = selectSkus as SkuExtension[];
|
||||||
|
spuPropertyList.value.push({
|
||||||
|
spuId: spu.id!,
|
||||||
|
spuDetail: spu,
|
||||||
|
propertyList: getPropertyList(spu) as PropertyAndValues[],
|
||||||
|
});
|
||||||
|
spuList.value.push(spu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 SPU */
|
||||||
|
function handleDeleteSpu(spuId: number) {
|
||||||
|
const spuIndex = spuIdList.value.indexOf(spuId);
|
||||||
|
if (spuIndex !== -1) {
|
||||||
|
spuIdList.value.splice(spuIndex, 1);
|
||||||
|
}
|
||||||
|
const propertyIndex = spuPropertyList.value.findIndex(
|
||||||
|
(item) => item.spuId === spuId,
|
||||||
|
);
|
||||||
|
if (propertyIndex !== -1) {
|
||||||
|
spuPropertyList.value.splice(propertyIndex, 1);
|
||||||
|
}
|
||||||
|
const listIndex = spuList.value.findIndex((item) => item.id === spuId);
|
||||||
|
if (listIndex !== -1) {
|
||||||
|
spuList.value.splice(listIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 SKU 优惠金额变动 */
|
||||||
|
function handleSkuDiscountPriceChange(row: SkuExtension) {
|
||||||
|
if (row.productConfig.discountPrice <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 设置优惠类型:满减
|
||||||
|
row.productConfig.discountType = PromotionDiscountTypeEnum.PRICE.type;
|
||||||
|
// 计算折扣百分比
|
||||||
|
const price = typeof row.price === 'number' ? row.price : Number(row.price);
|
||||||
|
const percent = erpCalculatePercentage(
|
||||||
|
price - yuanToFen(row.productConfig.discountPrice),
|
||||||
|
price,
|
||||||
|
);
|
||||||
|
row.productConfig.discountPercent =
|
||||||
|
typeof percent === 'number' ? percent : Number(percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 SKU 折扣百分比变动 */
|
||||||
|
function handleSkuDiscountPercentChange(row: SkuExtension) {
|
||||||
|
if (
|
||||||
|
row.productConfig.discountPercent <= 0 ||
|
||||||
|
row.productConfig.discountPercent >= 100
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 设置优惠类型:折扣
|
||||||
|
row.productConfig.discountType = PromotionDiscountTypeEnum.PERCENT.type;
|
||||||
|
// 计算优惠金额
|
||||||
|
const price = typeof row.price === 'number' ? row.price : Number(row.price);
|
||||||
|
row.productConfig.discountPrice = Number(
|
||||||
|
formatToFraction(price - price * (row.productConfig.discountPercent / 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置表单 */
|
||||||
|
async function resetForm() {
|
||||||
|
spuList.value = [];
|
||||||
|
spuPropertyList.value = [];
|
||||||
|
spuIdList.value = [];
|
||||||
|
formData.value = {};
|
||||||
|
await nextTick();
|
||||||
|
await formApi.resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 弹窗相关 =================
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 校验是否选择了商品
|
||||||
|
if (spuList.value.length === 0) {
|
||||||
|
ElMessage.warning('请选择活动商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const data =
|
|
||||||
(await formApi.getValues()) as MallDiscountActivityApi.DiscountActivity;
|
|
||||||
|
|
||||||
// 确保必要的默认值
|
|
||||||
if (!data.products) {
|
|
||||||
data.products = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 获取折扣商品配置
|
||||||
|
const products = cloneDeep(
|
||||||
|
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
|
||||||
|
) as MallDiscountActivityApi.DiscountProduct[];
|
||||||
|
// 转换金额为分
|
||||||
|
products.forEach((item) => {
|
||||||
|
item.discountPercent = convertToInteger(item.discountPercent);
|
||||||
|
item.discountPrice = convertToInteger(item.discountPrice);
|
||||||
|
});
|
||||||
|
const data = cloneDeep(
|
||||||
|
await formApi.getValues(),
|
||||||
|
) as MallDiscountActivityApi.DiscountActivity;
|
||||||
|
data.products = products;
|
||||||
|
// 提交请求
|
||||||
await (formData.value?.id
|
await (formData.value?.id
|
||||||
? updateDiscountActivity(data)
|
? updateDiscountActivity(data)
|
||||||
: createDiscountActivity(data));
|
: createDiscountActivity(data));
|
||||||
@@ -75,7 +264,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
},
|
},
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
formData.value = {};
|
await resetForm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 加载数据
|
// 加载数据
|
||||||
@@ -85,9 +274,30 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
}
|
}
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
formData.value = await getDiscountActivity(data.id);
|
const activityData = await getDiscountActivity(data.id);
|
||||||
|
formData.value = activityData;
|
||||||
|
// 加载商品详情
|
||||||
|
if (activityData.products && activityData.products.length > 0) {
|
||||||
|
// 按 spuId 分组
|
||||||
|
const spuProductsMap = new Map<
|
||||||
|
number,
|
||||||
|
MallDiscountActivityApi.DiscountProduct[]
|
||||||
|
>();
|
||||||
|
for (const product of activityData.products) {
|
||||||
|
const spuId = product.spuId;
|
||||||
|
if (!spuProductsMap.has(spuId)) {
|
||||||
|
spuProductsMap.set(spuId, []);
|
||||||
|
}
|
||||||
|
spuProductsMap.get(spuId)!.push(product);
|
||||||
|
}
|
||||||
|
// 加载每个 SPU 的详情
|
||||||
|
for (const [spuId, products] of spuProductsMap) {
|
||||||
|
const skuIdArr = products.map((p) => p.skuId);
|
||||||
|
await getSpuDetails(spuId, skuIdArr, products, 'load');
|
||||||
|
}
|
||||||
|
}
|
||||||
// 设置到 values
|
// 设置到 values
|
||||||
await formApi.setValues(formData.value);
|
await formApi.setValues(activityData);
|
||||||
} finally {
|
} finally {
|
||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
}
|
}
|
||||||
@@ -96,12 +306,59 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal class="w-3/5" :title="getTitle">
|
<Modal class="w-[70%]" :title="getTitle">
|
||||||
<Form>
|
<Form>
|
||||||
<!-- 自定义插槽:商品选择 -->
|
<!-- 自定义插槽:商品选择 -->
|
||||||
<template #spuIds>
|
<template #spuIds>
|
||||||
<SpuShowcase v-model="formData.spuIds" />
|
<div class="w-full">
|
||||||
|
<ElButton class="mb-4" @click="openSpuSelect">选择商品</ElButton>
|
||||||
|
<SpuAndSkuList
|
||||||
|
ref="spuAndSkuListRef"
|
||||||
|
:deletable="true"
|
||||||
|
:rule-config="ruleConfig"
|
||||||
|
:spu-list="spuList"
|
||||||
|
:spu-property-list-p="spuPropertyList"
|
||||||
|
@delete="handleDeleteSpu"
|
||||||
|
>
|
||||||
|
<!-- 扩展列:限时折扣活动特有配置 -->
|
||||||
|
<template #default>
|
||||||
|
<VxeColumn align="center" min-width="168" title="优惠金额(元)">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.productConfig.discountPrice"
|
||||||
|
:max="Number(formatToFraction(row.price))"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
@change="handleSkuDiscountPriceChange(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" min-width="168" title="折扣百分比(%)">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.productConfig.discountPercent"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
@change="handleSkuDiscountPercentChange(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
</SpuAndSkuList>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 商品选择弹窗 -->
|
||||||
|
<SpuSkuSelect
|
||||||
|
ref="spuSelectRef"
|
||||||
|
:is-select-sku="true"
|
||||||
|
@select="handleSpuSelected"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
componentProps: {
|
componentProps: {
|
||||||
showTime: true,
|
showTime: true,
|
||||||
format: 'YYYY-MM-DD HH:mm:ss',
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
valueFormat: 'x',
|
||||||
placeholder: [
|
placeholder: [
|
||||||
$t('utils.rangePicker.beginTime'),
|
$t('utils.rangePicker.beginTime'),
|
||||||
$t('utils.rangePicker.endTime'),
|
$t('utils.rangePicker.endTime'),
|
||||||
@@ -222,7 +223,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
label: '优惠设置',
|
label: '优惠设置',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
formItemClass: 'items-start',
|
formItemClass: 'items-start',
|
||||||
rules: 'required',
|
rules: z
|
||||||
|
.array(z.any())
|
||||||
|
.min(1, { message: '请添加至少一条优惠规则' })
|
||||||
|
.default([]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
PromotionConditionTypeEnum,
|
PromotionConditionTypeEnum,
|
||||||
PromotionProductScopeEnum,
|
PromotionProductScopeEnum,
|
||||||
} from '@vben/constants';
|
} from '@vben/constants';
|
||||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
@@ -52,6 +52,21 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
|
// 在验证前同步 formData 中的值到表单中
|
||||||
|
await formApi.setFieldValue('rules', formData.value.rules || []);
|
||||||
|
// 同步商品/分类选择到表单,确保验证时能获取到值
|
||||||
|
if (formData.value.productSpuIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productSpuIds',
|
||||||
|
formData.value.productSpuIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (formData.value.productCategoryIds) {
|
||||||
|
await formApi.setFieldValue(
|
||||||
|
'productCategoryIds',
|
||||||
|
formData.value.productCategoryIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return;
|
return;
|
||||||
@@ -60,18 +75,24 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
// 提交表单
|
// 提交表单
|
||||||
try {
|
try {
|
||||||
const values = await formApi.getValues();
|
const values = await formApi.getValues();
|
||||||
const data = { ...formData.value, ...values };
|
// 使用 formData.value 作为基础,确保 rules 来自 formData
|
||||||
|
const data = { ...values, ...formData.value };
|
||||||
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
|
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
|
||||||
data.startTime = data.startAndEndTime[0];
|
data.startTime = data.startAndEndTime[0];
|
||||||
data.endTime = data.startAndEndTime[1];
|
data.endTime = data.startAndEndTime[1];
|
||||||
delete data.startAndEndTime;
|
delete data.startAndEndTime;
|
||||||
}
|
}
|
||||||
data.rules?.forEach((item: any) => {
|
// 深拷贝 rules 避免修改原始数据
|
||||||
|
const rules = cloneDeep(
|
||||||
|
data.rules,
|
||||||
|
) as unknown as MallRewardActivityApi.RewardRule[];
|
||||||
|
rules.forEach((item: any) => {
|
||||||
item.discountPrice = convertToInteger(item.discountPrice || 0);
|
item.discountPrice = convertToInteger(item.discountPrice || 0);
|
||||||
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||||
item.limit = convertToInteger(item.limit || 0);
|
item.limit = convertToInteger(item.limit || 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
data.rules = rules;
|
||||||
await (data.id
|
await (data.id
|
||||||
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
||||||
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
||||||
@@ -96,7 +117,10 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
const result = await getReward(data.id);
|
const result = await getReward(data.id);
|
||||||
result.startAndEndTime = [result.startTime, result.endTime] as any[];
|
result.startAndEndTime = [
|
||||||
|
result.startTime ? String(result.startTime) : undefined,
|
||||||
|
result.endTime ? String(result.endTime) : undefined,
|
||||||
|
] as any[];
|
||||||
result.rules?.forEach((item: any) => {
|
result.rules?.forEach((item: any) => {
|
||||||
item.discountPrice = formatToFraction(item.discountPrice || 0);
|
item.discountPrice = formatToFraction(item.discountPrice || 0);
|
||||||
if (result.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
if (result.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||||
|
|||||||
@@ -216,6 +216,28 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
},
|
},
|
||||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'visible',
|
||||||
|
label: '显示状态',
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '显示', value: true },
|
||||||
|
{ label: '隐藏', value: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
defaultValue: true,
|
||||||
|
help: '选择隐藏时,路由将不会出现在侧边栏,但仍然可以访问',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['type'],
|
||||||
|
show: (values) => {
|
||||||
|
return [SystemMenuTypeEnum.DIR, SystemMenuTypeEnum.MENU].includes(
|
||||||
|
values.type,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'alwaysShow',
|
fieldName: 'alwaysShow',
|
||||||
label: '总是显示',
|
label: '总是显示',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user