feat(web): 新增摄像头管理与规则配置页面
This commit is contained in:
@@ -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