init
commit
60d0235013
@ -0,0 +1,30 @@
|
||||
.DS_Store
|
||||
.history
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
**/*.log
|
||||
|
||||
tests/**/coverage/
|
||||
tests/e2e/reports
|
||||
selenium-debug.log
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.local
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# 编译生成的文件
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>塔架 FOD</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "tower-fod",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"element-plus": "^2.11.8",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"vite": "^7.2.6"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@ -0,0 +1,21 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LoginView from '../view/login/index.vue'
|
||||
import MapView from '../view/map/index.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'map',
|
||||
component: MapView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@ -0,0 +1,243 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
color: #eef5ff;
|
||||
background: #030712;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.monitor-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(45, 212, 191, 0.18), transparent 32%),
|
||||
linear-gradient(135deg, #020617 0%, #111827 52%, #07111f 100%);
|
||||
}
|
||||
|
||||
.stream-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.video-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(0deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.video-backdrop::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
background: linear-gradient(180deg, rgba(2, 6, 23, 0.28), transparent 34%, rgba(2, 6, 23, 0.52));
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: -12%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 16%;
|
||||
background: linear-gradient(180deg, transparent, rgba(34, 211, 238, 0.16), transparent);
|
||||
animation: scan-move 5.8s linear infinite;
|
||||
}
|
||||
|
||||
.signal-grid {
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.24);
|
||||
box-shadow: inset 0 0 44px rgba(8, 145, 178, 0.12);
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
max-width: calc(100vw - 48px);
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 8px;
|
||||
color: #dbeafe;
|
||||
background: rgba(2, 6, 23, 0.62);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.stream-status strong {
|
||||
overflow: hidden;
|
||||
color: #93c5fd;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 14px rgba(245, 158, 11, 0.85);
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 14px rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 3;
|
||||
width: 236px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(186, 230, 253, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(3, 7, 18, 0.66);
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.direction-pad {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
". up ."
|
||||
"left center right"
|
||||
". down .";
|
||||
grid-template-columns: repeat(3, 48px);
|
||||
grid-template-rows: repeat(3, 42px);
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn,
|
||||
.zoom-btn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
border-color: rgba(147, 197, 253, 0.34);
|
||||
border-radius: 8px;
|
||||
color: #eff6ff;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
}
|
||||
|
||||
.control-btn:hover,
|
||||
.zoom-btn:hover,
|
||||
.function-btn:hover {
|
||||
border-color: rgba(34, 211, 238, 0.86);
|
||||
color: #ffffff;
|
||||
background: rgba(8, 145, 178, 0.76);
|
||||
}
|
||||
|
||||
.control-btn.up {
|
||||
grid-area: up;
|
||||
}
|
||||
|
||||
.control-btn.left {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.control-btn.center {
|
||||
grid-area: center;
|
||||
}
|
||||
|
||||
.control-btn.right {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.control-btn.down {
|
||||
grid-area: down;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.function-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.function-btn {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
margin: 0 !important;
|
||||
border-color: rgba(148, 163, 184, 0.24);
|
||||
border-radius: 8px;
|
||||
color: #dbeafe;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
@keyframes scan-move {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(760%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.control-panel {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 212px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.direction-pad {
|
||||
grid-template-columns: repeat(3, 42px);
|
||||
grid-template-rows: repeat(3, 38px);
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<section class="dialog-page">
|
||||
<el-table :data="rows" height="360" size="small" class="dialog-table">
|
||||
<el-table-column prop="index" label="序号" width="72" align="center" />
|
||||
<el-table-column prop="area" label="屏蔽区域" min-width="140" />
|
||||
<el-table-column prop="location" label="中心经纬度" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="radius" label="半径" min-width="90" />
|
||||
<el-table-column prop="reason" label="屏蔽原因" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="updatedAt" label="更新时间" min-width="170" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const rows = [
|
||||
{
|
||||
index: 1,
|
||||
area: '施工区域 A',
|
||||
location: '120.095812, 36.365016',
|
||||
radius: '18m',
|
||||
reason: '临时施工设备',
|
||||
updatedAt: '2026-06-12 10:20:00',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
area: '维护区域 B',
|
||||
location: '120.097301, 36.364782',
|
||||
radius: '12m',
|
||||
reason: '固定维护标识',
|
||||
updatedAt: '2026-06-12 11:05:00',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-page {
|
||||
min-height: 360px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.dialog-table {
|
||||
width: 100%;
|
||||
color: #e5eefb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__header-wrapper th) {
|
||||
border-bottom-color: rgba(226, 232, 240, 0.16);
|
||||
color: rgba(226, 232, 240, 0.86);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__row) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__row:hover > td.el-table__cell) {
|
||||
background: rgba(14, 165, 233, 0.16);
|
||||
}
|
||||
|
||||
.dialog-table :deep(td.el-table__cell) {
|
||||
border-bottom-color: rgba(226, 232, 240, 0.1);
|
||||
color: #e5eefb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__body-wrapper),
|
||||
.dialog-table :deep(.el-table__empty-block) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__empty-text) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<section class="dialog-page">
|
||||
<el-form :model="form" label-width="112px" class="config-form">
|
||||
<el-form-item label="探测阈值">
|
||||
<el-slider v-model="form.threshold" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="告警级别">
|
||||
<el-select v-model="form.alertLevel" placeholder="请选择告警级别">
|
||||
<el-option label="低" value="low" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="高" value="high" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动确认">
|
||||
<el-switch v-model="form.autoConfirm" />
|
||||
</el-form-item>
|
||||
<el-form-item label="巡检间隔">
|
||||
<el-input-number v-model="form.interval" :min="1" :max="60" />
|
||||
<span class="unit">秒</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="4" placeholder="输入配置备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const form = reactive({
|
||||
threshold: 75,
|
||||
alertLevel: 'high',
|
||||
autoConfirm: false,
|
||||
interval: 5,
|
||||
remark: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-page {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-form-item__label) {
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-input__wrapper),
|
||||
.config-form :deep(.el-textarea__inner),
|
||||
.config-form :deep(.el-input-number .el-input__wrapper),
|
||||
.config-form :deep(.el-select__wrapper) {
|
||||
border: 1px solid rgba(226, 232, 240, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-input__inner),
|
||||
.config-form :deep(.el-textarea__inner),
|
||||
.config-form :deep(.el-select__selected-item) {
|
||||
color: #e5eefb;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-textarea__inner::placeholder),
|
||||
.config-form :deep(.el-input__inner::placeholder) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-input-number__decrease),
|
||||
.config-form :deep(.el-input-number__increase) {
|
||||
border-color: rgba(226, 232, 240, 0.14);
|
||||
color: #cbd5e1;
|
||||
background: rgba(30, 41, 59, 0.82);
|
||||
}
|
||||
|
||||
.config-form :deep(.el-slider__runway) {
|
||||
background: rgba(51, 65, 85, 0.88);
|
||||
}
|
||||
|
||||
.config-form :deep(.el-slider__bar) {
|
||||
background: #0891b2;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-slider__button) {
|
||||
border-color: #22d3ee;
|
||||
background: #e0f2fe;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-switch.is-checked .el-switch__core) {
|
||||
border-color: #0891b2;
|
||||
background-color: #0891b2;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<section class="dialog-page">
|
||||
<el-table :data="rows" height="360" size="small" class="dialog-table">
|
||||
<el-table-column prop="index" label="序号" width="72" align="center" />
|
||||
<el-table-column prop="location" label="经纬度" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="异物类别" min-width="120" />
|
||||
<el-table-column prop="intensity" label="探测强度" min-width="110" />
|
||||
<el-table-column prop="status" label="处理状态" min-width="110" />
|
||||
<el-table-column prop="discoveredAt" label="发现时间" min-width="170" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const rows = [
|
||||
{
|
||||
index: 1,
|
||||
location: '120.096710, 36.365238',
|
||||
category: '疑似异物',
|
||||
intensity: '82%',
|
||||
status: '待处理',
|
||||
discoveredAt: '2026-06-12 14:35:20',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
location: '120.096842, 36.365104',
|
||||
category: '金属碎片',
|
||||
intensity: '76%',
|
||||
status: '处理中',
|
||||
discoveredAt: '2026-06-12 14:36:08',
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
location: '120.096514, 36.365387',
|
||||
category: '塑料残片',
|
||||
intensity: '64%',
|
||||
status: '已处理',
|
||||
discoveredAt: '2026-06-12 14:37:42',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-page {
|
||||
min-height: 360px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.dialog-table {
|
||||
width: 100%;
|
||||
color: #e5eefb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__header-wrapper th) {
|
||||
border-bottom-color: rgba(226, 232, 240, 0.16);
|
||||
color: rgba(226, 232, 240, 0.86);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__row) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__row:hover > td.el-table__cell) {
|
||||
background: rgba(14, 165, 233, 0.16);
|
||||
}
|
||||
|
||||
.dialog-table :deep(td.el-table__cell) {
|
||||
border-bottom-color: rgba(226, 232, 240, 0.1);
|
||||
color: #e5eefb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__body-wrapper),
|
||||
.dialog-table :deep(.el-table__empty-block) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-table :deep(.el-table__empty-text) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-shell">
|
||||
<div class="login-brand">
|
||||
<span class="brand-kicker">AIRFIELD FOD MONITOR</span>
|
||||
<h1>机场道面外来物检测系统</h1>
|
||||
<p>道面监控、异物识别、处置闭环统一入口</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="login-card" @submit.prevent>
|
||||
<div class="card-title">
|
||||
<span>系统登录</span>
|
||||
<small>Secure Access</small>
|
||||
</div>
|
||||
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
size="large"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
size="large"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="submitLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-button" type="primary" size="large" @click="submitLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {reactive, ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref(null)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const rules = {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入用户名',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
|
||||
if (valid) {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
padding: 32px;
|
||||
background: linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(0deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
radial-gradient(circle at 24% 20%, rgba(34, 211, 238, 0.2), transparent 30%),
|
||||
radial-gradient(circle at 78% 72%, rgba(245, 158, 11, 0.16), transparent 28%),
|
||||
linear-gradient(135deg, #020617 0%, #0f172a 48%, #030712 100%);
|
||||
background-size: 56px 56px, 56px 56px, auto, auto, auto;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
content: "";
|
||||
border: 1px solid rgba(125, 211, 252, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) 420px;
|
||||
gap: 46px;
|
||||
align-items: center;
|
||||
width: min(1080px, 100%);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-kicker {
|
||||
display: inline-flex;
|
||||
margin-bottom: 18px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.32);
|
||||
border-radius: 8px;
|
||||
color: #67e8f9;
|
||||
background: rgba(8, 145, 178, 0.12);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
font-size: clamp(34px, 5vw, 64px);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.06;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
max-width: 520px;
|
||||
margin: 18px 0 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
min-width: 0;
|
||||
padding: 26px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.22);
|
||||
border-radius: 16px;
|
||||
background: rgba(2, 6, 23, 0.64);
|
||||
box-shadow: 0 32px 100px rgba(0, 0, 0, 0.42);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
color: #f8fafc;
|
||||
font-size: 22px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.card-title small {
|
||||
color: #38bdf8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-card :deep(.el-input__wrapper) {
|
||||
min-height: 46px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-card :deep(.el-input__inner) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.login-card :deep(.el-input__inner::placeholder) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.login-card :deep(.el-form-item__error) {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #0891b2, #0f766e);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: linear-gradient(135deg, #0e7490, #115e59);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.login-shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login-page {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
inset: 12px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<main class="amap-page">
|
||||
<div ref="mapContainerRef" class="amap-container"></div>
|
||||
|
||||
<header class="top-menu">
|
||||
<h1>机场道面外来物检测系统</h1>
|
||||
<nav class="menu-actions" aria-label="页面菜单">
|
||||
<el-button
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
:type="activeDialog === item.key ? 'primary' : 'default'"
|
||||
@click="handleMenuClick(item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="bottom-card-row" aria-label="底部监控信息">
|
||||
<section class="video-card" aria-label="视频监控">
|
||||
<VideoView />
|
||||
</section>
|
||||
|
||||
<el-card class="fod-detail-card" shadow="never">
|
||||
<section class="fod-detail-panel" aria-label="异物检测详情">
|
||||
<div class="fod-header">
|
||||
<span>异物检测信息</span>
|
||||
<el-tag size="small" :type="statusTagType" effect="dark">
|
||||
{{ detectionInfo.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<dl class="fod-grid">
|
||||
<div v-for="item in detectionFields" :key="item.label" class="fod-item">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="fod-images">
|
||||
<el-image
|
||||
v-for="(image, index) in detectionImages"
|
||||
:key="image"
|
||||
class="fod-image"
|
||||
:src="image"
|
||||
:preview-src-list="detectionImages"
|
||||
:initial-index="index"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</el-card>
|
||||
|
||||
<el-card class="fod-table-card" shadow="never">
|
||||
<section class="fod-table-panel" aria-label="异物检测列表">
|
||||
<el-table :data="detectionRows" height="100%" size="small" class="fod-table">
|
||||
<el-table-column prop="index" label="序号" width="52" align="center" />
|
||||
<el-table-column prop="location" label="经纬度" min-width="132" show-overflow-tooltip />
|
||||
<el-table-column prop="discoveredAt" label="发现时间" min-width="132" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="selectDetection(row)">
|
||||
查看
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="currentDialogTitle"
|
||||
width="76vw"
|
||||
class="system-dialog"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@closed="activeDialog = ''"
|
||||
>
|
||||
<component :is="currentDialogComponent" v-if="currentDialogComponent" />
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import BlockedDataView from '../blocked-data/index.vue'
|
||||
import ConfigView from '../config/index.vue'
|
||||
import HistoryDataView from '../history-data/index.vue'
|
||||
import VideoView from '../video/index.vue'
|
||||
|
||||
const AMAP_KEY = 'f570ff5caa2f5c956fc6bf66cacabb93'
|
||||
const AMAP_SECURITY_CODE = '114b8c8d2356490283ea98293a9c4ae3'
|
||||
const AMAP_SCRIPT_ID = 'amap-jsapi'
|
||||
const AMAP_CALLBACK_NAME = '__initAmapMapView'
|
||||
const MAP_CENTER = [120.09671, 36.365238]
|
||||
const MAP_ZOOM = 15
|
||||
const MAP_ROTATION = -72.6
|
||||
|
||||
const mapContainerRef = ref(null)
|
||||
const activeDialog = ref('')
|
||||
const detectionInfo = {
|
||||
discoveredAt: '2026-06-12 14:35:20',
|
||||
location: '120.096710E, 36.365238N',
|
||||
intensity: '82%',
|
||||
count: 3,
|
||||
category: '疑似异物',
|
||||
status: '待处理',
|
||||
}
|
||||
const detectionImages = [
|
||||
createDetectionImage('#0f766e', 'FOD-01'),
|
||||
createDetectionImage('#1d4ed8', 'FOD-02'),
|
||||
createDetectionImage('#b45309', 'FOD-03'),
|
||||
]
|
||||
const detectionRows = [
|
||||
{
|
||||
index: 1,
|
||||
location: '120.096710, 36.365238',
|
||||
discoveredAt: '2026-06-12 14:35:20',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
location: '120.096842, 36.365104',
|
||||
discoveredAt: '2026-06-12 14:36:08',
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
location: '120.096514, 36.365387',
|
||||
discoveredAt: '2026-06-12 14:37:42',
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
location: '120.096933, 36.365291',
|
||||
discoveredAt: '2026-06-12 14:39:16',
|
||||
},
|
||||
]
|
||||
const detectionFields = computed(() => [
|
||||
{ label: '发现时间', value: detectionInfo.discoveredAt },
|
||||
{ label: '位置信息', value: detectionInfo.location },
|
||||
{ label: '探测强度', value: detectionInfo.intensity },
|
||||
{ label: '检测次数', value: detectionInfo.count },
|
||||
{ label: '异物类别', value: detectionInfo.category },
|
||||
{ label: '处理状态', value: detectionInfo.status },
|
||||
])
|
||||
const statusTagType = computed(() => {
|
||||
if (detectionInfo.status === '已处理') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (detectionInfo.status === '处理中') {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
return 'danger'
|
||||
})
|
||||
const menuItems = [
|
||||
{ key: 'home', label: '首页' },
|
||||
{ key: 'history', label: '历史数据' },
|
||||
{ key: 'config', label: '配置' },
|
||||
{ key: 'blocked', label: '屏蔽数据' },
|
||||
]
|
||||
const dialogMap = {
|
||||
history: {
|
||||
title: '历史数据',
|
||||
component: HistoryDataView,
|
||||
},
|
||||
config: {
|
||||
title: '配置',
|
||||
component: ConfigView,
|
||||
},
|
||||
blocked: {
|
||||
title: '屏蔽数据',
|
||||
component: BlockedDataView,
|
||||
},
|
||||
}
|
||||
const dialogVisible = computed({
|
||||
get: () => Boolean(activeDialog.value),
|
||||
set: (value) => {
|
||||
if (!value) {
|
||||
activeDialog.value = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
const currentDialogTitle = computed(() => dialogMap[activeDialog.value]?.title || '')
|
||||
const currentDialogComponent = computed(() => dialogMap[activeDialog.value]?.component || null)
|
||||
|
||||
let mapInstance = null
|
||||
let amapScriptPromise = null
|
||||
|
||||
function createDetectionImage(color, label) {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="${color}" stop-opacity="0.95"/>
|
||||
<stop offset="1" stop-color="#020617" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="320" height="200" fill="url(#bg)"/>
|
||||
<path d="M0 132 C62 108 94 154 150 130 C206 106 244 82 320 112 L320 200 L0 200 Z" fill="#111827" opacity="0.72"/>
|
||||
<rect x="18" y="18" width="284" height="164" rx="10" fill="none" stroke="#e2e8f0" stroke-opacity="0.36"/>
|
||||
<circle cx="160" cy="100" r="34" fill="none" stroke="#fef3c7" stroke-width="2.5" stroke-dasharray="7 6"/>
|
||||
<path d="M141 105 L158 74 L180 121 Z" fill="#fbbf24" opacity="0.86"/>
|
||||
<text x="24" y="44" fill="#f8fafc" font-size="18" font-family="Arial, sans-serif" font-weight="700">${label}</text>
|
||||
<text x="24" y="168" fill="#cbd5e1" font-size="12" font-family="Arial, sans-serif">Foreign Object Detection Snapshot</text>
|
||||
</svg>`
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
function waitForAmapMapConstructor(timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now()
|
||||
|
||||
const checkAmap = () => {
|
||||
if (window.AMap?.Map) {
|
||||
resolve(window.AMap)
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt >= timeout) {
|
||||
reject(new Error('AMap Map constructor is unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(checkAmap, 50)
|
||||
}
|
||||
|
||||
checkAmap()
|
||||
})
|
||||
}
|
||||
|
||||
function loadAmapScript() {
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: AMAP_SECURITY_CODE,
|
||||
}
|
||||
|
||||
if (window.AMap?.Map) {
|
||||
return Promise.resolve(window.AMap)
|
||||
}
|
||||
|
||||
if (amapScriptPromise) {
|
||||
return amapScriptPromise
|
||||
}
|
||||
|
||||
if (document.getElementById(AMAP_SCRIPT_ID)) {
|
||||
amapScriptPromise = waitForAmapMapConstructor()
|
||||
return amapScriptPromise
|
||||
}
|
||||
|
||||
amapScriptPromise = new Promise((resolve, reject) => {
|
||||
window[AMAP_CALLBACK_NAME] = () => {
|
||||
waitForAmapMapConstructor().then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = AMAP_SCRIPT_ID
|
||||
script.async = true
|
||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}&callback=${AMAP_CALLBACK_NAME}`
|
||||
script.onload = () => {
|
||||
waitForAmapMapConstructor().then(resolve).catch(reject)
|
||||
}
|
||||
script.onerror = (error) => {
|
||||
amapScriptPromise = null
|
||||
reject(error)
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
amapScriptPromise = amapScriptPromise.catch((error) => {
|
||||
amapScriptPromise = null
|
||||
throw error
|
||||
})
|
||||
|
||||
return amapScriptPromise
|
||||
}
|
||||
|
||||
function selectDetection(row) {
|
||||
console.info('查看异物检测记录:', row)
|
||||
}
|
||||
|
||||
function handleMenuClick(key) {
|
||||
if (key === 'home') {
|
||||
activeDialog.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
activeDialog.value = key
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
try {
|
||||
const AMap = await loadAmapScript()
|
||||
|
||||
if (!AMap?.Map) {
|
||||
throw new Error('AMap Map constructor is unavailable')
|
||||
}
|
||||
|
||||
mapInstance = new AMap.Map(mapContainerRef.value, {
|
||||
viewMode: '3D',
|
||||
zoom: MAP_ZOOM,
|
||||
center: MAP_CENTER,
|
||||
pitch: 0,
|
||||
rotation: MAP_ROTATION,
|
||||
showLabel: false,
|
||||
layers: [
|
||||
new AMap.TileLayer.Satellite(),
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Load AMap failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mapInstance?.destroy()
|
||||
mapInstance = null
|
||||
delete window[AMAP_CALLBACK_NAME]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.amap-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #030712;
|
||||
}
|
||||
|
||||
.amap-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 56px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.26);
|
||||
border-radius: 14px;
|
||||
color: #f8fafc;
|
||||
background: rgba(2, 6, 23, 0.5);
|
||||
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.top-menu h1 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-actions :deep(.el-button) {
|
||||
min-width: 86px;
|
||||
margin: 0;
|
||||
border-color: rgba(226, 232, 240, 0.22);
|
||||
border-radius: 8px;
|
||||
color: #e5eefb;
|
||||
background: rgba(15, 23, 42, 0.66);
|
||||
}
|
||||
|
||||
.menu-actions :deep(.el-button:hover) {
|
||||
border-color: rgba(56, 189, 248, 0.72);
|
||||
color: #ffffff;
|
||||
background: rgba(14, 116, 144, 0.72);
|
||||
}
|
||||
|
||||
.menu-actions :deep(.el-button--primary) {
|
||||
border-color: rgba(34, 211, 238, 0.85);
|
||||
background: rgba(8, 145, 178, 0.86);
|
||||
}
|
||||
|
||||
.bottom-card-row {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1fr;
|
||||
gap: 16px;
|
||||
height: 60%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.video-card,
|
||||
.fod-detail-card,
|
||||
.fod-table-card {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.28);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.video-card {
|
||||
background: rgba(2, 6, 23, 0.28);
|
||||
}
|
||||
|
||||
.video-card :deep(.monitor-page) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-card :deep(.control-panel) {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 212px;
|
||||
padding: 12px;
|
||||
transform: scale(0.5);
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.video-card :deep(.stream-status) {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.video-card :deep(.direction-pad) {
|
||||
grid-template-columns: repeat(3, 42px);
|
||||
grid-template-rows: repeat(3, 38px);
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.fod-detail-card,
|
||||
.fod-table-card {
|
||||
color: #e5eefb;
|
||||
background: rgba(2, 6, 23, 0.48);
|
||||
}
|
||||
|
||||
.fod-detail-card :deep(.el-card__body),
|
||||
.fod-table-card :deep(.el-card__body) {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.fod-detail-panel,
|
||||
.fod-table-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fod-detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fod-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.16);
|
||||
color: #f8fafc;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fod-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
flex: 0 0 auto;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.fod-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fod-item dt {
|
||||
margin-bottom: 4px;
|
||||
color: rgba(203, 213, 225, 0.74);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fod-item dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fod-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: end;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.16);
|
||||
}
|
||||
|
||||
.fod-image {
|
||||
width: 100%;
|
||||
height: 74px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.fod-table-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.fod-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #e5eefb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fod-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fod-table :deep(.el-table__header-wrapper th) {
|
||||
border-bottom-color: rgba(226, 232, 240, 0.16);
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fod-table :deep(.el-table__row) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fod-table :deep(.el-table__row:hover > td.el-table__cell) {
|
||||
background: rgba(14, 165, 233, 0.16);
|
||||
}
|
||||
|
||||
.fod-table :deep(td.el-table__cell) {
|
||||
border-bottom-color: rgba(226, 232, 240, 0.1);
|
||||
color: #e5eefb;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fod-table :deep(.el-table__body-wrapper) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.system-dialog) {
|
||||
border: 1px solid rgba(226, 232, 240, 0.22);
|
||||
border-radius: 14px;
|
||||
background: rgba(2, 6, 23, 0.92);
|
||||
box-shadow: 0 32px 120px rgba(0, 0, 0, 0.56);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
:global(.system-dialog .el-dialog__header) {
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.14);
|
||||
}
|
||||
|
||||
:global(.system-dialog .el-dialog__title) {
|
||||
color: #f8fafc;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
:global(.system-dialog .el-dialog__body) {
|
||||
padding: 18px 20px 20px;
|
||||
color: #e5eefb;
|
||||
}
|
||||
|
||||
:global(.el-select__popper.el-popper) {
|
||||
border-color: rgba(226, 232, 240, 0.18);
|
||||
background: rgba(2, 6, 23, 0.96);
|
||||
}
|
||||
|
||||
:global(.el-select-dropdown) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.el-select-dropdown__item) {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
:global(.el-select-dropdown__item.is-hovering),
|
||||
:global(.el-select-dropdown__item:hover) {
|
||||
color: #ffffff;
|
||||
background: rgba(14, 165, 233, 0.18);
|
||||
}
|
||||
|
||||
:global(.el-select-dropdown__item.is-selected) {
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.top-menu {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.top-menu h1 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.menu-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.menu-actions :deep(.el-button) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bottom-card-row {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
height: 72%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
min-height: 220px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.fod-detail-card,
|
||||
.fod-table-card {
|
||||
min-height: 220px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.fod-table-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.fod-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<main class="monitor-page">
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="stream-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
controls="false"
|
||||
></video>
|
||||
|
||||
<div class="video-backdrop" aria-hidden="true">
|
||||
<!-- <div class="scan-line"></div>-->
|
||||
<div class="signal-grid"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<aside class="control-panel" aria-label="视频控制器">
|
||||
<div class="direction-pad" aria-label="方向控制">
|
||||
<el-button
|
||||
class="control-btn up"
|
||||
type="primary"
|
||||
:icon="ArrowUp"
|
||||
aria-label="向上"
|
||||
@click="sendControl('up')"
|
||||
/>
|
||||
<el-button
|
||||
class="control-btn left"
|
||||
type="primary"
|
||||
:icon="ArrowLeft"
|
||||
aria-label="向左"
|
||||
@click="sendControl('left')"
|
||||
/>
|
||||
<el-button
|
||||
class="control-btn center"
|
||||
:icon="Aim"
|
||||
aria-label="居中"
|
||||
@click="sendControl('center')"
|
||||
/>
|
||||
<el-button
|
||||
class="control-btn right"
|
||||
type="primary"
|
||||
:icon="ArrowRight"
|
||||
aria-label="向右"
|
||||
@click="sendControl('right')"
|
||||
/>
|
||||
<el-button
|
||||
class="control-btn down"
|
||||
type="primary"
|
||||
:icon="ArrowDown"
|
||||
aria-label="向下"
|
||||
@click="sendControl('down')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="zoom-controls" aria-label="缩放控制">
|
||||
<el-button
|
||||
class="zoom-btn"
|
||||
:icon="ZoomIn"
|
||||
aria-label="放大"
|
||||
@click="sendControl('zoom-in')"
|
||||
/>
|
||||
<el-button
|
||||
class="zoom-btn"
|
||||
:icon="ZoomOut"
|
||||
aria-label="缩小"
|
||||
@click="sendControl('zoom-out')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="function-grid">
|
||||
<el-button
|
||||
v-for="item in functionButtons"
|
||||
:key="item"
|
||||
class="function-btn"
|
||||
@click="sendControl(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
import {
|
||||
Aim,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const WS_STREAM_URL = import.meta.env.VITE_VIDEO_WS_URL || 'ws://localhost:8080/video'
|
||||
const MIME_CODEC =
|
||||
import.meta.env.VITE_VIDEO_MIME_CODEC || 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
|
||||
|
||||
const videoRef = ref(null)
|
||||
const wsRef = ref(null)
|
||||
const mediaSourceRef = ref(null)
|
||||
const sourceBufferRef = ref(null)
|
||||
const queue = []
|
||||
const connectionState = ref('连接中')
|
||||
const streamUrl = computed(() => WS_STREAM_URL)
|
||||
const functionButtons = [
|
||||
'功能1',
|
||||
'功能2',
|
||||
'功能3',
|
||||
'功能4',
|
||||
'功能5',
|
||||
'功能6'
|
||||
]
|
||||
|
||||
function appendNextChunk() {
|
||||
const sourceBuffer = sourceBufferRef.value
|
||||
|
||||
if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sourceBuffer.appendBuffer(queue.shift())
|
||||
} catch (error) {
|
||||
connectionState.value = '视频数据异常'
|
||||
console.error('Append video chunk failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setupMediaSource() {
|
||||
const video = videoRef.value
|
||||
|
||||
if (!video || !window.MediaSource) {
|
||||
connectionState.value = '浏览器不支持 MediaSource'
|
||||
return
|
||||
}
|
||||
|
||||
const mediaSource = new MediaSource()
|
||||
mediaSourceRef.value = mediaSource
|
||||
video.src = URL.createObjectURL(mediaSource)
|
||||
|
||||
mediaSource.addEventListener('sourceopen', () => {
|
||||
if (!MediaSource.isTypeSupported(MIME_CODEC)) {
|
||||
connectionState.value = '视频编码不支持'
|
||||
return
|
||||
}
|
||||
|
||||
sourceBufferRef.value = mediaSource.addSourceBuffer(MIME_CODEC)
|
||||
sourceBufferRef.value.mode = 'segments'
|
||||
sourceBufferRef.value.addEventListener('updateend', appendNextChunk)
|
||||
connectStream()
|
||||
})
|
||||
}
|
||||
|
||||
function connectStream() {
|
||||
const ws = new WebSocket(WS_STREAM_URL)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.value = ws
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
connectionState.value = '已连接'
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
queue.push(event.data)
|
||||
appendNextChunk()
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
connectionState.value = '连接已断开'
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
connectionState.value = '连接失败'
|
||||
})
|
||||
}
|
||||
|
||||
function sendControl(action) {
|
||||
const ws = wsRef.value
|
||||
const payload = JSON.stringify({
|
||||
type: 'control',
|
||||
action,
|
||||
sentAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(payload)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(setupMediaSource)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
wsRef.value?.close()
|
||||
|
||||
if (videoRef.value?.src) {
|
||||
URL.revokeObjectURL(videoRef.value.src)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue