feat: add versioned Sense areas and directional lines (#27)

This commit is contained in:
QiuSW
2026-08-12 17:29:09 +08:00
parent 8509699fff
commit e2e18b0380
17 changed files with 593 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import request from '../../../bootstrap/request'
export const listAreas = (deviceID) => request.get('/areas', { params: { device_id: deviceID || undefined } })
export const createArea = (data) => request.post('/areas', data)
export const updateArea = (id, data) => request.patch(`/areas/${id}`, data)
export const recalibrateArea = (id, data) => request.post(`/areas/${id}/recalibrate`, data)
export const disableArea = (id, version) => request.post(`/areas/${id}/disable`, { version })
@@ -0,0 +1,21 @@
<template>
<div class="canvas-panel">
<div class="canvas-toolbar"><span>在画面上单击添加{{ kind === 'polygon' ? '多边形顶点' : '警戒线端点' }}</span><div><el-button size="small" :disabled="!points.length" @click="undo">撤销一点</el-button><el-button size="small" :disabled="!points.length" @click="clear">清空</el-button></div></div>
<svg ref="canvasRef" viewBox="0 0 1000 562.5" role="img" aria-label="区域绘制画布" @click="addPoint">
<defs><pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse"><path d="M 50 0 L 0 0 0 50" fill="none" stroke="#303844" stroke-width="1" /></pattern><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#f7ba2a" /></marker></defs>
<rect width="1000" height="562.5" fill="#151b23" /><rect width="1000" height="562.5" fill="url(#grid)" /><text x="500" y="280" text-anchor="middle" fill="#6b7785" font-size="24">视频画面区域</text>
<polygon v-if="kind === 'polygon' && points.length" :points="svgPoints" fill="rgba(24,144,255,.24)" stroke="#1890ff" stroke-width="4" />
<line v-if="kind === 'line' && points.length === 2" :x1="points[0].x*1000" :y1="points[0].y*562.5" :x2="points[1].x*1000" :y2="points[1].y*562.5" stroke="#f7ba2a" stroke-width="5" marker-end="url(#arrow)" />
<circle v-for="(point,index) in points" :key="index" :cx="point.x*1000" :cy="point.y*562.5" r="8" fill="#fff" stroke="#1890ff" stroke-width="4" />
</svg>
<div class="canvas-hint">坐标按当前 Profile 分辨率归一化保存;切换分辨率后必须重新校准。</div>
</div>
</template>
<script setup>
import{computed,ref}from'vue'
const props=defineProps({modelValue:{type:Array,default:()=>[]},kind:{type:String,default:'polygon'}}),emit=defineEmits(['update:modelValue']),canvasRef=ref();const points=computed(()=>props.modelValue);const svgPoints=computed(()=>points.value.map(point=>`${point.x*1000},${point.y*562.5}`).join(' '))
function addPoint(event){if(props.kind==='line'&&points.value.length>=2)return;const rect=canvasRef.value.getBoundingClientRect(),x=Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),y=Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height));emit('update:modelValue',[...points.value,{x:Number(x.toFixed(6)),y:Number(y.toFixed(6))}])}
function undo(){emit('update:modelValue',points.value.slice(0,-1))}function clear(){emit('update:modelValue',[])}
</script>
<style scoped>.canvas-panel{border:1px solid #e5e6eb;border-radius:4px;overflow:hidden}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#f7f8fa;color:#4e5969;font-size:13px}.canvas-panel svg{display:block;width:100%;cursor:crosshair}.canvas-hint{padding:9px 12px;background:#fff8e6;color:#86909c;font-size:12px}</style>
@@ -0,0 +1,3 @@
import Areas from '../../views/sense/area/Areas.vue'
export default { path: 'areas', name: 'sense-areas', component: Areas, meta: { title: '区域与警戒线', icon: 'Crop', order: 50, permission: 'area.read' } }
+19
View File
@@ -0,0 +1,19 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>区域与警戒线</h1><p>在已验证画面上配置检测区域和通行方向,每次发布都会保留版本。</p></div></div>
<div class="search-toolbar"><el-form :inline="true"><el-form-item label="设备"><el-select v-model="deviceFilter" clearable filterable placeholder="全部设备" style="width:260px" @change="load"><el-option v-for="item in deviceOptions" :key="item.device_id" :label="item.device_id" :value="item.device_id" /></el-select></el-form-item><el-form-item><el-button :icon="Refresh" @click="load">刷新</el-button></el-form-item></el-form></div>
<div class="table-container"><div class="table-actions"><strong>配置列表</strong><el-button v-if="canWrite" type="primary" :icon="Plus" @click="openCreate">新增配置</el-button></div><el-table v-loading="loading" :data="areas" border>
<el-table-column prop="name" label="名称" min-width="150" /><el-table-column prop="device_id" label="设备" min-width="180" /><el-table-column prop="profile_token" label="Profile" min-width="110" /><el-table-column label="类型" width="120"><template #default="scope">{{ scope.row.kind === 'polygon' ? '多边形区域' : '方向警戒线' }}</template></el-table-column><el-table-column label="分辨率" width="130"><template #default="scope">{{ scope.row.width }} × {{ scope.row.height }}</template></el-table-column><el-table-column label="版本" width="90"><template #default="scope">v{{ scope.row.version }}</template></el-table-column><el-table-column label="状态" width="130"><template #default="scope"><el-tag v-if="scope.row.recalibration_required" type="warning">需要重新校准</el-tag><el-tag v-else :type="scope.row.enabled ? 'success' : 'info'">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><el-table-column v-if="canWrite" label="操作" width="180" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">{{ scope.row.recalibration_required ? '重新校准' : '编辑' }}</el-button><el-button v-if="scope.row.enabled" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
</el-table></div>
<el-dialog v-model="dialog" :title="editing ? (editing.recalibration_required ? '重新校准配置' : '编辑配置') : '新增配置'" width="900px" destroy-on-close><el-form ref="formRef" :model="form" :rules="rules" label-width="92px"><el-row :gutter="16"><el-col :span="12"><el-form-item label="设备/Profile" prop="route_id"><el-select v-model="form.route_id" :disabled="Boolean(editing)" style="width:100%"><el-option v-for="item in routes" :key="item.id" :label="`${item.device_id} · ${item.profile_token}`" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item></el-col><el-col :span="12"><el-form-item label="类型" prop="kind"><el-radio-group v-model="form.kind" :disabled="Boolean(editing)" @change="form.points=[]"><el-radio-button value="polygon">多边形区域</el-radio-button><el-radio-button value="line">方向警戒线</el-radio-button></el-radio-group></el-form-item></el-col><el-col v-if="form.kind==='line'" :span="12"><el-form-item label="方向" prop="direction"><el-select v-model="form.direction" style="width:100%"><el-option label="A → B" value="a_to_b" /><el-option label="B → A" value="b_to_a" /><el-option label="双向" value="both" /></el-select></el-form-item></el-col></el-row><el-form-item label="画面坐标" prop="points"><AreaCanvas v-model="form.points" :kind="form.kind" /></el-form-item><el-alert v-if="editing?.recalibration_required" title="Profile 分辨率已变化,请按新画面重新绘制后发布。" type="warning" show-icon :closable="false" /></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">发布新版本</el-button></template></el-dialog>
</section>
</template>
<script setup>
import{computed,onMounted,reactive,ref}from'vue';import{ElMessage,ElMessageBox}from'element-plus';import{Plus,Refresh}from'@element-plus/icons-vue';import{useStore}from'vuex';import{createArea,disableArea,listAreas,recalibrateArea,updateArea}from'../../../api/sense/area';import{playbackRoutes}from'../../../api/sense/liveview';import AreaCanvas from'../../../components/sense/area/AreaCanvas.vue'
const store=useStore(),loading=ref(false),saving=ref(false),dialog=ref(false),editing=ref(null),formRef=ref(),areas=ref([]),routes=ref([]),deviceFilter=ref('');const form=reactive({route_id:'',name:'',kind:'polygon',direction:'',points:[]});const rules={route_id:[{required:true,message:'请选择设备和 Profile',trigger:'change'}],name:[{required:true,message:'请输入名称',trigger:'blur'}],kind:[{required:true,message:'请选择类型',trigger:'change'}],points:[{validator:(_r,value,done)=>value.length<(form.kind==='polygon'?3:2)?done(new Error(form.kind==='polygon'?'至少绘制 3 个点':'请绘制 2 个端点')):done(),trigger:'change'}]};const canWrite=computed(()=>store.getters['sense-identity/hasPermission']?.('area.write'));const deviceOptions=computed(()=>{const map=new Map();routes.value.forEach(item=>map.set(item.device_id,item));return[...map.values()]})
async function load(){loading.value=true;try{const[result,routeResult]=await Promise.all([listAreas(deviceFilter.value),playbackRoutes()]);areas.value=result.items||[];routes.value=routeResult.items||[]}finally{loading.value=false}}
function resetForm(){Object.assign(form,{route_id:'',name:'',kind:'polygon',direction:'',points:[]})}function openCreate(){editing.value=null;resetForm();dialog.value=true}function openEdit(item){editing.value=item;Object.assign(form,{route_id:`${item.device_id}:${item.profile_token}`,name:item.name,kind:item.kind,direction:item.direction,points:item.points.map(point=>({...point}))});dialog.value=true}
async function save(){const valid=await formRef.value?.validate().catch(()=>false);if(!valid)return;const route=routes.value.find(item=>item.id===form.route_id)||{device_id:editing.value?.device_id,profile_token:editing.value?.profile_token};const payload={device_id:route.device_id,profile_token:route.profile_token,name:form.name,kind:form.kind,direction:form.kind==='line'?form.direction:'',points:form.points,version:editing.value?.version||0};saving.value=true;try{if(!editing.value)await createArea(payload);else if(editing.value.recalibration_required)await recalibrateArea(editing.value.id,payload);else await updateArea(editing.value.id,payload);ElMessage.success('新版本已发布');dialog.value=false;await load()}catch(error){ElMessage.error(error.message||'发布失败')}finally{saving.value=false}}
async function disable(item){await ElMessageBox.confirm(`停用“${item.name}”将发布一个停用版本,历史版本仍保留。`,'确认停用',{type:'warning'});await disableArea(item.id,item.version);ElMessage.success('已停用');await load()}
onMounted(load)
</script>