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
+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>