mirror of
https://gitee.com/yudaocode/yudao-ui-admin-vben.git
synced 2026-01-12 00:40:16 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d5d1b889 | ||
|
|
a5c76ef89d | ||
|
|
e622c052a9 | ||
|
|
de28c5c4c2 | ||
|
|
f7f01c9280 | ||
|
|
a9f21c1acb | ||
|
|
19c7f0d5dd | ||
|
|
cd43149429 | ||
|
|
19919f6685 | ||
|
|
036ef294db | ||
|
|
d8f685708d | ||
|
|
f8ce09a203 | ||
|
|
59d83d29cb | ||
|
|
02193755be | ||
|
|
6d524906a3 | ||
|
|
cbd1f0bcbb | ||
|
|
2ba2c8e986 | ||
|
|
06f1ae1a66 | ||
|
|
029b2ffaab | ||
|
|
64ac25de00 | ||
|
|
6606dfd40a | ||
|
|
6da4a39ff9 | ||
|
|
aa95d0e87c | ||
|
|
6353f0a8e9 | ||
|
|
e6327ae9da | ||
|
|
4395353c22 | ||
|
|
c023ebbdb9 |
@@ -21,3 +21,6 @@ VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# 验证码的开关
|
||||
VITE_APP_CAPTCHA_ENABLE=true
|
||||
@@ -3,39 +3,78 @@ import type { PageParam, PageResult } from '@vben/request';
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotDeviceApi {
|
||||
// TODO @haohao:需要跟后端对齐,必要的 ReqVO、RespVO
|
||||
/** 设备 */
|
||||
export interface Device {
|
||||
id?: number; // 设备 ID,主键,自增
|
||||
/** 设备新增/修改 Request VO */
|
||||
// TODO @haohao:可以降低一些 VO 哈:DeviceSaveReqVO、DeviceRespVO 合并成 Device 就好,类似别的模块
|
||||
export interface DeviceSaveReqVO {
|
||||
id?: number; // 设备编号
|
||||
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; // 产品编号
|
||||
productKey?: string; // 产品标识
|
||||
deviceType?: number; // 设备类型
|
||||
nickname?: string; // 设备备注名称
|
||||
gatewayId?: number; // 网关设备 ID
|
||||
state?: number; // 设备状态
|
||||
status?: number; // 设备状态(兼容字段)
|
||||
onlineTime?: Date; // 最后上线时间
|
||||
offlineTime?: Date; // 最后离线时间
|
||||
activeTime?: Date; // 设备激活时间
|
||||
createTime?: Date; // 创建时间
|
||||
ip?: string; // 设备的 IP 地址
|
||||
firmwareVersion?: string; // 设备的固件版本
|
||||
deviceSecret?: string; // 设备密钥,用于设备认证,需安全存储
|
||||
mqttClientId?: string; // MQTT 客户端 ID
|
||||
mqttUsername?: string; // MQTT 用户名
|
||||
mqttPassword?: string; // MQTT 密码
|
||||
authType?: string; // 认证类型
|
||||
locationType?: number; // 定位类型
|
||||
deviceSecret?: string; // 设备密钥,用于设备认证
|
||||
authType?: string; // 认证类型(如一机一密、动态注册)
|
||||
config?: string; // 设备配置
|
||||
locationType?: number; // 定位方式
|
||||
latitude?: number; // 设备位置的纬度
|
||||
longitude?: number; // 设备位置的经度
|
||||
areaId?: number; // 地区编码
|
||||
address?: string; // 设备详细地址
|
||||
serialNumber?: string; // 设备序列号
|
||||
config?: string; // 设备配置
|
||||
groupIds?: number[]; // 添加分组 ID
|
||||
picUrl?: string; // 设备图片
|
||||
location?: string; // 位置信息(格式:经度,纬度)
|
||||
createTime?: Date; // 创建时间
|
||||
}
|
||||
|
||||
/** 设备认证信息 Response VO */
|
||||
export interface DeviceAuthInfoRespVO {
|
||||
clientId: string; // 客户端 ID
|
||||
username: string; // 用户名
|
||||
password: string; // 密码
|
||||
}
|
||||
|
||||
/** 设备导入 Response VO */
|
||||
export interface DeviceImportRespVO {
|
||||
createDeviceNames?: string[]; // 创建成功的设备名称列表
|
||||
updateDeviceNames?: string[]; // 更新成功的设备名称列表
|
||||
failureDeviceNames?: Record<string, string>; // 失败的设备名称及原因
|
||||
}
|
||||
|
||||
/** IoT 设备属性详细 VO */
|
||||
@@ -56,30 +95,17 @@ export namespace IotDeviceApi {
|
||||
updateTime: Date; // 更新时间
|
||||
}
|
||||
|
||||
/** 设备认证参数 VO */
|
||||
export interface DeviceAuthInfo {
|
||||
clientId: string; // 客户端 ID
|
||||
username: string; // 用户名
|
||||
password: string; // 密码
|
||||
}
|
||||
|
||||
/** 设备发送消息 Request VO */
|
||||
export interface DeviceMessageSendReq {
|
||||
deviceId: number; // 设备编号
|
||||
method: string; // 请求方法
|
||||
params?: any; // 请求参数
|
||||
}
|
||||
|
||||
/** 设备分组更新请求 */
|
||||
export interface DeviceGroupUpdateReq {
|
||||
ids: number[]; // 设备 ID 列表
|
||||
groupIds: number[]; // 分组 ID 列表
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询设备分页 */
|
||||
export function getDevicePage(params: PageParam) {
|
||||
return requestClient.get<PageResult<IotDeviceApi.Device>>(
|
||||
export function getDevicePage(params: IotDeviceApi.DevicePageReqVO) {
|
||||
return requestClient.get<PageResult<IotDeviceApi.DeviceRespVO>>(
|
||||
'/iot/device/page',
|
||||
{ params },
|
||||
);
|
||||
@@ -87,38 +113,40 @@ export function getDevicePage(params: PageParam) {
|
||||
|
||||
/** 查询设备详情 */
|
||||
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) {
|
||||
return requestClient.post('/iot/device/create', data);
|
||||
export function createDevice(data: IotDeviceApi.DeviceSaveReqVO) {
|
||||
return requestClient.post<number>('/iot/device/create', data);
|
||||
}
|
||||
|
||||
/** 修改设备 */
|
||||
export function updateDevice(data: IotDeviceApi.Device) {
|
||||
return requestClient.put('/iot/device/update', data);
|
||||
export function updateDevice(data: IotDeviceApi.DeviceSaveReqVO) {
|
||||
return requestClient.put<boolean>('/iot/device/update', data);
|
||||
}
|
||||
|
||||
/** 修改设备分组 */
|
||||
export function updateDeviceGroup(data: IotDeviceApi.DeviceGroupUpdateReq) {
|
||||
return requestClient.put('/iot/device/update-group', data);
|
||||
export function updateDeviceGroup(data: IotDeviceApi.DeviceUpdateGroupReqVO) {
|
||||
return requestClient.put<boolean>('/iot/device/update-group', data);
|
||||
}
|
||||
|
||||
/** 删除单个设备 */
|
||||
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[]) {
|
||||
return requestClient.delete('/iot/device/delete-list', {
|
||||
return requestClient.delete<boolean>('/iot/device/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 导出设备 */
|
||||
export function exportDeviceExcel(params: any) {
|
||||
export function exportDeviceExcel(params: IotDeviceApi.DevicePageReqVO) {
|
||||
return requestClient.download('/iot/device/export-excel', { params });
|
||||
}
|
||||
|
||||
@@ -129,16 +157,22 @@ export function getDeviceCount(productId: number) {
|
||||
|
||||
/** 获取设备的精简信息列表 */
|
||||
export function getSimpleDeviceList(deviceType?: number, productId?: number) {
|
||||
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', {
|
||||
params: { deviceType, productId },
|
||||
});
|
||||
return requestClient.get<IotDeviceApi.DeviceRespVO[]>(
|
||||
'/iot/device/simple-list',
|
||||
{
|
||||
params: { deviceType, productId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据产品编号,获取设备的精简信息列表 */
|
||||
export function getDeviceListByProductId(productId: number) {
|
||||
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', {
|
||||
params: { productId },
|
||||
});
|
||||
return requestClient.get<IotDeviceApi.DeviceRespVO[]>(
|
||||
'/iot/device/simple-list',
|
||||
{
|
||||
params: { productId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取导入模板 */
|
||||
@@ -148,10 +182,13 @@ export function importDeviceTemplate() {
|
||||
|
||||
/** 导入设备 */
|
||||
export function importDevice(file: File, updateSupport: boolean) {
|
||||
return requestClient.upload('/iot/device/import', {
|
||||
file,
|
||||
updateSupport,
|
||||
});
|
||||
return requestClient.upload<IotDeviceApi.DeviceImportRespVO>(
|
||||
'/iot/device/import',
|
||||
{
|
||||
file,
|
||||
updateSupport,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取设备属性最新数据 */
|
||||
@@ -172,7 +209,7 @@ export function getHistoryDevicePropertyList(params: any) {
|
||||
|
||||
/** 获取设备认证信息 */
|
||||
export function getDeviceAuthInfo(id: number) {
|
||||
return requestClient.get<IotDeviceApi.DeviceAuthInfo>(
|
||||
return requestClient.get<IotDeviceApi.DeviceAuthInfoRespVO>(
|
||||
'/iot/device/get-auth-info',
|
||||
{ params: { id } },
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (isOpen) {
|
||||
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading(通过 handleReady)
|
||||
modalLoading(true);
|
||||
// TODO @puhui999:这里比 ele 多了,是符合预期的哇?
|
||||
const img = new Image();
|
||||
img.src = src.value;
|
||||
img.addEventListener('load', () => {
|
||||
|
||||
@@ -121,7 +121,7 @@ const apiSelectRule = [
|
||||
field: 'data',
|
||||
title: '请求参数 JSON 格式',
|
||||
props: {
|
||||
autoSize: true,
|
||||
autoSize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||
type: 'textarea',
|
||||
placeholder: '{"type": 1}',
|
||||
},
|
||||
@@ -155,7 +155,7 @@ const apiSelectRule = [
|
||||
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
||||
(data: any)=>{ label: string; value: any }[]`,
|
||||
props: {
|
||||
autoSize: true,
|
||||
autoSize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||
rows: { minRows: 2, maxRows: 6 },
|
||||
type: 'textarea',
|
||||
placeholder: `
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useDictSelectRule() {
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
modelField: 'value',
|
||||
modelField: 'value', // 特殊:ele 里是 model-value,antd 里是 value
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<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 { DICT_TYPE } from '@vben/constants';
|
||||
@@ -14,37 +13,46 @@ withDefaults(defineProps<OperateLogProps>(), {
|
||||
logList: () => [],
|
||||
});
|
||||
|
||||
/** 获得 userType 颜色 */
|
||||
function getUserTypeColor(userType: number) {
|
||||
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
||||
if (dict && dict.colorType) {
|
||||
return `hsl(var(--${dict.colorType}))`;
|
||||
switch (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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="pt-5">
|
||||
<Timeline>
|
||||
<Timeline.Item
|
||||
v-for="log in logList"
|
||||
:key="log.id"
|
||||
:color="getUserTypeColor(log.userType)"
|
||||
>
|
||||
<Timeline.Item v-for="log in logList" :key="log.id">
|
||||
<template #dot>
|
||||
<p
|
||||
<span
|
||||
: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] }}
|
||||
</p>
|
||||
</span>
|
||||
</template>
|
||||
<p class="ml-2">{{ formatDateTime(log.createTime) }}</p>
|
||||
<p class="ml-2 mt-2">
|
||||
<Tag :color="getUserTypeColor(log.userType)">
|
||||
{{ log.userName }}
|
||||
</Tag>
|
||||
{{ log.action }}
|
||||
</p>
|
||||
<div class="ml-2 flex flex-wrap items-center gap-2 leading-[22px]">
|
||||
<span class="w-[140px] shrink-0 text-[13px] text-gray-400">
|
||||
{{ formatDateTime(log.createTime) }}
|
||||
</span>
|
||||
<Tag color="success" class="!mr-0">{{ log.userName }}</Tag>
|
||||
<span>{{ log.action }}</span>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
|
||||
},
|
||||
});
|
||||
|
||||
/** 解绑账号 */
|
||||
|
||||
@@ -158,7 +158,7 @@ function changeNodeName() {
|
||||
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
</script>
|
||||
<template>
|
||||
<Drawer class="w-1/3">
|
||||
<Drawer class="w-2/5">
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<Input
|
||||
|
||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer class="w-1/3">
|
||||
<Drawer class="w-2/5">
|
||||
<template #title>
|
||||
<div class="config-header">
|
||||
<Input
|
||||
|
||||
@@ -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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +29,8 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getForm } from '#/api/bpm/form';
|
||||
import {
|
||||
HttpRequestSetting,
|
||||
parseFormFields,
|
||||
} from '#/views/bpm/components/simple-process-design';
|
||||
import { parseFormFields } from '#/components/form-create';
|
||||
import { HttpRequestSetting } from '#/views/bpm/components/simple-process-design';
|
||||
|
||||
import PrintTemplate from './custom-print-template.vue';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { BpmCandidateStrategyEnum, BpmNodeIdEnum } from '@vben/constants';
|
||||
import { useTabs } from '@vben/hooks';
|
||||
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 { getProcessDefinition } from '#/api/bpm/definition';
|
||||
@@ -26,6 +26,7 @@ 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>([]); // 发起人需要选择审批人的用户任务列表
|
||||
@@ -91,10 +92,8 @@ async function onSubmit() {
|
||||
? updateLeave(submitData)
|
||||
: createLeave(submitData));
|
||||
// 关闭并提示
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.operationSuccess'),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await closeCurrentTab();
|
||||
await router.push({
|
||||
name: 'BpmOALeave',
|
||||
});
|
||||
@@ -119,38 +118,43 @@ function onBack() {
|
||||
|
||||
/** 审批相关:获取审批详情 */
|
||||
async function getApprovalDetail() {
|
||||
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) {
|
||||
message.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]
|
||||
: [];
|
||||
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) {
|
||||
message.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,30 +235,35 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<div class="mx-auto w-[80vw] max-w-[920px]">
|
||||
<Card :title="getTitle" class="w-full">
|
||||
<template #extra>
|
||||
<Button type="default" @click="onBack">
|
||||
<IconifyIcon icon="lucide:arrow-left" />
|
||||
返回
|
||||
</Button>
|
||||
</template>
|
||||
<Row :gutter="16">
|
||||
<Col :span="16">
|
||||
<Card :title="getTitle" class="w-full" v-loading="formLoading">
|
||||
<template #extra>
|
||||
<Button type="default" @click="onBack">
|
||||
<IconifyIcon icon="lucide:arrow-left" />
|
||||
返回
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<Form />
|
||||
</Card>
|
||||
|
||||
<Card title="流程" class="mt-2 w-full">
|
||||
<ProcessInstanceTimeline
|
||||
:activity-nodes="activityNodes"
|
||||
:show-status-icon="false"
|
||||
@select-user-confirm="selectUserConfirm"
|
||||
/>
|
||||
<template #actions>
|
||||
<Space warp :size="12" class="w-full px-6">
|
||||
<Button type="primary" @click="onSubmit"> 提交 </Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
<Form />
|
||||
<template #actions>
|
||||
<Space warp :size="12" class="w-full px-6">
|
||||
<Button type="primary" @click="onSubmit" :loading="formLoading">
|
||||
提交
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card title="流程" class="w-full" v-loading="processTimeLineLoading">
|
||||
<ProcessInstanceTimeline
|
||||
:activity-nodes="activityNodes"
|
||||
:show-status-icon="false"
|
||||
@select-user-confirm="selectUserConfirm"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
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 { BpmProcessInstanceStatus } from '@vben/constants';
|
||||
@@ -17,7 +17,6 @@ import { router } from '#/router';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
// TODO @jason:这里是不是要迁移下?
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
@@ -127,6 +126,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
},
|
||||
} as VxeTableGridOptions<BpmOALeaveApi.Leave>,
|
||||
});
|
||||
|
||||
/** 激活时 */
|
||||
onActivated(() => {
|
||||
handleRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -61,6 +61,24 @@ const dictTypeOptions = ref<SystemDictTypeApi.DictType[]>([]); // 字典类型
|
||||
onMounted(async () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -142,6 +160,7 @@ onMounted(async () => {
|
||||
class="w-full"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterDictTypeOption"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="option in dictTypeOptions"
|
||||
|
||||
@@ -9,8 +9,8 @@ import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
/** 基础表单字段 */
|
||||
export function useBasicFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
@@ -36,6 +36,14 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'deviceType',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceName',
|
||||
label: 'DeviceName',
|
||||
@@ -62,7 +70,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: () => getSimpleDeviceList(DeviceTypeEnum.GATEWAY),
|
||||
labelField: 'nickname',
|
||||
labelField: 'deviceName',
|
||||
valueField: 'id',
|
||||
placeholder: '子设备可选择父设备',
|
||||
},
|
||||
@@ -71,6 +79,12 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 高级设置表单字段(更多设置) */
|
||||
export function useAdvancedFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '备注名称',
|
||||
@@ -89,6 +103,11 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
fieldName: 'picUrl',
|
||||
label: '设备图片',
|
||||
component: 'ImageUpload',
|
||||
},
|
||||
{
|
||||
fieldName: 'groupIds',
|
||||
label: '设备分组',
|
||||
@@ -278,6 +297,14 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
title: '备注名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '设备图片',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '所属产品',
|
||||
|
||||
@@ -31,7 +31,7 @@ const router = useRouter();
|
||||
const id = Number(route.params.id);
|
||||
const loading = ref(true);
|
||||
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 thingModelList = ref<ThingModelData[]>([]);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
|
||||
defineOptions({ name: 'DeviceDetailConfig' });
|
||||
|
||||
const props = defineProps<{
|
||||
device: IotDeviceApi.Device;
|
||||
device: IotDeviceApi.DeviceRespVO;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -114,7 +114,7 @@ async function updateDeviceConfig() {
|
||||
await updateDevice({
|
||||
id: props.device.id,
|
||||
config: JSON.stringify(config.value),
|
||||
} as IotDeviceApi.Device);
|
||||
} as IotDeviceApi.DeviceSaveReqVO);
|
||||
message.success({ content: '更新成功!' });
|
||||
// 触发 success 事件
|
||||
emit('success');
|
||||
|
||||
@@ -12,7 +12,7 @@ import DeviceForm from '../../modules/form.vue';
|
||||
|
||||
interface Props {
|
||||
product: IotProductApi.Product;
|
||||
device: IotDeviceApi.Device;
|
||||
device: IotDeviceApi.DeviceRespVO;
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getDeviceAuthInfo } from '#/api/iot/device/device';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
|
||||
interface Props {
|
||||
device: IotDeviceApi.Device;
|
||||
device: IotDeviceApi.DeviceRespVO;
|
||||
product: IotProductApi.Product;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ const props = defineProps<Props>();
|
||||
|
||||
const authDialogVisible = ref(false);
|
||||
const authPasswordVisible = ref(false);
|
||||
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
|
||||
{} as IotDeviceApi.DeviceAuthInfo,
|
||||
const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
|
||||
{} as IotDeviceApi.DeviceAuthInfoRespVO,
|
||||
);
|
||||
|
||||
/** 控制地图显示的标志 */
|
||||
@@ -75,8 +75,7 @@ function handleAuthInfoDialogClose() {
|
||||
<Card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<!-- TODO @haohao:图标尽量使用中立的,这样 ep 版本呢好迁移 -->
|
||||
<IconifyIcon class="mr-2 text-primary" icon="ep:info-filled" />
|
||||
<IconifyIcon class="mr-2 text-primary" icon="lucide:info" />
|
||||
<span>设备信息</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -142,8 +141,7 @@ function handleAuthInfoDialogClose() {
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<!-- TODO @haohao:图标尽量使用中立的,这样 ep 版本呢好迁移 -->
|
||||
<IconifyIcon class="mr-2 text-primary" icon="ep:location" />
|
||||
<IconifyIcon class="mr-2 text-primary" icon="lucide:map-pin" />
|
||||
<span>设备位置</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
@@ -162,8 +160,7 @@ function handleAuthInfoDialogClose() {
|
||||
v-else
|
||||
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="ep:warning" />
|
||||
<IconifyIcon class="mr-2" icon="lucide:alert-triangle" />
|
||||
<span>暂无位置信息</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import DataDefinition from '../../../../thingmodel/modules/components/data-defin
|
||||
import DeviceDetailsMessage from './message.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
device: IotDeviceApi.Device;
|
||||
device: IotDeviceApi.DeviceRespVO;
|
||||
product: IotProductApi.Product;
|
||||
thingModelList: ThingModelData[];
|
||||
}>();
|
||||
|
||||
@@ -1,45 +1,216 @@
|
||||
<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 {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const subDevices = ref<any[]>([]);
|
||||
const products = ref<IotProductApi.Product[]>([]); // 产品列表
|
||||
const queryParams = reactive({
|
||||
deviceName: '',
|
||||
status: undefined as number | undefined,
|
||||
}); // 查询参数
|
||||
|
||||
/** 获取子设备列表 */
|
||||
async function getSubDeviceList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
// TODO: 实现获取子设备列表的API调用
|
||||
// const data = await getSubDevicesByGatewayId(deviceId);
|
||||
// subDevices.value = data || [];
|
||||
subDevices.value = [];
|
||||
} catch (error) {
|
||||
console.error('获取子设备列表失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: 'DeviceName',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
title: '备注名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
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(() => {
|
||||
getSubDeviceList();
|
||||
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Card :loading="loading" title="子设备管理">
|
||||
<Empty
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无子设备数据,此功能待实现"
|
||||
/>
|
||||
<!-- TODO: 实现子设备列表展示和管理功能 -->
|
||||
</Card>
|
||||
<Page auto-content-height>
|
||||
<!-- 搜索区域 -->
|
||||
<!-- TODO @haohao:这个 search 能不能融合到 Grid 里; -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="queryParams.deviceName"
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
@@ -51,6 +50,9 @@ const viewMode = ref<'card' | 'list'>('card');
|
||||
const cardViewRef = ref();
|
||||
const checkedIds = ref<number[]>([]);
|
||||
|
||||
/** 判断是否为列表视图 */
|
||||
const isListView = () => viewMode.value === 'list';
|
||||
|
||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceForm,
|
||||
destroyOnClose: true,
|
||||
@@ -66,13 +68,13 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const queryParams = ref({
|
||||
const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined as number | undefined,
|
||||
deviceType: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
groupId: undefined as number | undefined,
|
||||
productId: undefined,
|
||||
deviceType: undefined,
|
||||
status: undefined,
|
||||
groupId: undefined,
|
||||
}); // 搜索参数
|
||||
|
||||
/** 搜索 */
|
||||
@@ -112,7 +114,11 @@ async function handleViewModeChange(mode: 'card' | 'list') {
|
||||
|
||||
/** 导出表格 */
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -141,12 +147,12 @@ function handleCreate() {
|
||||
}
|
||||
|
||||
/** 编辑设备 */
|
||||
function handleEdit(row: IotDeviceApi.Device) {
|
||||
function handleEdit(row: IotDeviceApi.DeviceRespVO) {
|
||||
deviceFormModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除设备 */
|
||||
async function handleDelete(row: IotDeviceApi.Device) {
|
||||
async function handleDelete(row: IotDeviceApi.DeviceRespVO) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.deviceName]),
|
||||
duration: 0,
|
||||
@@ -197,12 +203,12 @@ function handleImport() {
|
||||
function handleRowCheckboxChange({
|
||||
records,
|
||||
}: {
|
||||
records: IotDeviceApi.Device[];
|
||||
records: IotDeviceApi.DeviceRespVO[];
|
||||
}) {
|
||||
checkedIds.value = records.map((item) => item.id!);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
|
||||
gridOptions: {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
@@ -213,12 +219,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
return await getDevicePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...queryParams.value,
|
||||
});
|
||||
} as IotDeviceApi.DevicePageReqVO);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -230,7 +240,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<IotDeviceApi.Device>,
|
||||
},
|
||||
gridEvents: {
|
||||
checkboxAll: handleRowCheckboxChange,
|
||||
checkboxChange: handleRowCheckboxChange,
|
||||
@@ -388,7 +398,7 @@ onMounted(async () => {
|
||||
type: 'primary',
|
||||
icon: 'ant-design:folder-add-outlined',
|
||||
auth: ['iot:device:update'],
|
||||
ifShow: () => viewMode === 'list',
|
||||
ifShow: isListView,
|
||||
disabled: isEmpty(checkedIds),
|
||||
onClick: handleAddToGroup,
|
||||
},
|
||||
@@ -398,13 +408,12 @@ onMounted(async () => {
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:delete'],
|
||||
ifShow: () => viewMode === 'list',
|
||||
ifShow: isListView,
|
||||
disabled: isEmpty(checkedIds),
|
||||
onClick: handleDeleteBatch,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space :size="4">
|
||||
<Button
|
||||
@@ -423,8 +432,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<Grid table-title="设备列表" v-show="viewMode === 'list'">
|
||||
<!-- 所属产品列 -->
|
||||
<template #product="{ row }">
|
||||
<a
|
||||
class="cursor-pointer text-primary"
|
||||
@@ -433,8 +442,6 @@ onMounted(async () => {
|
||||
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- 所属分组列 -->
|
||||
<template #groups="{ row }">
|
||||
<template v-if="row.groupIds?.length">
|
||||
<Tag
|
||||
@@ -448,8 +455,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -490,7 +495,14 @@ onMounted(async () => {
|
||||
ref="cardViewRef"
|
||||
:products="products"
|
||||
: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"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Image,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
@@ -43,9 +46,9 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const list = ref<IotDeviceApi.DeviceRespVO[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
@@ -63,7 +66,7 @@ async function getList() {
|
||||
const data = await getDevicePage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
} as IotDeviceApi.DevicePageReqVO);
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
@@ -128,8 +131,8 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="mb-3">
|
||||
<div class="info-list">
|
||||
<div class="mb-3 flex items-start">
|
||||
<div class="info-list flex-1">
|
||||
<div class="info-item">
|
||||
<span class="info-label">所属产品</span>
|
||||
<a
|
||||
@@ -154,13 +157,27 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<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">
|
||||
{{ item.Deviceid || item.id }}
|
||||
{{ item.id }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</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 class="action-buttons">
|
||||
@@ -263,6 +280,19 @@ onMounted(() => {
|
||||
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-item {
|
||||
@@ -385,6 +415,11 @@ html.dark {
|
||||
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">
|
||||
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 { message } from 'ant-design-vue';
|
||||
import { Collapse, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceForm' });
|
||||
|
||||
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(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['设备'])
|
||||
@@ -31,10 +36,54 @@ const [Form, formApi] = useVbenForm({
|
||||
},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
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,
|
||||
});
|
||||
|
||||
/** 获取高级表单的值(如果表单未挂载,则从 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({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
@@ -42,8 +91,13 @@ const [Modal, modalApi] = useVbenModal({
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
||||
// 关闭并提示
|
||||
@@ -57,11 +111,15 @@ const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
activeKey.value = [];
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<IotDeviceApi.Device>();
|
||||
const data = modalApi.getData<IotDeviceApi.DeviceRespVO>();
|
||||
if (!data || !data.id) {
|
||||
// 新增:确保 Collapse 折叠
|
||||
// TODO @haohao:是不是 activeKey 在上面的 112 到 115 就已经处理了哈;
|
||||
activeKey.value = [];
|
||||
return;
|
||||
}
|
||||
// 编辑模式:加载数据
|
||||
@@ -69,15 +127,44 @@ const [Modal, modalApi] = useVbenModal({
|
||||
try {
|
||||
formData.value = await getDevice(data.id);
|
||||
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 {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 初始化产品列表 */
|
||||
onMounted(async () => {
|
||||
products.value = await getSimpleProductList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
@@ -41,7 +43,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
await updateDeviceGroup({
|
||||
ids: deviceIds.value,
|
||||
groupIds: data.groupIds as number[],
|
||||
});
|
||||
} as IotDeviceApi.DeviceUpdateGroupReqVO);
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
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 { downloadFileFromBlobPart } from '@vben/utils';
|
||||
@@ -38,7 +39,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
try {
|
||||
const result = await importDevice(data.file, data.updateSupport);
|
||||
// 处理导入结果提示
|
||||
const importData = result.data || result;
|
||||
const importData = result as IotDeviceApi.DeviceImportRespVO;
|
||||
if (importData) {
|
||||
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
|
||||
if (importData.createDeviceNames?.length) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { computed, ref } from 'vue';
|
||||
@@ -57,7 +57,7 @@ const formRules = {
|
||||
},
|
||||
],
|
||||
};
|
||||
const devices = ref<IotDeviceApi.Device[]>([]);
|
||||
const devices = ref<IotDeviceApi.DeviceRespVO[]>([]);
|
||||
|
||||
/** 设备选项 */
|
||||
const deviceOptions = computed(() => {
|
||||
|
||||
@@ -236,6 +236,15 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '产品状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_PRODUCT_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
|
||||
@@ -115,6 +115,11 @@ onMounted(() => {
|
||||
<div class="ml-3 min-w-0 flex-1">
|
||||
<div class="product-title">{{ item.name }}</div>
|
||||
</div>
|
||||
<DictTag
|
||||
:type="DICT_TYPE.IOT_PRODUCT_STATUS"
|
||||
:value="item.status"
|
||||
class="status-tag"
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="mb-3 flex items-start">
|
||||
@@ -264,6 +269,11 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
.status-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.info-list {
|
||||
.info-item {
|
||||
|
||||
@@ -73,7 +73,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
fieldName: 'spuIds',
|
||||
label: '活动商品',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
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 {
|
||||
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 {
|
||||
createDiscountActivity,
|
||||
getDiscountActivity,
|
||||
updateDiscountActivity,
|
||||
} from '#/api/mall/promotion/discount/discountActivity';
|
||||
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';
|
||||
|
||||
defineOptions({ name: 'DiscountActivityForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<
|
||||
Partial<MallDiscountActivityApi.DiscountActivity> & {
|
||||
spuIds?: number[];
|
||||
}
|
||||
>({});
|
||||
|
||||
/** 折扣类型枚举 */
|
||||
// TODO @puhui999:这里可以使用 biz-mall 里的枚举噢;
|
||||
const PromotionDiscountTypeEnum = {
|
||||
PRICE: { type: 1 }, // 满减
|
||||
PERCENT: { type: 2 }, // 折扣
|
||||
};
|
||||
|
||||
// ================= 表单相关 =================
|
||||
const formData = ref<Partial<MallDiscountActivityApi.DiscountActivity>>({});
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['限时折扣活动'])
|
||||
@@ -44,23 +67,196 @@ const [Form, formApi] = useVbenForm({
|
||||
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({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
// 校验是否选择了商品
|
||||
if (spuList.value.length === 0) {
|
||||
message.warning('请选择活动商品');
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallDiscountActivityApi.DiscountActivity;
|
||||
|
||||
// 确保必要的默认值
|
||||
if (!data.products) {
|
||||
data.products = [];
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取折扣商品配置
|
||||
// TODO @puhui999:structuredClone 执行会报错;
|
||||
const products = structuredClone(
|
||||
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
|
||||
) as MallDiscountActivityApi.DiscountProduct[];
|
||||
// 转换金额为分
|
||||
products.forEach((item) => {
|
||||
item.discountPercent = convertToInteger(item.discountPercent);
|
||||
item.discountPrice = convertToInteger(item.discountPrice);
|
||||
});
|
||||
const data = structuredClone(
|
||||
await formApi.getValues(),
|
||||
) as MallDiscountActivityApi.DiscountActivity;
|
||||
data.products = products;
|
||||
// 提交请求
|
||||
await (formData.value?.id
|
||||
? updateDiscountActivity(data)
|
||||
: createDiscountActivity(data));
|
||||
@@ -74,7 +270,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = {};
|
||||
await resetForm();
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
@@ -84,9 +280,30 @@ const [Modal, modalApi] = useVbenModal({
|
||||
}
|
||||
modalApi.lock();
|
||||
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
|
||||
await formApi.setValues(formData.value);
|
||||
await formApi.setValues(activityData);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -95,12 +312,59 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<Modal class="w-[70%]" :title="getTitle">
|
||||
<Form>
|
||||
<!-- 自定义插槽:商品选择 -->
|
||||
<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>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<!-- 商品选择弹窗 -->
|
||||
<SpuSkuSelect
|
||||
ref="spuSelectRef"
|
||||
:is-select-sku="true"
|
||||
@select="handleSpuSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -138,6 +138,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'x',
|
||||
placeholder: [
|
||||
$t('utils.rangePicker.beginTime'),
|
||||
$t('utils.rangePicker.endTime'),
|
||||
@@ -217,13 +218,15 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
// TODO @puhui999:1)新增时:一直报:“请输入优惠设置”;2)修改老数据,出现报“请求参数类型错误:50.00”;
|
||||
{
|
||||
fieldName: 'rules',
|
||||
label: '优惠设置',
|
||||
component: 'Input',
|
||||
formItemClass: 'items-start',
|
||||
rules: 'required',
|
||||
rules: z
|
||||
.array(z.any())
|
||||
.min(1, { message: '请添加至少一条优惠规则' })
|
||||
.default([]),
|
||||
},
|
||||
{
|
||||
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
PromotionConditionTypeEnum,
|
||||
PromotionProductScopeEnum,
|
||||
} from '@vben/constants';
|
||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
||||
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
@@ -53,6 +52,9 @@ const [Form, formApi] = useVbenForm({
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
// 在验证前同步 formData.rules 到表单中
|
||||
// TODO @puhui999:选择了分类、或者商品,还是报没选择;
|
||||
await formApi.setFieldValue('rules', formData.value.rules || []);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -61,18 +63,24 @@ const [Modal, modalApi] = useVbenModal({
|
||||
// 提交表单
|
||||
try {
|
||||
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)) {
|
||||
data.startTime = data.startAndEndTime[0];
|
||||
data.endTime = data.startAndEndTime[1];
|
||||
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);
|
||||
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||
item.limit = convertToInteger(item.limit || 0);
|
||||
}
|
||||
});
|
||||
data.rules = rules;
|
||||
await (data.id
|
||||
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
||||
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
||||
@@ -98,8 +106,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
try {
|
||||
const result = await getReward(data.id);
|
||||
result.startAndEndTime = [
|
||||
result.startTime ? dayjs(result.startTime) : undefined,
|
||||
result.endTime ? dayjs(result.endTime) : undefined,
|
||||
result.startTime ? String(result.startTime) : undefined,
|
||||
result.endTime ? String(result.endTime) : undefined,
|
||||
] as any[];
|
||||
result.rules?.forEach((item: any) => {
|
||||
item.discountPrice = formatToFraction(item.discountPrice || 0);
|
||||
|
||||
@@ -217,6 +217,30 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
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',
|
||||
label: '总是显示',
|
||||
|
||||
@@ -23,3 +23,6 @@ VITE_INJECT_APP_LOADING=true
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
|
||||
|
||||
# 验证码的开关
|
||||
VITE_APP_CAPTCHA_ENABLE=true
|
||||
|
||||
@@ -18,7 +18,7 @@ const props = withDefaults(defineProps<CropperAvatarProps>(), {
|
||||
width: 200,
|
||||
value: '',
|
||||
showBtn: true,
|
||||
btnProps: () => ({}),
|
||||
btnProps: () => ({}) as any,
|
||||
btnText: '',
|
||||
uploadApi: () => Promise.resolve(),
|
||||
size: 5,
|
||||
@@ -27,14 +27,10 @@ const props = withDefaults(defineProps<CropperAvatarProps>(), {
|
||||
const emit = defineEmits(['update:value', 'change']);
|
||||
|
||||
const sourceValue = ref(props.value || '');
|
||||
// TODO @puhui999:这个有办法去掉么?
|
||||
const prefixCls = 'cropper-avatar';
|
||||
const [CropperModal, modalApi] = useVbenModal({
|
||||
connectedComponent: cropperModal,
|
||||
});
|
||||
|
||||
const getClass = computed(() => [prefixCls]);
|
||||
|
||||
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
|
||||
|
||||
const getIconWidth = computed(
|
||||
@@ -74,34 +70,42 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<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
|
||||
:class="`${prefixCls}-image-wrapper`"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-full border border-gray-200 bg-white"
|
||||
:style="getImageWrapperStyle"
|
||||
@click="openModal"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-avatar.vue 里的 Icon 么? -->
|
||||
<span
|
||||
:style="{
|
||||
...getImageWrapperStyle,
|
||||
width: `${getIconWidth}`,
|
||||
height: `${getIconWidth}`,
|
||||
lineHeight: `${getIconWidth}`,
|
||||
width: getIconWidth,
|
||||
height: getIconWidth,
|
||||
lineHeight: getIconWidth,
|
||||
}"
|
||||
class="icon-[ant-design--cloud-upload-outlined] text-[#d6d6d6]"
|
||||
class="icon-[ant-design--cloud-upload-outlined] text-gray-400"
|
||||
></span>
|
||||
</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>
|
||||
<!-- 上传按钮 -->
|
||||
<ElButton
|
||||
v-if="showBtn"
|
||||
:class="`${prefixCls}-upload-btn`"
|
||||
class="mx-auto mt-2"
|
||||
@click="openModal"
|
||||
v-bind="btnProps"
|
||||
>
|
||||
@@ -116,50 +120,3 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -36,7 +36,6 @@ const cropper = ref<CropperType>();
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
const prefixCls = 'cropper-am';
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
onConfirm: handleOk,
|
||||
onOpenChange(isOpen) {
|
||||
@@ -113,6 +112,7 @@ async function handleOk() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO @puhui999:antd 版本里是 2/3 宽度,两边要对齐么? -->
|
||||
<Modal
|
||||
v-bind="$attrs"
|
||||
:confirm-text="$t('ui.cropper.okText')"
|
||||
@@ -120,11 +120,37 @@ async function handleOk() {
|
||||
:title="$t('ui.cropper.modalTitle')"
|
||||
class="w-[800px]"
|
||||
>
|
||||
<div :class="prefixCls">
|
||||
<!-- TODO @puhui999:antd 版本有个 h-96,两边要对齐么? -->
|
||||
<div class="flex">
|
||||
<!-- 左侧区域 -->
|
||||
<div :class="`${prefixCls}-left`" class="w-full">
|
||||
<!-- TODO @puhui999:antd 版本是 h-full w-3/5 两边要对齐么? -->
|
||||
<div class="h-[340px] w-[55%]">
|
||||
<!-- 裁剪器容器 -->
|
||||
<div :class="`${prefixCls}-cropper`">
|
||||
<!-- TODO @puhui999:antd class 简单一点,看看要不要对齐 -->
|
||||
<div
|
||||
class="h-[300px] bg-[#eee]"
|
||||
style="
|
||||
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;
|
||||
"
|
||||
>
|
||||
<CropperImage
|
||||
v-if="src"
|
||||
:circled="circled"
|
||||
@@ -136,7 +162,8 @@ async function handleOk() {
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<!-- TODO @puhui999:antd 是 mt-4,看看两边要不要对齐 -->
|
||||
<div class="mt-2.5 flex items-center justify-between">
|
||||
<ElUpload
|
||||
:before-upload="handleBeforeUpload"
|
||||
:file-list="[]"
|
||||
@@ -147,6 +174,7 @@ async function handleOk() {
|
||||
placement="bottom"
|
||||
>
|
||||
<ElButton size="small" type="primary">
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--upload-outlined]"></span>
|
||||
@@ -164,6 +192,7 @@ async function handleOk() {
|
||||
@click="handlerToolbar('reset')"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--reload-outlined]"></span>
|
||||
</div>
|
||||
@@ -182,6 +211,7 @@ async function handleOk() {
|
||||
>
|
||||
<template #icon>
|
||||
<div class="flex items-center justify-center">
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<span
|
||||
class="icon-[ant-design--rotate-left-outlined]"
|
||||
></span>
|
||||
@@ -200,6 +230,7 @@ async function handleOk() {
|
||||
@click="handlerToolbar('rotate', 45)"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<div class="flex items-center justify-center">
|
||||
<span
|
||||
class="icon-[ant-design--rotate-right-outlined]"
|
||||
@@ -219,6 +250,7 @@ async function handleOk() {
|
||||
@click="handlerToolbar('scaleX')"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[vaadin--arrows-long-h]"></span>
|
||||
</div>
|
||||
@@ -236,6 +268,7 @@ async function handleOk() {
|
||||
@click="handlerToolbar('scaleY')"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[vaadin--arrows-long-v]"></span>
|
||||
</div>
|
||||
@@ -253,6 +286,7 @@ async function handleOk() {
|
||||
@click="handlerToolbar('zoom', 0.1)"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--zoom-in-outlined]"></span>
|
||||
</div>
|
||||
@@ -270,6 +304,7 @@ async function handleOk() {
|
||||
@click="handlerToolbar('zoom', -0.1)"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- TODO @puhui999:可以改成类似 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/components/cropper/cropper-modal.vue 里的 Icon 么? -->
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="icon-[ant-design--zoom-out-outlined]"></span>
|
||||
</div>
|
||||
@@ -281,18 +316,23 @@ async function handleOk() {
|
||||
</div>
|
||||
|
||||
<!-- 右侧区域 -->
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div class="h-[340px] w-[45%]">
|
||||
<!-- 预览区域 -->
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<div
|
||||
class="mx-auto h-[220px] w-[220px] overflow-hidden rounded-full border border-gray-200"
|
||||
>
|
||||
<img
|
||||
v-if="previewSource"
|
||||
:alt="$t('ui.cropper.preview')"
|
||||
:src="previewSource"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- 头像组合预览 -->
|
||||
<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 :size="48" :src="previewSource" />
|
||||
<ElAvatar :size="64" :src="previewSource" />
|
||||
@@ -303,77 +343,3 @@ async function handleOk() {
|
||||
</div>
|
||||
</Modal>
|
||||
</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 isReady = ref(false);
|
||||
|
||||
// TODO @puhui999:这个有办法去掉么?
|
||||
const prefixCls = 'cropper-image';
|
||||
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
@@ -47,10 +45,9 @@ const getImageStyle = computed((): CSSProperties => {
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled,
|
||||
'cropper-image--circled': props.circled,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -158,6 +155,7 @@ function getRoundedCanvas() {
|
||||
:crossorigin="crossorigin"
|
||||
:src="src"
|
||||
:style="getImageStyle"
|
||||
class="h-auto max-w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,7 +121,7 @@ const apiSelectRule = [
|
||||
field: 'data',
|
||||
title: '请求参数 JSON 格式',
|
||||
props: {
|
||||
autosize: true, // TODO @puhui999:这里时 autoSize 还是 autosize 哈?和 antd 不同
|
||||
autosize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||
type: 'textarea',
|
||||
placeholder: '{"type": 1}',
|
||||
},
|
||||
@@ -155,7 +155,7 @@ const apiSelectRule = [
|
||||
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
|
||||
(data: any)=>{ label: string; value: any }[]`,
|
||||
props: {
|
||||
autosize: true, // TODO @puhui999:这里时 autoSize 还是 autosize 哈?和 antd 不同
|
||||
autosize: true, // 特殊:ele 里是 autosize,antd 里是 autoSize
|
||||
rows: { minRows: 2, maxRows: 6 },
|
||||
type: 'textarea',
|
||||
placeholder: `
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useDictSelectRule() {
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false,
|
||||
// TODO @puhui999:vben 版本里,这里有个 modelField: 'value', 需要添加么?
|
||||
modelField: 'model-value', // 特殊:ele 里是 model-value,antd 里是 value
|
||||
};
|
||||
},
|
||||
props(_: any, { t }: any) {
|
||||
|
||||
@@ -13,37 +13,46 @@ withDefaults(defineProps<OperateLogProps>(), {
|
||||
logList: () => [],
|
||||
});
|
||||
|
||||
/** 获得 userType 颜色 */
|
||||
function getUserTypeColor(userType: number) {
|
||||
const dict = getDictObj(DICT_TYPE.USER_TYPE, userType);
|
||||
if (dict && dict.colorType) {
|
||||
return `hsl(var(--${dict.colorType}))`;
|
||||
switch (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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="pt-5">
|
||||
<ElTimeline>
|
||||
<ElTimelineItem
|
||||
v-for="log in logList"
|
||||
:key="log.id"
|
||||
:color="getUserTypeColor(log.userType)"
|
||||
>
|
||||
<ElTimelineItem v-for="log in logList" :key="log.id" class="!pb-3">
|
||||
<template #dot>
|
||||
<p
|
||||
<span
|
||||
: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] }}
|
||||
</p>
|
||||
</span>
|
||||
</template>
|
||||
<p class="ml-2">{{ formatDateTime(log.createTime) }}</p>
|
||||
<p class="ml-2 mt-2">
|
||||
<ElTag :color="getUserTypeColor(log.userType)">
|
||||
{{ log.userName }}
|
||||
</ElTag>
|
||||
{{ log.action }}
|
||||
</p>
|
||||
<div class="ml-2 flex flex-wrap items-center gap-2 leading-[22px]">
|
||||
<span class="w-[130px] shrink-0 text-[13px] text-gray-400">
|
||||
{{ formatDateTime(log.createTime) }}
|
||||
</span>
|
||||
<ElTag type="success" class="!mr-0">{{ log.userName }}</ElTag>
|
||||
<span>{{ log.action }}</span>
|
||||
</div>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</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;
|
||||
@@ -197,7 +197,7 @@ async function handleSubmit(values: Recordable<any>) {
|
||||
await smsResetPassword({ mobile, code, password });
|
||||
ElMessage.success($t('authentication.resetPasswordSuccess'));
|
||||
// 重置成功后跳转到首页
|
||||
router.push('/');
|
||||
await router.push('/');
|
||||
} catch (error) {
|
||||
console.error('重置密码失败:', error);
|
||||
} finally {
|
||||
|
||||
@@ -21,7 +21,6 @@ const emit = defineEmits<{
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
// TODO @puhui999:展示貌似不太对;应该是左右,不是上下哈
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelWidth: 70,
|
||||
@@ -101,7 +100,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +25,6 @@ const avatar = computed(
|
||||
() => props.profile?.avatar || preferences.app.defaultAvatar,
|
||||
);
|
||||
|
||||
// TODO @puhui999:头像上传没跑通
|
||||
async function handelUpload({
|
||||
file,
|
||||
filename,
|
||||
@@ -57,8 +56,8 @@ async function handelUpload({
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<ElDescriptions :column="2">
|
||||
<ElDescriptionsItem>
|
||||
<ElDescriptions :column="2" border>
|
||||
<ElDescriptionsItem label="用户账号">
|
||||
<template #label>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
|
||||
@@ -116,7 +115,11 @@ async function handelUpload({
|
||||
所属岗位
|
||||
</div>
|
||||
</template>
|
||||
{{ profile.posts.map((post) => post.name).join(',') }}
|
||||
{{
|
||||
profile.posts && profile.posts.length > 0
|
||||
? profile.posts.map((post) => post.name).join(',')
|
||||
: '-'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem>
|
||||
<template #label>
|
||||
|
||||
@@ -100,7 +100,7 @@ async function handleSubmit(values: Recordable<any>) {
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,7 +100,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
|
||||
},
|
||||
});
|
||||
|
||||
/** 解绑账号 */
|
||||
@@ -167,19 +167,16 @@ onMounted(() => {
|
||||
>
|
||||
<ElCard v-for="item in allBindList" :key="item.type" class="!mb-2">
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<!-- TODO @puhui999:图片大小不太对 -->
|
||||
<ElImage
|
||||
:src="item.img"
|
||||
:width="40"
|
||||
:height="40"
|
||||
style="width: 40px; height: 40px"
|
||||
:alt="item.title"
|
||||
:preview="false"
|
||||
:preview-disabled="true"
|
||||
fit="contain"
|
||||
/>
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h4
|
||||
class="mb-[4px] text-[14px] text-black/85 dark:text-white/85"
|
||||
>
|
||||
<h4 class="mb-1 text-sm text-black/85 dark:text-white/85">
|
||||
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
|
||||
</h4>
|
||||
<span class="text-black/45 dark:text-white/45">
|
||||
@@ -187,9 +184,9 @@ onMounted(() => {
|
||||
{{ item.socialUser?.nickname || item.socialUser?.openid }}
|
||||
</template>
|
||||
<template v-else>
|
||||
绑定{{
|
||||
getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type)
|
||||
}}账号
|
||||
绑定
|
||||
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
|
||||
账号
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -158,7 +158,6 @@ function changeNodeName() {
|
||||
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
</script>
|
||||
<template>
|
||||
<!-- TODO @jason:antd 是 1/3,这里要统一么? -->
|
||||
<Drawer class="w-2/5">
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -40,7 +40,7 @@ defineOptions({
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
// TODO @jason:这里 required: false,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,13 +54,12 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ElFormItem label="请求头">
|
||||
<ElFormItem label="请求头" label-position="top">
|
||||
<div
|
||||
v-for="(item, index) in props.header"
|
||||
:key="index"
|
||||
class="mb-2 flex items-center gap-2"
|
||||
>
|
||||
<!-- 参数名 -->
|
||||
<div class="w-[26%] min-w-32 shrink-0">
|
||||
<ElFormItem
|
||||
:prop="`${bind}.header.${index}.key`"
|
||||
@@ -73,8 +72,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
<ElInput placeholder="参数名不能为空" v-model="item.key" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<div class="w-[24%] min-w-11 shrink-0">
|
||||
<ElFormItem class="w-full">
|
||||
<ElSelect v-model="item.type">
|
||||
@@ -87,8 +84,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 参数值 -->
|
||||
<div class="w-[42%] flex-1">
|
||||
<ElFormItem
|
||||
:prop="`${bind}.header.${index}.value`"
|
||||
@@ -125,8 +120,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<div class="flex w-[8%] shrink-0 items-center">
|
||||
<IconifyIcon
|
||||
class="size-4 cursor-pointer text-red-500"
|
||||
@@ -144,13 +137,12 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
添加一行
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElFormItem label="请求体">
|
||||
<ElFormItem label="请求体" label-position="top">
|
||||
<div
|
||||
v-for="(item, index) in props.body"
|
||||
:key="index"
|
||||
class="mb-2 flex items-center gap-2"
|
||||
>
|
||||
<!-- 参数名 -->
|
||||
<div class="w-[26%] min-w-32 shrink-0">
|
||||
<ElFormItem
|
||||
:prop="`${bind}.body.${index}.key`"
|
||||
@@ -163,8 +155,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
<ElInput placeholder="参数名" v-model="item.key" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<div class="w-[24%] min-w-11 shrink-0">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="item.type">
|
||||
@@ -177,8 +167,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 参数值 -->
|
||||
<div class="w-[42%] flex-1">
|
||||
<ElFormItem
|
||||
:prop="`${bind}.body.${index}.value`"
|
||||
@@ -215,8 +203,6 @@ function deleteHttpRequestParam(arr: HttpRequestParam[], index: number) {
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<div class="flex w-[8%] shrink-0 items-center">
|
||||
<IconifyIcon
|
||||
class="size-4 cursor-pointer text-red-500"
|
||||
|
||||
@@ -65,7 +65,7 @@ function deleteHttpResponseSetting(
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ElFormItem>
|
||||
<ElFormItem label-position="top">
|
||||
<ElAlert
|
||||
title="仅支持 POST 请求,以请求体方式接收参数"
|
||||
type="warning"
|
||||
@@ -75,6 +75,7 @@ function deleteHttpResponseSetting(
|
||||
</ElFormItem>
|
||||
<!-- 请求地址-->
|
||||
<ElFormItem
|
||||
label-position="top"
|
||||
label="请求地址"
|
||||
:prop="`${formItemPrefix}.url`"
|
||||
:rules="{
|
||||
@@ -93,7 +94,7 @@ function deleteHttpResponseSetting(
|
||||
/>
|
||||
<!-- 返回值设置-->
|
||||
<div v-if="responseEnable">
|
||||
<ElFormItem label="返回值">
|
||||
<ElFormItem label="返回值" label-position="top">
|
||||
<ElAlert
|
||||
title="通过请求返回值, 可以修改流程表单的值"
|
||||
type="warning"
|
||||
@@ -101,6 +102,7 @@ function deleteHttpResponseSetting(
|
||||
:closable="false"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<!-- todo jason 更多设置排版需要优化一下-->
|
||||
<ElRow
|
||||
:gutter="8"
|
||||
v-for="(item, index) in setting.response"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormRules } from 'element-plus';
|
||||
// SelectValue type removed - use string | number | boolean directly
|
||||
|
||||
import type {
|
||||
FormTriggerSetting,
|
||||
@@ -383,7 +382,6 @@ onMounted(() => {
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<!-- TODO @jason:antd 这里是 1/3,需要保持一致么? -->
|
||||
<Drawer class="w-2/5">
|
||||
<template #title>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,8 +102,8 @@ onBeforeUnmount(() => {
|
||||
<Modal class="w-3/4" title="自定义模板">
|
||||
<div class="mb-3">
|
||||
<ElAlert
|
||||
message="输入 @ 可选择插入流程选项和表单选项"
|
||||
type="info"
|
||||
title="输入 @ 可选择插入流程选项和表单选项"
|
||||
type="primary"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,12 +30,8 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
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 { HttpRequestSetting } from '#/views/bpm/components/simple-process-design';
|
||||
|
||||
import PrintTemplate from './custom-print-template.vue';
|
||||
|
||||
@@ -514,16 +510,17 @@ defineExpose({ initData, validate });
|
||||
</div>
|
||||
</ElCol>
|
||||
</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>
|
||||
<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="流程后置通知">
|
||||
<ElRow class="mt-1">
|
||||
<ElCol :span="24">
|
||||
@@ -536,16 +533,17 @@ defineExpose({ initData, validate });
|
||||
</div>
|
||||
</ElCol>
|
||||
</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>
|
||||
<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="任务前置通知">
|
||||
<ElRow class="mt-1">
|
||||
<ElCol :span="24">
|
||||
@@ -558,16 +556,17 @@ defineExpose({ initData, validate });
|
||||
</div>
|
||||
</ElCol>
|
||||
</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>
|
||||
<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="任务后置通知">
|
||||
<ElRow class="mt-1">
|
||||
<ElCol :span="24">
|
||||
@@ -580,16 +579,17 @@ defineExpose({ initData, validate });
|
||||
</div>
|
||||
</ElCol>
|
||||
</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>
|
||||
<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="自定义打印模板">
|
||||
<div class="flex w-full flex-col">
|
||||
<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">
|
||||
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' });
|
||||
|
||||
@@ -50,11 +49,11 @@ watch(
|
||||
v-loading="loading"
|
||||
class="h-full w-full overflow-auto rounded-lg border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<!-- <MyProcessViewer
|
||||
<MyProcessViewer
|
||||
key="processViewer"
|
||||
:xml="view.bpmnXml"
|
||||
:view="view"
|
||||
class="h-full min-h-[500px] w-full"
|
||||
/> -->
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -275,8 +275,7 @@ async function openPopover(type: string) {
|
||||
}
|
||||
}
|
||||
Object.keys(popOverVisible.value).forEach((item) => {
|
||||
// TODO @jason:这里是不是保持和 antd 一致?
|
||||
popOverVisible.value[item] = item === type;
|
||||
if (popOverVisible.value[item]) popOverVisible.value[item] = item === type;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { InfraCodegenApi } from '#/api/infra/codegen';
|
||||
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';
|
||||
|
||||
@@ -61,6 +61,25 @@ const dictTypeOptions = ref<SystemDictTypeApi.DictType[]>([]); // 字典类型
|
||||
onMounted(async () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -134,9 +153,15 @@ onMounted(async () => {
|
||||
|
||||
<!-- 字典类型 -->
|
||||
<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
|
||||
v-for="option in dictTypeOptions"
|
||||
v-for="option in filteredDictTypeOptions"
|
||||
:key="option.type"
|
||||
:label="option.name"
|
||||
:value="option.type"
|
||||
|
||||
@@ -490,16 +490,25 @@ defineExpose({
|
||||
@checkbox-all="handleSelectionChange"
|
||||
>
|
||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||
<!-- TODO @puhui999:这里的宽度貌似有点问题,图片会寄出来; -->
|
||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||
<VxeColumn
|
||||
align="center"
|
||||
title="图片"
|
||||
width="80"
|
||||
min-width="80"
|
||||
fixed="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElImage
|
||||
v-if="row.picUrl"
|
||||
:src="row.picUrl"
|
||||
class="h-[50px] w-[50px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<ElImage
|
||||
v-if="row.picUrl"
|
||||
:src="row.picUrl"
|
||||
class="h-[50px] w-[50px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
:preview-teleported="true"
|
||||
:z-index="3000"
|
||||
fit="cover"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.specType && !isBatch">
|
||||
@@ -583,15 +592,24 @@ defineExpose({
|
||||
}"
|
||||
>
|
||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||
<!-- TODO @puhui999:这里的宽度貌似有点问题,图片会寄出来; -->
|
||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||
<VxeColumn
|
||||
align="center"
|
||||
title="图片"
|
||||
width="80"
|
||||
min-width="80"
|
||||
fixed="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElImage
|
||||
:src="row.picUrl"
|
||||
class="h-[60px] w-[60px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<ElImage
|
||||
:src="row.picUrl"
|
||||
class="h-[60px] w-[60px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
:preview-teleported="true"
|
||||
:z-index="3000"
|
||||
fit="cover"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.specType">
|
||||
|
||||
@@ -130,12 +130,13 @@ watch(
|
||||
<VxeColumn field="id" align="center" title="商品编号" min-width="30" />
|
||||
<VxeColumn title="商品图" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<!-- TODO @puhui999:它的 preview 貌似展示有点奇怪,不像 antd 是全屏的。。。 -->
|
||||
<ElImage
|
||||
v-if="row.picUrl"
|
||||
:src="row.picUrl"
|
||||
class="h-[30px] w-[30px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
:preview-teleported="true"
|
||||
:z-index="3000"
|
||||
fit="cover"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -320,7 +320,7 @@ onMounted(async () => {
|
||||
<ElCard class="h-full w-full" v-loading="formLoading">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- TODO @puhui999:这里有告警,需要修复下。 -->
|
||||
<!-- @puhui999:idea 这边会有告警 -->
|
||||
<ElTabs v-model="activeTabName" @tab-change="handleTabChange">
|
||||
<ElTabPane label="基础设置" name="info" />
|
||||
<ElTabPane label="价格库存" name="sku" />
|
||||
|
||||
@@ -75,7 +75,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
fieldName: 'spuIds',
|
||||
label: '活动商品',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
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 {
|
||||
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 {
|
||||
createDiscountActivity,
|
||||
getDiscountActivity,
|
||||
updateDiscountActivity,
|
||||
} from '#/api/mall/promotion/discount/discountActivity';
|
||||
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';
|
||||
|
||||
defineOptions({ name: 'DiscountActivityForm' });
|
||||
|
||||
/** 折扣类型枚举 */
|
||||
const PromotionDiscountTypeEnum = {
|
||||
PRICE: { type: 1 }, // 满减
|
||||
PERCENT: { type: 2 }, // 折扣
|
||||
};
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<
|
||||
Partial<MallDiscountActivityApi.DiscountActivity> & {
|
||||
spuIds?: number[];
|
||||
}
|
||||
>({});
|
||||
|
||||
// ================= 表单相关 =================
|
||||
const formData = ref<Partial<MallDiscountActivityApi.DiscountActivity>>({});
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['限时折扣活动'])
|
||||
@@ -44,28 +66,203 @@ const [Form, formApi] = useVbenForm({
|
||||
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({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallDiscountActivityApi.DiscountActivity;
|
||||
|
||||
// 确保必要的默认值
|
||||
if (!data.products) {
|
||||
data.products = [];
|
||||
// 校验是否选择了商品
|
||||
if (spuList.value.length === 0) {
|
||||
ElMessage.warning('请选择活动商品');
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
// 获取折扣商品配置
|
||||
const products = structuredClone(
|
||||
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
|
||||
) as MallDiscountActivityApi.DiscountProduct[];
|
||||
|
||||
// 转换金额为分
|
||||
products.forEach((item) => {
|
||||
item.discountPercent = convertToInteger(item.discountPercent);
|
||||
item.discountPrice = convertToInteger(item.discountPrice);
|
||||
});
|
||||
|
||||
const data = structuredClone(
|
||||
await formApi.getValues(),
|
||||
) as MallDiscountActivityApi.DiscountActivity;
|
||||
data.products = products;
|
||||
|
||||
// 提交请求
|
||||
await (formData.value?.id
|
||||
? updateDiscountActivity(data)
|
||||
: createDiscountActivity(data));
|
||||
// 关闭并提示
|
||||
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
@@ -75,19 +272,45 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = {};
|
||||
await resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const data = modalApi.getData<MallDiscountActivityApi.DiscountActivity>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDiscountActivity(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// 设置表单值
|
||||
await formApi.setValues(activityData);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -96,12 +319,59 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<Modal class="w-[70%]" :title="getTitle">
|
||||
<Form>
|
||||
<!-- 自定义插槽:商品选择 -->
|
||||
<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>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<!-- 商品选择弹窗 -->
|
||||
<SpuSkuSelect
|
||||
ref="spuSelectRef"
|
||||
:is-select-sku="true"
|
||||
@select="handleSpuSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -138,6 +138,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'x',
|
||||
placeholder: [
|
||||
$t('utils.rangePicker.beginTime'),
|
||||
$t('utils.rangePicker.endTime'),
|
||||
@@ -222,7 +223,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
label: '优惠设置',
|
||||
component: 'Input',
|
||||
formItemClass: 'items-start',
|
||||
rules: 'required',
|
||||
rules: z
|
||||
.array(z.any())
|
||||
.min(1, { message: '请添加至少一条优惠规则' })
|
||||
.default([]),
|
||||
},
|
||||
{
|
||||
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PromotionConditionTypeEnum,
|
||||
PromotionProductScopeEnum,
|
||||
} from '@vben/constants';
|
||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
||||
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
@@ -52,6 +52,8 @@ const [Form, formApi] = useVbenForm({
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
// 在验证前同步 formData.rules 到表单中
|
||||
await formApi.setFieldValue('rules', formData.value.rules || []);
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
@@ -60,18 +62,25 @@ const [Modal, modalApi] = useVbenModal({
|
||||
// 提交表单
|
||||
try {
|
||||
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)) {
|
||||
data.startTime = data.startAndEndTime[0];
|
||||
data.endTime = data.startAndEndTime[1];
|
||||
// TODO @puhui999:这里 ele 会告警;antd 不会告警,可能要看看;
|
||||
data.startTime = Number(data.startAndEndTime[0]);
|
||||
data.endTime = Number(data.startAndEndTime[1]);
|
||||
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);
|
||||
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||
item.limit = convertToInteger(item.limit || 0);
|
||||
}
|
||||
});
|
||||
data.rules = rules;
|
||||
await (data.id
|
||||
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
||||
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
||||
@@ -96,7 +105,11 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
const result = await getReward(data.id);
|
||||
result.startAndEndTime = [result.startTime, result.endTime] as any[];
|
||||
// valueFormat: 'x' 配置下,直接使用时间戳字符串
|
||||
result.startAndEndTime = [
|
||||
result.startTime ? String(result.startTime) : undefined,
|
||||
result.endTime ? String(result.endTime) : undefined,
|
||||
] as any[];
|
||||
result.rules?.forEach((item: any) => {
|
||||
item.discountPrice = formatToFraction(item.discountPrice || 0);
|
||||
if (result.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||
|
||||
@@ -216,6 +216,28 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
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',
|
||||
label: '总是显示',
|
||||
|
||||
@@ -110,17 +110,30 @@ function convertServerMenuToRouteRecordStringComponent(
|
||||
menuList.forEach((menu) => {
|
||||
// 处理顶级链接菜单
|
||||
if (isHttpUrl(menu.path) && menu.parentId === 0) {
|
||||
// add by 芋艿:如果有 ?_iframe 参数,则作为内嵌页面处理
|
||||
// 如果有 _iframe 参数,则使用 iframeSrc;如果没有,则使用 link
|
||||
const url = new URL(menu.path);
|
||||
let link: string | undefined;
|
||||
let iframeSrc: string | undefined;
|
||||
if (url.searchParams.has('_iframe')) {
|
||||
url.searchParams.delete('_iframe');
|
||||
iframeSrc = url.toString();
|
||||
} else {
|
||||
link = menu.path;
|
||||
}
|
||||
|
||||
const urlMenu: RouteRecordStringComponent = {
|
||||
component: 'IFrameView',
|
||||
meta: {
|
||||
hideInMenu: !menu.visible,
|
||||
icon: menu.icon,
|
||||
link: menu.path,
|
||||
iframeSrc,
|
||||
link,
|
||||
order: menu.sort,
|
||||
title: menu.name,
|
||||
},
|
||||
name: menu.name,
|
||||
path: `/${menu.path}/index`,
|
||||
path: `${menu.id}`,
|
||||
};
|
||||
menus.push(urlMenu);
|
||||
return;
|
||||
@@ -154,6 +167,18 @@ function convertServerMenuToRouteRecordStringComponent(
|
||||
}
|
||||
nameSet.add(finalName);
|
||||
|
||||
// add by 芋艿:处理 menu.component 中的 query 参数
|
||||
// https://doc.vben.pro/guide/essentials/route.html#query
|
||||
let query: Record<string, string> | undefined;
|
||||
const queryIndex = menu.component.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
// 提取 query 字符串并解析为对象
|
||||
const queryString = menu.component.slice(queryIndex + 1);
|
||||
query = Object.fromEntries(new URLSearchParams(queryString).entries());
|
||||
// 移除 component 中的 query 部分
|
||||
menu.component = menu.component.slice(0, queryIndex);
|
||||
}
|
||||
|
||||
const buildMenu: RouteRecordStringComponent = {
|
||||
component: menu.component,
|
||||
meta: {
|
||||
@@ -162,6 +187,7 @@ function convertServerMenuToRouteRecordStringComponent(
|
||||
keepAlive: menu.keepAlive,
|
||||
order: menu.sort,
|
||||
title: menu.name,
|
||||
...(query && { query }),
|
||||
},
|
||||
name: finalName,
|
||||
path: menu.path,
|
||||
|
||||
Reference in New Issue
Block a user