commit
54d0459f07
|
|
@ -60,6 +60,7 @@
|
|||
"pinia": "catalog:",
|
||||
"steady-xml": "catalog:",
|
||||
"tinymce": "catalog:",
|
||||
"tyme4ts": "catalog:",
|
||||
"video.js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-dompurify-html": "catalog:",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export namespace RuleSceneApi {
|
|||
operator?: string;
|
||||
value?: any;
|
||||
cronExpression?: string;
|
||||
conditionGroups?: TriggerCondition[][];
|
||||
conditionGroups?: TriggerCondition[][]; // 后端结构:List<List<TriggerCondition>>;外层「或」、组内「且」
|
||||
}
|
||||
|
||||
/** 场景联动规则的触发条件 */
|
||||
|
|
@ -80,13 +80,6 @@ export function deleteSceneRule(id: number) {
|
|||
return requestClient.delete(`/iot/scene-rule/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 批量删除场景联动规则 */
|
||||
export function deleteSceneRuleList(ids: number[]) {
|
||||
return requestClient.delete('/iot/scene-rule/delete-list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新场景联动规则状态 */
|
||||
export function updateSceneRuleStatus(id: number, status: number) {
|
||||
return requestClient.put(`/iot/scene-rule/update-status`, {
|
||||
|
|
|
|||
|
|
@ -148,8 +148,8 @@ export const ThingModelFormRules: Record<string, Rule[]> = {
|
|||
identifier: [
|
||||
{ required: true, message: '标识符不能为空', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^\w{1,50}$/,
|
||||
message: '支持大小写字母、数字和下划线,不超过 50 个字符',
|
||||
pattern: /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/,
|
||||
message: '支持大小写字母、数字和下划线,必须以字母开头,不超过 32 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -51,7 +51,5 @@ export function deleteAutoCodeRule(id: number) {
|
|||
|
||||
/** 导出编码规则 */
|
||||
export function exportAutoCodeRule(params: PageParam) {
|
||||
return requestClient.download('/mes/md/auto-code-rule/export-excel', {
|
||||
params,
|
||||
});
|
||||
return requestClient.download('/mes/md/auto-code-rule/export-excel', { params });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,7 @@ export namespace MesMdItemApi {
|
|||
|
||||
/** 查询物料产品分页 */
|
||||
export function getItemPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<MesMdItemApi.Item>>('/mes/md/item/page', {
|
||||
params,
|
||||
});
|
||||
return requestClient.get<PageResult<MesMdItemApi.Item>>('/mes/md/item/page', { params });
|
||||
}
|
||||
|
||||
/** 查询物料产品详情 */
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { requestClient } from '#/api/request';
|
|||
export namespace MesProProcessApi {
|
||||
/** MES 生产工序 */
|
||||
export interface Process {
|
||||
id: number;
|
||||
id?: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
attention?: string;
|
||||
|
|
@ -36,3 +36,23 @@ export function getProcess(id: number) {
|
|||
`/mes/pro/process/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增生产工序 */
|
||||
export function createProcess(data: MesProProcessApi.Process) {
|
||||
return requestClient.post('/mes/pro/process/create', data);
|
||||
}
|
||||
|
||||
/** 修改生产工序 */
|
||||
export function updateProcess(data: MesProProcessApi.Process) {
|
||||
return requestClient.put('/mes/pro/process/update', data);
|
||||
}
|
||||
|
||||
/** 删除生产工序 */
|
||||
export function deleteProcess(id: number) {
|
||||
return requestClient.delete(`/mes/pro/process/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 导出生产工序 Excel */
|
||||
export function exportProcess(params: any) {
|
||||
return requestClient.download('/mes/pro/process/export-excel', { params });
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><path fill="url(#b)" fill-rule="evenodd" d="M6.958.42C6.444.216 5.61.216 5.098.42L1.15 1.975c-.77.304-.77.797 0 1.1l3.947 1.558c.514.202 1.347.202 1.86 0l3.948-1.557c.77-.304.77-.797 0-1.1L6.958.418ZM4.715 11.788a.857.857 0 0 0 .3.056c.383 0 .671-.295.671-.7V6.404c0-.49-.364-1.007-.817-1.177L1.09 3.805a.808.808 0 0 0-.284-.056c-.353 0-.581.275-.581.7V9.19c0 .508.33 1.014.763 1.177l3.726 1.422Zm2.229-.024h-.02l.073.003c.074.004.154.009.227-.019L11 10.367c.45-.168.83-.686.83-1.177V4.45c0-.413-.29-.7-.673-.7a.965.965 0 0 0-.317.055l-3.72 1.422c-.44.165-.75.67-.75 1.177v4.74c0 .42.218.621.575.621Z" clip-rule="evenodd"/></g><defs><linearGradient id="b" x1=".226" x2="11.803" y1=".267" y2="11.871" gradientUnits="userSpaceOnUse"><stop stop-color="#1B3149"/><stop offset="1" stop-color="#717D8A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1011 B |
|
|
@ -4,6 +4,7 @@ import type { Dayjs } from 'dayjs';
|
|||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { DatePicker, Radio, RadioGroup } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils/rangePickerProps';
|
||||
|
||||
|
|
@ -19,8 +20,20 @@ const times = ref<[Dayjs, Dayjs]>(); // 日期范围
|
|||
const rangePickerProps = getRangePickerDefaultProps();
|
||||
const timeRangeOptions = [
|
||||
rangePickerProps.presets[3]!, // 昨天
|
||||
rangePickerProps.presets[1]!, // 最近 7 天
|
||||
rangePickerProps.presets[2]!, // 最近 30 天
|
||||
{
|
||||
label: rangePickerProps.presets[1]!.label,
|
||||
value: [
|
||||
dayjs().subtract(7, 'day').startOf('day'),
|
||||
dayjs().subtract(1, 'day').endOf('day'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: rangePickerProps.presets[2]!.label,
|
||||
value: [
|
||||
dayjs().subtract(30, 'day').startOf('day'),
|
||||
dayjs().subtract(1, 'day').endOf('day'),
|
||||
],
|
||||
},
|
||||
];
|
||||
const timeRangeType = ref(timeRangeOptions[1]!.label); // 默认选中第一个选项
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const props = withDefaults(defineProps<FileUploadProps>(), {
|
|||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
returnText: false,
|
||||
showDescription: false,
|
||||
});
|
||||
const emit = defineEmits([
|
||||
|
|
@ -147,9 +148,6 @@ function handleUploadError(error: any) {
|
|||
* @returns 是否允许上传
|
||||
*/
|
||||
async function beforeUpload(file: File) {
|
||||
const fileContent = await file.text();
|
||||
emit('returnText', fileContent);
|
||||
|
||||
// 检查文件数量限制
|
||||
if (fileList.value!.length >= props.maxNumber) {
|
||||
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
|
||||
|
|
@ -176,6 +174,10 @@ async function beforeUpload(file: File) {
|
|||
|
||||
// 只有在验证通过后才增加计数器
|
||||
uploadNumber.value++;
|
||||
if (props.returnText) {
|
||||
const fileContent = await file.text();
|
||||
emit('returnText', fileContent);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ const textareaProps = computed(() => {
|
|||
const fileUploadProps = computed(() => {
|
||||
return {
|
||||
...props.fileUploadProps,
|
||||
returnText: true,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface FileUploadProps {
|
|||
maxSize?: number; // 文件最大多少MB
|
||||
multiple?: boolean; // 是否支持多选
|
||||
resultField?: string; // support xxx.xxx.xx
|
||||
returnText?: boolean; // 是否返回文件文本内容
|
||||
showDescription?: boolean; // 是否显示下面的描述
|
||||
value?: string | string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
children: [
|
||||
{
|
||||
path: 'product/detail/:id',
|
||||
path: 'product/product/detail/:id',
|
||||
name: 'IoTProductDetail',
|
||||
meta: {
|
||||
title: '产品详情',
|
||||
|
|
@ -30,11 +30,11 @@ const routes: RouteRecordRaw[] = [
|
|||
component: () => import('#/views/iot/device/device/detail/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'ota/firmware/detail/:id',
|
||||
path: 'ota/operation/firmware/detail/:id',
|
||||
name: 'IoTOtaFirmwareDetail',
|
||||
meta: {
|
||||
title: '固件详情',
|
||||
activePath: '/iot/ota',
|
||||
activePath: '/iot/operation/ota/firmware',
|
||||
},
|
||||
component: () => import('#/views/iot/ota/firmware/detail/index.vue'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,13 +62,27 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||
label: '设备',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceList,
|
||||
api: (params?: { productId?: number }) =>
|
||||
getSimpleDeviceList(undefined, params?.productId),
|
||||
labelField: 'deviceName',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择设备',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['productId'],
|
||||
componentProps: (values) => {
|
||||
return {
|
||||
params: { productId: values.productId },
|
||||
};
|
||||
},
|
||||
trigger: (values, formApi) => {
|
||||
if (values.deviceId !== undefined) {
|
||||
void formApi.setFieldValue('deviceId', undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'processStatus',
|
||||
|
|
|
|||
|
|
@ -45,10 +45,6 @@ function handleProcess(row: AlertRecordApi.AlertRecord) {
|
|||
}),
|
||||
]),
|
||||
async onOk() {
|
||||
if (!processRemark.value) {
|
||||
message.warning('请输入处理原因');
|
||||
throw new Error('请输入处理原因');
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: '正在处理...',
|
||||
duration: 0,
|
||||
|
|
|
|||
|
|
@ -79,10 +79,18 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
|
|||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, '备注名称长度限制为 4~64 个字符')
|
||||
.max(64, '备注名称长度限制为 4~64 个字符')
|
||||
.regex(
|
||||
/^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/,
|
||||
.refine(
|
||||
(value) => {
|
||||
const length = value.replaceAll(
|
||||
/[\u4E00-\u9FA5\u3040-\u30FF]/g,
|
||||
'aa',
|
||||
).length;
|
||||
return length >= 4 && length <= 64;
|
||||
},
|
||||
'备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符',
|
||||
)
|
||||
.refine(
|
||||
(value) => /^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/.test(value),
|
||||
'备注名称只能包含中文、英文字母、日文、数字和下划线(_)',
|
||||
)
|
||||
.optional()
|
||||
|
|
@ -276,6 +284,7 @@ export function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['colu
|
|||
field: 'deviceName',
|
||||
title: 'DeviceName',
|
||||
minWidth: 150,
|
||||
slots: { default: 'deviceName' },
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ onMounted(async () => {
|
|||
<Tabs v-model:active-key="activeTab" class="mt-4">
|
||||
<Tabs.TabPane key="info" tab="设备信息">
|
||||
<DeviceDetailsInfo
|
||||
v-if="activeTab === 'info'"
|
||||
v-if="activeTab === 'info' && device.id"
|
||||
:device="device"
|
||||
:product="product"
|
||||
/>
|
||||
|
|
@ -127,7 +127,7 @@ onMounted(async () => {
|
|||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="simulator" tab="模拟设备">
|
||||
<DeviceDetailsSimulator
|
||||
v-if="activeTab === 'simulator'"
|
||||
v-if="activeTab === 'simulator' && device.id"
|
||||
:device="device"
|
||||
:product="product"
|
||||
:thing-model-list="thingModelList"
|
||||
|
|
@ -135,7 +135,7 @@ onMounted(async () => {
|
|||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="config" tab="设备配置">
|
||||
<DeviceDetailConfig
|
||||
v-if="activeTab === 'config'"
|
||||
v-if="activeTab === 'config' && device.id"
|
||||
:device="device"
|
||||
@success="() => getDeviceData(id)"
|
||||
/>
|
||||
|
|
@ -151,7 +151,7 @@ onMounted(async () => {
|
|||
tab="Modbus 配置"
|
||||
>
|
||||
<DeviceModbusConfig
|
||||
v-if="activeTab === 'modbus'"
|
||||
v-if="activeTab === 'modbus' && device.id"
|
||||
:device="device"
|
||||
:product="product"
|
||||
:thing-model-list="thingModelList"
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
|
|||
);
|
||||
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
|
||||
|
||||
/** 是否有位置信息 */
|
||||
/** 是否有位置信息(合法经纬度 0 不应视为空) */
|
||||
const hasLocation = computed(() => {
|
||||
return !!(props.device.longitude && props.device.latitude);
|
||||
return props.device.longitude != null && props.device.latitude != null;
|
||||
});
|
||||
|
||||
/** 打开地图弹窗 */
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const queryParams = reactive({
|
|||
|
||||
const autoRefresh = ref(false); // 自动刷新开关
|
||||
let autoRefreshTimer: any = null; // 自动刷新定时器
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined; // 延迟刷新定时器
|
||||
|
||||
/** 消息方法选项 */
|
||||
const methodOptions = computed(() => {
|
||||
|
|
@ -150,6 +151,10 @@ onBeforeUnmount(() => {
|
|||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
|
|
@ -161,9 +166,14 @@ onMounted(() => {
|
|||
|
||||
/** 刷新消息列表 */
|
||||
function refresh(delay = 0) {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (delay > 0) {
|
||||
setTimeout(() => {
|
||||
refreshTimer = setTimeout(() => {
|
||||
gridApi.query();
|
||||
refreshTimer = undefined;
|
||||
}, delay);
|
||||
} else {
|
||||
gridApi.query();
|
||||
|
|
|
|||
|
|
@ -58,11 +58,13 @@ const [Form, formApi] = useVbenForm({
|
|||
componentProps: {
|
||||
placeholder: '请输入 Modbus 服务器 IP 地址',
|
||||
},
|
||||
// Client 模式专有字段:必填;Server 模式不显示也不校验
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:IP 地址
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value ? z.string().min(1, '请输入 IP 地址') : null,
|
||||
},
|
||||
rules: z.string().min(1, '请输入 IP 地址').optional(),
|
||||
},
|
||||
{
|
||||
fieldName: 'port',
|
||||
|
|
@ -76,9 +78,12 @@ const [Form, formApi] = useVbenForm({
|
|||
},
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:端口
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value
|
||||
? z.number({ message: '请输入端口' }).min(1).max(65_535)
|
||||
: null,
|
||||
},
|
||||
rules: z.number().min(1).max(65_535).optional(),
|
||||
defaultValue: 502,
|
||||
},
|
||||
{
|
||||
|
|
@ -106,9 +111,12 @@ const [Form, formApi] = useVbenForm({
|
|||
},
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:连接超时
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value
|
||||
? z.number({ message: '请输入连接超时时间' }).min(1000)
|
||||
: null,
|
||||
},
|
||||
rules: z.number().min(1000).optional(),
|
||||
defaultValue: 3000,
|
||||
},
|
||||
{
|
||||
|
|
@ -123,9 +131,12 @@ const [Form, formApi] = useVbenForm({
|
|||
},
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => isClient.value, // Client 模式专有字段:重试间隔
|
||||
show: () => isClient.value,
|
||||
rules: () =>
|
||||
isClient.value
|
||||
? z.number({ message: '请输入重试间隔' }).min(1000)
|
||||
: null,
|
||||
},
|
||||
rules: z.number().min(1000).optional(),
|
||||
defaultValue: 10_000,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { computed, h, onMounted, ref } from 'vue';
|
|||
import { confirm, useVbenModal } from '@vben/common-ui';
|
||||
import { DICT_TYPE, ModbusFunctionCodeOptions } from '@vben/constants';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getModbusConfig } from '#/api/iot/device/modbus/config';
|
||||
|
|
@ -307,7 +307,16 @@ onMounted(async () => {
|
|||
<!-- 连接配置区域 -->
|
||||
<ConfigDescriptions :data="modbusConfig" class="mb-4">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="handleEditConfig">编辑</Button>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'primary',
|
||||
auth: ['iot:device:create'],
|
||||
onClick: handleEditConfig,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</ConfigDescriptions>
|
||||
|
||||
|
|
@ -320,6 +329,7 @@ onMounted(async () => {
|
|||
label: '新增点位',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device:create'],
|
||||
onClick: handleAddPoint,
|
||||
},
|
||||
]"
|
||||
|
|
@ -331,12 +341,14 @@ onMounted(async () => {
|
|||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
auth: ['iot:device:update'],
|
||||
onClick: () => handleEditPoint(row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
auth: ['iot:device:delete'],
|
||||
popConfirm: {
|
||||
title: `确定要删除点位【${row.name}】吗?`,
|
||||
confirm: () => handleDeletePoint(row),
|
||||
|
|
|
|||
|
|
@ -246,10 +246,20 @@ const [Form, formApi] = useVbenForm({
|
|||
if (option && option.registerCount > 0) {
|
||||
await formApi.setFieldValue('registerCount', option.registerCount);
|
||||
}
|
||||
// 重置字节序为第一个选项
|
||||
// 字节序:仅在当前值为空或不属于新 rawDataType 时才重置为第一个选项;
|
||||
// 编辑回填时 setValues 会触发本回调,无条件重置会覆盖已保存字节序
|
||||
const byteOrderOptions = getByteOrderOptions(rawDataType);
|
||||
if (byteOrderOptions.length > 0) {
|
||||
await formApi.setFieldValue('byteOrder', byteOrderOptions[0]!.value);
|
||||
const currentByteOrder = values.byteOrder;
|
||||
const isCurrentValid =
|
||||
!!currentByteOrder &&
|
||||
byteOrderOptions.some((opt) => opt.value === currentByteOrder);
|
||||
if (!isCurrentValid) {
|
||||
await formApi.setFieldValue(
|
||||
'byteOrder',
|
||||
byteOrderOptions[0]!.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
|
|||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ContentWrap } from '@vben/common-ui';
|
||||
import {
|
||||
DeviceStateEnum,
|
||||
IoTDataSpecsDataTypeEnum,
|
||||
IotDeviceMessageMethodEnum,
|
||||
IoTThingModelTypeEnum,
|
||||
} from '@vben/constants';
|
||||
|
|
@ -21,8 +22,10 @@ import {
|
|||
Card,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Row,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
Textarea,
|
||||
|
|
@ -51,7 +54,7 @@ const debugCollapsed = ref(false); // 指令调试区域折叠状态
|
|||
const messageCollapsed = ref(false); // 设备消息区域折叠状态
|
||||
|
||||
// 表单数据:存储用户输入的模拟值
|
||||
const formData = ref<Record<string, string>>({});
|
||||
const formData = ref<Record<string, any>>({});
|
||||
|
||||
// 根据类型过滤物模型数据
|
||||
const getFilteredThingModelList = (type: number) => {
|
||||
|
|
@ -184,21 +187,101 @@ const serviceColumns = [
|
|||
|
||||
// 获取表单值
|
||||
function getFormValue(identifier: string) {
|
||||
return formData.value[identifier] || '';
|
||||
return formData.value[identifier] ?? '';
|
||||
}
|
||||
|
||||
// 设置表单值
|
||||
function setFormValue(identifier: string, value: string) {
|
||||
function setFormValue(identifier: string, value: any) {
|
||||
formData.value[identifier] = value;
|
||||
}
|
||||
|
||||
/** 获取属性数据类型 */
|
||||
function getPropertyDataType(row: ThingModelApi.ThingModel) {
|
||||
return row.property?.dataType;
|
||||
}
|
||||
|
||||
/** 判断属性是否为数值类型 */
|
||||
function isNumberProperty(row: ThingModelApi.ThingModel) {
|
||||
return [
|
||||
IoTDataSpecsDataTypeEnum.DOUBLE,
|
||||
IoTDataSpecsDataTypeEnum.FLOAT,
|
||||
IoTDataSpecsDataTypeEnum.INT,
|
||||
].includes(getPropertyDataType(row) as any);
|
||||
}
|
||||
|
||||
/** 判断属性是否使用下拉选项 */
|
||||
function isSelectProperty(row: ThingModelApi.ThingModel) {
|
||||
return [
|
||||
IoTDataSpecsDataTypeEnum.BOOL,
|
||||
IoTDataSpecsDataTypeEnum.ENUM,
|
||||
].includes(getPropertyDataType(row) as any);
|
||||
}
|
||||
|
||||
/** 获取属性选项 */
|
||||
function getPropertyOptions(row: ThingModelApi.ThingModel) {
|
||||
const list = row.property?.dataSpecsList || [];
|
||||
if (list.length > 0) {
|
||||
return list.map((item: any) => ({
|
||||
label: item.name || item.label || String(item.value),
|
||||
value: String(item.value),
|
||||
}));
|
||||
}
|
||||
if (getPropertyDataType(row) === IoTDataSpecsDataTypeEnum.BOOL) {
|
||||
return [
|
||||
{ label: '真 (true)', value: 'true' },
|
||||
{ label: '假 (false)', value: 'false' },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 获取物模型选项原始值 */
|
||||
function getMatchedPropertyOption(row: ThingModelApi.ThingModel, value: any) {
|
||||
return row.property?.dataSpecsList?.find(
|
||||
(item: any) => String(item.value) === String(value),
|
||||
);
|
||||
}
|
||||
|
||||
/** 按物模型数据类型转换属性值 */
|
||||
function normalizePropertyValue(row: ThingModelApi.ThingModel, value: any) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const dataType = getPropertyDataType(row);
|
||||
if (isNumberProperty(row)) {
|
||||
return Number(value);
|
||||
}
|
||||
if (
|
||||
[IoTDataSpecsDataTypeEnum.BOOL, IoTDataSpecsDataTypeEnum.ENUM].includes(
|
||||
dataType as any,
|
||||
)
|
||||
) {
|
||||
const option = getMatchedPropertyOption(row, value);
|
||||
if (option) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
if (dataType === IoTDataSpecsDataTypeEnum.BOOL) {
|
||||
if (String(value) === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (String(value) === 'false') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 属性上报
|
||||
async function handlePropertyPost() {
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
propertyList.value.forEach((item) => {
|
||||
const value = formData.value[item.identifier!];
|
||||
if (value) {
|
||||
const value = normalizePropertyValue(
|
||||
item,
|
||||
formData.value[item.identifier!],
|
||||
);
|
||||
if (value !== undefined) {
|
||||
params[item.identifier!] = value;
|
||||
}
|
||||
});
|
||||
|
|
@ -229,13 +312,15 @@ async function handleEventPost(row: ThingModelApi.ThingModel) {
|
|||
const valueStr = formData.value[row.identifier!];
|
||||
let eventValue: any;
|
||||
|
||||
if (valueStr) {
|
||||
try {
|
||||
eventValue = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error('事件参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
if (valueStr === undefined || valueStr === null || valueStr === '') {
|
||||
message.warning('请输入事件参数');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
eventValue = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error('事件参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
|
||||
// 与后端 IotDeviceEventPostReqDTO 对齐 :{ identifier, value, time }
|
||||
|
|
@ -281,8 +366,11 @@ async function handlePropertySet() {
|
|||
try {
|
||||
const params: Record<string, any> = {};
|
||||
propertyList.value.forEach((item) => {
|
||||
const value = formData.value[item.identifier!];
|
||||
if (value) {
|
||||
const value = normalizePropertyValue(
|
||||
item,
|
||||
formData.value[item.identifier!],
|
||||
);
|
||||
if (value !== undefined) {
|
||||
params[item.identifier!] = value;
|
||||
}
|
||||
});
|
||||
|
|
@ -313,13 +401,23 @@ async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
|
|||
const valueStr = formData.value[row.identifier!];
|
||||
let inputParams: any = {};
|
||||
|
||||
if (valueStr) {
|
||||
try {
|
||||
inputParams = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error('服务参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
if (valueStr === undefined || valueStr === null || valueStr === '') {
|
||||
message.warning('请输入服务参数');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
inputParams = JSON.parse(valueStr);
|
||||
} catch {
|
||||
message.error('服务参数格式错误,请输入有效的JSON格式');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof inputParams !== 'object' ||
|
||||
inputParams === null ||
|
||||
Array.isArray(inputParams)
|
||||
) {
|
||||
message.error('服务参数必须是 JSON 对象');
|
||||
return;
|
||||
}
|
||||
|
||||
// 与后端 IotDeviceServiceInvokeReqDTO 对齐 :{ identifier, inputParams }
|
||||
|
|
@ -340,6 +438,11 @@ async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
|
|||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换调试方法时清空输入,避免不同方法之间串台提交 */
|
||||
watch([activeTab, upstreamTab, downstreamTab], () => {
|
||||
formData.value = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -392,7 +495,29 @@ async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
|
|||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<InputNumber
|
||||
v-if="isNumberProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="isSelectProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
:options="getPropertyOptions(record)"
|
||||
placeholder="请选择值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
|
|
@ -514,7 +639,29 @@ async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
|
|||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<InputNumber
|
||||
v-if="isNumberProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="isSelectProperty(record)"
|
||||
:value="getFormValue(record.identifier)"
|
||||
:options="getPropertyOptions(record)"
|
||||
placeholder="请选择值"
|
||||
size="small"
|
||||
class="w-full"
|
||||
@update:value="
|
||||
setFormValue(record.identifier, $event)
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:value="getFormValue(record.identifier)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ watch(
|
|||
label: '添加子设备',
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device:update'],
|
||||
onClick: openAddModal,
|
||||
},
|
||||
{
|
||||
|
|
@ -324,6 +325,7 @@ watch(
|
|||
type: 'primary',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:update'],
|
||||
disabled: isEmpty(checkedIds),
|
||||
onClick: handleUnbindBatch,
|
||||
},
|
||||
|
|
@ -342,6 +344,7 @@ watch(
|
|||
label: '解绑',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
auth: ['iot:device:update'],
|
||||
onClick: () => handleUnbind(row),
|
||||
},
|
||||
]"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onMounted, reactive, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import {
|
||||
|
|
@ -29,6 +29,7 @@ const queryParams = reactive({
|
|||
identifier: '',
|
||||
times: undefined as [string, string] | undefined,
|
||||
});
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined; // 延迟刷新定时器
|
||||
|
||||
/** 事件类型的物模型数据 */
|
||||
const eventThingModels = computed(() => {
|
||||
|
|
@ -153,8 +154,15 @@ function parseParams(params: string) {
|
|||
|
||||
/** 刷新列表 */
|
||||
function refresh(delay = 0) {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (delay > 0) {
|
||||
setTimeout(() => gridApi.query(), delay);
|
||||
refreshTimer = setTimeout(() => {
|
||||
gridApi.query();
|
||||
refreshTimer = undefined;
|
||||
}, delay);
|
||||
} else {
|
||||
gridApi.query();
|
||||
}
|
||||
|
|
@ -177,6 +185,14 @@ onMounted(() => {
|
|||
}
|
||||
});
|
||||
|
||||
/** 组件卸载时清除延迟刷新定时器 */
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** 暴露方法给父组件 */
|
||||
defineExpose({
|
||||
refresh,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ defineProps<{ deviceId: number }>();
|
|||
const dialogVisible = ref(false); // 弹窗的是否展示
|
||||
const loading = ref(false);
|
||||
const viewMode = ref<'chart' | 'list'>('chart'); // 视图模式状态
|
||||
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 列表的数据
|
||||
const list = ref<Array<IotDeviceApi.DeviceProperty & { _rowKey: string }>>([]); // 列表的数据
|
||||
const total = ref(0); // 总数据量
|
||||
const thingModelDataType = ref<string>(''); // 物模型数据类型
|
||||
const propertyIdentifier = ref<string>(''); // 属性标识符
|
||||
|
|
@ -137,23 +137,15 @@ const tableColumns = computed(() => [
|
|||
},
|
||||
]); // 表格列配置
|
||||
|
||||
const paginationConfig = computed(() => ({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total: number) => `共 ${total} 条数据`,
|
||||
})); // 分页配置
|
||||
|
||||
/** 获得设备历史数据 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 后端直接返回数组
|
||||
const data = await getHistoryDevicePropertyList(queryParams);
|
||||
list.value = (data || []) as IotDeviceApi.DevicePropertyDetail[];
|
||||
list.value = (data || []).map((item, idx) => ({
|
||||
...item,
|
||||
_rowKey: `${item.updateTime ?? ''}-${idx}`, // 后端直接返回数组,仅含 value/updateTime,给每行补 _rowKey 保证唯一
|
||||
}));
|
||||
total.value = list.value.length;
|
||||
|
||||
// 如果是图表模式且支持图表展示,等待渲染图表
|
||||
|
|
@ -436,9 +428,9 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||
<Table
|
||||
:columns="tableColumns"
|
||||
:data-source="list"
|
||||
:pagination="paginationConfig"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 500 }"
|
||||
row-key="updateTime"
|
||||
row-key="_rowKey"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
|
|
|
|||
|
|
@ -207,6 +207,13 @@ function handleQuery() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 搜索关键词变化 */
|
||||
function handleKeywordChange(event: Event) {
|
||||
if (!(event.target as HTMLInputElement).value) {
|
||||
handleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
/** 视图切换 */
|
||||
async function handleViewModeChange(mode: 'card' | 'list') {
|
||||
if (viewMode.value === mode) {
|
||||
|
|
@ -281,6 +288,7 @@ onBeforeUnmount(() => {
|
|||
allow-clear
|
||||
placeholder="请输入属性名称、标识符"
|
||||
style="width: 240px"
|
||||
@change="handleKeywordChange"
|
||||
@press-enter="handleQuery"
|
||||
/>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onMounted, reactive, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import {
|
||||
|
|
@ -29,6 +29,7 @@ const queryParams = reactive({
|
|||
identifier: '',
|
||||
times: undefined as [string, string] | undefined,
|
||||
});
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined; // 延迟刷新定时器
|
||||
|
||||
/** 服务类型的物模型数据 */
|
||||
const serviceThingModels = computed(() => {
|
||||
|
|
@ -167,8 +168,15 @@ function parseParams(params: string) {
|
|||
|
||||
/** 刷新列表 */
|
||||
function refresh(delay = 0) {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (delay > 0) {
|
||||
setTimeout(() => gridApi.query(), delay);
|
||||
refreshTimer = setTimeout(() => {
|
||||
gridApi.query();
|
||||
refreshTimer = undefined;
|
||||
}, delay);
|
||||
} else {
|
||||
gridApi.query();
|
||||
}
|
||||
|
|
@ -191,6 +199,14 @@ onMounted(() => {
|
|||
}
|
||||
});
|
||||
|
||||
/** 组件卸载时清除延迟刷新定时器 */
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** 暴露方法给父组件 */
|
||||
defineExpose({
|
||||
refresh,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { IotProductApi } from '#/api/iot/product/product';
|
|||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { confirm, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
|
@ -169,6 +169,7 @@ async function handleDeleteBatch() {
|
|||
message.warning('请选择要删除的设备');
|
||||
return;
|
||||
}
|
||||
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deletingBatch'),
|
||||
duration: 0,
|
||||
|
|
@ -214,6 +215,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
pageSize: 12,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
|
|
@ -429,6 +433,11 @@ onMounted(async () => {
|
|||
|
||||
<!-- 列表视图 -->
|
||||
<Grid table-title="设备列表" v-show="viewMode === 'list'">
|
||||
<template #deviceName="{ row }">
|
||||
<a class="cursor-pointer text-primary" @click="openDetail(row.id!)">
|
||||
{{ row.deviceName }}
|
||||
</a>
|
||||
</template>
|
||||
<template #product="{ row }">
|
||||
<a
|
||||
class="cursor-pointer text-primary"
|
||||
|
|
@ -465,7 +474,7 @@ onMounted(async () => {
|
|||
{
|
||||
label: '日志',
|
||||
type: 'link',
|
||||
auth: ['iot:device:message-query'],
|
||||
auth: ['iot:device:query'],
|
||||
onClick: openModel.bind(null, row.id!),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -177,13 +177,16 @@ onMounted(() => {
|
|||
</div>
|
||||
<div class="flex items-center text-[13px]">
|
||||
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
|
||||
Deviceid
|
||||
备注名称
|
||||
</span>
|
||||
<Tooltip :title="String(item.id)" placement="top">
|
||||
<Tooltip
|
||||
:title="item.nickname || item.deviceName"
|
||||
placement="top"
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
|
||||
class="inline-block max-w-[150px] cursor-pointer truncate align-middle text-xs opacity-85 dark:text-white/75"
|
||||
>
|
||||
{{ item.id }}
|
||||
{{ item.nickname || item.deviceName }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -226,7 +229,7 @@ onMounted(() => {
|
|||
详情
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['iot:device:message-query'])"
|
||||
v-if="hasAccessByCodes(['iot:device:query'])"
|
||||
size="small"
|
||||
class="!h-8 min-w-0 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
|
||||
@click="emit('model', item.id!)"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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 { alert, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { Button, message, Upload } from 'ant-design-vue';
|
||||
|
|
@ -61,7 +61,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||
text += `< ${deviceName}: ${importData.failureDeviceNames[deviceName]} >`;
|
||||
}
|
||||
}
|
||||
message.info(text);
|
||||
await alert(text, '导入结果');
|
||||
}
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
|
|
@ -92,18 +92,18 @@ async function handleDownload() {
|
|||
<template #file>
|
||||
<div class="w-full">
|
||||
<Upload
|
||||
:before-upload="beforeUpload"
|
||||
:max-count="1"
|
||||
accept=".xls,.xlsx"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<Button type="primary"> 选择 Excel 文件</Button>
|
||||
<Button type="primary"> 选择 Excel 文件 </Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
<template #prepend-footer>
|
||||
<div class="flex flex-auto items-center">
|
||||
<Button @click="handleDownload"> 下载导入模板</Button>
|
||||
<Button @click="handleDownload"> 下载导入模板 </Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { VbenFormSchema } from '#/adapter/form';
|
|||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
|
||||
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
|
|
@ -26,10 +26,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
componentProps: {
|
||||
placeholder: '请输入分组名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '分组名称不能为空')
|
||||
.max(64, '分组名称长度不能超过 64 个字符'),
|
||||
rules: z.string().min(1, '分组名称不能为空'),
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
|
|
@ -40,7 +37,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
|
|||
|
||||
/** 删除设备分组 */
|
||||
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
|
||||
if (row.deviceCount && row.deviceCount > 0) {
|
||||
message.warning(`分组「${row.name}」下存在 ${row.deviceCount} 台设备,无法删除`);
|
||||
return;
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ export const defaultStatsData: StatsData = {
|
|||
productCount: -1,
|
||||
deviceCount: -1,
|
||||
deviceMessageCount: -1,
|
||||
productCategoryTodayCount: 0,
|
||||
productTodayCount: 0,
|
||||
deviceTodayCount: 0,
|
||||
deviceMessageTodayCount: 0,
|
||||
deviceOnlineCount: 0,
|
||||
deviceOfflineCount: 0,
|
||||
deviceInactiveCount: 0,
|
||||
productCategoryTodayCount: -1,
|
||||
productTodayCount: -1,
|
||||
deviceTodayCount: -1,
|
||||
deviceMessageTodayCount: -1,
|
||||
deviceOnlineCount: -1,
|
||||
deviceOfflineCount: -1,
|
||||
deviceInactiveCount: -1,
|
||||
productCategoryDeviceCounts: {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import type { NumberDictDataType } from '@vben/hooks';
|
||||
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
import { getDictLabel, getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
|
|
@ -20,15 +22,20 @@ let mapInstance: any = null; // 百度地图实例
|
|||
const loading = ref(true); // 加载状态
|
||||
const deviceList = ref<IotDeviceApi.Device[]>([]); // 设备分布列表
|
||||
|
||||
/** 是否有数据 */
|
||||
const hasData = computed(() => deviceList.value.length > 0);
|
||||
const hasData = computed(() => deviceList.value.length > 0); // 是否有数据
|
||||
|
||||
const stateOptions = computed(() =>
|
||||
getDictOptions(
|
||||
DICT_TYPE.IOT_DEVICE_STATE,
|
||||
'number',
|
||||
) as NumberDictDataType[],
|
||||
); // 状态图例列表(从字典获取)
|
||||
|
||||
/** 设备状态颜色映射 */
|
||||
const stateColorMap: Record<number, string> = {
|
||||
[DeviceStateEnum.INACTIVE]: '#EAB308', // 待激活 - 黄色
|
||||
[DeviceStateEnum.ONLINE]: '#22C55E', // 在线 - 绿色
|
||||
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 离线 - 灰色
|
||||
};
|
||||
}; // 设备状态颜色映射
|
||||
|
||||
/** 获取设备状态配置;名称走字典,颜色用本地映射 */
|
||||
function getStateConfig(state: number): { color: string; name: string } {
|
||||
|
|
@ -111,19 +118,22 @@ function initMap() {
|
|||
// 信息窗口打开后绑定链接点击事件
|
||||
infoWindow.addEventListener('open', () => {
|
||||
setTimeout(() => {
|
||||
const link = document.querySelector('.device-link');
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const deviceId = (e.target as HTMLElement).dataset.id;
|
||||
if (deviceId) {
|
||||
router.push({
|
||||
name: 'IoTDeviceDetail',
|
||||
params: { id: deviceId },
|
||||
});
|
||||
}
|
||||
});
|
||||
const link = document.querySelector(
|
||||
'.BMap_bubble_content .device-link',
|
||||
);
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (device.id === undefined || device.id === null) {
|
||||
return;
|
||||
}
|
||||
router.push({
|
||||
name: 'IoTDeviceDetail',
|
||||
params: { id: device.id },
|
||||
});
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
|
|
@ -174,28 +184,18 @@ onUnmounted(() => {
|
|||
<Card class="h-full" title="设备分布地图">
|
||||
<template #extra>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-3 w-3 rounded-full"
|
||||
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.ONLINE] }"
|
||||
></span>
|
||||
<span class="text-gray-500">在线</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-3 w-3 rounded-full"
|
||||
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.OFFLINE] }"
|
||||
></span>
|
||||
<span class="text-gray-500">离线</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
v-for="item in stateOptions"
|
||||
:key="item.value"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-3 w-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE],
|
||||
backgroundColor: stateColorMap[item.value],
|
||||
}"
|
||||
></span>
|
||||
<span class="text-gray-500">待激活</span>
|
||||
<span class="text-gray-500">{{ item.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Dayjs } from 'dayjs';
|
|||
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
|
@ -26,12 +26,13 @@ const loading = ref(false); // 加载状态
|
|||
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDateRespVO[]>(
|
||||
[],
|
||||
); // 消息趋势数据
|
||||
const isFirstMount = ref(true); // 首次挂载标记,用于跳过子组件 mount 时的默认 emit
|
||||
|
||||
/** 时间范围(仅日期,不包含时分秒) */
|
||||
const dateRange = ref<[string, string]>([
|
||||
// 默认显示最近一周的数据(包含今天和前六天)
|
||||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
// 默认显示最近一周的数据(截至昨天)
|
||||
dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
||||
]);
|
||||
|
||||
/** 将日期范围转换为带时分秒的格式 */
|
||||
|
|
@ -74,6 +75,11 @@ function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
|
|||
];
|
||||
// 将选择的日期转换为带时分秒的格式(开始日期 00:00:00,结束日期 23:59:59)
|
||||
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||
if (isFirstMount.value) {
|
||||
// 子组件 ShortcutDateRangePicker mount 时会 emit 一次默认日期范围,跳过 fetch;
|
||||
// 首次请求统一由父组件 onMounted 发起,避免双请求
|
||||
return;
|
||||
}
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +97,8 @@ async function fetchMessageData() {
|
|||
loading.value = true;
|
||||
try {
|
||||
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
|
||||
} catch {
|
||||
messageData.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
await renderChartWhenReady();
|
||||
|
|
@ -122,6 +130,13 @@ async function renderChartWhenReady() {
|
|||
await nextTick();
|
||||
initChart();
|
||||
}
|
||||
// 父组件挂载后统一发起首次请求;
|
||||
// 原因:子组件 ShortcutDateRangePicker 早期 emit 触发的请求落在 useEcharts isActiveRef = false 阶段,会被 renderEcharts 静默丢弃;
|
||||
// 通过 handleDateRangeChange 在 isFirstMount=true 时跳过 fetch,由这里统一发起一次,避免双请求
|
||||
onMounted(() => {
|
||||
fetchMessageData();
|
||||
isFirstMount.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ export function getProductName(productId?: number): string {
|
|||
export function useDetailSchema(): DescriptionItemSchema[] {
|
||||
return [
|
||||
{ field: 'name', label: '固件名称' },
|
||||
{ field: 'productName', label: '所属产品' },
|
||||
{
|
||||
field: 'productName',
|
||||
label: '所属产品',
|
||||
render: (val) => val || '-',
|
||||
},
|
||||
{ field: 'version', label: '固件版本' },
|
||||
{
|
||||
field: 'createTime',
|
||||
|
|
@ -65,10 +69,12 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
show: (values) => !values.id,
|
||||
componentProps: (values) => ({
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
rules: (values) => (values.id ? null : 'required'),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -78,10 +84,12 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
componentProps: {
|
||||
placeholder: '请输入版本号',
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
show: (values) => !values.id,
|
||||
componentProps: (values) => ({
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
rules: (values) => (values.id ? null : 'required'),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -99,14 +107,16 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||
component: 'FileUpload',
|
||||
componentProps: {
|
||||
maxNumber: 1,
|
||||
accept: ['bin', 'hex', 'zip'],
|
||||
accept: ['bin', 'zip', 'pdf'],
|
||||
maxSize: 50,
|
||||
helpText: '支持上传 .bin、.hex、.zip 格式的固件文件,最大 50MB',
|
||||
helpText: '支持上传 .bin、.zip、.pdf 格式的固件文件,最大 50MB',
|
||||
},
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
show: (values) => !values.id,
|
||||
componentProps: (values) => ({
|
||||
disabled: !!values.id,
|
||||
}),
|
||||
rules: (values) => (values.id ? null : 'required'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
|||
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { getProductName, useGridColumns, useGridFormSchema } from './data';
|
||||
import {
|
||||
getProductName,
|
||||
useGridColumns,
|
||||
useGridFormSchema,
|
||||
} from './data';
|
||||
import OtaFirmwareForm from './modules/form.vue';
|
||||
|
||||
const { push } = useRouter();
|
||||
|
|
@ -116,7 +120,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
<!-- 所属产品列:点击跳产品详情 -->
|
||||
<template #productName="{ row }">
|
||||
<a
|
||||
v-if="row.productId"
|
||||
v-if="row.productId && getProductName(row.productId) !== '-'"
|
||||
class="cursor-pointer text-primary hover:underline"
|
||||
@click="handleOpenProductDetail(row.productId)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async function handleRefresh() {
|
|||
|
||||
/** 按任务名称搜索 */
|
||||
async function handleSearch() {
|
||||
await gridApi.query();
|
||||
await gridApi.reload();
|
||||
}
|
||||
|
||||
/** 新增任务 */
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const statusTabs = computed(() => {
|
|||
/** 切换标签 */
|
||||
async function handleTabChange(tabKey: number | string) {
|
||||
activeTab.value = String(tabKey);
|
||||
await gridApi.query();
|
||||
await gridApi.reload();
|
||||
}
|
||||
|
||||
/** 取消单条记录的升级 */
|
||||
|
|
@ -90,7 +90,7 @@ watch(
|
|||
async (val) => {
|
||||
if (val) {
|
||||
activeTab.value = '';
|
||||
await gridApi.query();
|
||||
await gridApi.reload();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -110,7 +110,6 @@ export function useBasicFormSchema(
|
|||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: DeviceTypeEnum.DEVICE,
|
||||
dependencies: {
|
||||
triggerFields: ['id'],
|
||||
componentProps: (values) => ({
|
||||
|
|
@ -131,7 +130,10 @@ export function useBasicFormSchema(
|
|||
// 网关子设备走网关联网,不需要联网方式
|
||||
dependencies: {
|
||||
triggerFields: ['deviceType'],
|
||||
show: (values) => values.deviceType !== DeviceTypeEnum.GATEWAY,
|
||||
show: (values) =>
|
||||
[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(
|
||||
values.deviceType,
|
||||
),
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -84,13 +84,14 @@ async function copyToClipboard(text: string) {
|
|||
>
|
||||
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="product.productSecret" label="ProductSecret">
|
||||
<Descriptions.Item label="产品密钥">
|
||||
<span v-if="showProductSecret">{{ product.productSecret }}</span>
|
||||
<span v-else>********</span>
|
||||
<Button class="ml-2" size="small" @click="toggleProductSecretVisible">
|
||||
{{ showProductSecret ? '隐藏' : '显示' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showProductSecret && product.productSecret"
|
||||
class="ml-2"
|
||||
size="small"
|
||||
@click="copyToClipboard(product.productSecret || '')"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ async function handleViewModeChange(mode: 'card' | 'list') {
|
|||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportProduct(queryParams.value);
|
||||
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
|
||||
downloadFileFromBlobPart({ fileName: '物联网产品.xls', source: data });
|
||||
}
|
||||
|
||||
/** 打开产品详情 */
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { onMounted, ref } from 'vue';
|
|||
import { useAccess } from '@vben/access';
|
||||
import { DICT_TYPE, ProductStatusEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isHttpUrl } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -18,6 +19,8 @@ import {
|
|||
} from 'ant-design-vue';
|
||||
|
||||
import { getProductPage } from '#/api/iot/product/product';
|
||||
import defaultPicUrl from '#/assets/imgs/iot/device.png';
|
||||
import defaultIconUrl from '#/assets/svgs/iot/cube.svg';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -49,9 +52,27 @@ const queryParams = ref({
|
|||
});
|
||||
|
||||
/** 获取分类名称 */
|
||||
function getCategoryName(categoryId: number) {
|
||||
const category = props.categoryList.find((c: any) => c.id === categoryId);
|
||||
return category?.name || '未分类';
|
||||
function getCategoryName(item: any) {
|
||||
const category = props.categoryList.find((c: any) => c.id === item.categoryId);
|
||||
return item.categoryName || category?.name || '未分类';
|
||||
}
|
||||
|
||||
/** 是否按图片 URL 渲染产品图标 */
|
||||
function isImageIcon(icon?: string) {
|
||||
if (!icon) {
|
||||
return true;
|
||||
}
|
||||
return isHttpUrl(icon);
|
||||
}
|
||||
|
||||
/** 产品图标 fallback */
|
||||
function getProductIcon(icon?: string) {
|
||||
return icon || defaultIconUrl;
|
||||
}
|
||||
|
||||
/** 产品图片 fallback */
|
||||
function getProductPic(picUrl?: string) {
|
||||
return picUrl || defaultPicUrl;
|
||||
}
|
||||
|
||||
/** 获取产品列表 */
|
||||
|
|
@ -117,8 +138,15 @@ onMounted(() => {
|
|||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
|
||||
>
|
||||
<img
|
||||
v-if="isImageIcon(item.icon)"
|
||||
:src="getProductIcon(item.icon)"
|
||||
alt=""
|
||||
class="size-6 object-contain"
|
||||
/>
|
||||
<IconifyIcon
|
||||
:icon="item.icon || 'lucide:box'"
|
||||
v-else
|
||||
:icon="item.icon"
|
||||
class="text-xl"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -138,7 +166,7 @@ onMounted(() => {
|
|||
产品分类
|
||||
</span>
|
||||
<span class="truncate font-medium text-primary">
|
||||
{{ getCategoryName(item.categoryId) }}
|
||||
{{ getCategoryName(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2 flex items-center text-[13px]">
|
||||
|
|
@ -169,16 +197,10 @@ onMounted(() => {
|
|||
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
|
||||
>
|
||||
<Image
|
||||
v-if="item.picUrl"
|
||||
:src="item.picUrl"
|
||||
:src="getProductPic(item.picUrl)"
|
||||
:preview="true"
|
||||
class="size-full rounded object-cover"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="lucide:image"
|
||||
class="text-2xl opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 按钮组 -->
|
||||
|
|
|
|||
|
|
@ -179,9 +179,11 @@ function validate() {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** 取当前所有行的值 */
|
||||
/** 取当前所有行的值(剔除 identifierLoading 等仅供 UI 使用的临时字段) */
|
||||
function getData() {
|
||||
return formData.value;
|
||||
return formData.value.map(
|
||||
({ identifierLoading: _identifierLoading, ...rest }) => rest,
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置初始数据 */
|
||||
|
|
@ -214,6 +216,7 @@ defineExpose({ validate, getData, setData });
|
|||
v-model:value="formData[rowIndex].productId"
|
||||
placeholder="请选择产品"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
|
|
@ -230,6 +233,7 @@ defineExpose({ validate, getData, setData });
|
|||
v-model:value="formData[rowIndex].deviceId"
|
||||
placeholder="请选择设备"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
|
|
@ -251,6 +255,7 @@ defineExpose({ validate, getData, setData });
|
|||
v-model:value="formData[rowIndex].method"
|
||||
placeholder="请选择消息"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
|
|
@ -271,6 +276,7 @@ defineExpose({ validate, getData, setData });
|
|||
v-model:value="formData[rowIndex].identifier"
|
||||
placeholder="请选择标识符"
|
||||
show-search
|
||||
allow-clear
|
||||
:loading="formData[rowIndex].identifierLoading"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
|
|
|
|||
|
|
@ -16,15 +16,18 @@ const props = defineProps<{
|
|||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
interface KeyValueItem {
|
||||
_uid: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let uidCounter = 0;
|
||||
const items = ref<KeyValueItem[]>([]); // 内部 key-value 项列表
|
||||
|
||||
/** 添加项目 */
|
||||
function addItem() {
|
||||
items.value.push({ key: '', value: '' });
|
||||
uidCounter += 1;
|
||||
items.value.push({ _uid: uidCounter, key: '', value: '' });
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
|
|
@ -54,16 +57,16 @@ watch(
|
|||
if (isEmpty(val) || !isEmpty(items.value)) {
|
||||
return;
|
||||
}
|
||||
items.value = Object.entries(props.modelValue).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
items.value = Object.entries(props.modelValue).map(([key, value]) => {
|
||||
uidCounter += 1;
|
||||
return { _uid: uidCounter, key, value };
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(item, index) in items" :key="index" class="mb-2 flex w-full">
|
||||
<div v-for="(item, index) in items" :key="item._uid" class="mb-2 flex w-full">
|
||||
<Input v-model:value="item.key" class="mr-2" placeholder="键" />
|
||||
<Input v-model:value="item.value" placeholder="值" />
|
||||
<Button class="ml-2" type="link" danger @click="removeItem(index)">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
|
@ -30,14 +30,28 @@ const config = useVModel(props, 'modelValue', emit);
|
|||
const showSqlTip = ref(false);
|
||||
const copied = ref(false);
|
||||
const { copy } = useClipboard();
|
||||
let copyResetTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
async function handleCopySql() {
|
||||
await copy(TABLE_SQL);
|
||||
copied.value = true;
|
||||
message.success('建表 SQL 已复制到剪贴板');
|
||||
setTimeout(() => (copied.value = false), 2000);
|
||||
if (copyResetTimer) {
|
||||
clearTimeout(copyResetTimer);
|
||||
}
|
||||
copyResetTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
copyResetTimer = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyResetTimer) {
|
||||
clearTimeout(copyResetTimer);
|
||||
copyResetTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -21,21 +21,31 @@ const fullUrl = computed(() =>
|
|||
urlPath.value ? urlPrefix.value + urlPath.value : '',
|
||||
);
|
||||
|
||||
function syncUrlFields(url?: string) {
|
||||
if (url?.startsWith('https://')) {
|
||||
urlPrefix.value = 'https://';
|
||||
urlPath.value = url.slice(8);
|
||||
} else if (url?.startsWith('http://')) {
|
||||
urlPrefix.value = 'http://';
|
||||
urlPath.value = url.slice(7);
|
||||
} else {
|
||||
urlPath.value = url ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
watch([urlPrefix, urlPath], () => {
|
||||
config.value.url = fullUrl.value;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => config.value?.url,
|
||||
(url) => syncUrlFields(url),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
if (config.value.url?.startsWith('https://')) {
|
||||
urlPrefix.value = 'https://';
|
||||
urlPath.value = config.value.url.slice(8);
|
||||
} else if (config.value.url?.startsWith('http://')) {
|
||||
urlPrefix.value = 'http://';
|
||||
urlPath.value = config.value.url.slice(7);
|
||||
} else {
|
||||
urlPath.value = config.value.url ?? '';
|
||||
}
|
||||
syncUrlFields(config.value.url);
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,16 @@ const props = defineProps<{ modelValue: any }>();
|
|||
const emit = defineEmits(['update:modelValue']);
|
||||
const config = useVModel(props, 'modelValue', emit);
|
||||
|
||||
/** 移除当前 Redis Stream API 类型未声明的旧扩展字段 */
|
||||
function removeUnsupportedFields() {
|
||||
delete config.value.dataStructure;
|
||||
delete config.value.hashField;
|
||||
delete config.value.scoreField;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isEmpty(config.value)) {
|
||||
removeUnsupportedFields();
|
||||
return;
|
||||
}
|
||||
config.value = {
|
||||
|
|
@ -60,7 +68,11 @@ onMounted(() => {
|
|||
class="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item :name="['config', 'password']" label="密码">
|
||||
<Form.Item
|
||||
:name="['config', 'password']"
|
||||
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
|
||||
label="密码"
|
||||
>
|
||||
<Input.Password v-model:value="config.password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<!-- 当前时间条件配置组件 -->
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { RuleSceneApi } from '#/api/iot/rule/scene';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { IotRuleSceneTriggerTimeOperatorEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDayjs } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import {
|
||||
|
|
@ -126,11 +129,12 @@ function updateConditionField(field: any, value: any) {
|
|||
* 处理第一个时间值变化
|
||||
* @param value 时间值
|
||||
*/
|
||||
function handleTimeValueChange(value: string) {
|
||||
function handleTimeValueChange(value: Dayjs | null | string) {
|
||||
const normalized = formatDayjs(value);
|
||||
const currentParams = condition.value.param
|
||||
? condition.value.param.split(',')
|
||||
: [];
|
||||
currentParams[0] = value || '';
|
||||
currentParams[0] = normalized;
|
||||
|
||||
// 如果是范围条件,保留第二个值;否则只保留第一个值
|
||||
condition.value.param = needsSecondTimeInput.value
|
||||
|
|
@ -142,11 +146,12 @@ function handleTimeValueChange(value: string) {
|
|||
* 处理第二个时间值变化
|
||||
* @param value 时间值
|
||||
*/
|
||||
function handleTimeValue2Change(value: string) {
|
||||
function handleTimeValue2Change(value: Dayjs | null | string) {
|
||||
const normalized = formatDayjs(value);
|
||||
const currentParams = condition.value.param
|
||||
? condition.value.param.split(',')
|
||||
: [''];
|
||||
currentParams[1] = value || '';
|
||||
currentParams[1] = normalized;
|
||||
condition.value.param = currentParams.slice(0, 2).join(',');
|
||||
}
|
||||
|
||||
|
|
@ -175,8 +180,8 @@ watch(
|
|||
<Col :span="8">
|
||||
<Form.Item label="时间条件" required>
|
||||
<Select
|
||||
:model-value="condition.operator"
|
||||
@update:model-value="
|
||||
:value="condition.operator"
|
||||
@update:value="
|
||||
(value: any) => updateConditionField('operator', value)
|
||||
"
|
||||
placeholder="请选择时间条件"
|
||||
|
|
@ -207,8 +212,8 @@ watch(
|
|||
<Form.Item label="时间值" required>
|
||||
<TimePicker
|
||||
v-if="needsTimeInput"
|
||||
:model-value="timeValue"
|
||||
@update:model-value="handleTimeValueChange"
|
||||
:value="timeValue"
|
||||
@update:value="handleTimeValueChange"
|
||||
placeholder="请选择时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
|
|
@ -216,8 +221,8 @@ watch(
|
|||
/>
|
||||
<DatePicker
|
||||
v-else-if="needsDateInput"
|
||||
:model-value="timeValue"
|
||||
@update:model-value="handleTimeValueChange"
|
||||
:value="timeValue"
|
||||
@update:value="handleTimeValueChange"
|
||||
type="datetime"
|
||||
placeholder="请选择日期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
|
|
@ -233,8 +238,8 @@ watch(
|
|||
<Form.Item label="结束时间" required>
|
||||
<TimePicker
|
||||
v-if="needsTimeInput"
|
||||
:model-value="timeValue2"
|
||||
@update:model-value="handleTimeValue2Change"
|
||||
:value="timeValue2"
|
||||
@update:value="handleTimeValue2Change"
|
||||
placeholder="请选择结束时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
|
|
@ -242,8 +247,8 @@ watch(
|
|||
/>
|
||||
<DatePicker
|
||||
v-else
|
||||
:model-value="timeValue2"
|
||||
@update:model-value="handleTimeValue2Change"
|
||||
:value="timeValue2"
|
||||
@update:value="handleTimeValue2Change"
|
||||
type="datetime"
|
||||
placeholder="请选择结束日期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
|
|
|
|||
|
|
@ -69,18 +69,16 @@ const isServiceInvokeAction = computed(() => {
|
|||
/**
|
||||
* 处理产品变化事件
|
||||
* @param productId 产品 ID
|
||||
*
|
||||
* ProductSelector 只在用户主动切换时 emit change,编辑回填阶段不会触发。
|
||||
*/
|
||||
function handleProductChange(productId?: number) {
|
||||
// 当产品变化时,清空设备选择和参数配置
|
||||
if (action.value.productId !== productId) {
|
||||
action.value.deviceId = undefined;
|
||||
action.value.identifier = undefined; // 清空服务标识符
|
||||
action.value.params = '' as any; // 清空参数,保存为空字符串
|
||||
selectedService.value = null; // 清空选中的服务
|
||||
serviceList.value = []; // 清空服务列表
|
||||
}
|
||||
action.value.deviceId = undefined;
|
||||
action.value.identifier = undefined;
|
||||
action.value.params = '' as any;
|
||||
selectedService.value = null;
|
||||
serviceList.value = [];
|
||||
|
||||
// 加载新产品的物模型属性或服务列表
|
||||
if (productId) {
|
||||
if (isPropertySetAction.value) {
|
||||
loadThingModelProperties(productId);
|
||||
|
|
@ -94,11 +92,8 @@ function handleProductChange(productId?: number) {
|
|||
* 处理设备变化事件
|
||||
* @param deviceId 设备 ID
|
||||
*/
|
||||
function handleDeviceChange(deviceId?: number) {
|
||||
// 当设备变化时,清空参数配置
|
||||
if (action.value.deviceId !== deviceId) {
|
||||
action.value.params = '' as any; // 清空参数,保存为空字符串
|
||||
}
|
||||
function handleDeviceChange(_deviceId?: number) {
|
||||
action.value.params = '' as any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -256,14 +251,10 @@ function getDefaultValueForParam(param: any) {
|
|||
}
|
||||
}
|
||||
|
||||
const isInitialized = ref(false); // 防止重复初始化的标志
|
||||
|
||||
/**
|
||||
* 初始化组件数据
|
||||
*/
|
||||
async function initializeComponent() {
|
||||
if (isInitialized.value) return;
|
||||
|
||||
const currentAction = action.value;
|
||||
if (!currentAction) return;
|
||||
|
||||
|
|
@ -281,8 +272,6 @@ async function initializeComponent() {
|
|||
// 加载物模型TSL以获取服务信息
|
||||
await loadServiceFromTSL(currentAction.productId, currentAction.identifier);
|
||||
}
|
||||
|
||||
isInitialized.value = true;
|
||||
}
|
||||
|
||||
/** 组件初始化 */
|
||||
|
|
@ -294,9 +283,6 @@ onMounted(() => {
|
|||
watch(
|
||||
() => [action.value.productId, action.value.type, action.value.identifier],
|
||||
async ([newProductId, , newIdentifier], [oldProductId, , oldIdentifier]) => {
|
||||
// 避免初始化时的重复调用
|
||||
if (!isInitialized.value) return;
|
||||
|
||||
// 产品变化时重新加载数据
|
||||
if (newProductId !== oldProductId) {
|
||||
if (newProductId && isPropertySetAction.value) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { RuleSceneApi } from '#/api/iot/rule/scene';
|
|||
import { nextTick } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { getStableObjectKey } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Tag } from 'ant-design-vue';
|
||||
|
|
@ -183,7 +184,7 @@ function removeConditionGroup() {
|
|||
<div class="relative">
|
||||
<div
|
||||
v-for="(subGroup, subGroupIndex) in trigger.conditionGroups"
|
||||
:key="`sub-group-${subGroupIndex}`"
|
||||
:key="getStableObjectKey(subGroup)"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 子条件组容器(橙色主题) -->
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from '@vben/constants';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Col, Form, Row, Select } from 'ant-design-vue';
|
||||
import { Col, Form, Input, Row, Select } from 'ant-design-vue';
|
||||
|
||||
import JsonParamsInput from '../inputs/json-params-input.vue';
|
||||
import ValueInput from '../inputs/value-input.vue';
|
||||
|
|
@ -105,22 +105,6 @@ const serviceConfig = computed(() => {
|
|||
return undefined;
|
||||
});
|
||||
|
||||
// 计算属性:事件配置 - 用于 JsonParamsInput
|
||||
const eventConfig = computed(() => {
|
||||
if (
|
||||
propertyConfig.value &&
|
||||
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
|
||||
) {
|
||||
return {
|
||||
event: {
|
||||
name: propertyConfig.value.name || '事件',
|
||||
outputParams: propertyConfig.value.outputParams || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新条件字段
|
||||
* @param field 字段名
|
||||
|
|
@ -140,15 +124,24 @@ function handleTriggerTypeChange(type: number) {
|
|||
|
||||
/** 处理产品变化事件 */
|
||||
function handleProductChange() {
|
||||
// 产品变化时清空设备和属性
|
||||
condition.value.deviceId = undefined;
|
||||
condition.value.identifier = '';
|
||||
const trigger = condition.value;
|
||||
trigger.deviceId = undefined;
|
||||
trigger.identifier = '';
|
||||
trigger.operator = undefined;
|
||||
// 主条件比较值字段是 Trigger.value(不是 TriggerCondition.param)
|
||||
trigger.value = '';
|
||||
propertyType.value = '';
|
||||
propertyConfig.value = null;
|
||||
}
|
||||
|
||||
/** 处理设备变化事件 */
|
||||
function handleDeviceChange() {
|
||||
// 设备变化时清空属性
|
||||
condition.value.identifier = '';
|
||||
const trigger = condition.value;
|
||||
trigger.identifier = '';
|
||||
trigger.operator = undefined;
|
||||
trigger.value = '';
|
||||
propertyType.value = '';
|
||||
propertyConfig.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -266,15 +259,16 @@ function handlePropertyChange(propertyInfo: any) {
|
|||
:config="serviceConfig as any"
|
||||
placeholder="请输入 JSON 格式的服务参数"
|
||||
/>
|
||||
<!-- 事件上报参数配置 -->
|
||||
<JsonParamsInput
|
||||
<!-- 事件上报参数配置:源项目允许标量值或留空表示事件发生即匹配 -->
|
||||
<Input
|
||||
v-else-if="
|
||||
triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
|
||||
"
|
||||
v-model="condition.value"
|
||||
type="event"
|
||||
:config="eventConfig as any"
|
||||
placeholder="请输入 JSON 格式的事件参数"
|
||||
:value="condition.value"
|
||||
@update:value="
|
||||
(value) => updateConditionField('value', value)
|
||||
"
|
||||
placeholder="留空则事件发生即匹配"
|
||||
/>
|
||||
<!-- 普通值输入 -->
|
||||
<ValueInput
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
IotRuleSceneTriggerConditionTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { getStableObjectKey } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
|
@ -105,7 +106,7 @@ function updateCondition(
|
|||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(condition, conditionIndex) in subGroup"
|
||||
:key="`condition-${conditionIndex}`"
|
||||
:key="getStableObjectKey(condition)"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 条件配置 -->
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { nextTick } from 'vue';
|
|||
|
||||
import { IotRuleSceneTriggerTypeEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { getStableObjectKey } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Tag } from 'ant-design-vue';
|
||||
|
|
@ -96,7 +97,7 @@ function updateConditionGroup(
|
|||
>
|
||||
<div
|
||||
v-for="(group, groupIndex) in conditionGroups"
|
||||
:key="`group-${groupIndex}`"
|
||||
:key="getStableObjectKey(group)"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 条件组容器(橙色主题) -->
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
JsonParamsInputTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { isEmptyVal } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Input, Popover, Tag } from 'ant-design-vue';
|
||||
|
|
@ -219,39 +220,40 @@ const emptyMessage = computed(() => {
|
|||
|
||||
/**
|
||||
* 处理参数变化事件
|
||||
*
|
||||
* 注意:必须在所有校验(合法 JSON / 必须是对象 / 必填参数)通过后再回写
|
||||
* localValue,否则父表单仅校验非空时会先写入非法值再提示错误。
|
||||
*/
|
||||
function handleParamsChange() {
|
||||
try {
|
||||
jsonError.value = ''; // 清除之前的错误
|
||||
jsonError.value = '';
|
||||
|
||||
if (paramsJson.value.trim()) {
|
||||
const parsed = JSON.parse(paramsJson.value);
|
||||
localValue.value = paramsJson.value;
|
||||
|
||||
// 额外的参数验证
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT;
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证必填参数
|
||||
for (const param of paramsList.value) {
|
||||
if (
|
||||
param.required &&
|
||||
(!parsed[param.identifier] || parsed[param.identifier] === '')
|
||||
) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
|
||||
param.name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!paramsJson.value.trim()) {
|
||||
localValue.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证通过
|
||||
jsonError.value = '';
|
||||
const parsed = JSON.parse(paramsJson.value);
|
||||
|
||||
// 必须是对象
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT;
|
||||
return;
|
||||
}
|
||||
|
||||
// 必填参数校验
|
||||
for (const param of paramsList.value) {
|
||||
const value = parsed[param.identifier];
|
||||
if (param.required && isEmptyVal(value)) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
|
||||
param.name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 所有校验通过后才回写到父表单
|
||||
localValue.value = paramsJson.value;
|
||||
} catch (error) {
|
||||
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_ERROR(
|
||||
error instanceof Error
|
||||
|
|
@ -383,6 +385,9 @@ watch(
|
|||
|
||||
// 使用 nextTick 确保在下一个 tick 中处理数据
|
||||
await nextTick();
|
||||
if ((newValue || '') === paramsJson.value) {
|
||||
return;
|
||||
}
|
||||
handleDataDisplay(newValue || '');
|
||||
},
|
||||
{ immediate: true },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
IoTDataSpecsDataTypeEnum,
|
||||
IotRuleSceneTriggerConditionParameterOperatorEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import {
|
||||
|
|
@ -144,10 +145,46 @@ function handleNumberChange(value: any) {
|
|||
localValue.value = value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
/** 根据外部值同步内部输入态 */
|
||||
function syncInternalValue(value = '') {
|
||||
const normalized = value;
|
||||
if (
|
||||
props.operator ===
|
||||
IotRuleSceneTriggerConditionParameterOperatorEnum.BETWEEN.value
|
||||
) {
|
||||
const [start = '', end = ''] = normalized.split(',');
|
||||
rangeStart.value = start;
|
||||
rangeEnd.value = end;
|
||||
numberValue.value = undefined;
|
||||
dateValue.value = '';
|
||||
return;
|
||||
}
|
||||
rangeStart.value = '';
|
||||
rangeEnd.value = '';
|
||||
if (props.propertyType === IoTDataSpecsDataTypeEnum.DATE) {
|
||||
dateValue.value = normalized;
|
||||
numberValue.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (isNumericType()) {
|
||||
const parsed = Number(normalized);
|
||||
numberValue.value =
|
||||
normalized === '' || Number.isNaN(parsed) ? undefined : parsed;
|
||||
dateValue.value = '';
|
||||
return;
|
||||
}
|
||||
numberValue.value = undefined;
|
||||
dateValue.value = '';
|
||||
}
|
||||
|
||||
/** 监听操作符变化 */
|
||||
watch(
|
||||
() => props.operator,
|
||||
() => {
|
||||
(_operator, oldOperator) => {
|
||||
if (oldOperator === undefined) {
|
||||
syncInternalValue(props.modelValue);
|
||||
return;
|
||||
}
|
||||
localValue.value = '';
|
||||
rangeStart.value = '';
|
||||
rangeEnd.value = '';
|
||||
|
|
@ -155,6 +192,12 @@ watch(
|
|||
numberValue.value = undefined;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.propertyType] as const,
|
||||
([value]) => syncInternalValue(value),
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -166,8 +209,8 @@ watch(
|
|||
placeholder="请选择布尔值"
|
||||
class="w-full!"
|
||||
>
|
||||
<Select.Option :value="true">真 (true)</Select.Option>
|
||||
<Select.Option :value="false">假 (false)</Select.Option>
|
||||
<Select.Option value="true">真 (true)</Select.Option>
|
||||
<Select.Option value="false">假 (false)</Select.Option>
|
||||
</Select>
|
||||
|
||||
<!-- 枚举值选择 -->
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
IotRuleSceneActionTypeEnum,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { getStableObjectKey } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Card, Empty, Form, Select, Tag } from 'ant-design-vue';
|
||||
|
|
@ -183,7 +184,7 @@ function onActionTypeChange(action: RuleSceneApi.Action, type: number) {
|
|||
<div v-else class="space-y-[24px]">
|
||||
<div
|
||||
v-for="(action, index) in actions"
|
||||
:key="`action-${index}`"
|
||||
:key="getStableObjectKey(action)"
|
||||
class="rounded-lg border border-blue-200 bg-blue-50/40 shadow-sm transition-shadow hover:shadow-md dark:border-blue-900/40 dark:bg-blue-950/20"
|
||||
>
|
||||
<!-- 执行器头部(蓝色主题) -->
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
isDeviceTrigger,
|
||||
} from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { getStableObjectKey } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Card, Empty, Form, Tag } from 'ant-design-vue';
|
||||
|
|
@ -133,7 +134,7 @@ onMounted(() => {
|
|||
<div v-if="triggers.length > 0" class="space-y-[24px]">
|
||||
<div
|
||||
v-for="(triggerItem, index) in triggers"
|
||||
:key="`trigger-${index}`"
|
||||
:key="getStableObjectKey(triggerItem)"
|
||||
class="rounded-[8px] border border-green-200 bg-green-50/40 shadow-sm transition-shadow hover:shadow-md dark:border-green-900/40 dark:bg-green-950/20"
|
||||
>
|
||||
<!-- 触发器头部(绿色主题) -->
|
||||
|
|
|
|||
|
|
@ -55,16 +55,26 @@ async function getDeviceList() {
|
|||
// 监听产品变化
|
||||
watch(
|
||||
() => props.productId,
|
||||
(newProductId) => {
|
||||
if (newProductId) {
|
||||
getDeviceList();
|
||||
} else {
|
||||
async (newProductId, oldProductId) => {
|
||||
if (!newProductId) {
|
||||
deviceList.value = [];
|
||||
// 清空当前选择的设备
|
||||
if (props.modelValue) {
|
||||
if (props.modelValue !== undefined && props.modelValue !== null) {
|
||||
emit('update:modelValue', undefined);
|
||||
emit('change', undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await getDeviceList();
|
||||
// 切换到新 productId 时,旧 deviceId 不在新列表里则清空
|
||||
if (
|
||||
oldProductId !== undefined &&
|
||||
oldProductId !== newProductId &&
|
||||
props.modelValue !== undefined &&
|
||||
props.modelValue !== null &&
|
||||
!deviceList.value.some((d: any) => d.id === props.modelValue)
|
||||
) {
|
||||
emit('update:modelValue', undefined);
|
||||
emit('change', undefined);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ onMounted(() => {
|
|||
{{ product.productKey }}
|
||||
</div>
|
||||
</div>
|
||||
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="product.status" />
|
||||
<DictTag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
|
||||
</div>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ const TRIGGER_TYPE_TO_GROUP: Record<
|
|||
label: THING_MODEL_GROUP_LABELS.PROPERTY,
|
||||
modelType: IoTThingModelTypeEnum.PROPERTY,
|
||||
},
|
||||
[IotRuleSceneTriggerTypeEnum.TIMER]: {
|
||||
label: THING_MODEL_GROUP_LABELS.PROPERTY,
|
||||
modelType: IoTThingModelTypeEnum.PROPERTY,
|
||||
},
|
||||
[IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST]: {
|
||||
label: THING_MODEL_GROUP_LABELS.EVENT,
|
||||
modelType: IoTThingModelTypeEnum.EVENT,
|
||||
|
|
|
|||
|
|
@ -178,10 +178,10 @@ function getNextExecutionTime(row: RuleSceneApi.SceneRule): Date | null {
|
|||
: null;
|
||||
}
|
||||
|
||||
/** 基于当前页列表刷新统计数据 */
|
||||
function updateStatistics(rows: RuleSceneApi.SceneRule[]) {
|
||||
/** 刷新规则统计卡片数据 */
|
||||
function updateStatistics(rows: RuleSceneApi.SceneRule[], total?: number) {
|
||||
statistics.value = {
|
||||
total: rows.length,
|
||||
total: total ?? rows.length,
|
||||
enabled: rows.filter((item) => item.status === CommonStatusEnum.ENABLE)
|
||||
.length,
|
||||
disabled: rows.filter((item) => item.status === CommonStatusEnum.DISABLE)
|
||||
|
|
@ -206,7 +206,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
updateStatistics(result.list || []);
|
||||
updateStatistics(result.list || [], result.total);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ import { useVbenDrawer } from '@vben/common-ui';
|
|||
import {
|
||||
CommonStatusEnum,
|
||||
IotRuleSceneActionTypeEnum,
|
||||
IotRuleSceneTriggerConditionTypeEnum,
|
||||
IotRuleSceneTriggerTimeOperatorEnum,
|
||||
IotRuleSceneTriggerTypeEnum,
|
||||
isDeviceTrigger,
|
||||
} from '@vben/constants';
|
||||
import { CronUtils } from '@vben/utils';
|
||||
|
||||
import { Form, message } from 'ant-design-vue';
|
||||
|
||||
|
|
@ -101,12 +104,14 @@ function buildEmptyFormData(): RuleSceneApi.SceneRule {
|
|||
|
||||
/** 回显时兜底,保证触发器/执行器数组不为空 */
|
||||
function normalizeFormData(result: any): RuleSceneApi.SceneRule {
|
||||
const triggers: RuleSceneApi.Trigger[] = result.triggers?.length
|
||||
? result.triggers
|
||||
: buildEmptyFormData().triggers!;
|
||||
const actions: RuleSceneApi.Action[] = result.actions || [];
|
||||
return {
|
||||
...result,
|
||||
triggers: result.triggers?.length
|
||||
? result.triggers
|
||||
: buildEmptyFormData().triggers,
|
||||
actions: result.actions || [],
|
||||
triggers,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -126,11 +131,14 @@ function validateTriggers(_rule: any, value: any, callback: any) {
|
|||
callback(new Error(`触发器 ${i + 1}:产品不能为空`));
|
||||
return;
|
||||
}
|
||||
if (!trigger.deviceId) {
|
||||
// deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES),是合法值;仅 undefined / null 视为未选
|
||||
if (trigger.deviceId === undefined || trigger.deviceId === null) {
|
||||
callback(new Error(`触发器 ${i + 1}:设备不能为空`));
|
||||
return;
|
||||
}
|
||||
if (!trigger.identifier) {
|
||||
const isStateUpdate =
|
||||
trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE;
|
||||
if (!isStateUpdate && !trigger.identifier) {
|
||||
callback(new Error(`触发器 ${i + 1}:物模型标识符不能为空`));
|
||||
return;
|
||||
}
|
||||
|
|
@ -153,12 +161,98 @@ function validateTriggers(_rule: any, value: any, callback: any) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
trigger.type === IotRuleSceneTriggerTypeEnum.TIMER &&
|
||||
!trigger.cronExpression
|
||||
) {
|
||||
callback(new Error(`触发器 ${i + 1}:CRON 表达式不能为空`));
|
||||
return;
|
||||
if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) {
|
||||
if (!trigger.cronExpression) {
|
||||
callback(new Error(`触发器 ${i + 1}:CRON 表达式不能为空`));
|
||||
return;
|
||||
}
|
||||
if (!CronUtils.validate(trigger.cronExpression)) {
|
||||
callback(new Error(`触发器 ${i + 1}:CRON 表达式格式不正确`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 递归校验 conditionGroups(嵌套条件组)
|
||||
if (trigger.conditionGroups?.length) {
|
||||
for (const [gi, group] of trigger.conditionGroups.entries()) {
|
||||
if (!Array.isArray(group) || group.length === 0) {
|
||||
callback(
|
||||
new Error(`触发器 ${i + 1}:条件组 ${gi + 1} 不能为空`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const [ci, condition] of group.entries()) {
|
||||
const prefix = `触发器 ${i + 1} 条件组 ${gi + 1} 条件 ${ci + 1}`;
|
||||
if (!condition.type) {
|
||||
callback(new Error(`${prefix}:条件类型不能为空`));
|
||||
return;
|
||||
}
|
||||
const isDeviceStatus =
|
||||
condition.type ===
|
||||
IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS;
|
||||
const isDeviceProperty =
|
||||
condition.type ===
|
||||
IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY;
|
||||
const isCurrentTime =
|
||||
condition.type ===
|
||||
IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME;
|
||||
if (isDeviceStatus || isDeviceProperty) {
|
||||
if (!condition.productId) {
|
||||
callback(new Error(`${prefix}:产品不能为空`));
|
||||
return;
|
||||
}
|
||||
// deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES),是合法值
|
||||
if (
|
||||
condition.deviceId === undefined ||
|
||||
condition.deviceId === null
|
||||
) {
|
||||
callback(new Error(`${prefix}:设备不能为空`));
|
||||
return;
|
||||
}
|
||||
if (isDeviceProperty && !condition.identifier) {
|
||||
callback(new Error(`${prefix}:物模型标识符不能为空`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!condition.operator) {
|
||||
callback(new Error(`${prefix}:操作符不能为空`));
|
||||
return;
|
||||
}
|
||||
// 设备状态:param 是状态值(必填);设备属性:param 是比较值(必填)
|
||||
if (
|
||||
(isDeviceStatus || isDeviceProperty) &&
|
||||
(condition.param === undefined ||
|
||||
condition.param === null ||
|
||||
condition.param === '')
|
||||
) {
|
||||
callback(
|
||||
new Error(
|
||||
`${prefix}:${isDeviceStatus ? '设备状态' : '比较值'}不能为空`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 当前时间:TODAY 不需要 param;BETWEEN_TIME 需要双段「v1,v2」;其它需要单段
|
||||
if (isCurrentTime) {
|
||||
const op = condition.operator;
|
||||
if (op === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value) {
|
||||
// TODAY 无需 param
|
||||
} else if (
|
||||
op === IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value
|
||||
) {
|
||||
const parts = condition.param
|
||||
? String(condition.param).split(',')
|
||||
: [];
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) {
|
||||
callback(new Error(`${prefix}:起止时间不能为空`));
|
||||
return;
|
||||
}
|
||||
} else if (!condition.param) {
|
||||
callback(new Error(`${prefix}:时间值不能为空`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
callback();
|
||||
|
|
@ -183,7 +277,10 @@ function validateActions(_rule: any, value: any, callback: any) {
|
|||
callback(new Error(`执行器 ${i + 1}:产品不能为空`));
|
||||
return;
|
||||
}
|
||||
if (!action.deviceId) {
|
||||
// deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES);
|
||||
// 后端 IotDevicePropertySetSceneRuleAction / IotDeviceServiceInvokeSceneRuleAction
|
||||
// 均支持广播执行,因此 0 是合法值,仅 undefined / null 视为未选
|
||||
if (action.deviceId === undefined || action.deviceId === null) {
|
||||
callback(new Error(`执行器 ${i + 1}:设备不能为空`));
|
||||
return;
|
||||
}
|
||||
|
|
@ -199,9 +296,9 @@ function validateActions(_rule: any, value: any, callback: any) {
|
|||
return;
|
||||
}
|
||||
}
|
||||
// 仅恢复告警动作需要选择已有告警配置;触发告警动作不需要预选 alertConfigId
|
||||
if (
|
||||
(action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER ||
|
||||
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER) &&
|
||||
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER &&
|
||||
!action.alertConfigId
|
||||
) {
|
||||
callback(new Error(`执行器 ${i + 1}:告警配置不能为空`));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import type { ThingModelApi } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
getEventTypeLabel,
|
||||
getThingModelServiceCallTypeLabel,
|
||||
|
|
@ -10,9 +8,7 @@ import {
|
|||
IoTThingModelTypeEnum,
|
||||
} from '@vben/constants';
|
||||
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps<{ data: ThingModelApi.ThingModel }>();
|
||||
defineProps<{ data: ThingModelApi.ThingModel }>();
|
||||
const NUMBER_TYPES = new Set<string>([
|
||||
IoTDataSpecsDataTypeEnum.DOUBLE,
|
||||
IoTDataSpecsDataTypeEnum.FLOAT,
|
||||
|
|
@ -27,26 +23,6 @@ const LIST_TYPES = new Set<string>([
|
|||
IoTDataSpecsDataTypeEnum.BOOL,
|
||||
IoTDataSpecsDataTypeEnum.ENUM,
|
||||
]);
|
||||
|
||||
const formattedDataSpecsList = computed(() => {
|
||||
if (!props.data.property?.dataSpecsList?.length) {
|
||||
return '';
|
||||
}
|
||||
return props.data.property.dataSpecsList
|
||||
.map((item) => `${item.value}-${item.name}`)
|
||||
.join('、');
|
||||
});
|
||||
|
||||
const shortText = computed(() => {
|
||||
const list = props.data.property?.dataSpecsList;
|
||||
if (!list?.length) {
|
||||
return '-';
|
||||
}
|
||||
const first = list[0];
|
||||
return list.length > 1
|
||||
? `${first.value}-${first.name} 等 ${list.length} 项`
|
||||
: `${first.value}-${first.name}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -62,17 +38,19 @@ const shortText = computed(() => {
|
|||
</div>
|
||||
<div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div>
|
||||
<div v-if="LIST_TYPES.has(data.property?.dataType as any)">
|
||||
<Tooltip :title="formattedDataSpecsList" placement="topLeft">
|
||||
<span
|
||||
class="cursor-help border-b border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-500"
|
||||
>
|
||||
{{
|
||||
data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL
|
||||
? '布尔值'
|
||||
: '枚举值'
|
||||
}}:{{ shortText }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div>
|
||||
{{
|
||||
data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL
|
||||
? '布尔值'
|
||||
: '枚举值'
|
||||
}}:
|
||||
</div>
|
||||
<div
|
||||
v-for="item in data.property?.dataSpecsList || []"
|
||||
:key="String(item.value)"
|
||||
>
|
||||
{{ item.name }}-{{ item.value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 服务 -->
|
||||
|
|
|
|||
|
|
@ -25,12 +25,11 @@ const childDataTypeOptions = getDataTypeOptions().filter(
|
|||
|
||||
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>;
|
||||
|
||||
/** 元素类型切到 struct 时,初始化 dataSpecsList 占位 */
|
||||
function handleChange(val: any) {
|
||||
if (val !== IoTDataSpecsDataTypeEnum.STRUCT) {
|
||||
return;
|
||||
}
|
||||
/** 元素类型切换时,清理旧子类型的结构体属性配置 */
|
||||
function handleChange(e: any) {
|
||||
const val = e?.target?.value ?? e;
|
||||
dataSpecs.value.dataSpecsList = [];
|
||||
dataSpecs.value.childDataType = val;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -65,5 +64,6 @@ function handleChange(val: any) {
|
|||
<ThingModelStructDataSpecs
|
||||
v-if="dataSpecs.childDataType === IoTDataSpecsDataTypeEnum.STRUCT"
|
||||
v-model="dataSpecs.dataSpecsList"
|
||||
:field-path="['property', 'dataSpecs', 'dataSpecsList']"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
|
@ -17,7 +18,11 @@ const validateEnumName = buildIdentifierLikeNameValidator('枚举描述');
|
|||
|
||||
/** 添加枚举项 */
|
||||
function addEnum() {
|
||||
dataSpecsList.value.push({ name: '', value: '' } as any);
|
||||
dataSpecsList.value.push({
|
||||
dataType: IoTDataSpecsDataTypeEnum.ENUM,
|
||||
name: '',
|
||||
value: '',
|
||||
} as any);
|
||||
}
|
||||
|
||||
/** 删除枚举项 */
|
||||
|
|
|
|||
|
|
@ -34,24 +34,99 @@ function unitChange(unitSpecs: any) {
|
|||
dataSpecs.value.unitName = unitName;
|
||||
dataSpecs.value.unit = unit;
|
||||
}
|
||||
|
||||
/** 校验最小值 */
|
||||
function validateMin(_rule: any, _value: any, callback: any) {
|
||||
const min = Number(dataSpecs.value.min);
|
||||
const max = Number(dataSpecs.value.max);
|
||||
if (Number.isNaN(min)) {
|
||||
callback(new Error('请输入有效的数值'));
|
||||
return;
|
||||
}
|
||||
if (!Number.isNaN(max) && min >= max) {
|
||||
callback(new Error('最小值必须小于最大值'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
/** 校验最大值 */
|
||||
function validateMax(_rule: any, _value: any, callback: any) {
|
||||
const min = Number(dataSpecs.value.min);
|
||||
const max = Number(dataSpecs.value.max);
|
||||
if (Number.isNaN(max)) {
|
||||
callback(new Error('请输入有效的数值'));
|
||||
return;
|
||||
}
|
||||
if (!Number.isNaN(min) && max <= min) {
|
||||
callback(new Error('最大值必须大于最小值'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
/** 校验步长 */
|
||||
function validateStep(_rule: any, _value: any, callback: any) {
|
||||
const step = Number(dataSpecs.value.step);
|
||||
if (Number.isNaN(step)) {
|
||||
callback(new Error('请输入有效的数值'));
|
||||
return;
|
||||
}
|
||||
if (step <= 0) {
|
||||
callback(new Error('步长必须大于 0'));
|
||||
return;
|
||||
}
|
||||
const min = Number(dataSpecs.value.min);
|
||||
const max = Number(dataSpecs.value.max);
|
||||
if (!Number.isNaN(min) && !Number.isNaN(max) && step > max - min) {
|
||||
callback(new Error('步长不能大于最大值与最小值的差值'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form.Item label="取值范围">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<Form.Item
|
||||
:name="['property', 'dataSpecs', 'min']"
|
||||
:rules="[
|
||||
{ required: true, message: '最小值不能为空', trigger: 'blur' },
|
||||
{ validator: validateMin, trigger: 'blur' },
|
||||
]"
|
||||
class="mb-0 flex-1"
|
||||
>
|
||||
<Input v-model:value="dataSpecs.min" placeholder="请输入最小值" />
|
||||
</div>
|
||||
</Form.Item>
|
||||
<span class="mx-2">~</span>
|
||||
<div class="flex-1">
|
||||
<Form.Item
|
||||
:name="['property', 'dataSpecs', 'max']"
|
||||
:rules="[
|
||||
{ required: true, message: '最大值不能为空', trigger: 'blur' },
|
||||
{ validator: validateMax, trigger: 'blur' },
|
||||
]"
|
||||
class="mb-0 flex-1"
|
||||
>
|
||||
<Input v-model:value="dataSpecs.max" placeholder="请输入最大值" />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="步长">
|
||||
<Form.Item
|
||||
:name="['property', 'dataSpecs', 'step']"
|
||||
:rules="[
|
||||
{ required: true, message: '步长不能为空', trigger: 'blur' },
|
||||
{ validator: validateStep, trigger: 'blur' },
|
||||
]"
|
||||
label="步长"
|
||||
>
|
||||
<Input v-model:value="dataSpecs.step" placeholder="请输入步长" />
|
||||
</Form.Item>
|
||||
<Form.Item label="单位">
|
||||
<Form.Item
|
||||
:name="['property', 'dataSpecs', 'unit']"
|
||||
:rules="[{ required: true, message: '请选择单位', trigger: 'change' }]"
|
||||
label="单位"
|
||||
>
|
||||
<Select
|
||||
:value="dataSpecs.unit ? `${dataSpecs.unitName}-${dataSpecs.unit}` : ''"
|
||||
show-search
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { onMounted, ref } from 'vue';
|
|||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
import { cloneDeep, isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Divider, Form, Input } from 'ant-design-vue';
|
||||
|
|
@ -14,13 +14,33 @@ import { ThingModelFormRules } from '#/api/iot/thingmodel';
|
|||
|
||||
import ThingModelProperty from '../property.vue';
|
||||
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 父表单中 dataSpecsList 的 name 路径,默认 property.dataSpecsList;
|
||||
* array 嵌套 struct 时父级需传 ['property', 'dataSpecs', 'dataSpecsList'],
|
||||
* 否则父表单 validate() 无法定位该字段,非空校验不会触发。 */
|
||||
fieldPath?: string[];
|
||||
modelValue: any;
|
||||
}>(),
|
||||
{
|
||||
fieldPath: () => ['property', 'dataSpecsList'],
|
||||
},
|
||||
);
|
||||
const emits = defineEmits(['update:modelValue']);
|
||||
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
|
||||
|
||||
const structFormRef = ref();
|
||||
const formData = ref<any>(buildEmptyFormData());
|
||||
|
||||
/** 校验结构体属性对象非空 */
|
||||
function validateStructSpecsList(_rule: any, _value: any, callback: any) {
|
||||
if (isEmpty(dataSpecsList.value)) {
|
||||
callback(new Error('请至少添加一个结构体属性对象'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
try {
|
||||
|
|
@ -64,14 +84,15 @@ const [Modal, modalApi] = useVbenModal({
|
|||
if (isEmpty(data)) {
|
||||
return;
|
||||
}
|
||||
// 编辑回显时 cloneDeep,避免弹窗 v-model 改到原始对象(用户取消时不污染外层 dataSpecsList)
|
||||
formData.value = {
|
||||
identifier: data.identifier ?? '',
|
||||
name: data.name ?? '',
|
||||
description: data.description ?? '',
|
||||
property: {
|
||||
dataType: data.childDataType ?? IoTDataSpecsDataTypeEnum.INT,
|
||||
dataSpecs: data.dataSpecs ?? {},
|
||||
dataSpecsList: data.dataSpecsList ?? [],
|
||||
dataSpecs: data.dataSpecs ? cloneDeep(data.dataSpecs) : {},
|
||||
dataSpecsList: data.dataSpecsList ? cloneDeep(data.dataSpecsList) : [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -109,7 +130,11 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Form.Item label="属性对象">
|
||||
<Form.Item
|
||||
:name="fieldPath"
|
||||
:rules="[{ validator: validateStructSpecsList, trigger: 'change' }]"
|
||||
label="属性对象"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in dataSpecsList"
|
||||
:key="index"
|
||||
|
|
|
|||
|
|
@ -148,9 +148,6 @@ function fillExtraAttributes(data: any) {
|
|||
data.dataType = data.event.dataType;
|
||||
data.event.identifier = data.identifier;
|
||||
data.event.name = data.name;
|
||||
if (isEmpty(data.event.outputParams)) {
|
||||
delete data.event.outputParams;
|
||||
}
|
||||
delete data.property;
|
||||
delete data.service;
|
||||
|
||||
|
|
@ -171,12 +168,6 @@ function fillExtraAttributes(data: any) {
|
|||
data.dataType = data.service.dataType;
|
||||
data.service.identifier = data.identifier;
|
||||
data.service.name = data.name;
|
||||
if (isEmpty(data.service.inputParams)) {
|
||||
delete data.service.inputParams;
|
||||
}
|
||||
if (isEmpty(data.service.outputParams)) {
|
||||
delete data.service.outputParams;
|
||||
}
|
||||
delete data.property;
|
||||
delete data.event;
|
||||
|
||||
|
|
@ -225,7 +216,10 @@ function removeDataSpecs(val: any) {
|
|||
label="标识符"
|
||||
name="identifier"
|
||||
>
|
||||
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
|
||||
<Input
|
||||
v-model:value="formData.identifier"
|
||||
placeholder="请输入标识符"
|
||||
/>
|
||||
</Form.Item>
|
||||
<!-- 属性配置 -->
|
||||
<ThingModelProperty
|
||||
|
|
|
|||
|
|
@ -5,16 +5,20 @@ import { ref } from 'vue';
|
|||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
import { cloneDeep, isEmpty } from '@vben/utils';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Button, Divider, Form, Input } from 'ant-design-vue';
|
||||
import { Button, Divider, Form, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { ThingModelFormRules } from '#/api/iot/thingmodel';
|
||||
|
||||
import ThingModelProperty from './property.vue';
|
||||
|
||||
const props = defineProps<{ direction: string; modelValue: any }>();
|
||||
const props = defineProps<{
|
||||
direction: string;
|
||||
existingIdentifiers?: string[];
|
||||
modelValue: any;
|
||||
}>();
|
||||
const emits = defineEmits(['update:modelValue']);
|
||||
const thingModelParams = useVModel(props, 'modelValue', emits) as Ref<any[]>;
|
||||
|
||||
|
|
@ -33,6 +37,13 @@ const [Modal, modalApi] = useVbenModal({
|
|||
}
|
||||
// 组装表单
|
||||
const data = formData.value;
|
||||
if (
|
||||
data.identifier &&
|
||||
props.existingIdentifiers?.includes(data.identifier)
|
||||
) {
|
||||
message.warning('输入参数和输出参数标识符不能重复');
|
||||
return;
|
||||
}
|
||||
const item = {
|
||||
identifier: data.identifier,
|
||||
name: data.name,
|
||||
|
|
@ -72,15 +83,15 @@ const [Modal, modalApi] = useVbenModal({
|
|||
if (isEmpty(data)) {
|
||||
return;
|
||||
}
|
||||
// 设置到 values
|
||||
// 编辑回显时 cloneDeep,避免弹窗 v-model 改到原始对象(用户取消时不污染外层 thingModelParams)
|
||||
formData.value = {
|
||||
identifier: data.identifier ?? '',
|
||||
name: data.name ?? '',
|
||||
description: data.description ?? '',
|
||||
property: {
|
||||
dataType: data.dataType ?? IoTDataSpecsDataTypeEnum.INT,
|
||||
dataSpecs: data.dataSpecs ?? {},
|
||||
dataSpecsList: data.dataSpecsList ?? [],
|
||||
dataSpecs: data.dataSpecs ? cloneDeep(data.dataSpecs) : {},
|
||||
dataSpecsList: data.dataSpecsList ? cloneDeep(data.dataSpecsList) : [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ watch(
|
|||
(service.value.callType = IoTThingModelServiceCallTypeEnum.ASYNC.value),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 提取参数标识符列表,用于输入 / 输出参数跨表去重 */
|
||||
function getParamIdentifiers(params?: any[]) {
|
||||
const identifiers: string[] = [];
|
||||
for (const item of params || []) {
|
||||
if (item.identifier) {
|
||||
identifiers.push(item.identifier);
|
||||
}
|
||||
}
|
||||
return identifiers;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -51,12 +62,14 @@ watch(
|
|||
<ThingModelInputOutputParam
|
||||
v-model="service.inputParams"
|
||||
:direction="IoTThingModelParamDirectionEnum.INPUT"
|
||||
:existing-identifiers="getParamIdentifiers(service.outputParams)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="输出参数">
|
||||
<ThingModelInputOutputParam
|
||||
v-model="service.outputParams"
|
||||
:direction="IoTThingModelParamDirectionEnum.OUTPUT"
|
||||
:existing-identifiers="getParamIdentifiers(service.inputParams)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
<script lang="ts" setup>
|
||||
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { SolarDay } from 'tyme4ts';
|
||||
|
||||
import { MesCalShiftTypeEnum } from '#/views/mes/utils/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
calendarDayMap: Map<string, MesCalCalendarApi.CalendarDay>; // 排班数据
|
||||
day: string; // 日期,格式 yyyy-MM-dd
|
||||
holidaySet: Set<string>; // 节假日集合
|
||||
}>();
|
||||
|
||||
const dayNumber = computed(() => props.day.split('-')[2] || ''); // 取日期中的"日"部分展示
|
||||
const isHoliday = computed(() => props.holidaySet.has(props.day));
|
||||
const isWeekend = computed(() => {
|
||||
const weekday = dayjs(props.day).day();
|
||||
return weekday === 0 || weekday === 6; // 0=周日,6=周六
|
||||
});
|
||||
|
||||
const calDay = computed(() => props.calendarDayMap.get(props.day));
|
||||
const teamShifts = computed(() => calDay.value?.teamShifts || []);
|
||||
const shiftType = computed(() => calDay.value?.shiftType);
|
||||
|
||||
/**
|
||||
* 班次标签展示数据:根据 sort 与 shiftType 推导背景色
|
||||
*
|
||||
* 配色规则(sort 对应轮班方式中的班次顺序):
|
||||
* sort=1 白班 → 绿色(#95d475)
|
||||
* sort=2 中班 → 三班倒用橙色(#f0a020),两班倒用灰色(#909399)
|
||||
* sort=3 夜班 → 灰色(#909399)
|
||||
*/
|
||||
const displayShifts = computed(() => {
|
||||
const isThreeShift = shiftType.value === MesCalShiftTypeEnum.THREE;
|
||||
const colorMap: Record<number, string> = {
|
||||
1: 'bg-[#95d475]',
|
||||
2: isThreeShift ? 'bg-[#f0a020]' : 'bg-[#909399]',
|
||||
3: 'bg-[#909399]',
|
||||
};
|
||||
return teamShifts.value
|
||||
.map((item) => {
|
||||
const bgClass = colorMap[item.sort ?? -1];
|
||||
if (!bgClass) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bgClass,
|
||||
key: `${item.teamId}-${item.shiftId}`,
|
||||
label: `${item.shiftName} · ${item.teamName}`,
|
||||
};
|
||||
})
|
||||
.filter((v): v is { bgClass: string; key: string; label: string } => !!v);
|
||||
});
|
||||
|
||||
/** 解析当天的农历、节气、节日信息 */
|
||||
const lunarInfo = computed(() => {
|
||||
const [year, month, date] = props.day.split('-').map(Number);
|
||||
try {
|
||||
const solarDay = SolarDay.fromYmd(year!, month!, date!);
|
||||
const lunarDay = solarDay.getLunarDay();
|
||||
const solarFestival = solarDay.getFestival(); // 公历节日,如 元旦
|
||||
const lunarFestival = lunarDay.getFestival(); // 农历节日,如 春节
|
||||
const termDay = solarDay.getTermDay();
|
||||
const termName =
|
||||
termDay.getDayIndex() === 0 ? termDay.getSolarTerm().getName() : '';
|
||||
const lunarMonthName = lunarDay.getLunarMonth().getName();
|
||||
const lunarDayName = lunarDay.getName();
|
||||
return {
|
||||
lunarFestival: lunarFestival ? lunarFestival.getName() : '',
|
||||
lunarText: lunarMonthName + lunarDayName,
|
||||
solarFestival: solarFestival ? solarFestival.getName() : '',
|
||||
termName,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
lunarFestival: '',
|
||||
lunarText: '',
|
||||
solarFestival: '',
|
||||
termName: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/** 优先级:公历节日 > 农历节日 > 节气 > 农历月日 */
|
||||
const lunarDisplay = computed(() => {
|
||||
const info = lunarInfo.value;
|
||||
return (
|
||||
info.solarFestival || info.lunarFestival || info.termName || info.lunarText
|
||||
);
|
||||
});
|
||||
|
||||
/** 当天是否有节日或节气(用于高亮显示农历文字) */
|
||||
const hasFestivalDay = computed(() => {
|
||||
const info = lunarInfo.value;
|
||||
return Boolean(info.solarFestival || info.lunarFestival || info.termName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden p-1">
|
||||
<!-- 顶部:日期数字 + 上班/休息标签 -->
|
||||
<div class="flex shrink-0 items-center justify-between">
|
||||
<span class="text-base font-medium" :class="{ 'text-red-500': isWeekend }">
|
||||
{{ dayNumber }}
|
||||
</span>
|
||||
<Tag v-if="isHoliday" color="green" class="!m-0"> 休 </Tag>
|
||||
<Tag v-else color="blue" class="!m-0"> 班 </Tag>
|
||||
</div>
|
||||
<!-- 农历 / 节气 / 节日显示 -->
|
||||
<div
|
||||
class="mt-0.5 shrink-0 truncate text-[11px]"
|
||||
:class="hasFestivalDay ? 'text-green-600' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ lunarDisplay }}
|
||||
</div>
|
||||
<!--
|
||||
班次列表:节假日不显示排班
|
||||
配色规则与背景类由 displayShifts 计算
|
||||
-->
|
||||
<div v-if="!isHoliday" class="mt-0.5 flex flex-col gap-px overflow-hidden">
|
||||
<div
|
||||
v-for="shift in displayShifts"
|
||||
:key="shift.key"
|
||||
class="block w-full truncate rounded-sm px-1 py-px text-[11px] leading-normal text-white"
|
||||
:class="shift.bgClass"
|
||||
>
|
||||
{{ shift.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts" setup>
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
/** 配色说明项:色块 + 文案 */
|
||||
const legendItems: Array<{ color: string; label: string }> = [
|
||||
{ color: 'bg-[#95d475]', label: '白班' },
|
||||
{ color: 'bg-[#f0a020]', label: '中班(三班倒)' },
|
||||
{ color: 'bg-[#909399]', label: '中班(两班倒)/ 夜班' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 px-1 py-2 text-xs"
|
||||
>
|
||||
<span class="shrink-0">配色说明:</span>
|
||||
<span
|
||||
v-for="item in legendItems"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
:class="item.color"
|
||||
></span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-sm bg-[#f56c6c] opacity-60"></span>
|
||||
<span class="text-red-500">红色日期</span>
|
||||
= 周末
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Tag color="green" class="!m-0"> 休 </Tag>
|
||||
= 节假日(不显示排班)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
|
||||
|
||||
import { Button, Calendar, Spin } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import CalendarDateCell from './calendar-date-cell.vue';
|
||||
import CalendarLegend from './calendar-legend.vue';
|
||||
|
||||
defineProps<{
|
||||
calendarDayMap: Map<string, MesCalCalendarApi.CalendarDay>;
|
||||
holidaySet: Set<string>;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const currentDate = defineModel<Dayjs>('currentDate', { required: true });
|
||||
|
||||
/** 切换到上月 */
|
||||
function handlePrevMonth() {
|
||||
currentDate.value = currentDate.value.subtract(1, 'month');
|
||||
}
|
||||
|
||||
/** 切换到下月 */
|
||||
function handleNextMonth() {
|
||||
currentDate.value = currentDate.value.add(1, 'month');
|
||||
}
|
||||
|
||||
/** 切换到今天 */
|
||||
function handleToday() {
|
||||
currentDate.value = dayjs();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<CalendarLegend />
|
||||
<Spin :spinning="loading" wrapper-class-name="block">
|
||||
<div class="bg-card overflow-hidden rounded-md">
|
||||
<Calendar v-model:value="currentDate" class="mes-calendar-panel">
|
||||
<template #headerRender>
|
||||
<div class="flex items-center justify-between p-3">
|
||||
<div class="text-base font-medium">
|
||||
{{ currentDate.format('YYYY 年 MM 月') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="handlePrevMonth">上月</Button>
|
||||
<Button @click="handleToday">今天</Button>
|
||||
<Button @click="handleNextMonth">下月</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #dateFullCellRender="{ current: date }">
|
||||
<div class="h-[110px] text-left">
|
||||
<CalendarDateCell
|
||||
v-if="date.isSame(currentDate, 'month')"
|
||||
:calendar-day-map="calendarDayMap"
|
||||
:day="date.format('YYYY-MM-DD')"
|
||||
:holiday-set="holidaySet"
|
||||
/>
|
||||
<div v-else class="text-muted-foreground/50 p-1 text-base">
|
||||
{{ date.format('DD') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Calendar>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
仅保留访问 Ant Design Calendar 内部 DOM 的样式,
|
||||
其余已通过 Tailwind 工具类实现
|
||||
-->
|
||||
<style lang="scss" scoped>
|
||||
.mes-calendar-panel {
|
||||
:deep(.ant-picker-content) {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
|
||||
th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-picker-cell) {
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getCalendarList } from '#/api/mes/cal/calendar';
|
||||
import { getHolidayList } from '#/api/mes/cal/holiday';
|
||||
import { HolidayType } from '#/views/mes/utils/constants';
|
||||
|
||||
/**
|
||||
* 排班日历通用 composable
|
||||
*
|
||||
* 封装日历组件中通用的响应式状态、假期加载、排班查询、月份切换逻辑
|
||||
*/
|
||||
export function useCalendar() {
|
||||
const loading = ref(false);
|
||||
const currentDate = ref<Dayjs>(dayjs());
|
||||
const calendarDayMap = ref<Map<string, MesCalCalendarApi.CalendarDay>>(
|
||||
new Map(),
|
||||
);
|
||||
const holidaySet = ref(new Set<string>());
|
||||
|
||||
/** 计算当前月份的起止时间 */
|
||||
function getMonthRange() {
|
||||
const startDay = currentDate.value
|
||||
.startOf('month')
|
||||
.format('YYYY-MM-DD 00:00:00');
|
||||
const endDay = currentDate.value
|
||||
.endOf('month')
|
||||
.format('YYYY-MM-DD 23:59:59');
|
||||
return { endDay, startDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节假日列表,按当前月份范围加载
|
||||
*
|
||||
* 失败(无权限 / 接口异常)时不抛出,仅清空当月假期标记,
|
||||
* 避免阻断使用方的主数据初始化
|
||||
*/
|
||||
async function loadHolidays() {
|
||||
const { endDay, startDay } = getMonthRange();
|
||||
const days = new Set<string>();
|
||||
try {
|
||||
const list = await getHolidayList({ endDay, startDay });
|
||||
for (const item of list || []) {
|
||||
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
|
||||
if (day && item.type === HolidayType.HOLIDAY) {
|
||||
days.add(day);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 没有 mes:cal-holiday:query 权限或接口异常时,仅忽略假期标记
|
||||
}
|
||||
holidaySet.value = days;
|
||||
}
|
||||
|
||||
/** 查询排班日历,params 由调用方提供 queryType 相关参数 */
|
||||
async function fetchCalendar(params: Record<string, any>) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { endDay, startDay } = getMonthRange();
|
||||
const list = await getCalendarList({ ...params, endDay, startDay });
|
||||
const map = new Map<string, MesCalCalendarApi.CalendarDay>();
|
||||
for (const item of list || []) {
|
||||
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
|
||||
if (day) {
|
||||
map.set(day, { ...item, day });
|
||||
}
|
||||
}
|
||||
calendarDayMap.value = map;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听月份切换,调用回调刷新数据 */
|
||||
function watchMonth(callback: () => void) {
|
||||
watch(
|
||||
() => currentDate.value.format('YYYY-MM'),
|
||||
() => {
|
||||
void loadHolidays();
|
||||
callback();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
calendarDayMap,
|
||||
currentDate,
|
||||
fetchCalendar,
|
||||
holidaySet,
|
||||
loadHolidays,
|
||||
loading,
|
||||
watchMonth,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { DocAlert, Page } from '@vben/common-ui';
|
||||
|
||||
import { Tabs } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import TeamView from './modules/team-view.vue';
|
||||
import TypeView from './modules/type-view.vue';
|
||||
import UserView from './modules/user-view.vue';
|
||||
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const activeTab = ref<string>('type');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert
|
||||
title="【排班】排班计划、排班日历"
|
||||
url="https://doc.iocoder.cn/mes/cal/calendar/"
|
||||
/>
|
||||
</template>
|
||||
<div class="bg-card rounded-md p-3">
|
||||
<Tabs v-model:active-key="activeTab" type="card">
|
||||
<Tabs.TabPane key="type" tab="按分类" force-render>
|
||||
<TypeView />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="team" tab="按班组" force-render>
|
||||
<TeamView />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="user" tab="按个人" force-render>
|
||||
<UserView />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts" setup>
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { getTeamList } from '#/api/mes/cal/team';
|
||||
|
||||
import CalendarPanel from '../components/calendar-panel.vue';
|
||||
import { useCalendar } from '../components/use-calendar';
|
||||
|
||||
const {
|
||||
calendarDayMap,
|
||||
currentDate,
|
||||
fetchCalendar,
|
||||
holidaySet,
|
||||
loadHolidays,
|
||||
loading,
|
||||
watchMonth,
|
||||
} = useCalendar();
|
||||
|
||||
const teamList = ref<MesCalTeamApi.Team[]>([]);
|
||||
const selectedTeamId = ref<number>();
|
||||
|
||||
/** 查询当前月份的排班日历,按选中班组过滤 */
|
||||
function doFetch() {
|
||||
if (!selectedTeamId.value) {
|
||||
return;
|
||||
}
|
||||
fetchCalendar({ queryType: 'TEAM', teamId: selectedTeamId.value });
|
||||
}
|
||||
|
||||
/** 点击左侧班组后切换并刷新日历 */
|
||||
function onSelectTeam(id: number) {
|
||||
selectedTeamId.value = id;
|
||||
doFetch();
|
||||
}
|
||||
|
||||
/** 监听月份切换,重新加载当月排班 */
|
||||
watchMonth(() => {
|
||||
if (selectedTeamId.value) {
|
||||
doFetch();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// 假期列表与班组列表并行加载,避免假期接口无权限或异常时阻断班组排班查询
|
||||
void loadHolidays();
|
||||
teamList.value = await getTeamList();
|
||||
if (teamList.value.length > 0 && teamList.value[0]?.id) {
|
||||
onSelectTeam(teamList.value[0].id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<!-- 左侧:班组列表选择 -->
|
||||
<div class="border-border mr-3 w-[150px] shrink-0 overflow-hidden rounded border">
|
||||
<div
|
||||
v-for="team in teamList"
|
||||
:key="team.id"
|
||||
class="text-foreground border-border hover:bg-muted/50 cursor-pointer border-b px-4 py-2.5 text-sm transition-colors last:border-b-0"
|
||||
:class="
|
||||
selectedTeamId === team.id
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: ''
|
||||
"
|
||||
@click="onSelectTeam(team.id!)"
|
||||
>
|
||||
{{ team.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:日历 -->
|
||||
<div class="flex-1">
|
||||
<CalendarPanel
|
||||
v-model:current-date="currentDate"
|
||||
:calendar-day-map="calendarDayMap"
|
||||
:holiday-set="holidaySet"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts" setup>
|
||||
import type { DictDataType } from '@vben/hooks';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import CalendarPanel from '../components/calendar-panel.vue';
|
||||
import { useCalendar } from '../components/use-calendar';
|
||||
|
||||
const {
|
||||
calendarDayMap,
|
||||
currentDate,
|
||||
fetchCalendar,
|
||||
holidaySet,
|
||||
loadHolidays,
|
||||
loading,
|
||||
watchMonth,
|
||||
} = useCalendar();
|
||||
|
||||
const typeOptions = ref<DictDataType[]>([]);
|
||||
const selectedType = ref<number>();
|
||||
|
||||
/** 查询当前月份的排班日历,按选中分类过滤 */
|
||||
function doFetch() {
|
||||
if (selectedType.value === undefined) {
|
||||
return;
|
||||
}
|
||||
fetchCalendar({ calendarType: selectedType.value, queryType: 'TYPE' });
|
||||
}
|
||||
|
||||
/** 点击左侧分类后切换并刷新日历 */
|
||||
function onSelectType(value: number) {
|
||||
selectedType.value = value;
|
||||
doFetch();
|
||||
}
|
||||
|
||||
/** 监听月份切换,重新加载当月排班 */
|
||||
watchMonth(() => {
|
||||
if (selectedType.value !== undefined) {
|
||||
doFetch();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 假期列表与排班主数据并行加载,避免假期接口无权限或异常时阻断分类/排班加载
|
||||
void loadHolidays();
|
||||
typeOptions.value = getDictOptions(DICT_TYPE.MES_CAL_CALENDAR_TYPE, 'number');
|
||||
if (typeOptions.value.length > 0) {
|
||||
onSelectType(typeOptions.value[0]!.value as number);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<!-- 左侧:班组类型选择 -->
|
||||
<div class="border-border mr-3 w-[150px] shrink-0 overflow-hidden rounded border">
|
||||
<div
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value as number"
|
||||
class="text-foreground border-border hover:bg-muted/50 cursor-pointer border-b px-4 py-2.5 text-sm transition-colors last:border-b-0"
|
||||
:class="
|
||||
selectedType === item.value
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: ''
|
||||
"
|
||||
@click="onSelectType(item.value as number)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:日历 -->
|
||||
<div class="flex-1">
|
||||
<CalendarPanel
|
||||
v-model:current-date="currentDate"
|
||||
:calendar-day-map="calendarDayMap"
|
||||
:holiday-set="holidaySet"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Form, FormItem, Select } from 'ant-design-vue';
|
||||
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
|
||||
import CalendarPanel from '../components/calendar-panel.vue';
|
||||
import { useCalendar } from '../components/use-calendar';
|
||||
|
||||
const {
|
||||
calendarDayMap,
|
||||
currentDate,
|
||||
fetchCalendar,
|
||||
holidaySet,
|
||||
loadHolidays,
|
||||
loading,
|
||||
watchMonth,
|
||||
} = useCalendar();
|
||||
|
||||
const userId = ref<number>();
|
||||
const userOptions = ref<SystemUserApi.User[]>([]);
|
||||
|
||||
/** 查询当前月份的排班日历,按选中人员过滤 */
|
||||
function doFetch() {
|
||||
if (!userId.value) {
|
||||
return;
|
||||
}
|
||||
fetchCalendar({ queryType: 'USER', userId: userId.value });
|
||||
}
|
||||
|
||||
/** 查询按钮 / 下拉选人后刷新日历 */
|
||||
function onUserQuery() {
|
||||
doFetch();
|
||||
}
|
||||
|
||||
/** 监听月份切换,重新加载当月排班 */
|
||||
watchMonth(() => {
|
||||
if (userId.value) {
|
||||
doFetch();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// 假期列表与人员列表并行加载,避免假期接口无权限或异常时阻断人员排班查询
|
||||
void loadHolidays();
|
||||
userOptions.value = await getSimpleUserList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:人员选择 -->
|
||||
<Form layout="inline" class="mb-2.5">
|
||||
<FormItem label="人员">
|
||||
<Select
|
||||
v-model:value="userId"
|
||||
allow-clear
|
||||
show-search
|
||||
placeholder="请输入人员姓名搜索"
|
||||
class="!w-[200px]"
|
||||
:options="userOptions"
|
||||
:field-names="{ label: 'nickname', value: 'id' }"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
(option?.nickname ?? '').includes(input)
|
||||
"
|
||||
@change="onUserQuery"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" @click="onUserQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:search" />
|
||||
</template>
|
||||
查询
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 日历 -->
|
||||
<CalendarPanel
|
||||
v-model:current-date="currentDate"
|
||||
:calendar-day-map="calendarDayMap"
|
||||
:holiday-set="holidaySet"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { HolidayType } from '#/views/mes/utils/constants';
|
||||
|
||||
/** 假期设置表单 */
|
||||
export function useHolidayFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'day',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'dayDisplay',
|
||||
label: '日期',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_HOLIDAY_TYPE, 'number'),
|
||||
},
|
||||
rules: z.number().default(HolidayType.WORKDAY),
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Calendar, message, Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { SolarDay } from 'tyme4ts';
|
||||
|
||||
import { getHolidayList } from '#/api/mes/cal/holiday';
|
||||
import { HolidayType } from '#/views/mes/utils/constants';
|
||||
|
||||
import HolidayForm from './modules/form.vue';
|
||||
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const currentDate = ref<Dayjs>(dayjs()); // 当前日历选中日期
|
||||
const holidaySet = ref(new Set<string>()); // 节假日日期集合
|
||||
const lastFetchedMonth = ref(''); // 上次加载月份
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const [HolidayFormModal, holidayFormModalApi] = useVbenModal({
|
||||
connectedComponent: HolidayForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 加载假期列表(按当前日历可见范围过滤) */
|
||||
async function getList() {
|
||||
const current = currentDate.value;
|
||||
const startDay = current
|
||||
.subtract(1, 'month')
|
||||
.startOf('month')
|
||||
.format('YYYY-MM-DD 00:00:00');
|
||||
const endDay = current
|
||||
.add(1, 'month')
|
||||
.endOf('month')
|
||||
.format('YYYY-MM-DD 23:59:59');
|
||||
const list = await getHolidayList({ startDay, endDay });
|
||||
const days = new Set<string>();
|
||||
for (const item of list || []) {
|
||||
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
|
||||
if (day && item.type === HolidayType.HOLIDAY) {
|
||||
days.add(day);
|
||||
}
|
||||
}
|
||||
holidaySet.value = days;
|
||||
lastFetchedMonth.value = current.format('YYYY-MM');
|
||||
}
|
||||
|
||||
/** 点击日期:打开假期设置弹窗 */
|
||||
function handleDayClick(date: Dayjs) {
|
||||
// 非当前月日期,不处理(避免切换月份)
|
||||
if (!date.isSame(currentDate.value, 'month')) {
|
||||
return;
|
||||
}
|
||||
if (!hasAccessByCodes(['mes:cal-holiday:create'])) {
|
||||
message.warning('没有假期设置权限');
|
||||
return;
|
||||
}
|
||||
holidayFormModalApi.setData({ day: date.format('YYYY-MM-DD') }).open();
|
||||
}
|
||||
|
||||
/** 切换到上月 */
|
||||
function handlePrevMonth() {
|
||||
currentDate.value = currentDate.value.subtract(1, 'month');
|
||||
}
|
||||
|
||||
/** 切换到下月 */
|
||||
function handleNextMonth() {
|
||||
currentDate.value = currentDate.value.add(1, 'month');
|
||||
}
|
||||
|
||||
/** 切换到今天 */
|
||||
function handleToday() {
|
||||
currentDate.value = dayjs();
|
||||
}
|
||||
|
||||
/** 判断是否周末 */
|
||||
function isWeekend(day: string) {
|
||||
const weekday = dayjs(day).day();
|
||||
return weekday === 0 || weekday === 6;
|
||||
}
|
||||
|
||||
/** 获取农历信息 */
|
||||
function getLunarInfo(day: string) {
|
||||
const [year, month, date] = day.split('-').map(Number);
|
||||
try {
|
||||
const solarDay = SolarDay.fromYmd(year!, month!, date!);
|
||||
const lunarDay = solarDay.getLunarDay();
|
||||
const solarFestival = solarDay.getFestival();
|
||||
const lunarFestival = lunarDay.getFestival();
|
||||
const termDay = solarDay.getTermDay();
|
||||
const termName =
|
||||
termDay.getDayIndex() === 0 ? termDay.getSolarTerm().getName() : '';
|
||||
return {
|
||||
lunarFestival: lunarFestival ? lunarFestival.getName() : '',
|
||||
lunarText: lunarDay.getLunarMonth().getName() + lunarDay.getName(),
|
||||
solarFestival: solarFestival ? solarFestival.getName() : '',
|
||||
termName,
|
||||
};
|
||||
} catch {
|
||||
return { lunarFestival: '', lunarText: '', solarFestival: '', termName: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取农历显示文本 */
|
||||
function getLunarDisplay(day: string) {
|
||||
const info = getLunarInfo(day);
|
||||
return (
|
||||
info.solarFestival || info.lunarFestival || info.termName || info.lunarText
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断是否有节日 */
|
||||
function hasFestival(day: string) {
|
||||
const info = getLunarInfo(day);
|
||||
return !!(info.solarFestival || info.lunarFestival || info.termName);
|
||||
}
|
||||
|
||||
/** 监听月份切换,重新加载可见范围内的数据 */
|
||||
watch(currentDate, (newDate) => {
|
||||
const newMonth = newDate.format('YYYY-MM');
|
||||
if (newMonth !== lastFetchedMonth.value) {
|
||||
getList();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(getList);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert
|
||||
title="【排班】班组设置、节假日设置"
|
||||
url="https://doc.iocoder.cn/mes/cal/team/"
|
||||
/>
|
||||
</template>
|
||||
<HolidayFormModal @success="getList" />
|
||||
<div class="bg-card overflow-hidden rounded-md">
|
||||
<Calendar v-model:value="currentDate" class="mes-holiday-calendar">
|
||||
<template #headerRender>
|
||||
<div class="flex items-center justify-between p-3">
|
||||
<div class="text-base font-medium">
|
||||
{{ currentDate.format('YYYY 年 MM 月') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="handlePrevMonth">上月</Button>
|
||||
<Button @click="handleToday">今天</Button>
|
||||
<Button @click="handleNextMonth">下月</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #dateFullCellRender="{ current: date }">
|
||||
<div
|
||||
class="hover:bg-muted/50 h-[84px] cursor-pointer p-2 text-left transition"
|
||||
@click.stop="handleDayClick(date)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="text-base font-medium"
|
||||
:class="{
|
||||
'text-red-500':
|
||||
isWeekend(date.format('YYYY-MM-DD')) &&
|
||||
date.isSame(currentDate, 'month'),
|
||||
}"
|
||||
>
|
||||
{{ date.format('DD') }}
|
||||
</span>
|
||||
<Tag
|
||||
v-if="
|
||||
date.isSame(currentDate, 'month') &&
|
||||
holidaySet.has(date.format('YYYY-MM-DD'))
|
||||
"
|
||||
color="green"
|
||||
>
|
||||
休
|
||||
</Tag>
|
||||
<Tag v-else-if="date.isSame(currentDate, 'month')" color="blue">
|
||||
班
|
||||
</Tag>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 text-xs"
|
||||
:class="
|
||||
hasFestival(date.format('YYYY-MM-DD'))
|
||||
? 'text-green-600'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ getLunarDisplay(date.format('YYYY-MM-DD')) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Calendar>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 重置 Ant Design Calendar 的默认对齐和内边距,使用自定义单元格 */
|
||||
.mes-holiday-calendar {
|
||||
:deep(.ant-picker-content) {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
|
||||
th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-picker-cell) {
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts" setup>
|
||||
import type { MesCalHolidayApi } from '#/api/mes/cal/holiday';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getHolidayByDay, saveHoliday } from '#/api/mes/cal/holiday';
|
||||
import { HolidayType } from '#/views/mes/utils/constants';
|
||||
|
||||
import { useHolidayFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useHolidayFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as MesCalHolidayApi.Holiday & { dayDisplay?: string };
|
||||
try {
|
||||
await saveHoliday({ day: data.day, type: data.type, remark: data.remark });
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success('设置成功');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
await formApi.resetForm();
|
||||
const data = modalApi.getData<{ day: string }>();
|
||||
if (!data?.day) {
|
||||
return;
|
||||
}
|
||||
const timestamp = dayjs(data.day + ' 00:00:00').valueOf();
|
||||
await formApi.setValues({
|
||||
day: timestamp,
|
||||
dayDisplay: data.day,
|
||||
type: HolidayType.WORKDAY,
|
||||
remark: '',
|
||||
});
|
||||
modalApi.lock();
|
||||
try {
|
||||
const holiday = await getHolidayByDay(dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss'));
|
||||
if (holiday) {
|
||||
await formApi.setValues({
|
||||
type: holiday.type ?? HolidayType.WORKDAY,
|
||||
remark: holiday.remark ?? '',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="假期设置" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalPlanApi } from '#/api/mes/cal/plan';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { generateAutoCode } from '#/api/mes/md/autocode/record';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
import {
|
||||
MesAutoCodeRuleCode,
|
||||
MesCalPlanStatusEnum,
|
||||
MesCalShiftMethodEnum,
|
||||
MesCalShiftTypeEnum,
|
||||
} from '#/views/mes/utils/constants';
|
||||
|
||||
/** 新增/修改排班计划的表单 */
|
||||
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
defaultValue: MesCalPlanStatusEnum.PREPARE,
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '计划编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入计划编码',
|
||||
},
|
||||
rules: 'required',
|
||||
suffix: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'default',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const code = await generateAutoCode(MesAutoCodeRuleCode.CAL_PLAN_CODE);
|
||||
await formApi?.setFieldValue('code', code);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ default: () => '生成' },
|
||||
),
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '计划名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入计划名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'calendarType',
|
||||
label: '班组类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_CALENDAR_TYPE, 'number'),
|
||||
placeholder: '请选择班组类型',
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'startDate',
|
||||
label: '开始日期',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
placeholder: '请选择开始日期',
|
||||
valueFormat: 'x',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'endDate',
|
||||
label: '结束日期',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
placeholder: '请选择结束日期',
|
||||
valueFormat: 'x',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'shiftType',
|
||||
label: '轮班方式',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_SHIFT_TYPE, 'number'),
|
||||
placeholder: '请选择轮班方式',
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'shiftMethod',
|
||||
label: '倒班方式',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_SHIFT_METHOD, 'number'),
|
||||
placeholder: '请选择倒班方式',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['shiftType'],
|
||||
show: (values) => !!values.shiftType && values.shiftType !== MesCalShiftTypeEnum.SINGLE,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'shiftCount',
|
||||
label: '倒班天数',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
min: 1,
|
||||
precision: 0,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['shiftMethod'],
|
||||
show: (values) => values.shiftMethod === MesCalShiftMethodEnum.DAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
formItemClass: 'col-span-3',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '计划编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入计划编码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '计划名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入计划名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'startDate',
|
||||
label: '开始日期',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'endDate',
|
||||
label: '结束日期',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'shiftType',
|
||||
label: '轮班方式',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_SHIFT_TYPE, 'number'),
|
||||
placeholder: '请选择轮班方式',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_PLAN_STATUS, 'number'),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions<MesCalPlanApi.Plan>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'code',
|
||||
title: '计划编码',
|
||||
minWidth: 140,
|
||||
slots: {
|
||||
default: 'code',
|
||||
},
|
||||
},
|
||||
{ field: 'name', title: '计划名称', minWidth: 150 },
|
||||
{
|
||||
field: 'calendarType',
|
||||
title: '班组类型',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_CAL_CALENDAR_TYPE },
|
||||
},
|
||||
},
|
||||
{ field: 'startDate', title: '开始日期', width: 150, formatter: 'formatDate' },
|
||||
{ field: 'endDate', title: '结束日期', width: 150, formatter: 'formatDate' },
|
||||
{
|
||||
field: 'shiftType',
|
||||
title: '轮班方式',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_CAL_SHIFT_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'shiftMethod',
|
||||
title: '倒班方式',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_CAL_SHIFT_METHOD },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '单据状态',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_CAL_PLAN_STATUS },
|
||||
},
|
||||
},
|
||||
{ field: 'createTime', title: '创建时间', width: 180, formatter: 'formatDateTime' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalPlanApi } from '#/api/mes/cal/plan';
|
||||
|
||||
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deletePlan, exportPlan, getPlanPage } from '#/api/mes/cal/plan';
|
||||
import { $t } from '#/locales';
|
||||
import { MesCalPlanStatusEnum } from '#/views/mes/utils/constants';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建排班计划 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 查看排班计划 */
|
||||
function handleDetail(row: MesCalPlanApi.Plan) {
|
||||
formModalApi.setData({ id: row.id, type: 'detail' }).open();
|
||||
}
|
||||
|
||||
/** 编辑排班计划 */
|
||||
function handleEdit(row: MesCalPlanApi.Plan) {
|
||||
formModalApi.setData({ id: row.id, type: 'update' }).open();
|
||||
}
|
||||
|
||||
/** 删除排班计划 */
|
||||
async function handleDelete(row: MesCalPlanApi.Plan) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deletePlan(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出排班计划 */
|
||||
async function handleExport() {
|
||||
const data = await exportPlan(await gridApi.formApi.getValues());
|
||||
downloadFileFromBlobPart({ fileName: '排班计划.xls', source: data });
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) =>
|
||||
await getPlanPage({ pageNo: page.currentPage, pageSize: page.pageSize, ...formValues }),
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalPlanApi.Plan>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert
|
||||
title="【排班】排班计划、排班日历"
|
||||
url="https://doc.iocoder.cn/mes/cal/calendar/"
|
||||
/>
|
||||
</template>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid table-title="排班计划列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['排班计划']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['mes:cal-plan:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
{
|
||||
label: $t('ui.actionTitle.export'),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
auth: ['mes:cal-plan:export'],
|
||||
onClick: handleExport,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #code="{ row }">
|
||||
<Button type="link" @click="handleDetail(row)">{{ row.code }}</Button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
v-if="row.status === MesCalPlanStatusEnum.PREPARE"
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['mes:cal-plan:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mes:cal-plan:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<script lang="ts" setup>
|
||||
import type { MesCalPlanApi } from '#/api/mes/cal/plan';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Popconfirm, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { confirmPlan, createPlan, getPlan, updatePlan } from '#/api/mes/cal/plan';
|
||||
import { $t } from '#/locales';
|
||||
import { MesCalPlanStatusEnum } from '#/views/mes/utils/constants';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import ShiftList from './shift-list.vue';
|
||||
import PlanTeamList from './team-list.vue';
|
||||
|
||||
type FormMode = 'create' | 'detail' | 'update';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formMode = ref<FormMode>('create'); // 表单模式
|
||||
const subTabsName = ref('shift'); // 当前资源页签
|
||||
const formData = ref<MesCalPlanApi.Plan>();
|
||||
const isDetail = computed(() => formMode.value === 'detail'); // 是否查看模式
|
||||
const canConfirm = computed(
|
||||
() => formMode.value === 'update' && formData.value?.status === MesCalPlanStatusEnum.PREPARE,
|
||||
); // 是否可确认计划
|
||||
const getTitle = computed(() => {
|
||||
if (formMode.value === 'detail') {
|
||||
return $t('ui.actionTitle.view', ['排班计划']);
|
||||
}
|
||||
return formMode.value === 'update'
|
||||
? $t('ui.actionTitle.edit', ['排班计划'])
|
||||
: $t('ui.actionTitle.create', ['排班计划']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-1',
|
||||
labelWidth: 100,
|
||||
},
|
||||
wrapperClass: 'grid-cols-3',
|
||||
layout: 'horizontal',
|
||||
schema: [],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
/** 表单 schema 需要 formApi 引用,所以通过 setState 设置 schema */
|
||||
formApi.setState({ schema: useFormSchema(formApi) });
|
||||
|
||||
/** 确认排班计划 */
|
||||
async function handleConfirmPlan() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid || !formData.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
const data = (await formApi.getValues()) as MesCalPlanApi.Plan;
|
||||
await updatePlan(data);
|
||||
await confirmPlan(formData.value.id);
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success('确认成功');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
if (isDetail.value) {
|
||||
await modalApi.close();
|
||||
return;
|
||||
}
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as MesCalPlanApi.Plan;
|
||||
try {
|
||||
if (formMode.value === 'create') {
|
||||
const id = await createPlan(data);
|
||||
formData.value = { ...data, id: id as number, status: MesCalPlanStatusEnum.PREPARE };
|
||||
await formApi.setFieldValue('id', id);
|
||||
formMode.value = 'update';
|
||||
} else {
|
||||
await updatePlan(data);
|
||||
formData.value = { ...formData.value, ...data };
|
||||
}
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
await formApi.resetForm();
|
||||
subTabsName.value = 'shift';
|
||||
// 加载数据
|
||||
const data = modalApi.getData<{ id?: number; type?: FormMode }>();
|
||||
formMode.value = data?.type || 'create';
|
||||
formApi.setDisabled(formMode.value === 'detail');
|
||||
modalApi.setState({ showConfirmButton: formMode.value !== 'detail' });
|
||||
if (!data?.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getPlan(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-4/5">
|
||||
<Form class="mx-4" />
|
||||
<Tabs
|
||||
v-if="formMode !== 'create' && formData?.id"
|
||||
v-model:active-key="subTabsName"
|
||||
class="mx-4 mt-4"
|
||||
>
|
||||
<Tabs.TabPane key="shift" tab="班次">
|
||||
<ShiftList :form-type="formMode" :plan-id="formData.id" />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="team" tab="班组">
|
||||
<PlanTeamList :form-type="formMode" :plan-id="formData.id" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<template #prepend-footer>
|
||||
<div class="flex flex-auto items-center">
|
||||
<Popconfirm
|
||||
v-if="canConfirm"
|
||||
title="确认该排班计划?确认后将不可修改或删除。"
|
||||
@confirm="handleConfirmPlan"
|
||||
>
|
||||
<Button type="primary">确认计划</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalPlanShiftApi } from '#/api/mes/cal/plan/shift';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createPlanShift,
|
||||
deletePlanShift,
|
||||
getPlanShiftListByPlan,
|
||||
updatePlanShift,
|
||||
} from '#/api/mes/cal/plan/shift';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = withDefaults(defineProps<{ formType?: string; planId: number }>(), {
|
||||
formType: 'update',
|
||||
});
|
||||
const isEditable = computed(() => props.formType !== 'detail'); // 是否可编辑
|
||||
const formOpen = ref(false); // 班次表单是否打开
|
||||
const formLoading = ref(false); // 班次表单提交中
|
||||
const shiftFormType = ref<'create' | 'update'>('create'); // 班次表单模式
|
||||
const formTitle = computed(() => (shiftFormType.value === 'create' ? '添加班次' : '修改班次'));
|
||||
const list = ref<MesCalPlanShiftApi.PlanShift[]>([]); // 班次列表
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'planId',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '顺序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
min: 1,
|
||||
precision: 0,
|
||||
},
|
||||
rules: z.number().default(1),
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '班次名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入班次名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'startTime',
|
||||
label: '开始时间',
|
||||
component: 'TimePicker',
|
||||
componentProps: {
|
||||
format: 'HH:mm',
|
||||
placeholder: '请选择开始时间',
|
||||
valueFormat: 'HH:mm',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'endTime',
|
||||
label: '结束时间',
|
||||
component: 'TimePicker',
|
||||
componentProps: {
|
||||
format: 'HH:mm',
|
||||
placeholder: '请选择结束时间',
|
||||
valueFormat: 'HH:mm',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
autoResize: true,
|
||||
border: true,
|
||||
columns: [
|
||||
{ field: 'sort', title: '顺序', width: 80 },
|
||||
{ field: 'name', title: '班次名称', minWidth: 120 },
|
||||
{ field: 'startTime', title: '开始时间', width: 100 },
|
||||
{ field: 'endTime', title: '结束时间', width: 100 },
|
||||
{ field: 'remark', title: '备注', minWidth: 150 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
visible: isEditable.value,
|
||||
},
|
||||
],
|
||||
data: list.value,
|
||||
minHeight: 240,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
keyField: 'id',
|
||||
},
|
||||
showOverflow: true,
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalPlanShiftApi.PlanShift>,
|
||||
});
|
||||
|
||||
/** 加载班次列表 */
|
||||
async function getList() {
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
list.value = await getPlanShiftListByPlan(props.planId);
|
||||
gridApi.setGridOptions({ data: list.value });
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开班次表单 */
|
||||
async function openForm(type: 'create' | 'update', row?: MesCalPlanShiftApi.PlanShift) {
|
||||
formOpen.value = true;
|
||||
shiftFormType.value = type;
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues(row ? { ...row } : { planId: props.planId, sort: 1 });
|
||||
}
|
||||
|
||||
/** 提交班次表单 */
|
||||
async function submitForm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
formLoading.value = true;
|
||||
try {
|
||||
const data = (await formApi.getValues()) as MesCalPlanShiftApi.PlanShift;
|
||||
await (shiftFormType.value === 'create' ? createPlanShift(data) : updatePlanShift(data));
|
||||
formOpen.value = false;
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await getList();
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除班次 */
|
||||
async function handleDelete(id: number) {
|
||||
await deletePlanShift(id);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', ['班次']));
|
||||
await getList();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.planId,
|
||||
(value) => {
|
||||
if (value) {
|
||||
getList();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isEditable" class="mb-3 flex items-center justify-start">
|
||||
<TableAction
|
||||
:actions="[{ label: '添加班次', type: 'primary', onClick: openForm.bind(null, 'create') }]"
|
||||
/>
|
||||
</div>
|
||||
<Grid class="w-full">
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', type: 'link', onClick: openForm.bind(null, 'update', row) },
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除该班次吗?',
|
||||
confirm: handleDelete.bind(null, row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal
|
||||
v-model:open="formOpen"
|
||||
:title="formTitle"
|
||||
width="520px"
|
||||
:confirm-loading="formLoading"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalPlanTeamApi } from '#/api/mes/cal/plan/team';
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
import type { MesCalTeamMemberApi } from '#/api/mes/cal/team/member';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Card, message } from 'ant-design-vue';
|
||||
|
||||
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { createPlanTeam, deletePlanTeam, getPlanTeamListByPlan } from '#/api/mes/cal/plan/team';
|
||||
import { getTeamMemberListByTeam } from '#/api/mes/cal/team/member';
|
||||
import { $t } from '#/locales';
|
||||
import { CalTeamSelectDialog } from '#/views/mes/cal/team/components';
|
||||
|
||||
const props = withDefaults(defineProps<{ formType?: string; planId: number }>(), {
|
||||
formType: 'update',
|
||||
});
|
||||
const isEditable = computed(() => props.formType !== 'detail'); // 是否可编辑
|
||||
const list = ref<MesCalPlanTeamApi.PlanTeam[]>([]); // 计划班组列表
|
||||
const memberList = ref<MesCalTeamMemberApi.TeamMember[]>([]); // 班组成员列表
|
||||
const selectedTeamId = ref<number>(); // 选中班组编号
|
||||
const selectedTeamName = ref(''); // 选中班组名称
|
||||
const teamDialogRef = ref<InstanceType<typeof CalTeamSelectDialog>>(); // 班组选择弹窗
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
autoResize: true,
|
||||
border: true,
|
||||
columns: [
|
||||
{ field: 'teamId', title: '班组编号', width: 100 },
|
||||
{ field: 'teamCode', title: '班组编码', minWidth: 120 },
|
||||
{ field: 'teamName', title: '班组名称', minWidth: 120 },
|
||||
{ field: 'remark', title: '备注', minWidth: 150 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
visible: isEditable.value,
|
||||
},
|
||||
],
|
||||
data: list.value,
|
||||
minHeight: 260,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
isCurrent: true,
|
||||
isHover: true,
|
||||
keyField: 'id',
|
||||
},
|
||||
showOverflow: true,
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalPlanTeamApi.PlanTeam>,
|
||||
gridEvents: {
|
||||
cellClick: ({ row }: { row: MesCalPlanTeamApi.PlanTeam }) => handleTeamSelect(row),
|
||||
},
|
||||
});
|
||||
|
||||
const [MemberGrid, memberGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
autoResize: true,
|
||||
border: true,
|
||||
columns: [
|
||||
{ field: 'nickname', title: '用户昵称', minWidth: 100 },
|
||||
{ field: 'telephone', title: '手机号', minWidth: 120 },
|
||||
{ field: 'remark', title: '备注', minWidth: 120 },
|
||||
],
|
||||
data: memberList.value,
|
||||
minHeight: 260,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
keyField: 'id',
|
||||
},
|
||||
showOverflow: true,
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalTeamMemberApi.TeamMember>,
|
||||
});
|
||||
|
||||
/** 加载计划班组列表 */
|
||||
async function getList() {
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
list.value = await getPlanTeamListByPlan(props.planId);
|
||||
gridApi.setGridOptions({ data: list.value });
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择班组后加载成员 */
|
||||
async function handleTeamSelect(row?: MesCalPlanTeamApi.PlanTeam) {
|
||||
if (!row?.teamId) {
|
||||
selectedTeamId.value = undefined;
|
||||
selectedTeamName.value = '';
|
||||
memberList.value = [];
|
||||
memberGridApi.setGridOptions({ data: memberList.value });
|
||||
return;
|
||||
}
|
||||
selectedTeamId.value = row.teamId;
|
||||
selectedTeamName.value = row.teamName || '';
|
||||
memberGridApi.setLoading(true);
|
||||
try {
|
||||
memberList.value = await getTeamMemberListByTeam(row.teamId);
|
||||
memberGridApi.setGridOptions({ data: memberList.value });
|
||||
} finally {
|
||||
memberGridApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开班组选择弹窗 */
|
||||
function openTeamSelect() {
|
||||
teamDialogRef.value?.open(list.value.map((item) => item.teamId!).filter(Boolean));
|
||||
}
|
||||
|
||||
/** 处理班组选择 */
|
||||
async function handleTeamsSelected(rows: MesCalTeamApi.Team[]) {
|
||||
const existingTeamIds = new Set(list.value.map((item) => item.teamId));
|
||||
const newTeams = rows.filter((team) => team.id && !existingTeamIds.has(team.id));
|
||||
if (newTeams.length === 0) {
|
||||
message.warning('所选班组已全部添加过');
|
||||
return;
|
||||
}
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
for (const team of newTeams) {
|
||||
await createPlanTeam({ planId: props.planId, teamId: team.id });
|
||||
}
|
||||
message.success('成功添加 ' + newTeams.length + ' 个班组');
|
||||
await getList();
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除计划班组 */
|
||||
async function handleDelete(row: MesCalPlanTeamApi.PlanTeam) {
|
||||
await deletePlanTeam(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.teamName]));
|
||||
if (row.teamId === selectedTeamId.value) {
|
||||
await handleTeamSelect(undefined);
|
||||
}
|
||||
await getList();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.planId,
|
||||
(value) => {
|
||||
if (value) {
|
||||
getList();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isEditable" class="mb-3 flex items-center justify-start">
|
||||
<TableAction :actions="[{ label: '添加班组', type: 'primary', onClick: openTeamSelect }]" />
|
||||
</div>
|
||||
<div class="grid grid-cols-5 gap-4">
|
||||
<div class="col-span-3">
|
||||
<Grid class="w-full">
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除该班组吗?',
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<Card class="h-full" size="small">
|
||||
<template #title>
|
||||
{{ selectedTeamName ? `「${selectedTeamName}」班组成员` : '班组成员' }}
|
||||
</template>
|
||||
<div v-if="!selectedTeamId">
|
||||
<div class="py-8 text-center text-gray-400">请点击左侧班组查看成员</div>
|
||||
</div>
|
||||
<MemberGrid v-else class="w-full" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<CalTeamSelectDialog ref="teamDialogRef" :multiple="true" @selected="handleTeamsSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getTeamPage } from '#/api/mes/cal/team';
|
||||
|
||||
import { useTeamSelectGridColumns, useTeamSelectGridFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits<{ selected: [rows: MesCalTeamApi.Team[]] }>();
|
||||
const open = ref(false); // 弹窗是否打开
|
||||
const multiple = ref(true); // 是否多选
|
||||
const selectedRows = ref<MesCalTeamApi.Team[]>([]); // 已选班组列表
|
||||
const preSelectedIds = ref<number[]>([]); // 预选班组编号列表
|
||||
|
||||
/** 处理勾选变化 */
|
||||
function handleCheckboxChange({ records }: { records: MesCalTeamApi.Team[] }) {
|
||||
selectedRows.value = records;
|
||||
}
|
||||
|
||||
/** 处理全选变化 */
|
||||
function handleCheckboxAll({ records }: { records: MesCalTeamApi.Team[] }) {
|
||||
selectedRows.value = records;
|
||||
}
|
||||
|
||||
/** 双击行:多选切换勾选,单选直接确认 */
|
||||
function handleCellDblclick({ row }: { row: MesCalTeamApi.Team }) {
|
||||
if (multiple.value) {
|
||||
const records = gridApi.grid.getCheckboxRecords() as MesCalTeamApi.Team[];
|
||||
const checked = records.some((item) => item.id === row.id);
|
||||
gridApi.grid.setCheckboxRow(row, !checked);
|
||||
selectedRows.value = gridApi.grid.getCheckboxRecords() as MesCalTeamApi.Team[];
|
||||
return;
|
||||
}
|
||||
selectedRows.value = [row];
|
||||
handleConfirm();
|
||||
}
|
||||
|
||||
/** 回显预选班组 */
|
||||
function applyPreSelection() {
|
||||
if (preSelectedIds.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rows = gridApi.grid.getData() as MesCalTeamApi.Team[];
|
||||
for (const row of rows) {
|
||||
if (row.id && preSelectedIds.value.includes(row.id)) {
|
||||
gridApi.grid.setCheckboxRow(row, true);
|
||||
}
|
||||
}
|
||||
selectedRows.value = gridApi.grid.getCheckboxRecords() as MesCalTeamApi.Team[];
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useTeamSelectGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useTeamSelectGridColumns(),
|
||||
height: 520,
|
||||
keepSource: true,
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
range: true,
|
||||
reserve: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) =>
|
||||
await getTeamPage({ pageNo: page.currentPage, pageSize: page.pageSize, ...formValues }),
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalTeamApi.Team>,
|
||||
gridEvents: {
|
||||
checkboxAll: handleCheckboxAll,
|
||||
checkboxChange: handleCheckboxChange,
|
||||
cellDblclick: handleCellDblclick,
|
||||
},
|
||||
});
|
||||
|
||||
/** 重置查询和选择状态 */
|
||||
async function resetQueryState() {
|
||||
selectedRows.value = [];
|
||||
await gridApi.grid.clearCheckboxRow();
|
||||
await gridApi.formApi.resetForm();
|
||||
}
|
||||
|
||||
/** 打开班组选择弹窗 */
|
||||
async function openModal(selectedIds?: number[], options?: { multiple?: boolean }) {
|
||||
open.value = true;
|
||||
multiple.value = options?.multiple ?? true;
|
||||
preSelectedIds.value = selectedIds || [];
|
||||
await nextTick();
|
||||
await resetQueryState();
|
||||
await gridApi.query();
|
||||
await nextTick();
|
||||
applyPreSelection();
|
||||
}
|
||||
|
||||
/** 关闭班组选择弹窗 */
|
||||
async function closeModal() {
|
||||
open.value = false;
|
||||
await resetQueryState();
|
||||
}
|
||||
|
||||
/** 确认选择班组 */
|
||||
function handleConfirm() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
message.warning(multiple.value ? '请至少选择一条数据' : '请选择一条数据');
|
||||
return;
|
||||
}
|
||||
emit('selected', multiple.value ? selectedRows.value : [selectedRows.value[0]!]);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ open: openModal });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="open"
|
||||
title="班组选择"
|
||||
width="720px"
|
||||
:destroy-on-close="true"
|
||||
@ok="handleConfirm"
|
||||
@cancel="closeModal"
|
||||
>
|
||||
<Grid table-title="班组列表" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SelectValue } from 'ant-design-vue/es/select';
|
||||
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Select } from 'ant-design-vue';
|
||||
|
||||
import { getTeamList } from '#/api/mes/cal/team';
|
||||
|
||||
import CalTeamSelectDialog from './cal-team-select-dialog.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
modelValue?: number;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
allowClear: true,
|
||||
disabled: false,
|
||||
modelValue: undefined,
|
||||
placeholder: '请选择班组',
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
change: [row?: MesCalTeamApi.Team];
|
||||
'update:modelValue': [value?: number];
|
||||
}>();
|
||||
const teamList = ref<MesCalTeamApi.Team[]>([]); // 班组选项
|
||||
const dialogRef = ref<InstanceType<typeof CalTeamSelectDialog>>(); // 班组选择弹窗
|
||||
|
||||
/** 加载班组选项 */
|
||||
async function loadTeamList() {
|
||||
teamList.value = await getTeamList();
|
||||
}
|
||||
|
||||
/** 处理下拉选择变化 */
|
||||
function handleChange(value: SelectValue) {
|
||||
const teamId = typeof value === 'number' ? value : undefined;
|
||||
emit('update:modelValue', teamId);
|
||||
emit(
|
||||
'change',
|
||||
teamList.value.find((item) => item.id === teamId),
|
||||
);
|
||||
}
|
||||
|
||||
/** 打开班组选择弹窗 */
|
||||
function openDialog() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
dialogRef.value?.open(props.modelValue ? [props.modelValue] : [], { multiple: false });
|
||||
}
|
||||
|
||||
/** 处理弹窗选择 */
|
||||
function handleSelected(rows: MesCalTeamApi.Team[]) {
|
||||
const row = rows[0];
|
||||
emit('update:modelValue', row?.id);
|
||||
emit('change', row);
|
||||
}
|
||||
|
||||
onMounted(loadTeamList);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full gap-2">
|
||||
<Select
|
||||
:allow-clear="allowClear"
|
||||
:disabled="disabled"
|
||||
:field-names="{ label: 'name', value: 'id' }"
|
||||
:options="teamList"
|
||||
:placeholder="placeholder"
|
||||
:value="modelValue"
|
||||
class="flex-1"
|
||||
option-filter-prop="name"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<Button :disabled="disabled" @click="openDialog">选择</Button>
|
||||
<CalTeamSelectDialog ref="dialogRef" :multiple="false" @selected="handleSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CalTeamSelectDialog } from './cal-team-select-dialog.vue';
|
||||
export { default as CalTeamSelect } from './cal-team-select.vue';
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { generateAutoCode } from '#/api/mes/md/autocode/record';
|
||||
import { MesAutoCodeRuleCode } from '#/views/mes/utils/constants';
|
||||
|
||||
/** 新增/修改班组的表单 */
|
||||
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '班组编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
maxLength: 64,
|
||||
placeholder: '请输入班组编码',
|
||||
},
|
||||
rules: z.string().min(1, '班组编码不能为空').max(64),
|
||||
suffix: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'default',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const code = await generateAutoCode(MesAutoCodeRuleCode.CAL_TEAM_CODE);
|
||||
await formApi?.setFieldValue('code', code);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ default: () => '生成' },
|
||||
),
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '班组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
maxLength: 100,
|
||||
placeholder: '请输入班组名称',
|
||||
},
|
||||
rules: z.string().min(1, '班组名称不能为空').max(100),
|
||||
},
|
||||
{
|
||||
fieldName: 'calendarType',
|
||||
label: '班组类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_CALENDAR_TYPE, 'number'),
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
formItemClass: 'col-span-3',
|
||||
componentProps: {
|
||||
maxLength: 250,
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '班组编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入班组编码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '班组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入班组名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'calendarType',
|
||||
label: '班组类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_CAL_CALENDAR_TYPE, 'number'),
|
||||
placeholder: '请选择班组类型',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions<MesCalTeamApi.Team>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'code',
|
||||
title: '班组编码',
|
||||
minWidth: 150,
|
||||
slots: {
|
||||
default: 'code',
|
||||
},
|
||||
},
|
||||
{ field: 'name', title: '班组名称', minWidth: 150 },
|
||||
{
|
||||
field: 'calendarType',
|
||||
title: '班组类型',
|
||||
width: 140,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_CAL_CALENDAR_TYPE },
|
||||
},
|
||||
},
|
||||
{ field: 'remark', title: '备注', minWidth: 180 },
|
||||
{ field: 'createTime', title: '创建时间', width: 180, formatter: 'formatDateTime' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 班组选择弹窗搜索表单 */
|
||||
export function useTeamSelectGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '班组编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入班组编码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '班组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入班组名称',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 班组选择弹窗字段 */
|
||||
export function useTeamSelectGridColumns(): VxeTableGridOptions<MesCalTeamApi.Team>['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 50 },
|
||||
{ field: 'code', title: '班组编码', minWidth: 140 },
|
||||
{ field: 'name', title: '班组名称', minWidth: 140 },
|
||||
{ field: 'remark', title: '备注', minWidth: 160 },
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
|
||||
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteTeam, exportTeam, getTeamPage } from '#/api/mes/cal/team';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建班组 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 查看班组 */
|
||||
function handleDetail(row: MesCalTeamApi.Team) {
|
||||
formModalApi.setData({ id: row.id, type: 'detail' }).open();
|
||||
}
|
||||
|
||||
/** 编辑班组 */
|
||||
function handleEdit(row: MesCalTeamApi.Team) {
|
||||
formModalApi.setData({ id: row.id, type: 'update' }).open();
|
||||
}
|
||||
|
||||
/** 删除班组 */
|
||||
async function handleDelete(row: MesCalTeamApi.Team) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteTeam(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出班组 */
|
||||
async function handleExport() {
|
||||
const data = await exportTeam(await gridApi.formApi.getValues());
|
||||
downloadFileFromBlobPart({ fileName: '班组.xls', source: data });
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTeamPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalTeamApi.Team>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert
|
||||
title="【排班】班组设置、节假日设置"
|
||||
url="https://doc.iocoder.cn/mes/cal/team/"
|
||||
/>
|
||||
</template>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid table-title="班组列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['班组']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['mes:cal-team:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
{
|
||||
label: $t('ui.actionTitle.export'),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
auth: ['mes:cal-team:export'],
|
||||
onClick: handleExport,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #code="{ row }">
|
||||
<Button type="link" @click="handleDetail(row)">{{ row.code }}</Button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['mes:cal-team:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mes:cal-team:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts" setup>
|
||||
import type { MesCalTeamApi } from '#/api/mes/cal/team';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createTeam, getTeam, updateTeam } from '#/api/mes/cal/team';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import MemberList from './member-list.vue';
|
||||
|
||||
|
||||
type FormMode = 'create' | 'detail' | 'update';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formMode = ref<FormMode>('create'); // 表单模式
|
||||
const subTabsName = ref('member'); // 当前资源页签
|
||||
const formData = ref<MesCalTeamApi.Team>();
|
||||
const isDetail = computed(() => formMode.value === 'detail'); // 是否查看模式
|
||||
const getTitle = computed(() => {
|
||||
if (formMode.value === 'detail') {
|
||||
return $t('ui.actionTitle.view', ['班组']);
|
||||
}
|
||||
return formMode.value === 'update'
|
||||
? $t('ui.actionTitle.edit', ['班组'])
|
||||
: $t('ui.actionTitle.create', ['班组']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-1',
|
||||
labelWidth: 100,
|
||||
},
|
||||
wrapperClass: 'grid-cols-3',
|
||||
layout: 'horizontal',
|
||||
schema: [],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
/** 表单 schema 需要 formApi 引用,所以通过 setState 设置 schema */
|
||||
formApi.setState({ schema: useFormSchema(formApi) });
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
if (isDetail.value) {
|
||||
await modalApi.close();
|
||||
return;
|
||||
}
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as MesCalTeamApi.Team;
|
||||
try {
|
||||
if (formMode.value === 'create') {
|
||||
const id = await createTeam(data);
|
||||
formData.value = { ...data, id: id as number };
|
||||
await formApi.setFieldValue('id', id);
|
||||
formMode.value = 'update';
|
||||
} else {
|
||||
await updateTeam(data);
|
||||
formData.value = { ...formData.value, ...data };
|
||||
}
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
await formApi.resetForm();
|
||||
subTabsName.value = 'member';
|
||||
// 加载数据
|
||||
const data = modalApi.getData<{ id?: number; type?: FormMode }>();
|
||||
formMode.value = data?.type || 'create';
|
||||
formApi.setDisabled(formMode.value === 'detail');
|
||||
modalApi.setState({ showConfirmButton: formMode.value !== 'detail' });
|
||||
if (!data?.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getTeam(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-4/5">
|
||||
<Form class="mx-4" />
|
||||
<Tabs
|
||||
v-if="formMode !== 'create' && formData?.id"
|
||||
v-model:active-key="subTabsName"
|
||||
class="mx-4 mt-4"
|
||||
>
|
||||
<Tabs.TabPane key="member" tab="班组成员">
|
||||
<MemberList :form-type="formMode" :team-id="formData.id" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesCalTeamMemberApi } from '#/api/mes/cal/team/member';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createTeamMember,
|
||||
deleteTeamMember,
|
||||
getTeamMemberListByTeam,
|
||||
} from '#/api/mes/cal/team/member';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = withDefaults(defineProps<{ formType?: string; teamId: number }>(), {
|
||||
formType: 'update',
|
||||
});
|
||||
const isEditable = computed(() => ['create', 'update'].includes(props.formType)); // 是否可编辑
|
||||
const formOpen = ref(false); // 成员表单是否打开
|
||||
const formLoading = ref(false); // 成员表单提交中
|
||||
const list = ref<MesCalTeamMemberApi.TeamMember[]>([]); // 成员列表
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
fieldName: 'teamId',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'userId',
|
||||
label: '用户',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getSimpleUserList,
|
||||
labelField: 'nickname',
|
||||
placeholder: '请选择用户',
|
||||
showSearch: true,
|
||||
valueField: 'id',
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
autoResize: true,
|
||||
border: true,
|
||||
columns: [
|
||||
{ field: 'userId', title: '用户编号', width: 100 },
|
||||
{ field: 'nickname', title: '用户昵称', minWidth: 120 },
|
||||
{ field: 'telephone', title: '手机号', minWidth: 120 },
|
||||
{ field: 'remark', title: '备注', minWidth: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
visible: isEditable.value,
|
||||
},
|
||||
],
|
||||
data: list.value,
|
||||
minHeight: 240,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
keyField: 'id',
|
||||
},
|
||||
showOverflow: true,
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MesCalTeamMemberApi.TeamMember>,
|
||||
});
|
||||
|
||||
/** 加载成员列表 */
|
||||
async function getList() {
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
list.value = await getTeamMemberListByTeam(props.teamId);
|
||||
gridApi.setGridOptions({ data: list.value });
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开成员表单 */
|
||||
async function openForm() {
|
||||
formOpen.value = true;
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues({ teamId: props.teamId });
|
||||
}
|
||||
|
||||
/** 提交成员表单 */
|
||||
async function submitForm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
formLoading.value = true;
|
||||
try {
|
||||
const data = (await formApi.getValues()) as MesCalTeamMemberApi.TeamMember;
|
||||
await createTeamMember(data);
|
||||
formOpen.value = false;
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await getList();
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除成员 */
|
||||
async function handleDelete(id: number) {
|
||||
await deleteTeamMember(id);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', ['成员']));
|
||||
await getList();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.teamId,
|
||||
(value) => {
|
||||
if (value) {
|
||||
getList();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isEditable" class="mb-3 flex items-center justify-start">
|
||||
<TableAction :actions="[{ label: '添加成员', type: 'primary', onClick: openForm }]" />
|
||||
</div>
|
||||
<Grid class="w-full">
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除该成员吗?',
|
||||
confirm: handleDelete.bind(null, row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<Modal
|
||||
v-model:open="formOpen"
|
||||
title="添加成员"
|
||||
width="520px"
|
||||
:confirm-loading="formLoading"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SelectValue } from 'ant-design-vue/es/select';
|
||||
|
||||
import type { MesDvCheckPlanApi } from '#/api/mes/dv/checkplan';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Select } from 'ant-design-vue';
|
||||
|
||||
import { getCheckPlanPage } from '#/api/mes/dv/checkplan';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
modelValue?: number;
|
||||
placeholder?: string;
|
||||
status?: number;
|
||||
type?: number;
|
||||
}>(),
|
||||
{
|
||||
allowClear: true,
|
||||
disabled: false,
|
||||
modelValue: undefined,
|
||||
placeholder: '请选择计划',
|
||||
status: undefined,
|
||||
type: undefined,
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
change: [row?: MesDvCheckPlanApi.CheckPlan];
|
||||
'update:modelValue': [value?: number];
|
||||
}>();
|
||||
const list = ref<MesDvCheckPlanApi.CheckPlan[]>([]); // 点检计划列表
|
||||
|
||||
/** 加载点检计划列表 */
|
||||
async function getList() {
|
||||
const data = await getCheckPlanPage({
|
||||
pageNo: 1,
|
||||
pageSize: 100,
|
||||
status: props.status,
|
||||
type: props.type,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
}
|
||||
|
||||
/** 处理点检计划选择变化 */
|
||||
function handleChange(value: SelectValue) {
|
||||
const planId = typeof value === 'number' ? value : undefined;
|
||||
emit('update:modelValue', planId);
|
||||
emit(
|
||||
'change',
|
||||
list.value.find((item) => item.id === planId),
|
||||
);
|
||||
}
|
||||
|
||||
watch(() => [props.status, props.type], getList);
|
||||
onMounted(getList);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select
|
||||
:allow-clear="allowClear"
|
||||
:disabled="disabled"
|
||||
:field-names="{ label: 'name', value: 'id' }"
|
||||
:options="list"
|
||||
:placeholder="placeholder"
|
||||
:value="modelValue"
|
||||
class="w-full"
|
||||
option-filter-prop="name"
|
||||
show-search
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as DvCheckPlanSelect } from './dv-check-plan-select.vue';
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesDvCheckPlanApi } from '#/api/mes/dv/checkplan';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { generateAutoCode } from '#/api/mes/md/autocode/record';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
import {
|
||||
MesAutoCodeRuleCode,
|
||||
MesDvCheckPlanStatusEnum,
|
||||
MesDvSubjectTypeEnum,
|
||||
} from '#/views/mes/utils/constants';
|
||||
|
||||
/** 新增/修改点检保养方案的表单 */
|
||||
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
defaultValue: MesDvCheckPlanStatusEnum.PREPARE,
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '方案编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入方案编码',
|
||||
},
|
||||
rules: 'required',
|
||||
suffix: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'default',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const code = await generateAutoCode(MesAutoCodeRuleCode.DV_CHECK_PLAN_CODE);
|
||||
await formApi?.setFieldValue('code', code);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ default: () => '生成' },
|
||||
),
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '方案名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入方案名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '方案类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
options: getDictOptions(DICT_TYPE.MES_DV_SUBJECT_TYPE, 'number'),
|
||||
},
|
||||
rules: z.number().default(MesDvSubjectTypeEnum.CHECK),
|
||||
},
|
||||
{
|
||||
fieldName: 'startDate',
|
||||
label: '开始日期',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
placeholder: '请选择开始日期',
|
||||
valueFormat: 'x',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'endDate',
|
||||
label: '结束日期',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
placeholder: '请选择结束日期',
|
||||
valueFormat: 'x',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'cycleType',
|
||||
label: '周期类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_DV_CYCLE_TYPE, 'number'),
|
||||
placeholder: '请选择周期类型',
|
||||
},
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
fieldName: 'cycleCount',
|
||||
label: '周期数量',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
min: 1,
|
||||
precision: 0,
|
||||
},
|
||||
rules: z.number().default(1),
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
formItemClass: 'col-span-3',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '方案编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入方案编码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '方案名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入方案名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '方案类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_DV_SUBJECT_TYPE, 'number'),
|
||||
placeholder: '请选择方案类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.MES_DV_CHECK_PLAN_STATUS, 'number'),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'startDate',
|
||||
label: '开始日期',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions<MesDvCheckPlanApi.CheckPlan>['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'code',
|
||||
title: '方案编码',
|
||||
minWidth: 140,
|
||||
slots: {
|
||||
default: 'code',
|
||||
},
|
||||
},
|
||||
{ field: 'name', title: '方案名称', minWidth: 150 },
|
||||
{
|
||||
field: 'type',
|
||||
title: '方案类型',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_DV_SUBJECT_TYPE },
|
||||
},
|
||||
},
|
||||
{ field: 'startDate', title: '开始日期', width: 150, formatter: 'formatDate' },
|
||||
{ field: 'endDate', title: '结束日期', width: 150, formatter: 'formatDate' },
|
||||
{
|
||||
field: 'cycleType',
|
||||
title: '周期类型',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_DV_CYCLE_TYPE },
|
||||
},
|
||||
},
|
||||
{ field: 'cycleCount', title: '周期数量', width: 100 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.MES_DV_CHECK_PLAN_STATUS },
|
||||
},
|
||||
},
|
||||
{ field: 'createTime', title: '创建时间', width: 180, formatter: 'formatDateTime' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
slots: {
|
||||
default: 'actions',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MesDvCheckPlanApi } from '#/api/mes/dv/checkplan';
|
||||
import type { ActionItem } from '#/components/table-action/typing';
|
||||
|
||||
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteCheckPlan,
|
||||
disableCheckPlan,
|
||||
enableCheckPlan,
|
||||
exportCheckPlan,
|
||||
getCheckPlanPage,
|
||||
} from '#/api/mes/dv/checkplan';
|
||||
import { $t } from '#/locales';
|
||||
import { MesDvCheckPlanStatusEnum } from '#/views/mes/utils/constants';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建点检计划 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData({ type: 'create' }).open();
|
||||
}
|
||||
|
||||
/** 查看点检计划 */
|
||||
function handleDetail(row: MesDvCheckPlanApi.CheckPlan) {
|
||||
formModalApi.setData({ id: row.id, type: 'detail' }).open();
|
||||
}
|
||||
|
||||
/** 编辑点检计划 */
|
||||
function handleEdit(row: MesDvCheckPlanApi.CheckPlan) {
|
||||
formModalApi.setData({ id: row.id, type: 'update' }).open();
|
||||
}
|
||||
|
||||
/** 删除点检计划 */
|
||||
async function handleDelete(row: MesDvCheckPlanApi.CheckPlan) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteCheckPlan(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出点检计划 */
|
||||
async function handleExport() {
|
||||
const data = await exportCheckPlan(await gridApi.formApi.getValues());
|
||||
downloadFileFromBlobPart({ fileName: '点检保养方案.xls', source: data });
|
||||
}
|
||||
|
||||
/** 启用点检计划 */
|
||||
async function handleEnable(row: MesDvCheckPlanApi.CheckPlan) {
|
||||
await enableCheckPlan(row.id!);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
/** 停用点检计划 */
|
||||
async function handleDisable(row: MesDvCheckPlanApi.CheckPlan) {
|
||||
await disableCheckPlan(row.id!);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
/** 获取行操作按钮 */
|
||||
function getTableActions(row: MesDvCheckPlanApi.CheckPlan): ActionItem[] {
|
||||
const actions: ActionItem[] = [];
|
||||
if (row.status === MesDvCheckPlanStatusEnum.PREPARE) {
|
||||
actions.push(
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['mes:dv-check-plan:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mes:dv-check-plan:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
actions.push(
|
||||
row.status === MesDvCheckPlanStatusEnum.PREPARE
|
||||
? {
|
||||
label: '启用',
|
||||
type: 'link',
|
||||
auth: ['mes:dv-check-plan:update'],
|
||||
onClick: handleEnable.bind(null, row),
|
||||
}
|
||||
: {
|
||||
label: '停用',
|
||||
type: 'link',
|
||||
auth: ['mes:dv-check-plan:update'],
|
||||
onClick: handleDisable.bind(null, row),
|
||||
},
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) =>
|
||||
await getCheckPlanPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
}),
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<MesDvCheckPlanApi.CheckPlan>,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert
|
||||
title="【设备】点检保养项目、点检保养方案"
|
||||
url="https://doc.iocoder.cn/mes/dv/check-plan/"
|
||||
/>
|
||||
</template>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid table-title="点检保养方案列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['点检保养方案']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['mes:dv-check-plan:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
{
|
||||
label: $t('ui.actionTitle.export'),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
auth: ['mes:dv-check-plan:export'],
|
||||
onClick: handleExport,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #code="{ row }">
|
||||
<Button type="link" @click="handleDetail(row)">{{ row.code }}</Button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction :actions="getTableActions(row)" />
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts" setup>
|
||||
import type { MesDvCheckPlanApi } from '#/api/mes/dv/checkplan';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createCheckPlan, getCheckPlan, updateCheckPlan } from '#/api/mes/dv/checkplan';
|
||||
import { $t } from '#/locales';
|
||||
import { MesDvCheckPlanStatusEnum } from '#/views/mes/utils/constants';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import MachineryList from './machinery-list.vue';
|
||||
import SubjectList from './subject-list.vue';
|
||||
|
||||
type FormMode = 'create' | 'detail' | 'update';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formMode = ref<FormMode>('create');
|
||||
const subTabsName = ref('machinery');
|
||||
const formData = ref<MesDvCheckPlanApi.CheckPlan>();
|
||||
const isDetail = computed(() => formMode.value === 'detail');
|
||||
const getTitle = computed(() => {
|
||||
if (formMode.value === 'detail') {
|
||||
return '查看点检保养方案';
|
||||
}
|
||||
return formMode.value === 'update' ? '修改点检保养方案' : '新增点检保养方案';
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-1',
|
||||
labelWidth: 110,
|
||||
},
|
||||
wrapperClass: 'grid-cols-3',
|
||||
layout: 'horizontal',
|
||||
schema: [],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
/** 表单 schema 需要 formApi 引用,所以通过 setState 设置 schema */
|
||||
formApi.setState({ schema: useFormSchema(formApi) });
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
if (isDetail.value) {
|
||||
await modalApi.close();
|
||||
return;
|
||||
}
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as MesDvCheckPlanApi.CheckPlan;
|
||||
try {
|
||||
if (formMode.value === 'create') {
|
||||
const id = await createCheckPlan(data);
|
||||
formData.value = { ...data, id: id as number, status: MesDvCheckPlanStatusEnum.PREPARE };
|
||||
await formApi.setFieldValue('id', id);
|
||||
formMode.value = 'update';
|
||||
} else {
|
||||
await updateCheckPlan(data);
|
||||
formData.value = { ...formData.value, ...data };
|
||||
}
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
await formApi.resetForm();
|
||||
subTabsName.value = 'machinery';
|
||||
// 加载数据
|
||||
const data = modalApi.getData<{ id?: number; type?: FormMode }>();
|
||||
formMode.value = data?.type || 'create';
|
||||
formApi.setDisabled(formMode.value === 'detail');
|
||||
modalApi.setState({ showConfirmButton: formMode.value !== 'detail' });
|
||||
if (!data?.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getCheckPlan(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-4/5">
|
||||
<Form class="mx-4" />
|
||||
<Tabs
|
||||
v-if="formMode !== 'create' && formData?.id"
|
||||
v-model:active-key="subTabsName"
|
||||
class="mx-4 mt-4"
|
||||
>
|
||||
<Tabs.TabPane key="machinery" tab="设备">
|
||||
<MachineryList :form-type="formMode" :plan-id="formData.id" />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="subject" tab="项目">
|
||||
<SubjectList :form-type="formMode" :plan-id="formData.id" :plan-type="formData.type" />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue