修改完毕

This commit is contained in:
2026-06-29 15:36:19 +08:00
parent d8c0885c84
commit fe0d4304f8
7 changed files with 2262 additions and 868 deletions

View File

@@ -352,6 +352,18 @@
content: "视"; content: "视";
} }
.evidence-photo {
display: block;
width: 88px;
height: 52px;
object-fit: cover;
border: 1px solid rgba(87, 198, 255, 0.18);
box-shadow:
inset 0 0 0 1px rgba(130, 232, 255, 0.08),
0 8px 18px rgba(2, 10, 18, 0.24);
background: rgba(8, 31, 62, 0.58);
}
.table-actions { .table-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -637,7 +649,6 @@
.tree-panel { .tree-panel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@@ -703,10 +714,45 @@
.tree-device-list { .tree-device-list {
margin-top: 10px; margin-top: 10px;
flex: 1;
min-height: 0;
overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding-left: 10px; padding-left: 10px;
padding-right: 2px;
}
.tree-panel-footer {
padding: 12px 10px 2px;
border-top: 1px solid rgba(77, 188, 248, 0.12);
}
.tree-playback-btn {
width: 100%;
height: 40px;
border: 1px solid rgba(102, 235, 255, 0.28);
background: linear-gradient(
90deg,
rgba(20, 86, 152, 0.94) 0%,
rgba(10, 42, 86, 0.98) 100%
);
color: #eefcff;
font-size: 13px;
font-weight: 700;
letter-spacing: 1px;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(102, 235, 255, 0.08),
0 0 18px rgba(29, 133, 222, 0.12);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.tree-playback-btn:hover {
transform: translateY(-1px);
border-color: rgba(102, 235, 255, 0.54);
box-shadow: inset 0 0 0 1px rgba(102, 235, 255, 0.12),
0 0 24px rgba(52, 177, 255, 0.18);
} }
.tree-device-btn { .tree-device-btn {
@@ -762,7 +808,7 @@
.grid-switch-btn, .grid-switch-btn,
.playback-btn, .playback-btn,
.playback-filter input { .playback-field input {
height: 34px; height: 34px;
border: 1px solid rgba(87, 198, 255, 0.18); border: 1px solid rgba(87, 198, 255, 0.18);
background: rgba(8, 31, 62, 0.72); background: rgba(8, 31, 62, 0.72);
@@ -872,42 +918,103 @@
letter-spacing: 1px; letter-spacing: 1px;
} }
.playback-panel { .playback-dialog-mask {
margin-top: 16px; position: fixed;
padding: 14px 16px; inset: 0;
background: rgba(8, 34, 68, 0.62); z-index: 90;
border: 1px solid rgba(77, 188, 248, 0.12); display: flex;
align-items: center;
justify-content: center;
background: rgba(1, 8, 18, 0.74);
backdrop-filter: blur(8px);
} }
.playback-header, .playback-dialog {
.playback-controls, width: min(960px, calc(100vw - 80px));
.playback-filter { max-height: calc(100vh - 90px);
display: flex;
flex-direction: column;
gap: 18px;
padding: 24px 24px 22px;
background: linear-gradient(
180deg,
rgba(8, 30, 58, 0.98) 0%,
rgba(4, 18, 36, 0.98) 100%
);
border: 1px solid rgba(89, 203, 255, 0.24);
box-shadow: 0 24px 54px rgba(0, 0, 0, 0.34),
inset 0 0 0 1px rgba(64, 180, 244, 0.08);
}
.playback-dialog-header,
.playback-dialog-controls {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.playback-header { .playback-dialog-header {
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
} }
.playback-title { .playback-dialog-title {
color: #f2fbff; color: #f2fbff;
font-size: 15px; font-size: 20px;
font-weight: 700; font-weight: 700;
letter-spacing: 1px;
} }
.playback-filter { .playback-dialog-meta {
margin-top: 6px;
color: rgba(166, 219, 245, 0.76);
font-size: 12px;
}
.playback-dialog-filter {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) 136px;
gap: 14px;
align-items: end;
}
.playback-field {
display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
} }
.playback-filter input { .playback-field span {
padding: 0 10px; color: rgba(182, 232, 248, 0.78);
font-size: 12px;
} }
.playback-controls { .playback-field input {
width: 100%;
padding: 0 12px;
}
.playback-launch-btn {
height: 38px;
}
.playback-preview {
position: relative;
height: 420px;
overflow: hidden;
border: 1px solid rgba(82, 191, 255, 0.16);
background: radial-gradient(circle at 50% 50%, rgba(34, 116, 190, 0.16), rgba(4, 16, 33, 0.96));
}
.playback-preview video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playback-dialog-controls {
gap: 10px; gap: 10px;
margin-top: 14px; flex-wrap: wrap;
} }
.playback-track { .playback-track {
@@ -1366,9 +1473,12 @@
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
background: background:
radial-gradient(circle at 50% 50%, rgba(32, 112, 183, 0.12), transparent 45%), radial-gradient(circle at 50% 58%, rgba(46, 163, 224, 0.12), transparent 34%),
linear-gradient(180deg, rgba(5, 17, 35, 0.94) 0%, rgba(3, 12, 27, 0.96) 100%); linear-gradient(180deg, rgba(6, 18, 31, 0.96) 0%, rgba(3, 10, 22, 0.98) 100%);
border: 1px solid rgba(82, 191, 255, 0.16); border: 1px solid rgba(82, 191, 255, 0.16);
box-shadow:
inset 0 0 38px rgba(24, 92, 140, 0.18),
inset 0 -46px 72px rgba(0, 0, 0, 0.34);
} }
.nav-map-stage::before { .nav-map-stage::before {
@@ -1376,10 +1486,24 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), linear-gradient(180deg, rgba(255, 255, 255, 0.015) 0, transparent 36%),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); linear-gradient(rgba(102, 208, 255, 0.028) 1px, transparent 1px),
background-size: 48px 48px; linear-gradient(90deg, rgba(102, 208, 255, 0.022) 1px, transparent 1px);
opacity: 0.5; background-size:
100% 100%,
46px 46px,
46px 46px;
opacity: 0.42;
pointer-events: none;
}
.nav-map-stage::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(2, 8, 18, 0.62), transparent 18%, transparent 82%, rgba(2, 8, 18, 0.62)),
linear-gradient(180deg, rgba(2, 8, 18, 0.3), transparent 20%, transparent 80%, rgba(2, 8, 18, 0.54));
pointer-events: none; pointer-events: none;
} }
@@ -1393,35 +1517,60 @@
stroke-width: 1; stroke-width: 1;
} }
.cloud-layer circle { .corridor-lane {
fill: rgba(140, 230, 255, 0.74); fill: rgba(14, 48, 76, 0.46);
stroke: rgba(104, 222, 255, 0.08);
stroke-width: 1.2;
filter: drop-shadow(0 0 16px rgba(54, 163, 235, 0.2));
}
.corridor-edge {
fill: none;
stroke: rgba(133, 224, 255, 0.36);
stroke-width: 1.5;
stroke-dasharray: 4 8;
stroke-linecap: round;
opacity: 0.72;
}
.wall-layer circle {
fill: rgba(153, 241, 255, 0.82);
opacity: 0.88; opacity: 0.88;
filter: drop-shadow(0 0 4px rgba(82, 198, 255, 0.28)); filter: drop-shadow(0 0 8px rgba(86, 214, 255, 0.24));
}
.wall-upper circle {
fill: rgba(151, 234, 255, 0.76);
}
.wall-lower circle {
fill: rgba(110, 216, 255, 0.64);
} }
.route-glow { .route-glow {
fill: none; fill: none;
stroke: rgba(88, 219, 255, 0.18); stroke: rgba(88, 219, 255, 0.16);
stroke-width: 18; stroke-width: 12;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
} }
.route-line { .route-line {
fill: none; fill: none;
stroke: #67e3ff; stroke: #76efff;
stroke-width: 5; stroke-width: 4;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
stroke-dasharray: 12 12; stroke-dasharray: 10 8;
animation: routeFlow 12s linear infinite; animation: routeFlow 12s linear infinite;
filter: drop-shadow(0 0 10px rgba(70, 212, 255, 0.32)); filter: drop-shadow(0 0 10px rgba(70, 212, 255, 0.26));
} }
.checkpoint circle { .checkpoint circle {
fill: rgba(6, 22, 51, 0.95); fill: rgba(5, 18, 42, 0.96);
stroke: #7ae5ff; stroke: #8becff;
stroke-width: 2; stroke-width: 2;
filter: drop-shadow(0 0 12px rgba(97, 220, 255, 0.24));
} }
.checkpoint text { .checkpoint text {
@@ -1451,6 +1600,7 @@
color: rgba(183, 234, 255, 0.82); color: rgba(183, 234, 255, 0.82);
font-size: 11px; font-size: 11px;
letter-spacing: 0.7px; letter-spacing: 0.7px;
text-shadow: 0 0 8px rgba(56, 190, 248, 0.22);
} }
.nav-map-overlay.top-left { .nav-map-overlay.top-left {
@@ -1808,27 +1958,73 @@
rgba(8, 31, 68, 0.92) 0%, rgba(8, 31, 68, 0.92) 0%,
rgba(10, 49, 92, 0.58) 100% rgba(10, 49, 92, 0.58) 100%
); );
border-left: 3px solid rgba(92, 196, 255, 0.76); border: 1px solid rgba(78, 194, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(65, 174, 243, 0.08); box-shadow:
inset 0 0 0 1px rgba(65, 174, 243, 0.08),
0 12px 22px rgba(3, 14, 28, 0.22);
} }
.alert-main { .alert-content {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 14px;
}
.alert-thumbnail {
width: 124px;
height: 78px;
flex: 0 0 124px;
object-fit: cover;
border: 1px solid rgba(116, 219, 255, 0.24);
box-shadow: 0 0 0 1px rgba(78, 194, 255, 0.08);
background: rgba(6, 22, 39, 0.72);
}
.alert-info {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 10px;
}
.alert-summary {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(92px, auto);
gap: 12px; gap: 12px;
margin-bottom: 8px; align-items: start;
}
.alert-field {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.alert-field-right {
align-items: flex-start;
}
.alert-label {
color: rgba(146, 213, 238, 0.72);
font-size: 11px;
line-height: 1;
} }
.alert-title { .alert-title {
color: #f0fbff; color: #f0fbff;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.4px;
line-height: 1.2;
} }
.alert-level { .alert-level {
min-width: 46px; min-width: 48px;
padding: 3px 8px; padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
text-align: center; text-align: center;
font-size: 11px; font-size: 11px;
@@ -1853,12 +2049,17 @@
border: 1px solid rgba(110, 255, 197, 0.2); border: 1px solid rgba(110, 255, 197, 0.2);
} }
.alert-meta { .alert-time-row {
width: 100%;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
gap: 10px; gap: 6px;
color: rgba(171, 223, 246, 0.8); }
font-size: 11px;
.alert-time {
color: rgba(190, 234, 249, 0.84);
font-size: 12px;
line-height: 1.2;
} }
.alert-empty { .alert-empty {
@@ -1872,8 +2073,12 @@
} }
.video-card { .video-card {
> .card-header .title {
margin-left: 80px;
}
.video-panel { .video-panel {
margin: 12px 24px 0; margin: 12px 4px 0 20px;
height: calc(100% - 56px); height: calc(100% - 56px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1984,8 +2189,12 @@
} }
.control-card { .control-card {
> .card-header .title {
margin-left: 80px;
}
.control-panel { .control-panel {
margin: 12px 24px 0; margin: 12px 4px 0 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
@@ -2032,7 +2241,7 @@
.control-top-row { .control-top-row {
display: grid; display: grid;
grid-template-columns: minmax(0, calc(50% - 10px)) minmax(0, calc(50% + 10px)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 14px;
align-items: stretch; align-items: stretch;
} }
@@ -2064,6 +2273,45 @@
font-size: 12px; font-size: 12px;
} }
.drive-mode-tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 2px 0 10px;
}
.drive-mode-tab {
height: 34px;
border: 1px solid rgba(87, 198, 255, 0.18);
background: rgba(8, 31, 62, 0.72);
color: rgba(192, 235, 250, 0.78);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
&:hover {
transform: translateY(-1px);
border-color: rgba(102, 235, 255, 0.38);
}
&.active {
color: #eefcff;
border-color: rgba(102, 235, 255, 0.7);
background: linear-gradient(
90deg,
rgba(24, 102, 180, 0.88),
rgba(35, 158, 215, 0.88)
);
box-shadow: 0 0 14px rgba(52, 177, 255, 0.18);
}
}
.vehicle-pad { .vehicle-pad {
width: 208px; width: 208px;
height: 208px; height: 208px;
@@ -2310,6 +2558,63 @@
transform: scale(0.88); transform: scale(0.88);
} }
.gimbal-preset-panel {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 0 10px;
padding: 0 2px;
}
.gimbal-preset-group {
display: flex;
flex: 1;
min-width: 0;
}
.gimbal-preset-select {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid rgba(91, 208, 255, 0.22);
background: linear-gradient(
180deg,
rgba(10, 36, 72, 0.9) 0%,
rgba(7, 23, 48, 0.82) 100%
);
color: #eefcff;
font-size: 12px;
outline: none;
box-shadow: inset 0 0 14px rgba(62, 177, 248, 0.08);
}
.gimbal-preset-btn {
flex: 0 0 72px;
height: 38px;
border: 1px solid rgba(91, 208, 255, 0.26);
background: linear-gradient(
90deg,
rgba(16, 74, 132, 0.96) 0%,
rgba(8, 38, 78, 0.98) 56%,
rgba(7, 26, 52, 0.98) 100%
);
color: #eefcff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.8px;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(57, 180, 246, 0.08),
0 0 18px rgba(29, 133, 222, 0.12);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: rgba(102, 235, 255, 0.72);
box-shadow: inset 0 0 0 1px rgba(57, 180, 246, 0.12),
0 0 22px rgba(52, 177, 255, 0.22);
}
}
.lift-control-panel { .lift-control-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,13 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { getRecentAlertPhoto } from "../shared/alertPhotos";
interface AlarmRecord { interface AlarmRecord {
id: string; id: string;
type: string; type: string;
robot: string; robot: string;
photo: string; photo: string;
video: string; status: "待处理";
status: "待研判" | "待处理" | "处置完成" | "误判";
time: string; time: string;
} }
@@ -16,17 +16,15 @@ const alarms: AlarmRecord[] = [
id: "AL-001", id: "AL-001",
type: "线缆破损", type: "线缆破损",
robot: "巡检机器人 R1", robot: "巡检机器人 R1",
photo: "破损抓拍_001.jpg", photo: getRecentAlertPhoto("线缆破损"),
video: "线缆破损_001.mp4", status: "待处理",
status: "待研判",
time: "2026-06-23 10:24:18", time: "2026-06-23 10:24:18",
}, },
{ {
id: "AL-002", id: "AL-002",
type: "局部高温", type: "局部高温",
robot: "巡检机器人 Q1", robot: "巡检机器人 Q1",
photo: "高温抓拍_017.jpg", photo: getRecentAlertPhoto("局部高温"),
video: "局部高温_017.mp4",
status: "待处理", status: "待处理",
time: "2026-06-23 10:18:42", time: "2026-06-23 10:18:42",
}, },
@@ -34,26 +32,23 @@ const alarms: AlarmRecord[] = [
id: "AL-003", id: "AL-003",
type: "墙体裂缝", type: "墙体裂缝",
robot: "巡检机器人 N3", robot: "巡检机器人 N3",
photo: "裂缝取证_006.jpg", photo: getRecentAlertPhoto("墙体裂缝"),
video: "墙体裂缝_006.mp4", status: "待处理",
status: "处置完成",
time: "2026-06-23 09:56:11", time: "2026-06-23 09:56:11",
}, },
{ {
id: "AL-004", id: "AL-004",
type: "水渍渗漏", type: "水渍渗漏",
robot: "巡检机器人 R2", robot: "巡检机器人 R2",
photo: "渗漏抓拍_012.jpg", photo: getRecentAlertPhoto("局部高温"),
video: "水渍渗漏_012.mp4", status: "待处理",
status: "误判",
time: "2026-06-23 09:20:36", time: "2026-06-23 09:20:36",
}, },
{ {
id: "AL-005", id: "AL-005",
type: "应急门开启异常", type: "应急门开启异常",
robot: "巡检机器人 N1", robot: "巡检机器人 N1",
photo: "门禁取证_031.jpg", photo: getRecentAlertPhoto("线缆破损"),
video: "门禁异常_031.mp4",
status: "待处理", status: "待处理",
time: "2026-06-23 08:42:57", time: "2026-06-23 08:42:57",
}, },
@@ -101,10 +96,7 @@ const visiblePages = computed(() => {
function getAlarmStatusClass(status: AlarmRecord["status"]) { function getAlarmStatusClass(status: AlarmRecord["status"]) {
const classMap = { const classMap = {
待研判: "alarm-status-pending",
待处理: "alarm-status-handling", 待处理: "alarm-status-handling",
处置完成: "alarm-status-finished",
误判: "alarm-status-false",
}; };
return classMap[status]; return classMap[status];
@@ -125,7 +117,6 @@ function handleSearch() {
<section class="page-card management-table-card"> <section class="page-card management-table-card">
<div class="management-toolbar"> <div class="management-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<button class="page-action-btn primary" type="button">批量通知</button>
<button class="page-action-btn ghost" type="button">导出告警</button> <button class="page-action-btn ghost" type="button">导出告警</button>
<input <input
v-model="keyword" v-model="keyword"
@@ -146,7 +137,6 @@ function handleSearch() {
<th>告警类型</th> <th>告警类型</th>
<th>报警机器人</th> <th>报警机器人</th>
<th>取证照片</th> <th>取证照片</th>
<th>取证视频</th>
<th>案件状态</th> <th>案件状态</th>
<th>告警时间</th> <th>告警时间</th>
<th>操作</th> <th>操作</th>
@@ -158,10 +148,7 @@ function handleSearch() {
<td>{{ alarm.type }}</td> <td>{{ alarm.type }}</td>
<td>{{ alarm.robot }}</td> <td>{{ alarm.robot }}</td>
<td> <td>
<div class="evidence-chip photo">{{ alarm.photo }}</div> <img class="evidence-photo" :src="alarm.photo" :alt="`${alarm.type}取证照片`" />
</td>
<td>
<div class="evidence-chip video">{{ alarm.video }}</div>
</td> </td>
<td> <td>
<span :class="['table-status', getAlarmStatusClass(alarm.status)]"> <span :class="['table-status', getAlarmStatusClass(alarm.status)]">
@@ -171,8 +158,6 @@ function handleSearch() {
<td>{{ alarm.time }}</td> <td>{{ alarm.time }}</td>
<td> <td>
<div class="table-actions"> <div class="table-actions">
<button type="button">研判</button>
<button type="button">通知</button>
<button class="danger" type="button">删除</button> <button class="danger" type="button">删除</button>
</div> </div>
</td> </td>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import monitorVideo from "../assets/循环背景动画.mp4"; import { recentAlertPhotoSources } from "../shared/alertPhotos";
const monitorVideo = "https://wuhuimg.oss-cn-shanghai.aliyuncs.com/video/output.mp4";
type DriveMode = "阿克曼" | "旋转" | "横移";
interface DeviceItem { interface DeviceItem {
id: string; id: string;
@@ -26,7 +30,11 @@ interface NavPoint {
interface NavScenario { interface NavScenario {
routePath: string; routePath: string;
cloudPoints: NavPoint[]; lanePath: string;
upperEdgePath: string;
lowerEdgePath: string;
upperWallPoints: NavPoint[];
lowerWallPoints: NavPoint[];
checkPoints: Array<{ x: number; y: number; label: string }>; checkPoints: Array<{ x: number; y: number; label: string }>;
} }
@@ -34,8 +42,8 @@ interface AlertItem {
id: string; id: string;
title: string; title: string;
level: "高" | "中" | "低"; level: "高" | "中" | "低";
location: string;
time: string; time: string;
photo: string;
} }
const projectDevices: ProjectItem[] = [ const projectDevices: ProjectItem[] = [
@@ -92,28 +100,35 @@ const recentAlerts: AlertItem[] = [
id: "AL-001", id: "AL-001",
title: "线缆破损", title: "线缆破损",
level: "高", level: "高",
location: "西江隧道 K1+240", time: "2026-06-26 10:24:18",
time: "10:24:18", photo: recentAlertPhotoSources["线缆破损"],
}, },
{ {
id: "AL-002", id: "AL-002",
title: "局部高温", title: "局部高温",
level: "中", level: "中",
location: "西江隧道 泵房区", time: "2026-06-26 10:18:42",
time: "10:18:42", photo: recentAlertPhotoSources["局部高温"],
}, },
{ {
id: "AL-003", id: "AL-003",
title: "墙体裂缝", title: "墙体裂缝",
level: "低", level: "低",
location: "西江隧道 K1+860", time: "2026-06-26 09:56:11",
time: "09:56:11", photo: recentAlertPhotoSources["墙体裂缝"],
}, },
]; ];
const alertTabs = ["全部", "线缆破损", "局部高温", "墙体裂缝"] as const; const alertTabs = ["全部", "线缆破损", "局部高温", "墙体裂缝"] as const;
const activeAlertTab = ref<(typeof alertTabs)[number]>("全部"); const activeAlertTab = ref<(typeof alertTabs)[number]>("全部");
const activeCameraMode = ref<"white" | "infrared">("white"); const activeCameraMode = ref<"white" | "infrared">("white");
const driveModeTabs: DriveMode[] = ["阿克曼", "旋转", "横移"];
const activeDriveMode = ref<DriveMode>("阿克曼");
const presetPointOptions = Array.from(
{ length: 10 },
(_, index) => `预置点${index + 1}`,
);
const selectedPresetPoint = ref("预置点1");
const controlMode = ref<"人工接管" | "自主巡检">("人工接管"); const controlMode = ref<"人工接管" | "自主巡检">("人工接管");
const targetLiftHeight = ref("1200"); const targetLiftHeight = ref("1200");
const liftRealtimeStatus = ref("待命"); const liftRealtimeStatus = ref("待命");
@@ -121,38 +136,73 @@ const liftRealtimeHeight = ref("1180");
const navScenario: NavScenario = { const navScenario: NavScenario = {
routePath: routePath:
"M 72 310 C 118 282, 164 265, 212 248 S 312 205, 360 216 S 446 270, 504 248 S 612 188, 690 210", "M 106 318 L 154 306 L 208 292 L 256 278 L 306 264 L 358 258 L 414 258 L 468 248 L 522 241 L 578 234 L 638 226",
cloudPoints: [ lanePath:
{ x: 52, y: 120 }, "M 82 270 L 136 258 L 190 244 L 244 230 L 296 216 L 350 208 L 408 208 L 464 198 L 518 190 L 576 182 L 642 174 L 656 188 L 650 278 L 586 286 L 528 294 L 472 302 L 414 312 L 358 312 L 304 320 L 252 334 L 198 348 L 142 360 L 88 370 L 74 356 Z",
{ x: 86, y: 132 }, upperEdgePath:
{ x: 128, y: 148 }, "M 82 270 L 136 258 L 190 244 L 244 230 L 296 216 L 350 208 L 408 208 L 464 198 L 518 190 L 576 182 L 642 174",
{ x: 164, y: 138 }, lowerEdgePath:
{ x: 204, y: 118 }, "M 88 370 L 142 360 L 198 348 L 252 334 L 304 320 L 358 312 L 414 312 L 472 302 L 528 294 L 586 286 L 650 278",
{ x: 252, y: 104 }, upperWallPoints: [
{ x: 304, y: 120 }, { x: 64, y: 164 },
{ x: 346, y: 142 }, { x: 84, y: 150 },
{ x: 392, y: 168 }, { x: 104, y: 142 },
{ x: 442, y: 162 }, { x: 126, y: 136 },
{ x: 494, y: 134 }, { x: 148, y: 132 },
{ x: 548, y: 116 }, { x: 172, y: 126 },
{ x: 602, y: 132 }, { x: 196, y: 122 },
{ x: 660, y: 152 }, { x: 224, y: 118 },
{ x: 94, y: 254 }, { x: 252, y: 116 },
{ x: 150, y: 236 }, { x: 280, y: 112 },
{ x: 214, y: 220 }, { x: 312, y: 110 },
{ x: 278, y: 208 }, { x: 344, y: 108 },
{ x: 338, y: 214 }, { x: 378, y: 108 },
{ x: 400, y: 236 }, { x: 412, y: 110 },
{ x: 468, y: 258 }, { x: 448, y: 112 },
{ x: 530, y: 238 }, { x: 484, y: 114 },
{ x: 592, y: 214 }, { x: 520, y: 116 },
{ x: 656, y: 226 }, { x: 556, y: 120 },
{ x: 590, y: 126 },
{ x: 622, y: 134 },
{ x: 650, y: 144 },
{ x: 676, y: 158 },
{ x: 116, y: 170 },
{ x: 214, y: 156 },
{ x: 326, y: 146 },
{ x: 446, y: 148 },
{ x: 566, y: 160 },
{ x: 638, y: 172 },
],
lowerWallPoints: [
{ x: 72, y: 380 },
{ x: 96, y: 392 },
{ x: 122, y: 404 },
{ x: 150, y: 410 },
{ x: 180, y: 416 },
{ x: 214, y: 420 },
{ x: 248, y: 422 },
{ x: 284, y: 424 },
{ x: 322, y: 426 },
{ x: 360, y: 426 },
{ x: 400, y: 424 },
{ x: 440, y: 420 },
{ x: 480, y: 414 },
{ x: 520, y: 408 },
{ x: 558, y: 400 },
{ x: 594, y: 392 },
{ x: 628, y: 384 },
{ x: 660, y: 374 },
{ x: 700, y: 366 },
{ x: 126, y: 376 },
{ x: 238, y: 392 },
{ x: 354, y: 396 },
{ x: 474, y: 388 },
{ x: 590, y: 372 },
], ],
checkPoints: [ checkPoints: [
{ x: 104, y: 280, label: "起点" }, { x: 106, y: 318, label: "起点" },
{ x: 268, y: 224, label: "高温检测点" }, { x: 358, y: 258, label: "途经点" },
{ x: 480, y: 256, label: "泵房区" }, { x: 638, y: 226, label: "终点" },
{ x: 674, y: 214, label: "终点" },
], ],
}; };
@@ -177,11 +227,19 @@ function switchCameraMode(mode: "white" | "infrared") {
activeCameraMode.value = mode; activeCameraMode.value = mode;
} }
function switchDriveMode(mode: DriveMode) {
activeDriveMode.value = mode;
}
function toggleControlMode() { function toggleControlMode() {
controlMode.value = controlMode.value =
controlMode.value === "人工接管" ? "自主巡检" : "人工接管"; controlMode.value === "人工接管" ? "自主巡检" : "人工接管";
} }
function jumpToPresetPoint() {
selectedPresetPoint.value = selectedPresetPoint.value;
}
function runToTargetHeight() { function runToTargetHeight() {
const normalizedHeight = targetLiftHeight.value.replace(/[^\d]/g, ""); const normalizedHeight = targetLiftHeight.value.replace(/[^\d]/g, "");
@@ -218,7 +276,6 @@ function getStatusClass(status: DeviceItem["status"]) {
</div> </div>
<div class="device-meta"> <div class="device-meta">
<span>{{ device.id }}</span> <span>{{ device.id }}</span>
<span>{{ device.location }}</span>
<span>{{ device.battery }}</span> <span>{{ device.battery }}</span>
</div> </div>
</div> </div>
@@ -242,14 +299,24 @@ function getStatusClass(status: DeviceItem["status"]) {
</div> </div>
<template v-if="filteredRecentAlerts.length"> <template v-if="filteredRecentAlerts.length">
<div v-for="alert in filteredRecentAlerts" :key="alert.id" class="alert-item"> <div v-for="alert in filteredRecentAlerts" :key="alert.id" class="alert-item">
<div class="alert-main"> <div class="alert-content">
<div class="alert-title">{{ alert.title }}</div> <img class="alert-thumbnail" :src="alert.photo" :alt="alert.title" />
<div class="alert-level">{{ alert.level }}</div> <div class="alert-info">
</div> <div class="alert-summary">
<div class="alert-meta"> <div class="alert-field">
<span>{{ alert.id }}</span> <div class="alert-label">告警类型</div>
<span>{{ alert.location }}</span> <div class="alert-title">{{ alert.title }}</div>
<span>{{ alert.time }}</span> </div>
<div class="alert-field alert-field-right">
<div class="alert-label">告警等级</div>
<div :class="['alert-level', `level-${alert.level}`]">{{ alert.level }}</div>
</div>
</div>
<div class="alert-time-row">
<span class="alert-label">告警时间</span>
<span class="alert-time">{{ alert.time }}</span>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -266,13 +333,25 @@ function getStatusClass(status: DeviceItem["status"]) {
<line v-for="line in 7" :key="`h-${line}`" :x1="40" :y1="line * 52" :x2="720" :y2="line * 52" /> <line v-for="line in 7" :key="`h-${line}`" :x1="40" :y1="line * 52" :x2="720" :y2="line * 52" />
<line v-for="line in 12" :key="`v-${line}`" :x1="line * 56" :y1="40" :x2="line * 56" :y2="380" /> <line v-for="line in 12" :key="`v-${line}`" :x1="line * 56" :y1="40" :x2="line * 56" :y2="380" />
</g> </g>
<g class="cloud-layer"> <path class="corridor-lane" :d="currentScenario.lanePath" />
<path class="corridor-edge" :d="currentScenario.upperEdgePath" />
<path class="corridor-edge" :d="currentScenario.lowerEdgePath" />
<g class="wall-layer wall-upper">
<circle <circle
v-for="(point, index) in currentScenario.cloudPoints" v-for="(point, index) in currentScenario.upperWallPoints"
:key="`point-${index}`" :key="`upper-wall-${index}`"
:cx="point.x" :cx="point.x"
:cy="point.y" :cy="point.y"
:r="index % 3 === 0 ? 3 : 2" :r="index % 4 === 0 ? 3.4 : 2.4"
/>
</g>
<g class="wall-layer wall-lower">
<circle
v-for="(point, index) in currentScenario.lowerWallPoints"
:key="`lower-wall-${index}`"
:cx="point.x"
:cy="point.y"
:r="index % 5 === 0 ? 3.2 : 2.3"
/> />
</g> </g>
<path class="route-glow" :d="currentScenario.routePath" /> <path class="route-glow" :d="currentScenario.routePath" />
@@ -300,35 +379,14 @@ function getStatusClass(status: DeviceItem["status"]) {
</path> </path>
</g> </g>
</svg> </svg>
<div class="nav-map-overlay top-left">激光雷达点云</div> <div class="nav-map-overlay top-left">隧道点云走廊</div>
<div class="nav-map-overlay top-right">路径跟踪</div> <div class="nav-map-overlay top-right">窄通道路径跟踪</div>
<div class="nav-map-overlay bottom-left"> <div class="nav-map-overlay bottom-left">
机器人编号{{ currentRobot.id }} / {{ currentRobot.task }} 机器人编号{{ currentRobot.id }} / {{ currentRobot.task }}
</div> </div>
<div class="nav-map-overlay bottom-right"> <div class="nav-map-overlay bottom-right">
线速度 {{ currentRobot.linearSpeed }} / 角速度 {{ currentRobot.angularSpeed }} 电量 {{ currentRobot.battery }} / 线速度 {{ currentRobot.linearSpeed }} / 角速度
</div> {{ currentRobot.angularSpeed }}
</div>
<div class="nav-map-footer">
<div class="footer-item">
<span class="footer-label">当前位置</span>
<span class="footer-value">{{ currentRobot.location }}</span>
</div>
<div class="footer-item">
<span class="footer-label">电量</span>
<span class="footer-value">{{ currentRobot.battery }}</span>
</div>
<div class="footer-item">
<span class="footer-label">任务状态</span>
<span class="footer-value">{{ currentRobot.task }}</span>
</div>
<div class="footer-item">
<span class="footer-label">实时线速度</span>
<span class="footer-value">{{ currentRobot.linearSpeed }}</span>
</div>
<div class="footer-item">
<span class="footer-label">实时角速度</span>
<span class="footer-value">{{ currentRobot.angularSpeed }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -371,7 +429,6 @@ function getStatusClass(status: DeviceItem["status"]) {
{{ activeCameraMode === "white" ? "白光镜头" : "红外镜头" }} {{ activeCameraMode === "white" ? "白光镜头" : "红外镜头" }}
</div> </div>
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div> <div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
<div class="video-overlay bottom-right">{{ currentRobot.location }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -403,6 +460,17 @@ function getStatusClass(status: DeviceItem["status"]) {
<div class="control-top-row"> <div class="control-top-row">
<div class="vehicle-section"> <div class="vehicle-section">
<div class="section-title control-block-title">底盘驱动</div> <div class="section-title control-block-title">底盘驱动</div>
<div class="drive-mode-tabs">
<button
v-for="mode in driveModeTabs"
:key="mode"
:class="['drive-mode-tab', { active: activeDriveMode === mode }]"
type="button"
@click="switchDriveMode(mode)"
>
{{ mode }}
</button>
</div>
<div class="vehicle-pad"> <div class="vehicle-pad">
<button class="drive-btn drive-up" type="button"> <button class="drive-btn drive-up" type="button">
<span class="drive-arrow"></span> <span class="drive-arrow"></span>
@@ -425,6 +493,22 @@ function getStatusClass(status: DeviceItem["status"]) {
</div> </div>
<div class="control-section gimbal-section"> <div class="control-section gimbal-section">
<div class="section-title">云台控制</div> <div class="section-title">云台控制</div>
<div class="gimbal-preset-panel">
<label class="gimbal-preset-group">
<select v-model="selectedPresetPoint" class="gimbal-preset-select">
<option
v-for="preset in presetPointOptions"
:key="preset"
:value="preset"
>
{{ preset }}
</option>
</select>
</label>
<button class="gimbal-preset-btn" type="button" @click="jumpToPresetPoint">
跳转
</button>
</div>
<div class="vehicle-pad gimbal-wheel"> <div class="vehicle-pad gimbal-wheel">
<button class="drive-btn drive-up" type="button"> <button class="drive-btn drive-up" type="button">
<span class="drive-arrow"></span> <span class="drive-arrow"></span>
@@ -482,3 +566,4 @@ function getStatusClass(status: DeviceItem["status"]) {
</div> </div>
</div> </div>
</template> </template>

View File

@@ -12,7 +12,6 @@ interface PatrolRecord {
robot: string; robot: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
mileage: string;
alarms: PatrolAlarmRecord[]; alarms: PatrolAlarmRecord[];
} }
@@ -22,7 +21,6 @@ const patrolRecords: PatrolRecord[] = [
robot: "巡检机器人 R1", robot: "巡检机器人 R1",
startTime: "2026-06-23 08:10:22", startTime: "2026-06-23 08:10:22",
endTime: "2026-06-23 09:02:15", endTime: "2026-06-23 09:02:15",
mileage: "3.8 km",
alarms: [ alarms: [
{ id: "AL-001", type: "线缆破损", reportTime: "2026-06-23 08:36:12" }, { id: "AL-001", type: "线缆破损", reportTime: "2026-06-23 08:36:12" },
{ id: "AL-002", type: "局部高温", reportTime: "2026-06-23 08:42:09" }, { id: "AL-002", type: "局部高温", reportTime: "2026-06-23 08:42:09" },
@@ -33,7 +31,6 @@ const patrolRecords: PatrolRecord[] = [
robot: "巡检机器人 Q1", robot: "巡检机器人 Q1",
startTime: "2026-06-23 09:20:08", startTime: "2026-06-23 09:20:08",
endTime: "2026-06-23 10:01:46", endTime: "2026-06-23 10:01:46",
mileage: "2.9 km",
alarms: [ alarms: [
{ id: "AL-005", type: "应急门开启异常", reportTime: "2026-06-23 09:48:57" }, { id: "AL-005", type: "应急门开启异常", reportTime: "2026-06-23 09:48:57" },
], ],
@@ -43,7 +40,6 @@ const patrolRecords: PatrolRecord[] = [
robot: "巡检机器人 N3", robot: "巡检机器人 N3",
startTime: "2026-06-23 10:08:34", startTime: "2026-06-23 10:08:34",
endTime: "2026-06-23 11:12:25", endTime: "2026-06-23 11:12:25",
mileage: "4.6 km",
alarms: [ alarms: [
{ id: "AL-003", type: "墙体裂缝", reportTime: "2026-06-23 10:36:41" }, { id: "AL-003", type: "墙体裂缝", reportTime: "2026-06-23 10:36:41" },
{ id: "AL-006", type: "水渍渗漏", reportTime: "2026-06-23 10:54:18" }, { id: "AL-006", type: "水渍渗漏", reportTime: "2026-06-23 10:54:18" },
@@ -55,7 +51,6 @@ const patrolRecords: PatrolRecord[] = [
robot: "巡检机器人 R2", robot: "巡检机器人 R2",
startTime: "2026-06-23 13:15:17", startTime: "2026-06-23 13:15:17",
endTime: "2026-06-23 14:05:52", endTime: "2026-06-23 14:05:52",
mileage: "3.2 km",
alarms: [], alarms: [],
}, },
]; ];
@@ -93,7 +88,6 @@ function closeDialog() {
<th>巡检机器人</th> <th>巡检机器人</th>
<th>开始巡检时间</th> <th>开始巡检时间</th>
<th>结束巡检时间</th> <th>结束巡检时间</th>
<th>巡检里程</th>
<th>告警触发次数</th> <th>告警触发次数</th>
</tr> </tr>
</thead> </thead>
@@ -103,7 +97,6 @@ function closeDialog() {
<td>{{ record.robot }}</td> <td>{{ record.robot }}</td>
<td>{{ record.startTime }}</td> <td>{{ record.startTime }}</td>
<td>{{ record.endTime }}</td> <td>{{ record.endTime }}</td>
<td>{{ record.mileage }}</td>
<td> <td>
<button <button
class="alarm-link-btn" class="alarm-link-btn"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, nextTick, ref } from "vue";
import monitorVideo from "../assets/循环背景动画.mp4"; import monitorVideo from "../assets/循环背景动画.mp4";
type GridMode = 1 | 4 | 9; type GridMode = 1 | 4 | 9;
@@ -38,7 +38,7 @@ const projectData: ProjectNode = {
cameras: [ cameras: [
{ id: "CAM-001", name: "白光镜头", type: "白光", status: "在线" }, { id: "CAM-001", name: "白光镜头", type: "白光", status: "在线" },
{ id: "CAM-002", name: "红外镜头", type: "红外", status: "在线" }, { id: "CAM-002", name: "红外镜头", type: "红外", status: "在线" },
{ id: "CAM-003", name: "云台辅镜头", type: "辅助", status: "在线" }, { id: "CAM-003", name: "云台辅镜头", type: "辅助", status: "在线" },
], ],
}, },
{ {
@@ -53,10 +53,13 @@ const projectData: ProjectNode = {
}; };
const gridMode = ref<GridMode>(4); const gridMode = ref<GridMode>(4);
const playbackProgress = ref(42);
const playbackDate = ref("2026-06-23");
const playbackTime = ref("14:20:00");
const activeCameraId = ref(projectData.devices[0].cameras[0].id); const activeCameraId = ref(projectData.devices[0].cameras[0].id);
const playbackDialogVisible = ref(false);
const playbackStartAt = ref("2026-06-23T14:20");
const playbackEndAt = ref("2026-06-23T14:50");
const playbackProgress = ref(0);
const playbackPaused = ref(true);
const playbackPlayerRef = ref<HTMLVideoElement | null>(null);
const currentProject = computed(() => projectData); const currentProject = computed(() => projectData);
@@ -71,7 +74,9 @@ const projectCameras = computed<ProjectCameraItem[]>(() =>
); );
const activeCamera = computed( const activeCamera = computed(
() => projectCameras.value.find((camera) => camera.id === activeCameraId.value) ?? projectCameras.value[0], () =>
projectCameras.value.find((camera) => camera.id === activeCameraId.value) ??
projectCameras.value[0],
); );
const orderedCameras = computed(() => { const orderedCameras = computed(() => {
@@ -97,6 +102,12 @@ const visibleCameras = computed(() => {
return Array.from({ length: targetCount }, (_, index) => cameras[index] ?? null); return Array.from({ length: targetCount }, (_, index) => cameras[index] ?? null);
}); });
const playbackRangeLabel = computed(() => {
const startLabel = playbackStartAt.value.replace("T", " ");
const endLabel = playbackEndAt.value.replace("T", " ");
return `${startLabel} - ${endLabel}`;
});
function selectCamera(cameraId: string) { function selectCamera(cameraId: string) {
activeCameraId.value = cameraId; activeCameraId.value = cameraId;
} }
@@ -104,6 +115,92 @@ function selectCamera(cameraId: string) {
function setGridMode(mode: GridMode) { function setGridMode(mode: GridMode) {
gridMode.value = mode; gridMode.value = mode;
} }
function syncPlaybackProgress() {
const video = playbackPlayerRef.value;
if (!video) {
playbackProgress.value = 0;
playbackPaused.value = true;
return;
}
const duration = Number.isFinite(video.duration) ? video.duration : 0;
playbackProgress.value = duration > 0 ? (video.currentTime / duration) * 100 : 0;
playbackPaused.value = video.paused;
}
function startPlayback() {
const video = playbackPlayerRef.value;
if (!video) {
return;
}
video.currentTime = 0;
void video.play();
playbackPaused.value = false;
syncPlaybackProgress();
}
async function openPlaybackDialog() {
playbackDialogVisible.value = true;
playbackProgress.value = 0;
playbackPaused.value = true;
await nextTick();
startPlayback();
}
function closePlaybackDialog() {
playbackPlayerRef.value?.pause();
playbackDialogVisible.value = false;
playbackPaused.value = true;
}
function togglePlaybackPause() {
const video = playbackPlayerRef.value;
if (!video) {
return;
}
if (video.paused) {
void video.play();
} else {
video.pause();
}
syncPlaybackProgress();
}
function seekPlayback(offsetSeconds: number) {
const video = playbackPlayerRef.value;
if (!video) {
return;
}
const duration = Number.isFinite(video.duration) ? video.duration : 0;
const targetTime = Math.max(0, Math.min(duration, video.currentTime + offsetSeconds));
video.currentTime = targetTime;
syncPlaybackProgress();
}
function updatePlaybackProgress() {
const video = playbackPlayerRef.value;
if (!video) {
return;
}
const duration = Number.isFinite(video.duration) ? video.duration : 0;
if (duration <= 0) {
return;
}
video.currentTime = (playbackProgress.value / 100) * duration;
}
</script> </script>
<template> <template>
@@ -128,6 +225,11 @@ function setGridMode(mode: GridMode) {
<span class="tree-device-id">{{ camera.deviceName }} / {{ camera.id }}</span> <span class="tree-device-id">{{ camera.deviceName }} / {{ camera.id }}</span>
</button> </button>
</div> </div>
<div class="tree-panel-footer">
<button class="tree-playback-btn" type="button" @click="openPlaybackDialog">
录像回放
</button>
</div>
</div> </div>
</aside> </aside>
@@ -176,36 +278,74 @@ function setGridMode(mode: GridMode) {
</template> </template>
</div> </div>
</div> </div>
<div class="playback-panel">
<div class="playback-header">
<div class="playback-title">录像回放</div>
<div class="playback-filter">
<input v-model="playbackDate" type="date" />
<input v-model="playbackTime" type="time" step="1" />
</div>
</div>
<div class="playback-controls">
<button class="playback-btn" type="button">回放</button>
<button class="playback-btn" type="button">暂停</button>
<button class="playback-btn" type="button">快退</button>
<button class="playback-btn" type="button">快进</button>
<div class="playback-track">
<div class="playback-track-line">
<span :style="{ width: `${playbackProgress}%` }"></span>
</div>
<input
v-model="playbackProgress"
class="playback-range"
max="100"
min="0"
type="range"
/>
</div>
<div class="playback-time">{{ playbackDate }} {{ playbackTime }}</div>
</div>
</div>
</section> </section>
</div> </div>
<div
v-if="playbackDialogVisible && activeCamera"
class="playback-dialog-mask"
@click.self="closePlaybackDialog"
>
<div class="playback-dialog">
<div class="playback-dialog-header">
<div>
<div class="playback-dialog-title">录像回放</div>
<div class="playback-dialog-meta">
{{ activeCamera.deviceName }} / {{ activeCamera.name }} / {{ activeCamera.id }}
</div>
</div>
<button class="dialog-close" type="button" @click="closePlaybackDialog">×</button>
</div>
<div class="playback-dialog-filter">
<label class="playback-field">
<span>开始时间</span>
<input v-model="playbackStartAt" type="datetime-local" />
</label>
<label class="playback-field">
<span>结束时间</span>
<input v-model="playbackEndAt" type="datetime-local" />
</label>
<button class="playback-btn playback-launch-btn" type="button" @click="startPlayback">
开始回放
</button>
</div>
<div class="playback-preview">
<video
ref="playbackPlayerRef"
:src="monitorVideo"
playsinline
@ended="syncPlaybackProgress"
@loadedmetadata="syncPlaybackProgress"
@pause="syncPlaybackProgress"
@play="syncPlaybackProgress"
@timeupdate="syncPlaybackProgress"
></video>
</div>
<div class="playback-dialog-controls">
<button class="playback-btn" type="button" @click="togglePlaybackPause">
{{ playbackPaused ? "播放" : "暂停" }}
</button>
<button class="playback-btn" type="button" @click="seekPlayback(-10)">快退 10s</button>
<button class="playback-btn" type="button" @click="seekPlayback(10)">快进 10s</button>
<div class="playback-track">
<div class="playback-track-line">
<span :style="{ width: `${playbackProgress}%` }"></span>
</div>
<input
v-model="playbackProgress"
class="playback-range"
max="100"
min="0"
type="range"
@input="updatePlaybackProgress"
/>
</div>
<div class="playback-time">{{ playbackRangeLabel }}</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,78 @@
export type AlertPhotoScene = "cable" | "heat" | "crack";
export function createAlertPhoto(scene: AlertPhotoScene, accent: string) {
const sceneMarkup =
scene === "cable"
? `
<path d="M64 112 C140 90, 222 88, 304 104 S470 132, 556 110" stroke="${accent}" stroke-width="6" fill="none" stroke-linecap="round" opacity="0.9" />
<path d="M314 106 L340 92 L366 120" stroke="#ffe9a6" stroke-width="5" fill="none" stroke-linecap="round" />
<circle cx="340" cy="106" r="32" fill="rgba(255,102,82,0.18)" stroke="#ff8e79" stroke-width="2" stroke-dasharray="6 6" />
`
: scene === "heat"
? `
<ellipse cx="340" cy="122" rx="84" ry="46" fill="rgba(255,122,68,0.26)" />
<ellipse cx="344" cy="122" rx="58" ry="30" fill="rgba(255,193,82,0.28)" />
<path d="M320 146 C330 126, 334 114, 332 96 C344 112, 350 126, 348 148" fill="none" stroke="#ffd36a" stroke-width="4" stroke-linecap="round" />
<path d="M358 142 C366 126, 372 114, 370 100 C382 112, 388 126, 384 144" fill="none" stroke="#ff8a5b" stroke-width="4" stroke-linecap="round" />
`
: `
<path d="M206 88 L234 130 L226 166 L268 210 L254 248 L294 282" stroke="#d8ecff" stroke-width="4" fill="none" stroke-linecap="round" />
<path d="M248 152 L280 136 L306 164" stroke="#d8ecff" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M266 212 L302 196 L328 220" stroke="#d8ecff" stroke-width="3" fill="none" stroke-linecap="round" />
<ellipse cx="258" cy="178" rx="86" ry="118" fill="rgba(132,196,255,0.08)" stroke="${accent}" stroke-width="2" stroke-dasharray="5 7" />
`;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#041523" />
<stop offset="55%" stop-color="#0a2740" />
<stop offset="100%" stop-color="#06111d" />
</linearGradient>
<linearGradient id="tunnel" x1="0" x2="1" y1="0.4" y2="0.6">
<stop offset="0%" stop-color="#132b3f" />
<stop offset="50%" stop-color="#0a2135" />
<stop offset="100%" stop-color="#132b3f" />
</linearGradient>
</defs>
<rect width="640" height="360" fill="url(#bg)" />
<rect x="0" y="0" width="640" height="58" fill="rgba(7,18,30,0.52)" />
<rect x="0" y="302" width="640" height="58" fill="rgba(3,10,18,0.66)" />
<path d="M0 82 C98 58, 196 52, 320 56 C448 60, 548 58, 640 78 L640 0 L0 0 Z" fill="rgba(17,38,56,0.88)" />
<path d="M0 314 C112 286, 228 278, 320 282 C456 286, 554 280, 640 256 L640 360 L0 360 Z" fill="rgba(14,24,36,0.96)" />
<path d="M44 250 C148 226, 254 214, 352 214 C452 214, 548 204, 606 188" stroke="url(#tunnel)" stroke-width="92" fill="none" stroke-linecap="round" opacity="0.92" />
<path d="M54 248 C158 226, 254 214, 352 214 C452 214, 540 204, 596 190" stroke="rgba(113,227,255,0.12)" stroke-width="1.6" fill="none" stroke-dasharray="4 10" />
<g opacity="0.78">
<circle cx="96" cy="104" r="3" fill="#8ae5ff" />
<circle cx="134" cy="118" r="2.2" fill="#8ae5ff" />
<circle cx="182" cy="94" r="2.6" fill="#8ae5ff" />
<circle cx="444" cy="96" r="2.8" fill="#8ae5ff" />
<circle cx="522" cy="116" r="2.4" fill="#8ae5ff" />
<circle cx="564" cy="102" r="2.8" fill="#8ae5ff" />
</g>
<g opacity="0.54">
<circle cx="86" cy="286" r="2.2" fill="#74d9ff" />
<circle cx="154" cy="300" r="2.8" fill="#74d9ff" />
<circle cx="452" cy="292" r="2.2" fill="#74d9ff" />
<circle cx="548" cy="284" r="2.6" fill="#74d9ff" />
</g>
${sceneMarkup}
<rect x="20" y="20" width="600" height="320" rx="16" fill="none" stroke="rgba(110,224,255,0.12)" stroke-width="2" />
</svg>
`;
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
}
export const recentAlertPhotoSources = {
"线缆破损": createAlertPhoto("cable", "#ff8f79"),
"局部高温": createAlertPhoto("heat", "#ffc86c"),
"墙体裂缝": createAlertPhoto("crack", "#89d8ff"),
} as const;
const fallbackAlertPhoto = recentAlertPhotoSources["墙体裂缝"];
export function getRecentAlertPhoto(type: string) {
return recentAlertPhotoSources[type as keyof typeof recentAlertPhotoSources] ?? fallbackAlertPhoto;
}