feat: 重建 Sense 区域与警戒线配置 (#69)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listAreaConfigurations(query) {
|
||||
return request({ url: '/api/v1/area/configurations', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function createAreaConfiguration(data) {
|
||||
return request({ url: '/api/v1/area/configurations', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateAreaConfiguration(id, data) {
|
||||
return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function listAreaVersions(id) {
|
||||
return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export const POLYGON = 'polygon'
|
||||
export const DIRECTION_LINE = 'direction_line'
|
||||
|
||||
export function clamp(value) {
|
||||
return Math.min(1, Math.max(0, Number(value)))
|
||||
}
|
||||
|
||||
export function geometryError(kind, points, direction = '') {
|
||||
if (!Array.isArray(points)) return '请在画面中添加坐标点'
|
||||
if (kind === POLYGON && (points.length < 3 || points.length > 64)) return '多边形需要 3 到 64 个点'
|
||||
if (kind === DIRECTION_LINE && points.length !== 2) return '方向警戒线需要恰好 2 个点'
|
||||
if (kind === DIRECTION_LINE && !['forward', 'reverse'].includes(direction)) return '请选择警戒方向'
|
||||
if (![POLYGON, DIRECTION_LINE].includes(kind)) return '请选择配置类型'
|
||||
if (points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1)) return '坐标必须位于画面范围内'
|
||||
if (kind === POLYGON && selfIntersects(points)) return '多边形边线不能交叉,请调整顶点'
|
||||
if (kind === POLYGON && Math.abs(polygonArea(points)) < 0.000001) return '多边形面积过小,请重新绘制'
|
||||
if (kind === DIRECTION_LINE && samePoint(points[0], points[1])) return '警戒线起点和终点不能重合'
|
||||
return ''
|
||||
}
|
||||
|
||||
function samePoint(a, b) {
|
||||
return Math.abs(a.x - b.x) < 0.0000001 && Math.abs(a.y - b.y) < 0.0000001
|
||||
}
|
||||
|
||||
function polygonArea(points) {
|
||||
return points.reduce((total, point, index) => {
|
||||
const next = points[(index + 1) % points.length]
|
||||
return total + point.x * next.y - next.x * point.y
|
||||
}, 0) / 2
|
||||
}
|
||||
|
||||
function selfIntersects(points) {
|
||||
for (let first = 0; first < points.length; first += 1) {
|
||||
const a = points[first]
|
||||
const b = points[(first + 1) % points.length]
|
||||
for (let second = first + 1; second < points.length; second += 1) {
|
||||
if (second === first || second === (first + 1) % points.length || first === (second + 1) % points.length) continue
|
||||
const c = points[second]
|
||||
const d = points[(second + 1) % points.length]
|
||||
if (segmentsIntersect(a, b, c, d)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function segmentsIntersect(a, b, c, d) {
|
||||
const orientation = (p, q, r) => (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
|
||||
const values = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)]
|
||||
if (((values[0] > 0 && values[1] < 0) || (values[0] < 0 && values[1] > 0)) && ((values[2] > 0 && values[3] < 0) || (values[2] < 0 && values[3] > 0))) return true
|
||||
const onSegment = (p, q, r) => q.x <= Math.max(p.x, r.x) + 0.0000001 && q.x >= Math.min(p.x, r.x) - 0.0000001 && q.y <= Math.max(p.y, r.y) + 0.0000001 && q.y >= Math.min(p.y, r.y) - 0.0000001
|
||||
return (Math.abs(values[0]) < 0.0000001 && onSegment(a, c, b)) ||
|
||||
(Math.abs(values[1]) < 0.0000001 && onSegment(a, d, b)) ||
|
||||
(Math.abs(values[2]) < 0.0000001 && onSegment(c, a, d)) ||
|
||||
(Math.abs(values[3]) < 0.0000001 && onSegment(c, b, d))
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<section class="geometry-editor" aria-labelledby="geometry-editor-title">
|
||||
<div class="editor-toolbar">
|
||||
<div>
|
||||
<strong id="geometry-editor-title">画面坐标</strong>
|
||||
<span>{{ instruction }}</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<el-button :disabled="disabled || !canAdd" @click="addCenterPoint">添加中心点</el-button>
|
||||
<el-button :disabled="disabled || undoStack.length === 0" @click="undo">撤销</el-button>
|
||||
<el-button :disabled="disabled || points.length === 0" @click="clear">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-shell" :class="{ 'is-disabled': disabled }">
|
||||
<iframe
|
||||
v-if="playerUrl"
|
||||
:src="playerUrl"
|
||||
title="区域校准实时视频"
|
||||
allow="autoplay; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div v-else class="canvas-placeholder">选择可用视频后,可在实时画面上绘制</div>
|
||||
<svg
|
||||
ref="canvas"
|
||||
class="geometry-canvas"
|
||||
viewBox="0 0 1000 562.5"
|
||||
role="application"
|
||||
:aria-label="instruction"
|
||||
tabindex="0"
|
||||
@click="addFromPointer"
|
||||
@pointermove="dragPoint"
|
||||
@pointerup="stopDrag"
|
||||
@pointercancel="stopDrag"
|
||||
@keydown.enter.prevent="addCenterPoint"
|
||||
@keydown.space.prevent="addCenterPoint"
|
||||
>
|
||||
<defs>
|
||||
<marker id="sense-area-arrow" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,8 L10,4 z" class="arrow-head" />
|
||||
</marker>
|
||||
</defs>
|
||||
<polygon v-if="kind === 'polygon' && points.length >= 2" :points="svgPoints" class="area-shape" />
|
||||
<line
|
||||
v-if="kind === 'direction_line' && points.length === 2"
|
||||
:x1="scaledPoints[0].x"
|
||||
:y1="scaledPoints[0].y"
|
||||
:x2="scaledPoints[1].x"
|
||||
:y2="scaledPoints[1].y"
|
||||
class="direction-line"
|
||||
:marker-start="direction === 'reverse' ? 'url(#sense-area-arrow)' : undefined"
|
||||
:marker-end="direction === 'forward' ? 'url(#sense-area-arrow)' : undefined"
|
||||
/>
|
||||
<g
|
||||
v-for="(point, index) in scaledPoints"
|
||||
:key="index"
|
||||
class="point-control"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`坐标点 ${index + 1},横向 ${Math.round(points[index].x * 100)}%,纵向 ${Math.round(points[index].y * 100)}%`"
|
||||
@click.stop
|
||||
@pointerdown.stop.prevent="startDrag(index, $event)"
|
||||
@keydown="movePointByKeyboard(index, $event)"
|
||||
>
|
||||
<circle :cx="point.x" :cy="point.y" r="12" />
|
||||
<text :x="point.x" :y="point.y + 4" text-anchor="middle">{{ index + 1 }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="point-summary" aria-live="polite">
|
||||
<span>已添加 {{ points.length }} 个点</span>
|
||||
<span>键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。</span>
|
||||
</div>
|
||||
<p v-if="error" class="geometry-error" role="alert">{{ error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { clamp, geometryError } from './geometry'
|
||||
|
||||
export default {
|
||||
name: 'SenseGeometryEditor',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
kind: { type: String, required: true },
|
||||
direction: { type: String, default: '' },
|
||||
playerUrl: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { undoStack: [], draggingIndex: -1 }
|
||||
},
|
||||
computed: {
|
||||
points() {
|
||||
return this.modelValue || []
|
||||
},
|
||||
scaledPoints() {
|
||||
return this.points.map(point => ({ x: point.x * 1000, y: point.y * 562.5 }))
|
||||
},
|
||||
svgPoints() {
|
||||
return this.scaledPoints.map(point => `${point.x},${point.y}`).join(' ')
|
||||
},
|
||||
canAdd() {
|
||||
return this.kind === 'polygon' ? this.points.length < 64 : this.points.length < 2
|
||||
},
|
||||
instruction() {
|
||||
return this.kind === 'polygon' ? '点击画面添加顶点,拖动顶点调整危险区域' : '依次添加起点和终点,箭头表示警戒方向'
|
||||
},
|
||||
error() {
|
||||
if (this.points.length === 0) return ''
|
||||
return geometryError(this.kind, this.points, this.direction)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
commit(next, remember = true) {
|
||||
if (this.disabled) return
|
||||
if (remember) this.undoStack.push(this.points.map(point => ({ ...point })))
|
||||
this.$emit('update:modelValue', next.map(point => ({ x: clamp(point.x), y: clamp(point.y) })))
|
||||
},
|
||||
addFromPointer(event) {
|
||||
if (this.disabled || !this.canAdd || event.target.closest('.point-control')) return
|
||||
this.commit([...this.points, this.eventPoint(event)])
|
||||
},
|
||||
addCenterPoint() {
|
||||
if (!this.disabled && this.canAdd) this.commit([...this.points, { x: 0.5, y: 0.5 }])
|
||||
},
|
||||
clear() {
|
||||
if (this.points.length) this.commit([])
|
||||
},
|
||||
undo() {
|
||||
if (!this.undoStack.length || this.disabled) return
|
||||
const previous = this.undoStack.pop()
|
||||
this.commit(previous, false)
|
||||
},
|
||||
startDrag(index, event) {
|
||||
if (this.disabled) return
|
||||
this.undoStack.push(this.points.map(point => ({ ...point })))
|
||||
this.draggingIndex = index
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
},
|
||||
dragPoint(event) {
|
||||
if (this.draggingIndex < 0 || this.disabled) return
|
||||
const next = this.points.map(point => ({ ...point }))
|
||||
next[this.draggingIndex] = this.eventPoint(event)
|
||||
this.commit(next, false)
|
||||
},
|
||||
stopDrag() {
|
||||
this.draggingIndex = -1
|
||||
},
|
||||
movePointByKeyboard(index, event) {
|
||||
if (this.disabled) return
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
this.commit(this.points.filter((_, pointIndex) => pointIndex !== index))
|
||||
return
|
||||
}
|
||||
const movement = { ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0], ArrowUp: [0, -0.01], ArrowDown: [0, 0.01] }[event.key]
|
||||
if (!movement) return
|
||||
event.preventDefault()
|
||||
const next = this.points.map(point => ({ ...point }))
|
||||
next[index] = { x: next[index].x + movement[0], y: next[index].y + movement[1] }
|
||||
this.commit(next)
|
||||
},
|
||||
eventPoint(event) {
|
||||
const rect = this.$refs.canvas.getBoundingClientRect()
|
||||
return { x: clamp((event.clientX - rect.left) / rect.width), y: clamp((event.clientY - rect.top) / rect.height) }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.geometry-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-toolbar,
|
||||
.point-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-toolbar span,
|
||||
.point-summary {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-darker);
|
||||
}
|
||||
|
||||
.canvas-shell iframe,
|
||||
.geometry-canvas,
|
||||
.canvas-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.geometry-canvas {
|
||||
cursor: crosshair;
|
||||
outline-offset: -3px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.geometry-canvas:focus-visible {
|
||||
outline: 3px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.area-shape {
|
||||
fill: color-mix(in srgb, var(--el-color-warning) 24%, transparent);
|
||||
stroke: var(--el-color-warning-dark-2);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.direction-line {
|
||||
stroke: var(--el-color-danger);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.arrow-head {
|
||||
fill: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.point-control {
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.point-control circle {
|
||||
fill: var(--el-color-primary);
|
||||
stroke: var(--el-color-white);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.point-control text {
|
||||
fill: var(--el-color-white);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.point-control:focus-visible circle {
|
||||
stroke: var(--el-color-warning);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.point-summary {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.geometry-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--el-color-danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.is-disabled .geometry-canvas {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.editor-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DIRECTION_LINE, geometryError } from '@/components/sense/geometry-editor/geometry'
|
||||
|
||||
export function buildAreaPayload(form) {
|
||||
return {
|
||||
name: String(form.name || '').trim(),
|
||||
kind: form.kind,
|
||||
routeId: form.routeId,
|
||||
points: (form.points || []).map(point => ({ x: Number(point.x), y: Number(point.y) })),
|
||||
direction: form.kind === DIRECTION_LINE ? form.direction : '',
|
||||
enabled: Boolean(form.enabled),
|
||||
expectedVersion: Number(form.version || 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAreaForm(form) {
|
||||
if (!String(form.name || '').trim()) return '请填写配置名称'
|
||||
if (!form.routeId) return '请选择设备与视频码流'
|
||||
const payload = buildAreaPayload(form)
|
||||
return geometryError(payload.kind, payload.points, payload.direction)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>区域与警戒线</h3>
|
||||
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
|
||||
</div>
|
||||
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
|
||||
</div>
|
||||
|
||||
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
|
||||
<el-form-item label="名称" prop="keyword">
|
||||
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="kind">
|
||||
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
|
||||
<el-option label="危险区域" value="polygon" />
|
||||
<el-option label="方向警戒线" value="direction_line" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="校准状态" prop="recalibrationState">
|
||||
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
|
||||
<el-option label="需要重新校准" value="needed" />
|
||||
<el-option label="已校准" value="ready" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!loading && total === 0"
|
||||
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="empty-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loading" :data="items" border stripe>
|
||||
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="130">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备与码流" min-width="220">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
|
||||
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
|
||||
</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="150">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
|
||||
<el-tag v-else type="success" size="small">已校准</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
|
||||
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:page="queryParams.pageIndex"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
|
||||
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
|
||||
</div>
|
||||
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="form.needsRecalibration"
|
||||
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="dialog-alert"
|
||||
/>
|
||||
|
||||
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="配置名称" prop="name">
|
||||
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置类型" prop="kind">
|
||||
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
|
||||
<el-option label="危险区域" value="polygon" />
|
||||
<el-option label="方向警戒线" value="direction_line" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备与码流" prop="routeId" class="route-field">
|
||||
<el-select
|
||||
v-model="form.routeId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchRoutes"
|
||||
:loading="routesLoading"
|
||||
placeholder="输入设备、位置或码流名称"
|
||||
@change="handleRouteChange"
|
||||
>
|
||||
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
|
||||
<el-select v-model="form.direction" @change="dirty = true">
|
||||
<el-option label="从起点到终点" value="forward" />
|
||||
<el-option label="从终点到起点" value="reverse" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用配置">
|
||||
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="绑定规格" class="profile-summary">
|
||||
<span>{{ selectedProfileSummary }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="绘制区域" prop="points">
|
||||
<SenseGeometryEditor
|
||||
:model-value="form.points"
|
||||
:kind="form.kind"
|
||||
:direction="form.direction"
|
||||
:player-url="preview.playerUrl"
|
||||
:disabled="!form.routeId"
|
||||
@update:model-value="handlePointsChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="requestEditorClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
|
||||
<el-table v-loading="versionsLoading" :data="versions" border>
|
||||
<el-table-column label="版本" width="80">
|
||||
<template #default="scope">v{{ scope.row.version }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="180" />
|
||||
<el-table-column label="绑定规格" min-width="190">
|
||||
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="坐标点" width="90">
|
||||
<template #default="scope">{{ scope.row.points.length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
|
||||
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
import SenseGeometryEditor from '@/components/sense/geometry-editor'
|
||||
import { buildAreaPayload, validateAreaForm } from './areaPayload'
|
||||
|
||||
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
|
||||
|
||||
export default {
|
||||
name: 'SenseArea',
|
||||
components: { SenseGeometryEditor },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
items: [],
|
||||
total: 0,
|
||||
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
|
||||
editorOpen: false,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
form: emptyForm(),
|
||||
formError: '',
|
||||
rules: {
|
||||
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
|
||||
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
|
||||
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
|
||||
},
|
||||
routeOptions: [],
|
||||
routesLoading: false,
|
||||
preview: {},
|
||||
versionsOpen: false,
|
||||
versionsLoading: false,
|
||||
versions: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedRoute() {
|
||||
return this.routeOptions.find(route => route.id === this.form.routeId) || null
|
||||
},
|
||||
selectedProfileSummary() {
|
||||
if (!this.selectedRoute) return '尚未选择视频码流'
|
||||
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listAreaConfigurations(this.queryParams)
|
||||
this.items = response.data.list || []
|
||||
this.total = response.data.count || 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.$refs.queryForm.resetFields()
|
||||
this.handleQuery()
|
||||
},
|
||||
kindLabel(kind) {
|
||||
return kind === 'polygon' ? '危险区域' : '方向警戒线'
|
||||
},
|
||||
profileLabel(item) {
|
||||
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
|
||||
},
|
||||
routeLabel(route) {
|
||||
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
|
||||
},
|
||||
async searchRoutes(keyword = '') {
|
||||
this.routesLoading = true
|
||||
try {
|
||||
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
|
||||
const routes = response.data.list || []
|
||||
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
|
||||
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
|
||||
} finally {
|
||||
this.routesLoading = false
|
||||
}
|
||||
},
|
||||
async openCreate() {
|
||||
this.form = emptyForm()
|
||||
this.formError = ''
|
||||
this.preview = {}
|
||||
this.dirty = false
|
||||
this.editorOpen = true
|
||||
await this.searchRoutes('')
|
||||
},
|
||||
async openEdit(item) {
|
||||
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
|
||||
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
|
||||
this.formError = ''
|
||||
this.preview = {}
|
||||
this.dirty = false
|
||||
this.editorOpen = true
|
||||
if (item.routeId) await this.openPreview()
|
||||
},
|
||||
handleKindChange() {
|
||||
this.form.points = []
|
||||
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
},
|
||||
async handleRouteChange() {
|
||||
this.form.points = []
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
await this.openPreview()
|
||||
},
|
||||
handlePointsChange(points) {
|
||||
this.form.points = points
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
},
|
||||
async openPreview() {
|
||||
this.preview = {}
|
||||
if (!this.form.routeId) return
|
||||
try {
|
||||
const response = await createLiveviewSession(this.form.routeId)
|
||||
this.preview = response.data || {}
|
||||
} catch (error) {
|
||||
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
this.formError = validateAreaForm(this.form)
|
||||
if (this.formError) return
|
||||
try {
|
||||
await this.$refs.editorForm.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
this.saving = true
|
||||
try {
|
||||
const payload = buildAreaPayload(this.form)
|
||||
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
|
||||
else await createAreaConfiguration(payload)
|
||||
this.dirty = false
|
||||
this.editorOpen = false
|
||||
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
|
||||
await this.getList()
|
||||
} catch (error) {
|
||||
this.formError = error.message || '保存失败,请刷新后重试'
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
requestEditorClose() {
|
||||
this.beforeEditorClose(() => { this.editorOpen = false })
|
||||
},
|
||||
beforeEditorClose(done) {
|
||||
if (!this.dirty || this.saving) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
|
||||
},
|
||||
async openVersions(item) {
|
||||
this.versionsOpen = true
|
||||
this.versionsLoading = true
|
||||
this.versions = []
|
||||
try {
|
||||
const response = await listAreaVersions(item.id)
|
||||
this.versions = response.data || []
|
||||
} finally {
|
||||
this.versionsLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.dialog-header span,
|
||||
.muted-text,
|
||||
.profile-summary span {
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.empty-alert,
|
||||
.dialog-alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-input) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-select) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 20px;
|
||||
}
|
||||
|
||||
.route-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.route-field :deep(.el-select),
|
||||
.form-grid :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0 110px;
|
||||
color: var(--el-color-danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.route-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user