27 Commits
master ... dev

Author SHA1 Message Date
YunaiV
17d5d1b889 review:【antd】【iot】设备管理相关 2026-01-05 20:45:39 +08:00
芋道源码
a5c76ef89d !315 refactor:【antd】【iot】设备管理跟后端对齐,必要的 ReqVO、RespVO,子设备管理实现
Merge pull request !315 from haohaoMT/dev
2026-01-05 12:10:39 +00:00
YunaiV
e622c052a9 feat:【antd/ele】菜单支持查询参数、iframe 内嵌功能 2026-01-05 19:35:01 +08:00
haohao
de28c5c4c2 refactor:【antd】【iot】设备管理跟后端对齐,必要的 ReqVO、RespVO,子设备管理实现 2026-01-04 12:25:25 +08:00
YunaiV
f7f01c9280 feat:【antd/ele】生产环境下,默认开启 CAPTCHA 验证码,保证安全性 2026-01-03 19:20:25 +08:00
YunaiV
a9f21c1acb feat:【system】菜单管理:增加 visible 管理字段 2026-01-02 19:50:38 +08:00
YunaiV
19c7f0d5dd feat: 【框架】更新 operate-log 的实现 2026-01-02 19:44:09 +08:00
YunaiV
cd43149429 review:【antd/ele】【bpm】流程模型的迁移 2026-01-02 18:49:47 +08:00
YunaiV
19919f6685 Merge remote-tracking branch 'origin/dev' into dev
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-01-02 18:29:54 +08:00
YunaiV
036ef294db feat:【infra】代码生成:字典筛选时,支持 key、name 两种类型 2026-01-02 18:12:23 +08:00
芋道源码
d8f685708d !314 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !314 from Jason/dev
2026-01-01 13:27:55 +00:00
jason
f8ce09a203 Merge remote-tracking branch 'yudao/dev' into dev 2025-12-31 11:02:11 +08:00
jason
59d83d29cb feat: [bpm][ele] bpm oa 请假迁移 2025-12-31 11:00:13 +08:00
jason
02193755be feat: [bpm][antd] oa 请假优化 2025-12-31 00:04:40 +08:00
芋道源码
6d524906a3 !313 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !313 from Jason/dev
2025-12-29 14:43:47 +00:00
jason
cbd1f0bcbb Merge remote-tracking branch 'yudao/dev' into dev 2025-12-29 11:53:00 +08:00
jason
2ba2c8e986 feat: [bpm] [ele,antd] todo 优化, 更多设置问题修复 2025-12-29 11:51:48 +08:00
YunaiV
06f1ae1a66 review:【mall】营销相关 2025-12-29 08:17:35 +08:00
YunaiV
029b2ffaab review:【antd/ele】【mall】营销活动的实现 2025-12-29 07:03:00 +08:00
jason
64ac25de00 Merge remote-tracking branch 'yudao/dev' into dev 2025-12-29 00:12:30 +08:00
jason
6606dfd40a feat: [bpm][ele] todo 优化 2025-12-29 00:09:22 +08:00
芋道源码
6da4a39ff9 !312 feat:【ele/antd】mall todo 优化
Merge pull request !312 from puhui999/dev-mall
2025-12-28 13:23:18 +00:00
puhui999
aa95d0e87c feat:【ele/antd】profile todo 优化 2025-12-28 18:55:07 +08:00
puhui999
6353f0a8e9 feat:【ele/antd】discountActivity todo 优化 2025-12-28 18:34:28 +08:00
puhui999
e6327ae9da feat:【ele】spu todo 优化 2025-12-28 17:57:10 +08:00
puhui999
4395353c22 feat:【ele/antd】rewardActivity todo 优化 2025-12-28 17:35:43 +08:00
puhui999
c023ebbdb9 feat:【ele】cropper、form-create\rules todo 优化 2025-12-28 15:44:21 +08:00
71 changed files with 2385 additions and 586 deletions

View File

@@ -21,3 +21,6 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=true

View File

@@ -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 } },
);

View File

@@ -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', () => {

View File

@@ -121,7 +121,7 @@ const apiSelectRule = [
field: 'data',
title: '请求参数 JSON 格式',
props: {
autoSize: true,
autoSize: true, // 特殊ele 里是 autosizeantd 里是 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 里是 autosizeantd 里是 autoSize
rows: { minRows: 2, maxRows: 6 },
type: 'textarea',
placeholder: `

View File

@@ -39,7 +39,7 @@ export function useDictSelectRule() {
title: label,
info: '',
$required: false,
modelField: 'value',
modelField: 'value', // 特殊ele 里是 model-valueantd 里是 value
};
},
props(_: any, { t }: any) {

View File

@@ -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>

View File

@@ -100,7 +100,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
},
});
/** 解绑账号 */

View File

@@ -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

View File

@@ -383,7 +383,7 @@ onMounted(() => {
});
</script>
<template>
<Drawer class="w-1/3">
<Drawer class="w-2/5">
<template #title>
<div class="config-header">
<Input

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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: '所属产品',

View File

@@ -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[]>([]);

View File

@@ -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');

View File

@@ -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>

View File

@@ -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>

View File

@@ -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[];
}>();

View File

@@ -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>

View File

@@ -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"

View File

@@ -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%);
}
}
}
}

View File

@@ -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>

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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: '创建时间',

View File

@@ -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 {

View File

@@ -73,7 +73,6 @@ export function useFormSchema(): VbenFormSchema[] {
fieldName: 'spuIds',
label: '活动商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
},
];

View File

@@ -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 @puhui999structuredClone 执行会报错;
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>

View File

@@ -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 @puhui9991新增时一直报“请输入优惠设置”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

View File

@@ -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);

View File

@@ -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: '总是显示',

View File

@@ -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

View File

@@ -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 @puhui999html 部分看看有没办法和 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>

View File

@@ -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 @puhui999antd 版本里是 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 @puhui999antd 版本有个 h-96两边要对齐么 -->
<div class="flex">
<!-- 左侧区域 -->
<div :class="`${prefixCls}-left`" class="w-full">
<!-- TODO @puhui999antd 版本是 h-full w-3/5 两边要对齐么 -->
<div class="h-[340px] w-[55%]">
<!-- 裁剪器容器 -->
<div :class="`${prefixCls}-cropper`">
<!-- TODO @puhui999antd 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 @puhui999antd 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>

View File

@@ -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>

View File

@@ -121,7 +121,7 @@ const apiSelectRule = [
field: 'data',
title: '请求参数 JSON 格式',
props: {
autosize: true, // TODO @puhui999这里时 autoSize是 autosize 哈?和 antd 不同
autosize: true, // 特殊ele 里是 autosizeantd 里是 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 里是 autosizeantd 里是 autoSize
rows: { minRows: 2, maxRows: 6 },
type: 'textarea',
placeholder: `

View File

@@ -39,7 +39,7 @@ export function useDictSelectRule() {
title: label,
info: '',
$required: false,
// TODO @puhui999vben 版本里,这里有个 modelField: 'value', 需要添加么?
modelField: 'model-value', // 特殊ele 里是 model-valueantd 里是 value
};
},
props(_: any, { t }: any) {

View File

@@ -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>

View 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;

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -158,7 +158,6 @@ function changeNodeName() {
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</script>
<template>
<!-- TODO @jasonantd 1/3这里要统一么 -->
<Drawer class="w-2/5">
<template #title>
<div class="flex items-center">

View File

@@ -40,7 +40,7 @@ defineOptions({
const props = defineProps({
modelValue: {
type: Object,
// TODO @jason这里 required: false,
required: false,
default: () => ({}),
},
});

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 @jasonantd 这里是 1/3需要保持一致么 -->
<Drawer class="w-2/5">
<template #title>
<div class="config-header">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View 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>

View 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',
},
];
}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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;
});
}

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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这里有告警需要修复下 -->
<!-- @puhui999idea 这边会有告警 -->
<ElTabs v-model="activeTabName" @tab-change="handleTabChange">
<ElTabPane label="基础设置" name="info" />
<ElTabPane label="价格库存" name="sku" />

View File

@@ -75,7 +75,6 @@ export function useFormSchema(): VbenFormSchema[] {
fieldName: 'spuIds',
label: '活动商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
},
];

View File

@@ -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 @puhui999antd 和 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>

View File

@@ -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

View File

@@ -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) {

View File

@@ -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: '总是显示',

View File

@@ -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,