feat:太阳能网控设置,新增功能。

This commit is contained in:
tianyongbao
2026-07-01 11:20:31 +08:00
parent b0c8057f8d
commit 3d3bdea35b
5 changed files with 670 additions and 0 deletions

View File

@@ -16,6 +16,19 @@ export const listDevice = (query?: DeviceQuery): AxiosPromise<DeviceVO[]> => {
}); });
}; };
/**
* 查询太阳能网控设备列表
* @param query
* @returns {*}
*/
export const listSolarDevice = (query?: DeviceQuery): AxiosPromise<DeviceVO[]> => {
return request({
url: '/fishery/device/solar/list',
method: 'get',
params: query
});
};
/** /**
* 查询设备管理详细 * 查询设备管理详细
* @param id * @param id

View File

@@ -234,6 +234,11 @@ export interface DeviceVO {
*/ */
pondName?: string; pondName?: string;
/**
* 在线状态online/offline
*/
onlineStatus?: string;
/** /**
* 创建时间(激活时间) * 创建时间(激活时间)
*/ */

38
src/api/iot/index.ts Normal file
View File

@@ -0,0 +1,38 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
export interface SolarPropertyCommand {
id: string | number;
properties: Record<string, any>;
cache?: boolean;
}
export interface SolarPropertyCommandResult {
deviceId: string | number;
serialNum: string;
online: boolean;
statusCode?: number;
properties: Record<string, any>;
mode: 'direct' | 'cached';
cached: boolean;
sent: boolean;
}
export const getDeviceStatus = (devicetype: number, serialnum: string): AxiosPromise<number> => {
return request({
url: '/iot/device/status',
method: 'get',
params: {
devicetype,
serialnum
}
});
};
export const sendSolarPropertyCommand = (data: SolarPropertyCommand): AxiosPromise<SolarPropertyCommandResult> => {
return request({
url: '/iot/device/solar/property-command',
method: 'post',
data
});
};

View File

@@ -13,6 +13,7 @@ declare module 'vue' {
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default'] Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
DictTag: typeof import('./../components/DictTag/index.vue')['default'] DictTag: typeof import('./../components/DictTag/index.vue')['default']
Editor: typeof import('./../components/Editor/index.vue')['default'] Editor: typeof import('./../components/Editor/index.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElBadge: typeof import('element-plus/es')['ElBadge'] ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
@@ -42,6 +43,7 @@ declare module 'vue' {
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']

View File

@@ -0,0 +1,612 @@
<template>
<div class="p-2">
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="100px">
<el-form-item label="用户信息" prop="params.userKeyword">
<el-input v-model="queryParams.params.userKeyword" placeholder="请输入用户名或手机号" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="设备编号" prop="serialNum">
<el-input v-model="queryParams.serialNum" placeholder="请输入设备编号" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="塘口名称" prop="params.pondKeyword">
<el-input v-model="queryParams.params.pondKeyword" placeholder="请输入塘口名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="在线状态" prop="params.onlineStatus">
<el-select v-model="queryParams.params.onlineStatus" placeholder="请选择在线状态" clearable style="width: 180px" @change="handleQuery">
<el-option v-for="item in onlineStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</transition>
<el-card shadow="never">
<template #header>
<el-row :gutter="10" class="mb8">
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</template>
<el-table v-loading="loading" border :data="deviceList" row-key="id">
<el-table-column label="用户名" align="center" prop="userName" min-width="120" />
<el-table-column label="手机号" align="center" prop="mobilePhone" min-width="120" />
<el-table-column label="设备编号" align="center" prop="serialNum" min-width="150" />
<el-table-column label="设备名称" align="center" prop="deviceName" min-width="130" />
<el-table-column label="塘口名称" align="center" prop="pondName" min-width="120" />
<el-table-column label="在线状态" align="center" width="120">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row)" effect="light">
{{ getStatusText(scope.row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="盐度补偿" align="center" prop="salinityCompensation" width="120">
<template #default="scope">
{{ formatValue(scope.row.salinityCompensation) }}
</template>
</el-table-column>
<el-table-column label="线性补偿" align="center" prop="phaseCompensation" width="120">
<template #default="scope">
{{ formatValue(scope.row.phaseCompensation) }}
</template>
</el-table-column>
<el-table-column label="相位差补偿" align="center" prop="phasedifCompensation" width="120">
<template #default="scope">
{{ formatValue(scope.row.phasedifCompensation) }}
</template>
</el-table-column>
<el-table-column label="温度补偿" align="center" prop="temperatureCompensation" width="120">
<template #default="scope">
{{ formatValue(scope.row.temperatureCompensation) }}
</template>
</el-table-column>
<el-table-column label="最后上数时间" align="center" prop="lastReportTime" min-width="170">
<template #default="scope">
<span>{{ parseTime(scope.row.lastReportTime, '{y}-{m}-{d} {h}:{i}:{s}') || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="170">
<template #default="scope">
<el-button link type="primary" icon="Refresh" :loading="statusLoadingId === scope.row.id" @click="refreshStatus(scope.row)">刷新状态</el-button>
<el-button link type="primary" icon="Position" @click="openCommand(scope.row)" v-hasPermi="['fishery:solarControl:command']">下发指令</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
</el-card>
<el-dialog v-model="commandOpen" :title="commandTitle" width="680px" append-to-body destroy-on-close>
<template v-if="currentDevice">
<div class="device-summary">
<div>
<div class="device-summary__name">{{ currentDevice.deviceName || '-' }}</div>
<div class="device-summary__serial">{{ currentDevice.serialNum }}</div>
</div>
<el-tag :type="getStatusTagType(currentDevice)" size="large">{{ getStatusText(currentDevice) }}</el-tag>
</div>
<el-alert
class="mb-[12px]"
:title="currentDevice.online ? '设备在线,将直接下发到太阳能网控。' : '设备离线,将写入缓存,待设备上线后自动下发。'"
:type="currentDevice.online ? 'success' : 'warning'"
show-icon
:closable="false"
/>
<el-form ref="commandFormRef" :model="commandForm" :rules="commandRules" label-width="130px">
<el-form-item label="下发属性" prop="identifier">
<el-select v-model="commandForm.identifier" filterable placeholder="请选择属性" style="width: 100%" @change="handlePropertyChange">
<el-option-group v-for="group in propertyGroups" :key="group.label" :label="group.label">
<el-option v-for="item in group.options" :key="item.identifier" :label="item.name" :value="item.identifier">
<span>{{ item.name }}</span>
<span class="property-option-code">{{ item.identifier }}</span>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<template v-if="selectedProperty">
<el-form-item v-if="selectedProperty.type === 'enum'" :label="selectedProperty.name" prop="value">
<el-select v-model="commandForm.value" placeholder="请选择值" style="width: 100%">
<el-option v-for="option in selectedProperty.options" :key="String(option.value)" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<template v-else-if="selectedProperty.type === 'struct'">
<el-form-item
v-for="field in selectedProperty.fields"
:key="field.identifier"
:label="field.name"
:prop="`structValue.${field.identifier}`"
:rules="[{ required: true, message: `${field.name}不能为空`, trigger: 'blur' }]"
>
<el-input-number
v-model="commandForm.structValue[field.identifier]"
:min="field.min"
:max="field.max"
:step="field.step"
:precision="field.precision"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</template>
<el-form-item v-else :label="selectedProperty.name" prop="value">
<el-input-number
v-model="commandForm.value"
:min="selectedProperty.min"
:max="selectedProperty.max"
:step="selectedProperty.step"
:precision="selectedProperty.precision"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<div class="property-meta">
<span>{{ selectedProperty.identifier }}</span>
<span v-if="selectedProperty.rangeText">{{ selectedProperty.rangeText }}</span>
<span v-if="selectedProperty.unit">{{ selectedProperty.unit }}</span>
</div>
</template>
</el-form>
</template>
<template #footer>
<div class="command-footer">
<el-button @click="commandOpen = false">取消</el-button>
<el-button
v-if="currentDevice?.online"
type="primary"
icon="Position"
:loading="submitLoading"
@click="submitCommand(false)"
>
直接下发
</el-button>
<el-button
v-else
type="warning"
icon="Clock"
:loading="submitLoading"
@click="submitCommand(true)"
>
缓存下发
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="SolarControl" lang="ts">
import { listSolarDevice } from '@/api/fishery/device';
import { DeviceQuery, DeviceVO } from '@/api/fishery/device/types';
import { getDeviceStatus, sendSolarPropertyCommand } from '@/api/iot';
type PropertyType = 'float' | 'double' | 'int' | 'enum' | 'struct';
interface PropertyOption {
identifier: string;
name: string;
type: PropertyType;
min?: number;
max?: number;
step?: number;
precision?: number;
unit?: string;
rangeText?: string;
options?: Array<{ label: string; value: number }>;
fields?: Array<Required<Pick<PropertyOption, 'identifier' | 'name' | 'min' | 'max' | 'step' | 'precision'>> & { unit?: string }>;
defaultValue?: number;
}
interface SolarDeviceVO extends DeviceVO {
online?: boolean;
statusCode?: number;
}
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const queryFormRef = ref<ElFormInstance>();
const commandFormRef = ref<ElFormInstance>();
const deviceList = ref<SolarDeviceVO[]>([]);
const loading = ref(false);
const showSearch = ref(true);
const total = ref(0);
const commandOpen = ref(false);
const currentDevice = ref<SolarDeviceVO>();
const submitLoading = ref(false);
const statusLoadingId = ref<string | number>();
const onlineStatusOptions = [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' }
];
const data = reactive<PageData<{}, DeviceQuery>>({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
serialNum: undefined,
deviceName: undefined,
deviceType: 3,
params: {
userKeyword: undefined,
onlineStatus: undefined,
pondKeyword: undefined
}
},
rules: {}
});
const { queryParams } = toRefs(data);
const propertyGroups = [
{
label: '常用设置',
options: [
numberProperty('salinitySet', '盐度设置', 'float', 0, 100, 0.01, undefined, 0),
enumProperty('PowerMode', '电源模式', {
0: '太阳能网控常驻模式',
1: '太阳能网控节能模式',
2: '适配器网控'
}),
numberProperty('WakeUpTime', '休眠时长', 'int', 1, 2147483647, 1, 'min', 10),
numberProperty('BootToSleepTimeout', '工作时长', 'int', 2, 2147483647, 1, 'min', 2),
numberProperty('updatatime', '数据上报时间', 'int', 0, 65535, 1, 's', 60),
numberProperty('FirstBootToSleepTimeout', '首次上电工作时间', 'int', 3, 2147483647, 1, 'min', 3)
]
},
{
label: '校准与补偿',
options: [
enumProperty('correct', '校准', {
1: '下发溶解氧传感器校准指令',
2: '下发盐度传感器校准指令',
11: '氧探头饱和校准',
12: '氧探头无氧校准',
13: '氧探头低温系数设置',
14: '氧探头高温系数设置',
31: '下发pH传感器校准第1步',
32: '下发pH传感器校准第2步'
}),
numberProperty('phasedif_compensation', '相位差补偿', 'float', -10, 100, 0.001, '°', 0),
numberProperty('phase_compensation', '线性补偿', 'float', 0, 10, 0.0001, undefined, 1),
numberProperty('temperature_compensation', '温度补偿', 'float', -10, 10, 0.001, '°C', 0),
numberProperty('stdsatphase', '标准饱和相位差', 'double', 0, 360, 0.01, '°', 0),
numberProperty('stdphaseinterval', '标准相位区间', 'double', 0, 360, 0.01, '°', 0)
]
},
{
label: '高级参数',
options: [
numberProperty('SaturationPoint', '饱和相位差测量值', 'float', -1000, 1000, 0.01, '°', 0),
numberProperty('AnaerobicPoint', '无氧相位差测量值', 'float', -1000, 1000, 0.01, '°', 0),
numberProperty('LowtempCOEF', '低温系数', 'float', -1000, 1000, 0.0001, undefined, 0),
numberProperty('HightempCOEF', '高温系数', 'float', -1000, 1000, 0.0001, undefined, 0),
numberProperty('ammonia_nitrogen', '氨氮', 'float', 0, 1000, 0.01, 'mg/L', 0),
numberProperty('TempPobeOffSet1', '温度偏移量通道1', 'float', -12.7, 12.7, 0.1, '°C', 0),
numberProperty('TempPobeOffSet2', '温度偏移量通道2', 'float', -12.7, 12.7, 1, '°C', 0),
structProperty('CorrectCharge', '电量校准', [
numberProperty('lowvoltage', '低压', 'float', 0, 10, 0.01, 'V', 0),
numberProperty('highvoltage', '高压', 'float', 0, 10, 0.01, 'V', 0)
])
]
}
];
const propertyList = computed(() => propertyGroups.flatMap((group) => group.options));
const selectedProperty = computed(() => propertyList.value.find((item) => item.identifier === commandForm.identifier));
const commandTitle = computed(() => `太阳能网控指令 - ${currentDevice.value?.deviceName || ''}`);
const commandForm = reactive({
identifier: 'PowerMode',
value: undefined as number | undefined,
structValue: {} as Record<string, number | undefined>
});
const commandRules = {
identifier: [{ required: true, message: '请选择下发属性', trigger: 'change' }],
value: [{ required: true, message: '属性值不能为空', trigger: 'blur' }]
};
function numberProperty(
identifier: string,
name: string,
type: Extract<PropertyType, 'float' | 'double' | 'int'>,
min: number,
max: number,
step: number,
unit?: string,
defaultValue?: number
): PropertyOption {
return {
identifier,
name,
type,
min,
max,
step,
unit,
precision: getPrecision(step),
rangeText: `${min} - ${max}`,
defaultValue
};
}
function enumProperty(identifier: string, name: string, options: Record<number, string>): PropertyOption {
const values = Object.entries(options).map(([value, label]) => ({ value: Number(value), label }));
return {
identifier,
name,
type: 'enum',
options: values,
defaultValue: values[0]?.value
};
}
function structProperty(identifier: string, name: string, fields: PropertyOption[]): PropertyOption {
return {
identifier,
name,
type: 'struct',
fields: fields.map((field) => ({
identifier: field.identifier,
name: field.name,
min: field.min ?? 0,
max: field.max ?? 0,
step: field.step ?? 1,
precision: field.precision ?? 0,
unit: field.unit
}))
};
}
function getPrecision(step: number) {
const text = String(step);
if (!text.includes('.')) {
return 0;
}
return text.split('.')[1].length;
}
const getList = async () => {
loading.value = true;
try {
queryParams.value.deviceType = 3;
const res = await listSolarDevice(queryParams.value);
deviceList.value = ((res.rows || []) as SolarDeviceVO[]).map(initStatusFromList);
total.value = res.total;
await refreshVisibleStatuses();
sortDeviceListByOnline();
} finally {
loading.value = false;
}
};
const initStatusFromList = (item: SolarDeviceVO) => {
if (item.onlineStatus?.toLowerCase() === 'online') {
item.online = true;
item.statusCode = 1;
} else {
item.online = false;
item.statusCode = 3;
}
return item;
};
const refreshVisibleStatuses = async () => {
await Promise.all(
deviceList.value.map(async (item) => {
try {
await refreshStatus(item, false);
} catch {
// 列表已有后端状态,单台云端状态刷新失败时不影响整页展示。
}
})
);
};
const refreshStatus = async (row: SolarDeviceVO, showMessage = true) => {
if (!row.serialNum) {
row.online = false;
row.statusCode = 0;
return;
}
statusLoadingId.value = row.id;
try {
const res = await getDeviceStatus(3, row.serialNum);
row.statusCode = res.data;
row.online = res.data === 1;
if (currentDevice.value?.id === row.id) {
currentDevice.value = row;
}
sortDeviceListByOnline();
if (showMessage) {
ElMessage.success('状态已刷新');
}
} finally {
statusLoadingId.value = undefined;
}
};
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
const resetQuery = () => {
queryFormRef.value?.resetFields();
queryParams.value.deviceType = 3;
queryParams.value.params = {
userKeyword: undefined,
onlineStatus: undefined,
pondKeyword: undefined
};
getList();
};
const openCommand = async (row: SolarDeviceVO) => {
currentDevice.value = row;
resetCommandForm();
commandOpen.value = true;
await refreshStatus(row, false);
};
const resetCommandForm = () => {
commandForm.identifier = 'PowerMode';
handlePropertyChange('PowerMode');
nextTick(() => commandFormRef.value?.clearValidate());
};
const handlePropertyChange = (identifier: string) => {
const property = propertyList.value.find((item) => item.identifier === identifier);
commandForm.value = property?.defaultValue;
commandForm.structValue = {};
if (property?.type === 'struct') {
property.fields?.forEach((field) => {
commandForm.structValue[field.identifier] = 0;
});
}
};
const submitCommand = async (cache: boolean) => {
if (!currentDevice.value || !selectedProperty.value) {
return;
}
await commandFormRef.value?.validate();
const property = selectedProperty.value;
const value = property.type === 'struct' ? { ...commandForm.structValue } : commandForm.value;
submitLoading.value = true;
try {
await sendSolarPropertyCommand({
id: currentDevice.value.id,
properties: {
[property.identifier]: value
},
cache
});
ElMessage.success(cache ? '已写入缓存,设备上线后自动下发' : '下发成功');
commandOpen.value = false;
await getList();
} finally {
submitLoading.value = false;
}
};
const getStatusText = (row: SolarDeviceVO) => {
if (row.online) {
return '在线';
}
if (row.statusCode === 3) {
return '离线';
}
if (row.statusCode === 8) {
return '禁用';
}
if (row.statusCode === 0) {
return '未激活';
}
return '未知';
};
const getStatusTagType = (row: SolarDeviceVO) => {
if (row.online) {
return 'success';
}
if (row.statusCode === 3) {
return 'warning';
}
if (row.statusCode === 8) {
return 'danger';
}
return 'info';
};
const formatValue = (value: unknown) => {
if (value === undefined || value === null || value === '') {
return '-';
}
return value;
};
const sortDeviceListByOnline = () => {
deviceList.value = deviceList.value
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const onlineDiff = Number(Boolean(b.item.online)) - Number(Boolean(a.item.online));
if (onlineDiff !== 0) {
return onlineDiff;
}
return a.index - b.index;
})
.map(({ item }) => item);
};
onMounted(() => {
getList();
});
</script>
<style scoped lang="scss">
.device-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
margin-bottom: 12px;
background: #f6f8fb;
border: 1px solid #e4e7ed;
border-radius: 6px;
}
.device-summary__name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.device-summary__serial {
margin-top: 4px;
font-size: 13px;
color: #606266;
}
.property-option-code {
float: right;
margin-left: 16px;
color: #909399;
font-size: 12px;
}
.property-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: 130px;
margin-bottom: 16px;
color: #909399;
font-size: 12px;
}
.command-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>