Files
jc-video-recognize/apps/web/src/views/RuleConfiguration.vue
T

904 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>