Files
iot-device-management-frontend/apps/web-antd/src/views/video/device/algorithm/index.vue
lzh fd946c132e 重构: aiot 模块重命名为 video,WVP 凭据移至环境变量
路径重命名:
- api/aiot/{alarm,device,edge,request} → api/video/{alarm,device,edge,request}
- views/aiot/{alarm,device,edge} → views/video/{alarm,device,edge}
- vite.config.mts 代理路径 /admin-api/aiot/* → /admin-api/video/*

video/request.ts 改造:
- WVP 用户名/密码 MD5 改读 import.meta.env,不再写死在源码里
- force 截图失败时补一条 console.debug,便于回溯 COS 图片加载异常

video/alarm/index.ts 顺带清理:
- 移除无调用方的重复 API getRecentAlerts(与 getAlertPage 重叠)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:40:02 +08:00

525 lines
16 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 { VideoDeviceApi } from '#/api/video/device';
import type { VideoEdgeApi } from '#/api/video/edge';
import { computed, onMounted, ref } from 'vue';
import {
Button,
Card,
Form,
InputNumber,
message,
Select,
SelectOption,
Spin,
Tabs,
} from 'ant-design-vue';
import {
getAlgorithmList,
saveAlgoGlobalParams,
updateDeviceAlgoParams,
} from '#/api/video/device';
import { getDeviceList } from '#/api/video/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 selectedDeviceId = ref<string>('');
const deviceList = ref<VideoEdgeApi.Device[]>([]);
const isDeviceMode = computed(() => selectedDeviceId.value !== '');
const saveButtonText = computed(() => {
if (!isDeviceMode.value) return '保存全局默认';
const device = deviceList.value.find(
(d) => d.deviceId === selectedDeviceId.value,
);
const name = device?.deviceName || device?.deviceId || selectedDeviceId.value;
return `保存到 ${name}`;
});
// ==================== 算法数据 ====================
const algorithms = ref<VideoDeviceApi.Algorithm[]>([]);
const activeTab = ref<string>('');
const loading = ref(false);
const saving = ref(false);
// 每个算法的表单数据: { algoCode: { paramKey: value } }
const formDataMap = ref<Record<string, Record<string, any>>>({});
// 记录用户修改过的字段
const dirtyFieldsMap = ref<Record<string, Set<string>>>({});
// 设备模式下缓存的全局默认参数: { algoCode: { paramKey: value } }
const originalGlobalParamsMap = ref<Record<string, Record<string, any>>>({});
// 防竞态loadAlgorithms 请求版本计数器
let loadRequestId = 0;
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] || {};
});
function getParamLabel(key: string): string {
return paramNameMap[key] || key;
}
function getParamDesc(key: string): string | undefined {
return paramDescMap[key];
}
onMounted(async () => {
await Promise.all([loadDevices(), loadAlgorithms()]);
});
async function loadDevices() {
try {
const list = await getDeviceList();
deviceList.value = Array.isArray(list) ? list : [];
} catch {
deviceList.value = [];
}
}
async function onDeviceChange() {
await loadAlgorithms();
}
async function loadAlgorithms() {
loading.value = true;
const currentRequestId = ++loadRequestId;
try {
// 设备模式下,先请求全局默认参数作为比较基准
if (isDeviceMode.value) {
try {
const globalData = await getAlgorithmList(undefined);
if (currentRequestId !== loadRequestId) return;
if (Array.isArray(globalData)) {
for (const algo of globalData) {
try {
const gp = JSON.parse(algo.globalParams || '{}');
const schema = JSON.parse(algo.paramSchema || '{}');
const baseline: Record<string, any> = {};
for (const k of Object.keys(schema)) {
baseline[k] = gp[k] !== undefined ? gp[k] : schema[k]?.default;
}
originalGlobalParamsMap.value[algo.algoCode] = baseline;
} catch { /* empty */ }
}
}
} catch { /* empty */ }
}
const deviceId = selectedDeviceId.value || undefined;
const data = await getAlgorithmList(deviceId);
// 防竞态:请求期间设备已切换,丢弃本次响应
if (currentRequestId !== loadRequestId) return;
algorithms.value = Array.isArray(data) ? data : [];
if (algorithms.value.length > 0) {
// 保持当前 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);
}
} else {
activeTab.value = '';
}
} catch {
if (currentRequestId === loadRequestId) {
message.error('加载算法列表失败');
}
} finally {
if (currentRequestId === loadRequestId) {
loading.value = false;
}
}
}
function initFormData(algo: VideoDeviceApi.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;
}
/** 获取比较基准值:设备模式用全局默认,全局模式用 schema default */
function getBaselineValue(key: string): any {
if (isDeviceMode.value) {
const algo = currentAlgo.value;
if (algo) {
const ogp = originalGlobalParamsMap.value[algo.algoCode];
if (ogp && ogp[key] !== undefined) return ogp[key];
}
}
return getSchemaDefault(key);
}
function isModified(key: string): boolean {
return currentFormData.value[key] !== getBaselineValue(key);
}
async function handleSave() {
const algo = currentAlgo.value;
if (!algo?.algoCode) return;
saving.value = true;
try {
const form = currentFormData.value;
const schema = currentSchema.value;
// 只保存与基准值不同的字段(设备模式对比全局默认,全局模式对比 schema default
const toSave: Record<string, any> = {};
for (const key of Object.keys(schema)) {
const baselineVal = getBaselineValue(key);
if (form[key] !== baselineVal) {
toSave[key] = form[key];
}
}
if (isDeviceMode.value) {
// 设备模式:保存到设备绑定
await updateDeviceAlgoParams(
selectedDeviceId.value,
algo.algoCode,
JSON.stringify(toSave),
);
message.success('设备参数保存成功');
} else {
// 全局模式:保存全局参数
await saveAlgoGlobalParams(algo.algoCode, JSON.stringify(toSave));
message.success('全局参数保存成功');
}
// 更新本地算法数据
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 baselineVal = getBaselineValue(key);
if (formDataMap.value[code]) {
formDataMap.value[code]![key] = baselineVal;
onFieldChange(key);
}
}
/** 判断字段是否为不适合在全局配置中编辑的类型 */
function isSkippedField(key: string, schema: any): boolean {
// working_hours 和 list 类型在全局配置中跳过(场景差异大,不适合全局设置)
return key === 'working_hours' || schema.type === 'list';
}
</script>
<template>
<div class="algo-global-config">
<Card title="算法参数配置">
<template #extra>
<div class="card-extra">
<Select
v-model:value="selectedDeviceId"
style="width: 240px"
placeholder="选择设备"
@change="onDeviceChange"
>
<SelectOption value="">全局默认</SelectOption>
<SelectOption
v-for="d in deviceList"
:key="d.deviceId"
:value="d.deviceId"
>
{{ d.deviceName || d.deviceId }}
</SelectOption>
</Select>
</div>
</template>
<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>
</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>
</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">
当前查看的是该设备各 ROI 绑定的实际参数修改后将更新该设备所有 ROI 绑定中对应算法的参数需重新推送配置到边缘端才能生效
</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: 12px;
}
.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;
}
.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>