feat(web): 新增摄像头管理与规则配置页面

This commit is contained in:
2026-06-16 11:25:59 +08:00
parent f5b1fe3a0a
commit b097983f1b
4 changed files with 1606 additions and 2 deletions
+5 -1
View File
@@ -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 }
] ]
+15 -1
View File
@@ -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',
@@ -29,4 +43,4 @@ const router = createRouter({
routes routes
}) })
export default router export default router
+683
View File
@@ -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>
+903
View File
@@ -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>