Compare commits

..

15 Commits

Author SHA1 Message Date
8375d3c8a1 refactor(aiot): 算法抽帧频率改为固定展示,不允许用户配置
- 在算法绑定列表中显示固定帧率标签(绿色Tag)
- 离岗检测:3帧/秒
- 周界入侵:1帧/秒
- 其他算法:默认5帧/秒
- 删除参数配置界面的帧率选择器
- 帧率由算法类型决定,不允许用户修改

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:01:55 +08:00
b0ea479493 feat(aiot): 添加算法抽帧频率选择器
功能特性:
- 新增抽帧频率参数映射和选择器
- 7个预设选项:10帧/秒到1帧/30秒
- 每个选项带有场景说明和适用描述
- 美观的下拉选择界面,清晰展示频率和用途

抽帧频率选项(物业场景优化):
1. 10帧/秒 - 高频检测,快速移动场景
2. 5帧/秒 - 中高频,正常人员活动
3. 3帧/秒 - 中频,人员离岗、聚集(离岗推荐)
4. 1帧/秒 - 标准频率,周界入侵(入侵推荐)
5. 1帧/3秒 - 低频,慢速场景
6. 1帧/10秒 - 极低频,车辆检测
7. 1帧/30秒 - 超低频,垃圾堆放等静态检测

界面优化:
- 下拉选项采用双行显示(频率+说明)
- 参数说明提示算法处理频率的影响
- 自动显示默认值对应的频率标签

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:15:10 +08:00
e90ffec8c8 refactor(aiot): 简化离岗检测参数显示,只展示核心配置项
用户只需配置:
- 离岗倒计时(秒):人员离开后多久触发告警(默认300秒)
- 工作时间段:监控时间配置

移除前端显示的技术细节参数:
- confirm_on_duty_sec(固定10秒)
- confirm_leave_sec(固定30秒)

优点:
- 界面简洁清晰,只显示用户关心的参数
- 避免用户困惑,降低配置难度
- 保留其他算法的参数映射不受影响

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:01:49 +08:00
0a3658dc57 refactor(aiot): 删除离岗检测的cooldown_sec参数映射
- 移除 cooldown_sec 参数(状态机已保证必须回岗才能再次告警)
- 保留 cooldown_seconds 用于其他算法(周界入侵等)
- 简化参数配置,减少用户困惑

离岗检测现在只需配置3个关键参数:
1. 离岗确认时间(30秒)
2. 离岗倒计时(300秒)
3. 在岗确认时间(10秒)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:59:25 +08:00
bddae7367a fix(aiot): 修正离岗检测参数标签和说明
- 添加 leave_countdown_sec(离岗倒计时)参数映射
- 修正 confirm_leave_sec 标签:离岗倒计时 → 离岗确认时间
- 明确三个参数的区别:
  1. 离岗确认时间(30秒):持续离开多久确认为离岗
  2. 离岗倒计时(300秒):确认离岗后倒计时多久触发告警 
  3. 告警冷却期(600秒):触发告警后多久内不再重复告警

- 在说明中标注"您要找的参数!"帮助用户识别

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:56:18 +08:00
37af7ea117 feat(aiot): 改进算法参数标签和说明
- 修改参数标签更易理解:
  - confirm_leave_sec: 确认离岗时间 → 离岗倒计时
  - cooldown_sec: 冷却时间 → 告警冷却期
  - confirm_on_duty_sec: 确认在岗时间 → 在岗确认时间
  - confirm_seconds: 确认时间 → 触发确认时间

- 添加参数说明功能:
  - 每个参数下方显示详细说明
  - 解释参数的具体作用和用途
  - 避免用户混淆参数含义

- 优化用户体验:
  - 离岗倒计时:持续离开X秒才触发告警(避免误报)
  - 告警冷却期:触发后X秒内不再重复告警(避免骚扰)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:53:12 +08:00
6b096862b2 refactor(aiot): 调整工作时间快捷模板
- 删除三班制模板
- 修改两班制时间为 08:30-11:00 和 12:00-17:30
- 保留全天监控和单班制模板

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:45:43 +08:00
5f04a3c401 refactor(aiot): 使用原生time input替换TimePicker组件
- 移除 ant-design-vue TimePicker 和 dayjs 依赖
- 使用原生 HTML5 <input type="time"> 实现时间选择
- 更简单、更稳定、浏览器原生支持
- 添加 Ant Design 风格的样式适配
- 简化代码逻辑,直接使用字符串格式 HH:mm
- 完美支持编辑、添加、模板功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:37:37 +08:00
e163bf5152 fix(aiot): 重构时间选择器为两个独立的TimePicker
- 将 TimePicker.RangePicker 改为两个独立的 TimePicker
- 使用 startTime 和 endTime 分别控制开始和结束时间
- 添加时间分隔符"至"提升可读性
- 修复编辑功能,现在可以正常编辑模板时间段
- 解决时间选择器无法显示和选择的问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:35:50 +08:00
fd6ac90b67 fix(aiot): 修复时间选择器placeholder语法错误
- 将 placeholder 字符串改为数组绑定 :placeholder
- 修复 Vue 编译错误:Attribute name cannot contain quotes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:31:08 +08:00
d70ce2fa79 feat(aiot): 工作时间段增加编辑功能
- 添加时间段编辑功能,点击编辑按钮可修改时间
- 快捷模板生成的时间段现在可以编辑调整
- 编辑模式下显示保存和取消按钮
- 正在编辑的时间段高亮显示
- 改进 TimePicker 提示文本,更清晰易用
- 删除、清空、切换模板时自动取消编辑状态
- 更新使用说明,添加编辑功能提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:26:59 +08:00
10701468c8 chore: 更新.gitignore排除诊断文档和开发辅助文件
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:17:59 +08:00
fd4673d661 fix(aiot): 修复算法参数保存失败 + 添加中文参数标签
- 修复 Column 'priority' cannot be null 错误
- AlgorithmParamEditor 增加 priority/enabled props 并在保存时传递
- RoiAlgorithmBind 提取绑定的 priority/enabled 并传递给编辑器
- 添加中文参数名映射 (working_hours -> 工作时间段等)
- 添加参数验证逻辑和详细错误日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:15:15 +08:00
5d43b46155 debug(aiot): 添加工作时间参数保存的详细日志
问题:用户保存工作时间参数时提示"参数保存失败"
调试:添加详细console日志帮助排查问题
- 显示保存前的原始数据
- 显示参数验证过程
- 显示working_hours格式检查
- 显示请求payload
- 显示完整的错误响应

使用:打开浏览器F12控制台查看日志
2026-02-13 16:07:25 +08:00
792424717a fix(aiot): ROI配置页面显示应用名而不是camera_code
问题:ROI配置页面显示camera_code(技术标识符),用户体验差
修复:
- 摄像头选择器显示应用名
- 页面标题显示应用名(如:测试区域A - ROI配置)
- camera_code仅作为后台标识符使用,不暴露给用户

影响页面:/aiot/device/roi
2026-02-13 15:52:58 +08:00
5 changed files with 289 additions and 73 deletions

6
.gitignore vendored
View File

@@ -50,3 +50,9 @@ vite.config.ts.*
*.sw?
.history
.cursor
# Diagnostic documents - DO NOT COMMIT
docs/问题诊断/*.md
!docs/问题诊断/README.md
CLAUDE.md
AGENTS.md

View File

@@ -20,6 +20,8 @@ interface Props {
paramSchema: string;
currentParams: string;
bindId: string;
priority?: number;
enabled?: number;
}
const props = withDefaults(defineProps<Props>(), {
@@ -27,6 +29,8 @@ const props = withDefaults(defineProps<Props>(), {
paramSchema: '{}',
currentParams: '{}',
bindId: '',
priority: 0,
enabled: 1,
});
const emit = defineEmits<{
@@ -37,6 +41,39 @@ const emit = defineEmits<{
const formData = ref<Record<string, any>>({});
const newListItem = ref('');
// 参数名中英文映射
const paramNameMap: Record<string, string> = {
leave_countdown_sec: '离岗倒计时(秒)',
working_hours: '工作时间段',
// 其他算法参数
cooldown_seconds: '告警冷却期(秒)',
confirm_seconds: '触发确认时间(秒)',
min_confidence: '最小置信度',
max_targets: '最大目标数',
detection_interval: '检测间隔(秒)',
enable_tracking: '启用跟踪',
tracking_timeout: '跟踪超时(秒)',
};
// 参数说明映射
const paramDescMap: Record<string, string> = {
leave_countdown_sec: '人员离开后,倒计时多少秒才触发离岗告警',
working_hours: '仅在指定时间段内进行监控留空表示24小时监控',
// 其他算法参数说明
cooldown_seconds: '触发告警后,多少秒内不再重复告警(用于周界入侵等算法)',
confirm_seconds: '检测到目标后,持续多少秒才触发告警(避免瞬间误报)',
};
// 获取参数的中文名称
function getParamLabel(key: string): string {
return paramNameMap[key] || key;
}
// 获取参数说明
function getParamDesc(key: string): string | undefined {
return paramDescMap[key];
}
const parsedSchema = computed(() => {
try {
return JSON.parse(props.paramSchema);
@@ -94,22 +131,36 @@ function isWorkingHoursField(key: string): boolean {
async function handleSave() {
try {
console.log('[算法参数保存] 开始保存,原始数据:', formData.value);
// 保存前验证参数
const validation = validateParams(formData.value);
console.log('[算法参数保存] 验证结果:', validation);
if (!validation.valid) {
message.error(validation.error || '参数格式错误');
return;
}
await updateAlgoParams({
const paramsJson = JSON.stringify(formData.value);
console.log('[算法参数保存] JSON序列化后:', paramsJson);
const payload = {
bindId: props.bindId,
params: JSON.stringify(formData.value),
});
params: paramsJson,
priority: props.priority ?? 0, // 保留原有priority
enabled: props.enabled ?? 1, // 保留原有enabled
};
console.log('[算法参数保存] 发送请求:', payload);
await updateAlgoParams(payload);
message.success('参数保存成功');
emit('saved', formData.value);
emit('update:open', false);
} catch (error: any) {
console.error('保存失败:', error);
console.error('[算法参数保存] 保存失败,完整错误:', error);
console.error('[算法参数保存] 错误响应:', error?.response);
// 改进错误提示:显示后端返回的具体错误信息
const errorMsg = error?.response?.data?.msg ||
error?.response?.data?.message ||
@@ -151,18 +202,30 @@ function validateParams(params: Record<string, any>): {
// 特殊校验 working_hours 格式
if (key === 'working_hours' && value.length > 0) {
console.log('[working_hours 验证] 当前值:', value);
console.log('[working_hours 验证] 值类型:', typeof value);
console.log('[working_hours 验证] 是否数组:', Array.isArray(value));
const isValid = value.every(
(item: any) =>
typeof item === 'object' &&
typeof item.start === 'string' &&
typeof item.end === 'string',
(item: any, index: number) => {
const valid = typeof item === 'object' &&
typeof item.start === 'string' &&
typeof item.end === 'string';
if (!valid) {
console.error(`[working_hours 验证] 第 ${index} 项格式错误:`, item);
}
return valid;
}
);
if (!isValid) {
return {
valid: false,
error: '工作时间段格式错误,每项需包含 start 和 end',
error: '工作时间段格式错误,每项需包含 start 和 end 字符串',
};
}
console.log('[working_hours 验证] 格式正确 ✓');
}
}
}
@@ -187,22 +250,28 @@ function validateParams(params: Record<string, any>): {
<Form.Item
v-for="(schema, key) in parsedSchema"
:key="key"
:label="String(key)"
:label="getParamLabel(String(key))"
>
<!-- 整数类型 -->
<InputNumber
v-if="schema.type === 'int'"
v-model:value="formData[String(key)]"
:min="schema.min"
:placeholder="`默认: ${schema.default}`"
style="width: 100%"
/>
<template v-if="schema.type === 'int'">
<InputNumber
v-model:value="formData[String(key)]"
:min="schema.min"
:placeholder="`默认: ${schema.default}`"
style="width: 100%"
/>
<div v-if="getParamDesc(String(key))" class="param-desc">
{{ getParamDesc(String(key)) }}
</div>
</template>
<!-- 工作时间段特殊处理 -->
<WorkingHoursEditor
v-else-if="isWorkingHoursField(String(key))"
v-model="formData[String(key)]"
/>
<template v-else-if="isWorkingHoursField(String(key))">
<WorkingHoursEditor v-model="formData[String(key)]" />
<div v-if="getParamDesc(String(key))" class="param-desc">
{{ getParamDesc(String(key)) }}
</div>
</template>
<!-- 普通列表类型 -->
<div v-else-if="schema.type === 'list'">
@@ -275,4 +344,11 @@ function validateParams(params: Record<string, any>): {
.help-text {
flex: 1;
}
.param-desc {
margin-top: 4px;
font-size: 12px;
color: #8c8c8c;
line-height: 1.5;
}
</style>

View File

@@ -43,6 +43,8 @@ const paramEditorOpen = ref(false);
const currentParamSchema = ref('{}');
const currentParams = ref('{}');
const currentBindId = ref('');
const currentPriority = ref(0);
const currentEnabled = ref(1);
watch(
() => props.roiId,
@@ -102,6 +104,8 @@ function openParamEditor(item: AiotDeviceApi.RoiAlgoBinding) {
currentBindId.value = item.bind.bindId || '';
currentParams.value = item.bind.params || '{}';
currentParamSchema.value = item.algorithm?.paramSchema || '{}';
currentPriority.value = item.bind.priority ?? 0;
currentEnabled.value = item.bind.enabled ?? 1;
paramEditorOpen.value = true;
}
@@ -120,6 +124,15 @@ async function onToggleEnabled(bind: AiotDeviceApi.AlgoBind, val: string | numbe
function onParamSaved() {
emit('changed');
}
// 获取算法的固定帧率
function getAlgoFrameRate(algoCode: string): string {
const frameRates: Record<string, string> = {
leave_post: '3帧/秒',
intrusion: '1帧/秒',
};
return frameRates[algoCode] || '5帧/秒';
}
</script>
<template>
@@ -170,6 +183,9 @@ function onParamSaved() {
<Tag color="blue">
{{ item.algorithm?.algoName || item.bind.algoCode }}
</Tag>
<Tag color="green" style="font-size: 11px">
{{ getAlgoFrameRate(item.bind.algoCode) }}
</Tag>
<span style="color: #999; font-size: 12px">
{{ item.bind.algoCode }}
</span>
@@ -223,6 +239,8 @@ function onParamSaved() {
:param-schema="currentParamSchema"
:current-params="currentParams"
:bind-id="currentBindId"
:priority="currentPriority"
:enabled="currentEnabled"
@saved="onParamSaved"
/>
</div>

View File

@@ -7,10 +7,7 @@ import {
message,
Space,
Tag,
TimePicker,
} from 'ant-design-vue';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
interface WorkingHourPeriod {
start: string; // "HH:mm" 格式
@@ -30,7 +27,11 @@ const emit = defineEmits<{
}>();
// 当前编辑的时间段
const currentRange = ref<[Dayjs, Dayjs] | null>(null);
const startTime = ref('');
const endTime = ref('');
// 正在编辑的时间段索引
const editingIndex = ref<number | null>(null);
// 内部工作时间段列表
const periods = computed<WorkingHourPeriod[]>({
@@ -57,33 +58,22 @@ const templates = [
icon: '⏰',
description: '上午+下午',
periods: [
{ start: '09:00', end: '12:00' },
{ start: '14:00', end: '18:00' },
],
},
{
label: '三班制',
icon: '🔄',
description: '早中晚班',
periods: [
{ start: '08:00', end: '16:00' },
{ start: '16:00', end: '00:00' },
{ start: '00:00', end: '08:00' },
{ start: '08:30', end: '11:00' },
{ start: '12:00', end: '17:30' },
],
},
];
// 添加时间段
// 添加或更新时间段
function addPeriod() {
if (!currentRange.value) {
message.warning('请选择时间');
if (!startTime.value || !endTime.value) {
message.warning('请选择开始时间和结束时间');
return;
}
const [start, end] = currentRange.value;
const newPeriod: WorkingHourPeriod = {
start: start.format('HH:mm'),
end: end.format('HH:mm'),
start: startTime.value,
end: endTime.value,
};
// 校验:结束时间必须晚于开始时间
@@ -92,37 +82,85 @@ function addPeriod() {
return;
}
// 校验:时间段不能重叠
const hasOverlap = periods.value.some((p) => {
return !(newPeriod.end <= p.start || newPeriod.start >= p.end);
});
// 如果是编辑模式
if (editingIndex.value !== null) {
// 校验:时间段不能与其他时间段重叠(排除自己)
const hasOverlap = periods.value.some((p, i) => {
if (i === editingIndex.value) return false;
return !(newPeriod.end <= p.start || newPeriod.start >= p.end);
});
if (hasOverlap) {
message.error('时间段不能重叠');
return;
if (hasOverlap) {
message.error('时间段不能重叠');
return;
}
// 更新现有时间段
const newPeriods = [...periods.value];
newPeriods[editingIndex.value] = newPeriod;
periods.value = newPeriods.sort((a, b) => a.start.localeCompare(b.start));
message.success('时间段已更新');
editingIndex.value = null;
} else {
// 校验:时间段不能重叠
const hasOverlap = periods.value.some((p) => {
return !(newPeriod.end <= p.start || newPeriod.start >= p.end);
});
if (hasOverlap) {
message.error('时间段不能重叠');
return;
}
// 添加新时间段
periods.value = [...periods.value, newPeriod].sort((a, b) =>
a.start.localeCompare(b.start),
);
message.success('时间段已添加');
}
periods.value = [...periods.value, newPeriod].sort((a, b) =>
a.start.localeCompare(b.start),
);
currentRange.value = null;
message.success('时间段已添加');
startTime.value = '';
endTime.value = '';
}
// 编辑时间段
function editPeriod(index: number) {
const period = periods.value[index];
startTime.value = period.start;
endTime.value = period.end;
editingIndex.value = index;
}
// 取消编辑
function cancelEdit() {
startTime.value = '';
endTime.value = '';
editingIndex.value = null;
}
// 删除时间段
function removePeriod(index: number) {
periods.value = periods.value.filter((_, i) => i !== index);
// 如果删除的是正在编辑的时间段,取消编辑状态
if (editingIndex.value === index) {
cancelEdit();
} else if (editingIndex.value !== null && editingIndex.value > index) {
// 如果删除的时间段在编辑时间段之前,需要调整编辑索引
editingIndex.value--;
}
}
// 使用模板
function useTemplate(template: (typeof templates)[0]) {
periods.value = template.periods;
cancelEdit(); // 取消编辑状态
message.success(`已应用模板:${template.label}`);
}
// 清空所有时间段
function clearAll() {
periods.value = [];
cancelEdit(); // 取消编辑状态
}
// 时间段描述文本
@@ -178,18 +216,27 @@ function getDuration(period: WorkingHourPeriod): string {
</div>
<div class="time-picker-row">
<TimePicker.RangePicker
v-model:value="currentRange"
format="HH:mm"
:minute-step="30"
placeholder="选择开始和结束时间"
style="flex: 1"
<input
v-model="startTime"
type="time"
class="time-input"
placeholder="开始时间"
/>
<span class="time-separator"></span>
<input
v-model="endTime"
type="time"
class="time-input"
placeholder="结束时间"
/>
<Button v-if="editingIndex !== null" @click="cancelEdit">
取消
</Button>
<Button type="primary" @click="addPeriod">
<template #icon>
<span></span>
<span>{{ editingIndex !== null ? '💾' : '' }}</span>
</template>
添加
{{ editingIndex !== null ? '保存' : '添加' }}
</Button>
</div>
@@ -207,6 +254,7 @@ function getDuration(period: WorkingHourPeriod): string {
v-for="(period, index) in periods"
:key="index"
class="period-card"
:class="{ editing: editingIndex === index }"
size="small"
>
<div class="period-content">
@@ -219,14 +267,23 @@ function getDuration(period: WorkingHourPeriod): string {
{{ getDuration(period) }}
</Tag>
</div>
<Button
size="small"
type="text"
danger
@click="removePeriod(index)"
>
删除
</Button>
<Space>
<Button
size="small"
type="text"
@click="editPeriod(index)"
>
编辑
</Button>
<Button
size="small"
type="text"
danger
@click="removePeriod(index)"
>
删除
</Button>
</Space>
</div>
</Card>
</div>
@@ -247,6 +304,7 @@ function getDuration(period: WorkingHourPeriod): string {
<div class="help-title">💡 使用说明</div>
<ul class="help-list">
<li>工作时间段为空表示24小时监控</li>
<li>点击快捷模板后可继续编辑调整时间</li>
<li>时间段不能重叠系统会自动校验</li>
<li>支持跨天时间段 16:00-08:00</li>
<li>可添加多个不连续的时间段</li>
@@ -327,9 +385,44 @@ function getDuration(period: WorkingHourPeriod): string {
.time-picker-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.time-input {
flex: 1;
height: 32px;
padding: 4px 11px;
font-size: 14px;
line-height: 1.5715;
color: rgba(0, 0, 0, 0.88);
background-color: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.2s;
}
.time-input:hover {
border-color: #4096ff;
}
.time-input:focus {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
outline: 0;
}
.time-input:disabled {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.04);
cursor: not-allowed;
}
.time-separator {
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
/* 时间段列表 */
.periods-list {
background: #fafafa;
@@ -357,6 +450,12 @@ function getDuration(period: WorkingHourPeriod): string {
border: 1px solid #d9d9d9;
}
.period-card.editing {
border-color: #1890ff;
background-color: #e6f4ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
}
.period-content {
display: flex;
justify-content: space-between;

View File

@@ -47,6 +47,7 @@ const router = useRouter();
// ==================== 摄像头选择 ====================
const cameraCode = ref('');
const currentCamera = ref<AiotDeviceApi.Camera | null>(null);
const showCameraSelector = ref(false);
const cameraOptions = ref<
@@ -74,6 +75,8 @@ onMounted(async () => {
const q = route.query;
if (q.cameraCode) {
cameraCode.value = String(q.cameraCode);
// 加载摄像头信息以获取应用名
await loadCurrentCamera();
await buildSnapUrl();
loadRois();
} else {
@@ -84,6 +87,19 @@ onMounted(async () => {
// ==================== 摄像头加载与选择 ====================
async function loadCurrentCamera() {
try {
const res = await getCameraList({ page: 1, count: 200 });
const list = res.list || [];
const camera = list.find((c: AiotDeviceApi.Camera) => c.cameraCode === cameraCode.value);
if (camera) {
currentCamera.value = camera;
}
} catch {
console.error('加载摄像头信息失败');
}
}
async function loadCameraOptions() {
cameraLoading.value = true;
try {
@@ -91,7 +107,7 @@ async function loadCameraOptions() {
const list = res.list || [];
cameraOptions.value = list.map((cam: AiotDeviceApi.Camera) => ({
value: cam.cameraCode || '',
label: `${cam.cameraCode || cam.stream}${cam.srcUrl ? ` (${cam.srcUrl})` : ''}`,
label: `${cam.app || cam.stream}${cam.srcUrl ? ` (${cam.srcUrl})` : ''}`,
camera: cam,
}));
} catch {
@@ -105,6 +121,7 @@ async function onCameraSelected(val: string) {
const opt = cameraOptions.value.find((o) => o.value === val);
if (opt) {
cameraCode.value = val;
currentCamera.value = opt.camera;
showCameraSelector.value = false;
await buildSnapUrl();
loadRois();
@@ -280,7 +297,7 @@ function handlePush() {
<div class="page-header">
<div class="header-left">
<Button size="small" @click="goBack">返回</Button>
<h3>{{ cameraCode }} - ROI配置</h3>
<h3>{{ currentCamera?.app || cameraCode }} - ROI配置</h3>
</div>
<div class="header-right">
<Button