!348 wms、iot 迁移最新

Merge pull request !348 from 芋道源码/migration
pull/349/MERGE
芋道源码 2026-05-25 00:23:33 +00:00 committed by Gitee
commit 54d0459f07
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
320 changed files with 22135 additions and 1040 deletions

View File

@ -60,6 +60,7 @@
"pinia": "catalog:",
"steady-xml": "catalog:",
"tinymce": "catalog:",
"tyme4ts": "catalog:",
"video.js": "catalog:",
"vue": "catalog:",
"vue-dompurify-html": "catalog:",

View File

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

View File

@ -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',
},
{

View File

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

View File

@ -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 });
}
/** 查询物料产品详情 */

View File

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

View File

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

View File

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

View File

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

View File

@ -58,6 +58,7 @@ const textareaProps = computed(() => {
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
returnText: true,
};
});
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
});
/** 打开地图弹窗 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,7 +44,7 @@ async function handleRefresh() {
/** 按任务名称搜索 */
async function handleSearch() {
await gridApi.query();
await gridApi.reload();
}
/** 新增任务 */

View File

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

View File

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

View File

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

View File

@ -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 });
}
/** 打开产品详情 */

View File

@ -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>
<!-- 按钮组 -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
>
<!-- 子条件组容器橙色主题 -->

View File

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

View File

@ -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"
>
<!-- 条件配置 -->

View File

@ -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"
>
<!-- 条件组容器橙色主题 -->

View File

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

View File

@ -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>
<!-- 枚举值选择 -->

View File

@ -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"
>
<!-- 执行器头部蓝色主题 -->

View File

@ -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"
>
<!-- 触发器头部绿色主题 -->

View File

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

View File

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

View File

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

View File

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

View File

@ -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 paramBETWEEN_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}:告警配置不能为空`));

View File

@ -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>
<!-- 服务 -->

View File

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

View File

@ -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);
}
/** 删除枚举项 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { default as CalTeamSelectDialog } from './cal-team-select-dialog.vue';
export { default as CalTeamSelect } from './cal-team-select.vue';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default as DvCheckPlanSelect } from './dv-check-plan-select.vue';

View File

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

View File

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

View File

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