Files
iot-device-management-frontend/apps/web-antd/src/views/aiot/device/algorithm/index.vue

594 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { AiotDeviceApi } from '#/api/aiot/device';
import type { AiotEdgeApi } from '#/api/aiot/edge';
import { computed, onMounted, ref, watch } from 'vue';
import {
Button,
Card,
Form,
InputNumber,
message,
Select,
Spin,
Tabs,
Tag,
} from 'ant-design-vue';
import {
getAlgorithmList,
saveAlgoGlobalParams,
saveDeviceAlgoParams,
} from '#/api/aiot/device';
import { getDeviceList } from '#/api/aiot/edge';
// 参数名中英文映射(与 AlgorithmParamEditor 保持一致)
const paramNameMap: Record<string, string> = {
leave_countdown_sec: '离岗倒计时(秒)',
working_hours: '工作时间段',
cooldown_seconds: '告警冷却期(秒)',
confirm_seconds: '确认时间(秒)',
confirm_intrusion_seconds: '入侵确认时间(秒)',
confirm_clear_seconds: '消失确认时间(秒)',
min_confidence: '最小置信度',
max_targets: '最大目标数',
detection_interval: '检测间隔(秒)',
enable_tracking: '启用跟踪',
tracking_timeout: '跟踪超时(秒)',
confirm_vehicle_sec: '车辆确认时间(秒)',
parking_countdown_sec: '违停倒计时(秒)',
confirm_clear_sec: '消失确认时间(秒)',
cooldown_sec: '告警冷却期(秒)',
count_threshold: '车辆数量阈值',
confirm_congestion_sec: '拥堵确认时间(秒)',
};
// 参数说明映射
const paramDescMap: Record<string, string> = {
leave_countdown_sec: '人员离开后,倒计时多少秒才触发离岗告警',
working_hours: '仅在指定时间段内进行监控留空表示24小时监控',
cooldown_seconds: '触发告警后,多少秒内不再重复告警(用于周界入侵等算法)',
confirm_seconds: '持续检测到人达到该时间后触发告警',
confirm_intrusion_seconds: '入侵确认:持续检测到人达到该时间后触发告警',
confirm_clear_seconds: '消失确认:持续无人达到该时间后自动结束告警',
confirm_vehicle_sec: '确认车辆停留的时间,超过该时间开始违停倒计时',
parking_countdown_sec: '确认有车后的违停等待时间,超过该时间触发违停告警',
confirm_clear_sec: '车辆离开后,持续无车达到该时间后自动结束告警',
cooldown_sec: '触发告警后,多少秒内不再重复告警',
count_threshold: '区域内车辆数量达到该值时判定为拥堵',
confirm_congestion_sec: '车辆数持续超过阈值达到该时间后触发拥堵告警',
};
const algorithms = ref<AiotDeviceApi.Algorithm[]>([]);
const activeTab = ref<string>('');
const loading = ref(false);
const saving = ref(false);
// 边缘设备
const edgeDevices = ref<AiotEdgeApi.Device[]>([]);
const selectedDeviceId = ref<string>('');
const deviceLoading = ref(false);
// 全局默认参数缓存(用于对比设备参数是否与全局一致)
const globalParamsCache = ref<Record<string, Record<string, any>>>({});
// 每个算法的表单数据: { algoCode: { paramKey: value } }
const formDataMap = ref<Record<string, Record<string, any>>>({});
// 记录用户修改过的字段
const dirtyFieldsMap = ref<Record<string, Set<string>>>({});
const isDeviceMode = computed(() => !!selectedDeviceId.value);
const currentAlgo = computed(() => {
return algorithms.value.find((a) => a.algoCode === activeTab.value);
});
const currentSchema = computed(() => {
const algo = currentAlgo.value;
if (!algo?.paramSchema) return {};
try {
return JSON.parse(algo.paramSchema);
} catch {
return {};
}
});
const currentFormData = computed(() => {
return formDataMap.value[activeTab.value] || {};
});
// 当前算法的全局默认值(非 schema default而是保存的 globalParams
const currentGlobalParams = computed(() => {
return globalParamsCache.value[activeTab.value] || {};
});
function getParamLabel(key: string): string {
return paramNameMap[key] || key;
}
function getParamDesc(key: string): string | undefined {
return paramDescMap[key];
}
/** 判断设备参数值是否与全局默认一致 */
function isSameAsGlobal(key: string): boolean {
if (!isDeviceMode.value) return false;
const form = currentFormData.value;
const globalVal = currentGlobalParams.value[key];
const schemaDefault = getSchemaDefault(key);
// 全局值:优先 globalParams回退 schema default
const effectiveGlobal = globalVal !== undefined ? globalVal : schemaDefault;
return form[key] === effectiveGlobal;
}
onMounted(async () => {
await Promise.all([loadEdgeDevices(), loadAlgorithms()]);
});
// 切换设备时重新加载算法参数
watch(selectedDeviceId, async () => {
await loadAlgorithms();
});
async function loadEdgeDevices() {
deviceLoading.value = true;
try {
const list = await getDeviceList();
edgeDevices.value = Array.isArray(list) ? list : [];
} catch {
edgeDevices.value = [];
} finally {
deviceLoading.value = false;
}
}
async function loadAlgorithms() {
loading.value = true;
try {
const deviceId = selectedDeviceId.value || undefined;
const data = await getAlgorithmList(deviceId);
algorithms.value = Array.isArray(data) ? data : [];
// 如果是设备模式且全局缓存为空,先加载一次全局参数
if (isDeviceMode.value && Object.keys(globalParamsCache.value).length === 0) {
try {
const globalData = await getAlgorithmList();
const globalAlgos = Array.isArray(globalData) ? globalData : [];
for (const algo of globalAlgos) {
const code = algo.algoCode || '';
try {
globalParamsCache.value[code] = JSON.parse(algo.globalParams || '{}');
} catch {
globalParamsCache.value[code] = {};
}
}
} catch { /* ignore */ }
}
// 非设备模式时更新全局缓存
if (!isDeviceMode.value) {
for (const algo of algorithms.value) {
const code = algo.algoCode || '';
try {
globalParamsCache.value[code] = JSON.parse(algo.globalParams || '{}');
} catch {
globalParamsCache.value[code] = {};
}
}
}
if (algorithms.value.length > 0) {
// 保持当前 tab 选中,如果当前 tab 不在列表中则切到第一个
const codes = algorithms.value.map((a) => a.algoCode);
if (!codes.includes(activeTab.value)) {
activeTab.value = algorithms.value[0]!.algoCode || '';
}
// 初始化所有算法的表单数据
for (const algo of algorithms.value) {
initFormData(algo);
}
}
} catch {
message.error('加载算法列表失败');
} finally {
loading.value = false;
}
}
function initFormData(algo: AiotDeviceApi.Algorithm) {
const code = algo.algoCode || '';
let schema: Record<string, any> = {};
let globalParams: Record<string, any> = {};
try {
schema = JSON.parse(algo.paramSchema || '{}');
} catch { /* empty */ }
try {
globalParams = JSON.parse(algo.globalParams || '{}');
} catch { /* empty */ }
const form: Record<string, any> = {};
for (const key of Object.keys(schema)) {
// 优先用已保存的 globalParams 值,否则用 schema default
if (globalParams[key] !== undefined) {
form[key] = globalParams[key];
} else {
form[key] = schema[key]?.type === 'list'
? (schema[key]?.default || [])
: schema[key]?.default;
}
}
formDataMap.value[code] = form;
dirtyFieldsMap.value[code] = new Set();
}
function onFieldChange(key: string) {
const code = activeTab.value;
if (!dirtyFieldsMap.value[code]) {
dirtyFieldsMap.value[code] = new Set();
}
dirtyFieldsMap.value[code]!.add(key);
}
function getSchemaDefault(key: string): any {
return currentSchema.value[key]?.default;
}
function isModified(key: string): boolean {
const form = currentFormData.value;
const defaultVal = getSchemaDefault(key);
return form[key] !== defaultVal;
}
async function handleSave() {
const algo = currentAlgo.value;
if (!algo?.algoCode) return;
saving.value = true;
try {
const form = currentFormData.value;
const schema = currentSchema.value;
// 只保存与 paramSchema default 不同的字段
const toSave: Record<string, any> = {};
for (const key of Object.keys(schema)) {
const defaultVal = schema[key]?.default;
if (form[key] !== defaultVal) {
toSave[key] = form[key];
}
}
if (isDeviceMode.value) {
// 设备级保存
await saveDeviceAlgoParams(selectedDeviceId.value, algo.algoCode, JSON.stringify(toSave));
const device = edgeDevices.value.find((d) => d.deviceId === selectedDeviceId.value);
const deviceLabel = device?.deviceName || device?.deviceId || selectedDeviceId.value;
message.success(`参数已保存并推送到 ${deviceLabel}`);
} else {
// 全局保存
await saveAlgoGlobalParams(algo.algoCode, JSON.stringify(toSave));
message.success('参数已保存并推送到所有设备');
// 更新全局缓存
globalParamsCache.value[algo.algoCode] = { ...toSave };
}
// 更新本地算法数据
algo.globalParams = JSON.stringify(toSave);
dirtyFieldsMap.value[algo.algoCode] = new Set();
} catch (err: any) {
const errorMsg =
err?.response?.data?.msg ||
err?.response?.data?.message ||
err?.message ||
'保存失败';
message.error(errorMsg);
} finally {
saving.value = false;
}
}
function resetToDefault(key: string) {
const code = activeTab.value;
const defaultVal = getSchemaDefault(key);
if (formDataMap.value[code]) {
formDataMap.value[code]![key] = defaultVal;
onFieldChange(key);
}
}
/** 判断字段是否为不适合在全局配置中编辑的类型 */
function isSkippedField(key: string, schema: any): boolean {
// working_hours 和 list 类型在全局配置中跳过(场景差异大,不适合全局设置)
return key === 'working_hours' || schema.type === 'list';
}
// 设备选择器选项
const deviceOptions = computed(() => {
const options = [
{ value: '', label: '全局默认' },
];
for (const d of edgeDevices.value) {
options.push({
value: d.deviceId || '',
label: d.deviceName || d.deviceId || '未知设备',
});
}
return options;
});
const saveButtonText = computed(() => {
if (isDeviceMode.value) {
const device = edgeDevices.value.find((d) => d.deviceId === selectedDeviceId.value);
return `保存到 ${device?.deviceName || device?.deviceId || '设备'}`;
}
return '保存全局参数';
});
</script>
<template>
<div class="algo-global-config">
<Card title="算法参数配置">
<template #extra>
<div class="card-extra">
<span class="device-label">边缘设备</span>
<Select
v-model:value="selectedDeviceId"
:options="deviceOptions"
:loading="deviceLoading"
style="width: 220px"
placeholder="选择设备"
/>
</div>
</template>
<div v-if="isDeviceMode" class="device-mode-tip">
<Tag color="blue">设备级配置</Tag>
<span>当前配置仅对选中的边缘设备生效未单独设置的参数将继承全局默认值</span>
</div>
<div v-else class="global-mode-tip">
<Tag color="green">全局默认</Tag>
<span>配置各算法的全局默认参数ROI 绑定时将以此为默认值</span>
</div>
<Spin :spinning="loading">
<div v-if="algorithms.length === 0 && !loading" class="empty-state">
暂无可用算法
</div>
<Tabs
v-if="algorithms.length > 0"
v-model:activeKey="activeTab"
type="card"
>
<Tabs.TabPane
v-for="algo in algorithms"
:key="algo.algoCode"
:tab="algo.algoName || algo.algoCode"
>
<div class="algo-desc" v-if="algo.description">
{{ algo.description }}
</div>
<Form
layout="horizontal"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
class="param-form"
>
<template
v-for="(schema, key) in currentSchema"
:key="key"
>
<Form.Item
v-if="!isSkippedField(String(key), schema)"
:label="getParamLabel(String(key))"
>
<!-- 整数类型 -->
<template v-if="schema.type === 'int'">
<div class="field-row">
<InputNumber
v-model:value="currentFormData[String(key)]"
:min="schema.min"
:placeholder="`默认: ${schema.default}`"
style="width: 200px"
@change="onFieldChange(String(key))"
/>
<Button
v-if="isModified(String(key))"
size="small"
type="link"
@click="resetToDefault(String(key))"
>
恢复默认
</Button>
<Tag
v-if="isDeviceMode && isSameAsGlobal(String(key))"
color="default"
class="global-hint-tag"
>
与全局默认一致
</Tag>
</div>
<div v-if="getParamDesc(String(key))" class="param-desc">
{{ getParamDesc(String(key)) }}
</div>
<div class="schema-default">
Schema 默认值: {{ schema.default }}
</div>
</template>
<!-- 浮点数类型 -->
<template v-else-if="schema.type === 'float'">
<div class="field-row">
<InputNumber
v-model:value="currentFormData[String(key)]"
:min="schema.min"
:max="schema.max"
:step="0.01"
:placeholder="`默认: ${schema.default}`"
style="width: 200px"
@change="onFieldChange(String(key))"
/>
<Button
v-if="isModified(String(key))"
size="small"
type="link"
@click="resetToDefault(String(key))"
>
恢复默认
</Button>
<Tag
v-if="isDeviceMode && isSameAsGlobal(String(key))"
color="default"
class="global-hint-tag"
>
与全局默认一致
</Tag>
</div>
<div v-if="getParamDesc(String(key))" class="param-desc">
{{ getParamDesc(String(key)) }}
</div>
<div class="schema-default">
Schema 默认值: {{ schema.default }}
</div>
</template>
</Form.Item>
</template>
</Form>
<div class="form-footer">
<Button
type="primary"
:loading="saving"
@click="handleSave"
>
{{ saveButtonText }}
</Button>
</div>
</Tabs.TabPane>
</Tabs>
</Spin>
<!-- 帮助说明 -->
<div class="help-tip">
<span class="help-text">
<template v-if="isDeviceMode">
设备级参数仅覆盖选中设备的配置未设置的参数将使用全局默认值修改后需重新推送配置到边缘端才能生效
</template>
<template v-else>
全局参数为各算法的默认配置 ROI 绑定算法时未单独设置参数将使用此处的全局默认值修改后需重新推送配置到边缘端才能生效
</template>
</span>
</div>
</Card>
</div>
</template>
<style scoped>
.algo-global-config {
padding: 16px;
}
.card-extra {
display: flex;
align-items: center;
gap: 8px;
}
.device-label {
font-size: 13px;
color: #595959;
white-space: nowrap;
}
.device-mode-tip,
.global-mode-tip {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
color: #595959;
}
.device-mode-tip {
background: #e6f7ff;
border: 1px solid #91d5ff;
}
.global-mode-tip {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.empty-state {
text-align: center;
padding: 40px 0;
color: #999;
font-size: 14px;
}
.algo-desc {
margin-bottom: 16px;
padding: 8px 12px;
background: #fafafa;
border-radius: 4px;
font-size: 13px;
color: #666;
}
.param-form {
max-width: 700px;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
}
.global-hint-tag {
font-size: 11px;
color: #8c8c8c !important;
}
.param-desc {
margin-top: 4px;
font-size: 12px;
color: #8c8c8c;
line-height: 1.5;
}
.schema-default {
margin-top: 2px;
font-size: 11px;
color: #bfbfbf;
}
.form-footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.help-tip {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px;
background: #e6f4ff;
border: 1px solid #91caff;
border-radius: 6px;
font-size: 13px;
color: #0958d9;
}
.help-text {
flex: 1;
}
</style>