feat(web): 新增摄像头管理与规则配置页面
This commit is contained in:
@@ -99,7 +99,9 @@ import {
|
|||||||
WarningFilled,
|
WarningFilled,
|
||||||
Bell,
|
Bell,
|
||||||
Fold,
|
Fold,
|
||||||
Expand
|
Expand,
|
||||||
|
Connection,
|
||||||
|
SetUp
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useAlertStore } from '@/stores/alertStore'
|
import { useAlertStore } from '@/stores/alertStore'
|
||||||
import { connectAlertSocket, disconnectAlertSocket } from '@/services/mqtt.client'
|
import { connectAlertSocket, disconnectAlertSocket } from '@/services/mqtt.client'
|
||||||
@@ -111,6 +113,8 @@ const alertStore = useAlertStore()
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/', title: '模型检测', icon: VideoCameraFilled },
|
{ path: '/', title: '模型检测', icon: VideoCameraFilled },
|
||||||
|
{ path: '/cameras', title: '摄像头管理', icon: Connection },
|
||||||
|
{ path: '/rules', title: '规则配置', icon: SetUp },
|
||||||
{ path: '/alerts', title: '预警列表', icon: WarningFilled }
|
{ path: '/alerts', title: '预警列表', icon: WarningFilled }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import Home from '@/views/Home.vue'
|
import Home from '@/views/Home.vue'
|
||||||
import AlertList from '@/views/AlertList.vue'
|
import AlertList from '@/views/AlertList.vue'
|
||||||
|
import CameraManagement from '@/views/CameraManagement.vue'
|
||||||
|
import RuleConfiguration from '@/views/RuleConfiguration.vue'
|
||||||
import Layout from '@/layouts/MainLayout.vue'
|
import Layout from '@/layouts/MainLayout.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -14,6 +16,18 @@ const routes = [
|
|||||||
component: Home,
|
component: Home,
|
||||||
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
|
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/cameras',
|
||||||
|
name: 'CameraManagement',
|
||||||
|
component: CameraManagement,
|
||||||
|
meta: { title: '摄像头管理', icon: 'Connection', keepAlive: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/rules',
|
||||||
|
name: 'RuleConfiguration',
|
||||||
|
component: RuleConfiguration,
|
||||||
|
meta: { title: '规则配置', icon: 'SetUp', keepAlive: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/alerts',
|
path: '/alerts',
|
||||||
name: 'AlertList',
|
name: 'AlertList',
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-mgmt-page">
|
||||||
|
<!-- 顶部统计 -->
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div
|
||||||
|
v-for="stat in stats"
|
||||||
|
:key="stat.key"
|
||||||
|
class="stat-card"
|
||||||
|
:class="`stat-card--${stat.tone}`"
|
||||||
|
>
|
||||||
|
<div class="stat-icon">
|
||||||
|
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
<div class="stat-value">{{ stat.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<el-input
|
||||||
|
v-model="filter.keyword"
|
||||||
|
placeholder="按 ID/URL 搜索"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filter.status"
|
||||||
|
placeholder="状态"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<el-option label="已连接" value="connected" />
|
||||||
|
<el-option label="重连中" value="reconnecting" />
|
||||||
|
<el-option label="错误" value="error" />
|
||||||
|
<el-option label="未启动" value="idle" />
|
||||||
|
<el-option label="已停止" value="stopped" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button size="small" @click="loadStreams">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="success" plain @click="handleStartAll">
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
全部启动
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="handleStopAll">
|
||||||
|
<el-icon><VideoPause /></el-icon>
|
||||||
|
全部停止
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加摄像头
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 摄像头列表 -->
|
||||||
|
<div class="camera-list">
|
||||||
|
<el-empty
|
||||||
|
v-if="filteredStreams.length === 0"
|
||||||
|
description="尚未配置摄像头,点击右上角添加"
|
||||||
|
class="empty-state"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="stream in filteredStreams"
|
||||||
|
:key="stream.stream_id"
|
||||||
|
class="camera-card"
|
||||||
|
:class="`status-${stream.status}`"
|
||||||
|
>
|
||||||
|
<div class="status-bar"></div>
|
||||||
|
|
||||||
|
<div class="camera-body">
|
||||||
|
<div class="camera-head">
|
||||||
|
<div class="camera-title">
|
||||||
|
<el-tag :type="statusTagType(stream.status)" size="small" effect="dark" round>
|
||||||
|
{{ statusLabel(stream.status) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="camera-id">{{ stream.stream_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="camera-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="!isRunning(stream)"
|
||||||
|
text
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleStart(stream)"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
启动
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
text
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
@click="handleStop(stream)"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPause /></el-icon>
|
||||||
|
停止
|
||||||
|
</el-button>
|
||||||
|
<el-button text size="small" @click="openEditDialog(stream)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
配置
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm
|
||||||
|
title="确认移除该摄像头?"
|
||||||
|
confirm-button-text="移除"
|
||||||
|
cancel-button-text="取消"
|
||||||
|
@confirm="handleRemove(stream)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button text type="danger" size="small">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
移除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-url">{{ stream.rtsp_url }}</div>
|
||||||
|
|
||||||
|
<div class="camera-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><Cpu /></el-icon>
|
||||||
|
<span>{{ stream.config?.model_id || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><DataLine /></el-icon>
|
||||||
|
<span>置信度 {{ formatNumber(stream.config?.confidence) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><PictureFilled /></el-icon>
|
||||||
|
<span>{{ stream.frame_count || 0 }} 帧</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><Timer /></el-icon>
|
||||||
|
<span>{{ formatLastFrame(stream.last_frame_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="stream.error_message" class="meta-item meta-error">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
<span>{{ stream.error_message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加 / 编辑摄像头弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingStreamId ? '配置摄像头' : '添加摄像头'"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="camera-dialog"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-position="top"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
<el-form-item label="摄像头 ID" prop="stream_id">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.stream_id"
|
||||||
|
placeholder="例如 cam-01"
|
||||||
|
:disabled="!!editingStreamId"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="RTSP URL" prop="rtsp_url">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.rtsp_url"
|
||||||
|
placeholder="rtsp://user:pass@host:554/stream"
|
||||||
|
:disabled="!!editingStreamId"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检测模型" prop="model_id">
|
||||||
|
<el-select v-model="formState.model_id" placeholder="选择检测模型" style="width: 100%">
|
||||||
|
<el-option label="火灾检测" value="fire_detection" />
|
||||||
|
<el-option label="烟雾检测" value="smoke_detection" />
|
||||||
|
<el-option label="抽烟检测" value="smoking_detection" />
|
||||||
|
<el-option label="打架斗殴" value="fight_detection" />
|
||||||
|
<el-option label="徘徊检测" value="loitering_detection" />
|
||||||
|
<el-option label="违章停车" value="illegal_parking_detection" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="置信度阈值">
|
||||||
|
<el-slider
|
||||||
|
v-model="formState.confidence"
|
||||||
|
:min="0.1"
|
||||||
|
:max="0.95"
|
||||||
|
:step="0.05"
|
||||||
|
show-stops
|
||||||
|
:format-tooltip="(v) => v.toFixed(2)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="IOU 阈值">
|
||||||
|
<el-slider
|
||||||
|
v-model="formState.iou"
|
||||||
|
:min="0.2"
|
||||||
|
:max="0.9"
|
||||||
|
:step="0.05"
|
||||||
|
show-stops
|
||||||
|
:format-tooltip="(v) => v.toFixed(2)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="帧采样间隔">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formState.frame_skip"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">0 = 每帧检测,N = 每 N+1 帧检测一次</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ editingStreamId ? '保存' : '添加' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Refresh,
|
||||||
|
Plus,
|
||||||
|
VideoPlay,
|
||||||
|
VideoPause,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Cpu,
|
||||||
|
DataLine,
|
||||||
|
PictureFilled,
|
||||||
|
Timer,
|
||||||
|
WarningFilled,
|
||||||
|
CircleCheckFilled,
|
||||||
|
Connection,
|
||||||
|
Bell
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { cameraApi } from '@/api/detection'
|
||||||
|
|
||||||
|
const streams = ref([])
|
||||||
|
const filter = reactive({ keyword: '', status: '' })
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const editingStreamId = ref('')
|
||||||
|
const formRef = ref(null)
|
||||||
|
const formState = reactive(makeEmptyForm())
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
stream_id: [{ required: true, message: '请输入摄像头 ID', trigger: 'blur' }],
|
||||||
|
rtsp_url: [{ required: true, message: '请输入 RTSP 地址', trigger: 'blur' }],
|
||||||
|
model_id: [{ required: true, message: '请选择检测模型', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmptyForm() {
|
||||||
|
return {
|
||||||
|
stream_id: '',
|
||||||
|
rtsp_url: '',
|
||||||
|
model_id: 'fire_detection',
|
||||||
|
confidence: 0.5,
|
||||||
|
iou: 0.45,
|
||||||
|
frame_skip: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = streams.value.length
|
||||||
|
const active = streams.value.filter((s) => s.status === 'connected').length
|
||||||
|
const errored = streams.value.filter((s) => s.status === 'error').length
|
||||||
|
const reconnecting = streams.value.filter((s) => s.status === 'reconnecting').length
|
||||||
|
return [
|
||||||
|
{ key: 'total', label: '摄像头总数', value: total, icon: Connection, tone: 'neutral' },
|
||||||
|
{ key: 'active', label: '运行中', value: active, icon: CircleCheckFilled, tone: 'success' },
|
||||||
|
{ key: 'reconnecting', label: '重连中', value: reconnecting, icon: Bell, tone: 'warning' },
|
||||||
|
{ key: 'error', label: '故障', value: errored, icon: WarningFilled, tone: 'danger' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredStreams = computed(() => {
|
||||||
|
return streams.value.filter((s) => {
|
||||||
|
if (filter.status && s.status !== filter.status) return false
|
||||||
|
if (filter.keyword) {
|
||||||
|
const kw = filter.keyword.toLowerCase()
|
||||||
|
const text = `${s.stream_id} ${s.rtsp_url || ''}`.toLowerCase()
|
||||||
|
if (!text.includes(kw)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRunning(stream) {
|
||||||
|
return ['connecting', 'connected', 'reconnecting'].includes(stream.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
const map = {
|
||||||
|
idle: '未启动',
|
||||||
|
connecting: '连接中',
|
||||||
|
connected: '已连接',
|
||||||
|
reconnecting: '重连中',
|
||||||
|
stopped: '已停止',
|
||||||
|
error: '错误'
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTagType(status) {
|
||||||
|
const map = {
|
||||||
|
connected: 'success',
|
||||||
|
connecting: 'warning',
|
||||||
|
reconnecting: 'warning',
|
||||||
|
error: 'danger',
|
||||||
|
idle: 'info',
|
||||||
|
stopped: 'info'
|
||||||
|
}
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(v) {
|
||||||
|
if (v === undefined || v === null) return '-'
|
||||||
|
return Number(v).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastFrame(ts) {
|
||||||
|
if (!ts) return '无数据'
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const diff = (Date.now() - date.getTime()) / 1000
|
||||||
|
if (diff < 60) return `${Math.floor(diff)} 秒前`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStreams() {
|
||||||
|
try {
|
||||||
|
const { data } = await cameraApi.list()
|
||||||
|
streams.value = data?.streams || []
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`加载摄像头列表失败: ${err.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
editingStreamId.value = ''
|
||||||
|
Object.assign(formState, makeEmptyForm())
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(stream) {
|
||||||
|
editingStreamId.value = stream.stream_id
|
||||||
|
Object.assign(formState, {
|
||||||
|
stream_id: stream.stream_id,
|
||||||
|
rtsp_url: stream.rtsp_url,
|
||||||
|
model_id: stream.config?.model_id || 'fire_detection',
|
||||||
|
confidence: Number(stream.config?.confidence ?? 0.5),
|
||||||
|
iou: Number(stream.config?.iou ?? 0.45),
|
||||||
|
frame_skip: Number(stream.config?.frame_skip ?? 0)
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingStreamId.value) {
|
||||||
|
await cameraApi.updateConfig(editingStreamId.value, {
|
||||||
|
model_id: formState.model_id,
|
||||||
|
confidence: formState.confidence,
|
||||||
|
iou: formState.iou,
|
||||||
|
frame_skip: formState.frame_skip
|
||||||
|
})
|
||||||
|
ElMessage.success('已保存配置')
|
||||||
|
} else {
|
||||||
|
await cameraApi.add({
|
||||||
|
stream_id: formState.stream_id,
|
||||||
|
rtsp_url: formState.rtsp_url,
|
||||||
|
model_id: formState.model_id,
|
||||||
|
confidence: formState.confidence,
|
||||||
|
iou: formState.iou,
|
||||||
|
frame_skip: formState.frame_skip
|
||||||
|
})
|
||||||
|
ElMessage.success('摄像头已添加')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.detail || err.message || String(err)
|
||||||
|
ElMessage.error(`操作失败: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStart(stream) {
|
||||||
|
try {
|
||||||
|
await cameraApi.start(stream.stream_id)
|
||||||
|
ElMessage.success(`已启动 ${stream.stream_id}`)
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`启动失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop(stream) {
|
||||||
|
try {
|
||||||
|
await cameraApi.stop(stream.stream_id)
|
||||||
|
ElMessage.success(`已停止 ${stream.stream_id}`)
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`停止失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(stream) {
|
||||||
|
try {
|
||||||
|
await cameraApi.remove(stream.stream_id)
|
||||||
|
ElMessage.success(`已移除 ${stream.stream_id}`)
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`移除失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartAll() {
|
||||||
|
try {
|
||||||
|
await cameraApi.startAll()
|
||||||
|
ElMessage.success('已启动全部摄像头')
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopAll() {
|
||||||
|
try {
|
||||||
|
await cameraApi.stopAll()
|
||||||
|
ElMessage.success('已停止全部摄像头')
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStreams()
|
||||||
|
// 每 5 秒刷新一次状态
|
||||||
|
pollTimer = setInterval(loadStreams, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-mgmt-page {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
color: #94A3B8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card--success .stat-icon { background: rgba(34, 197, 94, 0.12); color: #22C55E; }
|
||||||
|
.stat-card--warning .stat-icon { background: rgba(245, 158, 11, 0.12); color: #F59E0B; }
|
||||||
|
.stat-card--danger .stat-icon { background: rgba(239, 68, 68, 0.12); color: #EF4444; }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-card {
|
||||||
|
display: flex;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-card:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
width: 4px;
|
||||||
|
background: #64748B;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected .status-bar { background: #22C55E; }
|
||||||
|
.status-connecting .status-bar { background: #F59E0B; }
|
||||||
|
.status-reconnecting .status-bar { background: #F59E0B; }
|
||||||
|
.status-error .status-bar { background: #EF4444; }
|
||||||
|
.status-idle .status-bar { background: #94A3B8; }
|
||||||
|
.status-stopped .status-bar { background: #64748B; }
|
||||||
|
|
||||||
|
.camera-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-id {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .el-icon { font-size: 14px; }
|
||||||
|
|
||||||
|
.meta-error {
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.camera-dialog .el-dialog) {
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.camera-dialog .el-dialog__title) { color: #F8FAFC; }
|
||||||
|
:deep(.camera-dialog .el-form-item__label) { color: #CBD5E1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rule-config-page">
|
||||||
|
<!-- LLM 状态面板 (MVP-3 / D34) -->
|
||||||
|
<section class="llm-panel" v-if="llmStatus">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">
|
||||||
|
<el-icon :size="18"><Cpu /></el-icon>
|
||||||
|
<span>LLM 二次判断</span>
|
||||||
|
<el-tag
|
||||||
|
:type="llmStatus.enabled ? 'success' : 'info'"
|
||||||
|
effect="dark"
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ llmStatus.enabled ? '已启用' : '未启用' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="llmCircuitState"
|
||||||
|
:type="circuitTagType"
|
||||||
|
effect="plain"
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
熔断: {{ circuitLabel }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<el-button size="small" @click="loadLLMStatus">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="llmTrackerEnabled"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
@click="handleLLMDisable"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPause /></el-icon>
|
||||||
|
紧急停用
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
@click="handleLLMEnable"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
重新启用
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm title="确认重置成本统计与熔断?" @confirm="handleLLMReset">
|
||||||
|
<template #reference>
|
||||||
|
<el-button size="small" type="danger" plain>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
重置统计
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="llm-grid">
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">Provider</div>
|
||||||
|
<div class="llm-value">{{ llmStatus.provider }}</div>
|
||||||
|
<div class="llm-sub">{{ llmStatus.model || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">融合策略</div>
|
||||||
|
<div class="llm-value">{{ fusionLabel }}</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
YOLO {{ formatRatio(llmStatus.fusion?.yolo_weight) }} /
|
||||||
|
LLM {{ formatRatio(llmStatus.fusion?.llm_weight) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">触发阈值</div>
|
||||||
|
<div class="llm-value">
|
||||||
|
{{ llmStatus.triggers?.min_consecutive_hits }} 连续帧
|
||||||
|
</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
avg≥{{ formatRatio(llmStatus.triggers?.min_avg_confidence) }} ·
|
||||||
|
冷却{{ llmStatus.triggers?.cooldown_seconds }}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">今日调用</div>
|
||||||
|
<div class="llm-value">{{ todayCost?.call_count ?? 0 }} 次</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
成功 {{ todayCost?.success_count ?? 0 }} /
|
||||||
|
失败 {{ todayCost?.failure_count ?? 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">今日费用</div>
|
||||||
|
<div class="llm-value">${{ formatCost(todayCost?.cost_usd) }}</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
预算
|
||||||
|
{{ todayCost?.budget_usd ? `$${formatCost(todayCost.budget_usd)}` : '∞' }}
|
||||||
|
<span v-if="todayCost?.budget_usd > 0" class="budget-bar-wrap">
|
||||||
|
<span
|
||||||
|
class="budget-bar"
|
||||||
|
:class="{ 'is-danger': (todayCost?.budget_used_ratio ?? 0) >= 0.8 }"
|
||||||
|
:style="{ width: `${Math.min(100, (todayCost?.budget_used_ratio ?? 0) * 100)}%` }"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">最近错误率</div>
|
||||||
|
<div class="llm-value">
|
||||||
|
{{ formatRatio(llmStatus.cost?.recent_error_rate) }}
|
||||||
|
</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
阈值 {{ formatRatio(llmStatus.cost?.error_rate_threshold) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="llmStatus.cost?.block_reason" class="llm-warning">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
当前不会调用 LLM,原因:{{ llmStatus.cost.block_reason }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<el-input
|
||||||
|
v-model="filter.keyword"
|
||||||
|
placeholder="按规则名/描述搜索"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="filter.event_type"
|
||||||
|
placeholder="事件类型"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in eventTypeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button size="small" @click="loadRules">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="handleReload">
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
热重载
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openCreateDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增规则
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 规则列表 -->
|
||||||
|
<div class="rule-list">
|
||||||
|
<el-empty
|
||||||
|
v-if="filteredRules.length === 0"
|
||||||
|
description="暂无规则"
|
||||||
|
class="empty-state"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="rule in filteredRules"
|
||||||
|
:key="rule.name"
|
||||||
|
class="rule-card"
|
||||||
|
:class="{ 'is-disabled': !rule.enabled }"
|
||||||
|
>
|
||||||
|
<div class="rule-bar" :class="`severity-${rule.severity || 'info'}`"></div>
|
||||||
|
<div class="rule-body">
|
||||||
|
<div class="rule-head">
|
||||||
|
<div class="rule-title">
|
||||||
|
<el-tag
|
||||||
|
:type="severityTagType(rule.severity)"
|
||||||
|
size="small"
|
||||||
|
effect="dark"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
{{ severityLabel(rule.severity) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="rule-name">{{ rule.name }}</span>
|
||||||
|
<el-tag size="small" effect="plain">
|
||||||
|
{{ eventTypeLabel(rule.event_type) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-switch
|
||||||
|
:model-value="rule.enabled"
|
||||||
|
size="small"
|
||||||
|
@change="(v) => handleToggle(rule, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rule-actions">
|
||||||
|
<el-button text size="small" @click="openEditDialog(rule)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm
|
||||||
|
title="确认删除该规则?(仅 custom.yaml 中的规则可删除)"
|
||||||
|
@confirm="handleDelete(rule)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button text type="danger" size="small">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rule.description" class="rule-desc">{{ rule.description }}</div>
|
||||||
|
|
||||||
|
<div class="rule-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><DataLine /></el-icon>
|
||||||
|
<span>min_conf ≥ {{ formatRatio(rule.min_confidence) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><Crop /></el-icon>
|
||||||
|
<span>min_area ≥ {{ rule.min_bbox_area || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rule.allowed_sources?.length" class="meta-item">
|
||||||
|
<el-icon><VideoCamera /></el-icon>
|
||||||
|
<span>限定: {{ rule.allowed_sources.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rule.required_labels?.length" class="meta-item">
|
||||||
|
<el-icon><Collection /></el-icon>
|
||||||
|
<span>标签: {{ rule.required_labels.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑/新增规则弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingName ? '编辑规则' : '新增规则'"
|
||||||
|
width="560px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="rule-dialog"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<el-form-item label="规则名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.name"
|
||||||
|
placeholder="例如 fire_high_priority"
|
||||||
|
:disabled="!!editingName"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="事件类型" prop="event_type">
|
||||||
|
<el-select v-model="formState.event_type" placeholder="选择事件类型" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in eventTypeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="严重级别">
|
||||||
|
<el-select v-model="formState.severity" placeholder="使用默认" clearable style="width: 100%">
|
||||||
|
<el-option label="信息" value="info" />
|
||||||
|
<el-option label="低危" value="low" />
|
||||||
|
<el-option label="中等" value="medium" />
|
||||||
|
<el-option label="高危" value="high" />
|
||||||
|
<el-option label="严重" value="critical" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最低置信度">
|
||||||
|
<el-slider
|
||||||
|
v-model="formState.min_confidence"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.05"
|
||||||
|
:format-tooltip="(v) => Number(v).toFixed(2)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最小边界框面积">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formState.min_bbox_area"
|
||||||
|
:min="0"
|
||||||
|
:step="100"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">单位:像素²,0 表示不限</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="限定摄像头 ID(可选)">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.allowed_sources"
|
||||||
|
multiple
|
||||||
|
allow-create
|
||||||
|
filterable
|
||||||
|
default-first-option
|
||||||
|
placeholder="留空表示所有摄像头"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in cameraOptions"
|
||||||
|
:key="opt"
|
||||||
|
:label="opt"
|
||||||
|
:value="opt"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="必备标签(可选)">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.required_labels"
|
||||||
|
multiple
|
||||||
|
allow-create
|
||||||
|
filterable
|
||||||
|
default-first-option
|
||||||
|
placeholder="检测项 class_name 或 label 必须包含其中之一"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="说明该规则的业务用途"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="formState.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ editingName ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Refresh,
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Cpu,
|
||||||
|
DataLine,
|
||||||
|
Crop,
|
||||||
|
Collection,
|
||||||
|
VideoCamera,
|
||||||
|
VideoPlay,
|
||||||
|
VideoPause,
|
||||||
|
WarningFilled,
|
||||||
|
MagicStick
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { ruleApi, llmApi, cameraApi } from '@/api/detection'
|
||||||
|
|
||||||
|
const eventTypeOptions = [
|
||||||
|
{ label: '火灾', value: 'fire' },
|
||||||
|
{ label: '烟雾', value: 'smoke' },
|
||||||
|
{ label: '抽烟', value: 'smoking' },
|
||||||
|
{ label: '打架', value: 'fight' },
|
||||||
|
{ label: '徘徊', value: 'loitering' },
|
||||||
|
{ label: '滞留', value: 'stationary' },
|
||||||
|
{ label: '入侵', value: 'intrusion' },
|
||||||
|
{ label: '违章停车', value: 'illegal_parking' },
|
||||||
|
{ label: '车辆', value: 'vehicle' },
|
||||||
|
{ label: '人员', value: 'person' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const rules = ref([])
|
||||||
|
const llmStatus = ref(null)
|
||||||
|
const cameraOptions = ref([])
|
||||||
|
const filter = reactive({ keyword: '', event_type: '' })
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const editingName = ref('')
|
||||||
|
const formRef = ref(null)
|
||||||
|
const formState = reactive(makeEmptyForm())
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入规则名称', trigger: 'blur' },
|
||||||
|
{ pattern: /^[A-Za-z0-9_\-]+$/, message: '仅允许字母、数字、下划线和短横线', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
event_type: [{ required: true, message: '请选择事件类型', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmptyForm() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
event_type: 'fire',
|
||||||
|
enabled: true,
|
||||||
|
min_confidence: 0.5,
|
||||||
|
severity: '',
|
||||||
|
allowed_sources: [],
|
||||||
|
required_labels: [],
|
||||||
|
min_bbox_area: 0,
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRules = computed(() => {
|
||||||
|
return rules.value.filter((r) => {
|
||||||
|
if (filter.event_type && r.event_type !== filter.event_type) return false
|
||||||
|
if (filter.keyword) {
|
||||||
|
const kw = filter.keyword.toLowerCase()
|
||||||
|
const text = `${r.name} ${r.description || ''}`.toLowerCase()
|
||||||
|
if (!text.includes(kw)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const todayCost = computed(() => llmStatus.value?.cost?.today)
|
||||||
|
const llmCircuitState = computed(() => llmStatus.value?.cost?.circuit_state)
|
||||||
|
const llmTrackerEnabled = computed(() => llmStatus.value?.cost?.enabled !== false)
|
||||||
|
|
||||||
|
const circuitLabel = computed(() => {
|
||||||
|
const map = { closed: '正常', open: '熔断', half_open: '半开试探' }
|
||||||
|
return map[llmCircuitState.value] || llmCircuitState.value || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const circuitTagType = computed(() => {
|
||||||
|
if (llmCircuitState.value === 'open') return 'danger'
|
||||||
|
if (llmCircuitState.value === 'half_open') return 'warning'
|
||||||
|
return 'success'
|
||||||
|
})
|
||||||
|
|
||||||
|
const fusionLabel = computed(() => {
|
||||||
|
const map = {
|
||||||
|
weighted: '加权融合',
|
||||||
|
conservative: '保守策略',
|
||||||
|
llm_priority: 'LLM 优先'
|
||||||
|
}
|
||||||
|
return map[llmStatus.value?.fusion?.strategy] || llmStatus.value?.fusion?.strategy || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
function severityTagType(severity) {
|
||||||
|
const map = {
|
||||||
|
critical: 'danger',
|
||||||
|
high: 'warning',
|
||||||
|
medium: 'primary',
|
||||||
|
low: 'info',
|
||||||
|
info: 'success'
|
||||||
|
}
|
||||||
|
return map[severity] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLabel(severity) {
|
||||||
|
const map = {
|
||||||
|
critical: '严重',
|
||||||
|
high: '高危',
|
||||||
|
medium: '中等',
|
||||||
|
low: '低危',
|
||||||
|
info: '信息'
|
||||||
|
}
|
||||||
|
return map[severity] || severity || '默认'
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTypeLabel(type) {
|
||||||
|
const opt = eventTypeOptions.find((o) => o.value === type)
|
||||||
|
return opt ? opt.label : type
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRatio(v) {
|
||||||
|
if (v === undefined || v === null) return '-'
|
||||||
|
return Number(v).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(v) {
|
||||||
|
if (v === undefined || v === null) return '0.0000'
|
||||||
|
return Number(v).toFixed(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
try {
|
||||||
|
const { data } = await ruleApi.list()
|
||||||
|
rules.value = data?.rules || []
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`加载规则失败: ${err.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLLMStatus() {
|
||||||
|
try {
|
||||||
|
const { data } = await llmApi.status()
|
||||||
|
llmStatus.value = data
|
||||||
|
} catch (err) {
|
||||||
|
// LLM 未启用时 cost 接口可能 503,状态接口仍可用 - 失败时静默
|
||||||
|
console.warn('加载 LLM 状态失败:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCameras() {
|
||||||
|
try {
|
||||||
|
const { data } = await cameraApi.list()
|
||||||
|
cameraOptions.value = (data?.streams || []).map((s) => s.stream_id)
|
||||||
|
} catch (err) {
|
||||||
|
// 摄像头列表加载失败不阻塞规则页
|
||||||
|
cameraOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editingName.value = ''
|
||||||
|
Object.assign(formState, makeEmptyForm())
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(rule) {
|
||||||
|
editingName.value = rule.name
|
||||||
|
Object.assign(formState, {
|
||||||
|
name: rule.name,
|
||||||
|
event_type: rule.event_type,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
min_confidence: Number(rule.min_confidence ?? 0),
|
||||||
|
severity: rule.severity || '',
|
||||||
|
allowed_sources: rule.allowed_sources || [],
|
||||||
|
required_labels: rule.required_labels || [],
|
||||||
|
min_bbox_area: Number(rule.min_bbox_area ?? 0),
|
||||||
|
description: rule.description || ''
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
const payload = {
|
||||||
|
name: formState.name,
|
||||||
|
event_type: formState.event_type,
|
||||||
|
enabled: formState.enabled,
|
||||||
|
min_confidence: formState.min_confidence,
|
||||||
|
severity: formState.severity || null,
|
||||||
|
allowed_sources: formState.allowed_sources?.length ? formState.allowed_sources : null,
|
||||||
|
required_labels: formState.required_labels?.length ? formState.required_labels : null,
|
||||||
|
min_bbox_area: formState.min_bbox_area || 0,
|
||||||
|
description: formState.description || ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingName.value) {
|
||||||
|
await ruleApi.update(editingName.value, payload)
|
||||||
|
ElMessage.success('规则已保存')
|
||||||
|
} else {
|
||||||
|
await ruleApi.create(payload)
|
||||||
|
ElMessage.success('规则已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.detail || err.message || String(err)
|
||||||
|
ElMessage.error(`操作失败: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(rule, enabled) {
|
||||||
|
try {
|
||||||
|
await ruleApi.update(rule.name, { ...rule, enabled })
|
||||||
|
ElMessage.success(`已${enabled ? '启用' : '停用'} ${rule.name}`)
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.detail || err.message
|
||||||
|
ElMessage.error(`切换失败: ${msg}`)
|
||||||
|
await loadRules()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(rule) {
|
||||||
|
try {
|
||||||
|
await ruleApi.remove(rule.name)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`删除失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReload() {
|
||||||
|
try {
|
||||||
|
await ruleApi.reload()
|
||||||
|
ElMessage.success('规则已热重载')
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`重载失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLLMEnable() {
|
||||||
|
try {
|
||||||
|
await llmApi.enable()
|
||||||
|
ElMessage.success('LLM 已重新启用')
|
||||||
|
await loadLLMStatus()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLLMDisable() {
|
||||||
|
try {
|
||||||
|
await llmApi.disable()
|
||||||
|
ElMessage.success('LLM 已停用')
|
||||||
|
await loadLLMStatus()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLLMReset() {
|
||||||
|
try {
|
||||||
|
await llmApi.reset()
|
||||||
|
ElMessage.success('成本统计已重置')
|
||||||
|
await loadLLMStatus()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRules()
|
||||||
|
loadLLMStatus()
|
||||||
|
loadCameras()
|
||||||
|
pollTimer = setInterval(loadLLMStatus, 10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rule-config-page {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LLM 状态面板 ---- */
|
||||||
|
.llm-panel {
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #131E33;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748B;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #22C55E;
|
||||||
|
transition: width 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar.is-danger {
|
||||||
|
background: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-warning {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #FCA5A5;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 工具栏 ---- */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 规则列表 ---- */
|
||||||
|
.rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
display: flex;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card.is-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-bar {
|
||||||
|
width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-bar.severity-critical { background: #EF4444; }
|
||||||
|
.rule-bar.severity-high { background: #F59E0B; }
|
||||||
|
.rule-bar.severity-medium { background: #3B82F6; }
|
||||||
|
.rule-bar.severity-low { background: #94A3B8; }
|
||||||
|
.rule-bar.severity-info { background: #22C55E; }
|
||||||
|
|
||||||
|
.rule-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .el-icon { font-size: 14px; }
|
||||||
|
|
||||||
|
.empty-state { padding: 60px 0; }
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rule-dialog .el-dialog) {
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rule-dialog .el-dialog__title) { color: #F8FAFC; }
|
||||||
|
:deep(.rule-dialog .el-form-item__label) { color: #CBD5E1; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user