Browse Source

根据站内提出的要求,优化界面

master
douyage 3 weeks ago
parent
commit
5ac0ab6d61
18 changed files with 604 additions and 209 deletions
  1. BIN
      src/assets/images/ptz-control.png
  2. +1
    -1
      src/components/Breadcrumb/index.vue
  3. +1
    -1
      src/layout/components/Sidebar/Item.vue
  4. +7
    -15
      src/layout/components/Sidebar/index.vue
  5. +34
    -0
      src/layout/components/TagsView/index.vue
  6. +5
    -9
      src/layout/index.vue
  7. +3
    -3
      src/router/index.js
  8. +19
    -0
      src/views/cards/components/ImagePreview/index.vue
  9. +1
    -1
      src/views/cards/components/ImagePreviewZoom/index.vue
  10. +1
    -1
      src/views/cards/components/ImagePreviewZoomTemperature/index.vue
  11. +4
    -3
      src/views/cards/patrolPlan/notstart.vue
  12. +50
    -44
      src/views/cards/patrolPlan/summary_editTask.vue
  13. +52
    -43
      src/views/cards/patrolPlan/summary_newTask.vue
  14. +219
    -27
      src/views/cards/site_management/site_management.vue
  15. +3
    -2
      src/views/point/site_management.vue
  16. +132
    -25
      src/views/video/LivePreview.vue
  17. +2
    -4
      src/views/video/components/NvrDeviceTree.vue
  18. +70
    -30
      src/views/video/components/NvrStreamPlayer.vue

BIN
src/assets/images/ptz-control.png View File

Before After
Width: 22  |  Height: 22  |  Size: 708 B

+ 1
- 1
src/components/Breadcrumb/index.vue View File

@ -78,7 +78,7 @@ export default {
const videoPaths = ['/video-preview', '/video-playback']
if (videoPaths.some(item => path === item || path.startsWith(`${item}/`))) {
return { title: '视频监控', path: '/inspection-workspace/video-preview' }
return { title: '动环巡检', path: '/inspection-workspace/task' }
}
return null


+ 1
- 1
src/layout/components/Sidebar/Item.vue View File

@ -18,7 +18,7 @@ const navIconMap = {
'巡视报告': require('@/assets/images/nav/nav-patrol-report.png'),
'巡视计划': require('@/assets/images/nav/nav-patrol-schedule.png'),
'缺陷记录': require('@/assets/images/nav/nav-defect.png'),
'视频预览': require('@/assets/images/nav/nav-video-preview.png'),
'实时监控': require('@/assets/images/nav/nav-video-preview.png'),
'历史回放': require('@/assets/images/nav/nav-video-playback.png'),
'遥视任务管理': require('@/assets/images/nav/nav-task-management.png'),
'巡检任务管理': require('@/assets/images/nav/nav-task-management.png'),


+ 7
- 15
src/layout/components/Sidebar/index.vue View File

@ -74,26 +74,18 @@ export default {
const groupedPaths = new Set([...inspectionPaths, ...videoPaths]);
const otherRoutes = visibleRoutes.filter(route => !groupedPaths.has(route.path));
const groups = [];
if (inspectionRoutes.length) groups.push({
const patrolRoutes = [...inspectionRoutes, ...videoRoutes];
if (patrolRoutes.length) groups.push({
path: "/inspection-menu",
meta: { title: "动环巡检", icon: "dashboard" },
alwaysShow: true,
children: inspectionRoutes,
});
if (videoRoutes.length) groups.push({
path: "/video-menu",
meta: { title: "视频监控", icon: "monitor" },
alwaysShow: true,
children: videoRoutes,
children: patrolRoutes,
});
const routePath = this.$route.path;
if (routePath === "/index" || routePath === "/" || routePath === "/xfdh/index" || inspectionRoutes.some(route => routePath.indexOf(route.path + "/") === 0 || routePath === route.path)) {
return groups.filter(group => group.path === "/inspection-menu");
}
if (routePath.indexOf("/video-preview") === 0 || routePath.indexOf("/video-playback") === 0) {
return groups.filter(group => group.path === "/video-menu");
if (routePath === "/index" || routePath === "/" || routePath === "/xfdh/index" || patrolRoutes.some(route => routePath.indexOf(route.path + "/") === 0 || routePath === route.path)) {
return patrolRoutes;
}
return [...groups, ...otherRoutes];
return [...patrolRoutes, ...otherRoutes];
}
const workspacePaths = {
"/task-summary": "/inspection-workspace/task",
@ -139,7 +131,7 @@ export default {
defaultOpeneds() {
if (this.isCollapse) return [];
const paths = (this.displayedRoutes || []).map(route => route.path);
return paths.filter(path => path === "/inspection-menu" || path === "/video-menu");
return paths.filter(path => path === "/inspection-menu");
},
variables() {
return variables;


+ 34
- 0
src/layout/components/TagsView/index.vue View File

@ -24,6 +24,17 @@
</span>
</button>
</el-tooltip>
<el-tooltip :content="videoControlPanelExpanded ? '收起云台控制' : '展开云台控制'" placement="bottom">
<button
type="button"
class="video-control-toggle"
:class="{ active: videoControlPanelExpanded }"
:aria-label="videoControlPanelExpanded ? '收起云台控制' : '展开云台控制'"
@click="toggleVideoControlPanel"
>
<img :src="ptzControlIcon" alt="" aria-hidden="true">
</button>
</el-tooltip>
</div>
<div v-if="isPatrolSchedule" class="schedule-view-switch">
<button
@ -79,6 +90,8 @@ export default {
selectedTag: {},
affixTags: [],
videoLayout: 9,
videoControlPanelExpanded: false,
ptzControlIcon: require('@/assets/images/ptz-control.png'),
videoLayouts: [
{ value: 1, columns: 1, label: '单画面' },
{ value: 4, columns: 2, label: '四画面' },
@ -129,9 +142,11 @@ export default {
this.initTags()
this.addTags()
this.$root.$on('video-layout-updated', this.updateVideoLayout)
this.$root.$on('video-control-panel-updated', this.updateVideoControlPanel)
},
beforeDestroy() {
this.$root.$off('video-layout-updated', this.updateVideoLayout)
this.$root.$off('video-control-panel-updated', this.updateVideoControlPanel)
},
methods: {
setVideoLayout(layout) {
@ -142,6 +157,13 @@ export default {
updateVideoLayout(layout) {
this.videoLayout = layout
},
toggleVideoControlPanel() {
this.videoControlPanelExpanded = !this.videoControlPanelExpanded
this.$root.$emit('video-control-panel-change', this.videoControlPanelExpanded)
},
updateVideoControlPanel(expanded) {
this.videoControlPanelExpanded = Boolean(expanded)
},
setScheduleView(view) {
if (view === this.scheduleView) return
this.$router.replace({
@ -396,6 +418,18 @@ export default {
}
}
.video-control-toggle {
width: 40px;
margin-left: 2px;
border-left: 1px solid rgba(57, 139, 242, 0.45);
img {
width: 29px;
height: 29px;
object-fit: contain;
}
}
.video-layout-icon {
display: grid;
gap: 3px;


+ 5
- 9
src/layout/index.vue View File

@ -17,7 +17,8 @@
@click="goModule(item.path)"
>{{ item.label }}</button>
</div>
<el-select
<!-- station selector removed -->
<!-- <el-select
v-model="selectedStation"
class="station-select"
size="mini"
@ -31,7 +32,7 @@
:label="station.label"
:value="station.value"
/>
</el-select>
</el-select> -->
</div>
<tags-view v-if="needTagsView"/>
</div>
@ -66,8 +67,7 @@ export default {
stationOptions: [],
selectedStation: '',
moduleLinks: [
{ key: 'home', label: '动环巡检', path: '/index' },
{ key: 'video', label: '视频监控', path: '/video-preview/index' }
{ key: 'home', label: '动环巡检', path: '/index' }
]
}
},
@ -94,11 +94,7 @@ export default {
return this.$route.matched.some(route => route.meta && route.meta.patrolWorkspace)
},
activeModule() {
const path = this.$route.path
const inspectionPaths = ['/task-summary', '/patrol-plan', '/site-management', '/patrol-report', '/patrol-schedule', '/defect-record', '/inspection-workspace/task', '/inspection-workspace/plan', '/inspection-workspace/points', '/inspection-workspace/report', '/inspection-workspace/schedule', '/inspection-workspace/defects']
if (path === '/' || path === '/index' || path === '/xfdh/index' || inspectionPaths.some(item => path === item || path.indexOf(item + '/') === 0)) return 'home'
if (path.indexOf('/video-preview') === 0 || path.indexOf('/video-playback') === 0 || path.indexOf('/inspection-workspace/video-preview') === 0 || path.indexOf('/inspection-workspace/video-playback') === 0) return 'video'
return ''
return 'home'
},
variables() {
return variables;


+ 3
- 3
src/router/index.js View File

@ -129,7 +129,7 @@ export const constantRoutes = [
path: 'video-preview',
component: () => import('@/views/video/LivePreview'),
name: 'InspectionWorkspaceVideoPreview',
meta: { title: '视频预览', activeMenu: '/inspection-workspace/video-preview', patrolWorkspace: true }
meta: { title: '实时监控', activeMenu: '/inspection-workspace/video-preview', patrolWorkspace: true }
},
{
path: 'video-playback',
@ -265,13 +265,13 @@ export const constantRoutes = [
path: '/video-preview',
component: Layout,
redirect: '/video-preview/index',
meta: { title: '视频预览' },
meta: { title: '实时监控' },
children: [
{
path: 'index',
component: () => import('@/views/video/LivePreview'),
name: 'VideoLivePreview',
meta: { title: '视频预览', activeMenu: '/video-preview/index' }
meta: { title: '实时监控', activeMenu: '/video-preview/index' }
}
]
},


+ 19
- 0
src/views/cards/components/ImagePreview/index.vue View File

@ -10,7 +10,11 @@
style="width: 100%; height: 100%"
fit="contain"
:src="selectImgUrl"
error-tips="图片加载失败"
@imgloaderror="imageError = true"
@imgload="imageError = false"
></AsyncImage>
<div v-if="imageError" class="image-error-overlay">图片加载失败</div>
<DrawShape
ref="drawShapeRef"
:brokenLineCount="1"
@ -156,6 +160,7 @@ export default {
loading: false,
dataShowTemperatureTool: false,
dataImageUrls: [],
imageError: false,
};
},
computed: {
@ -225,11 +230,13 @@ export default {
methods: {
reset() {
this.selectIndex = 0;
this.imageError = false;
this.hideMeasureBox();
},
//
onSelectImage(item, index) {
this.selectIndex = index;
this.imageError = false;
},
//
showMeasureBox() {
@ -285,6 +292,18 @@ export default {
height: 0;
position: relative;
.image-error-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
color: #c8d8f2;
background: rgba(9, 9, 24, .86);
font-size: 14px;
}
.img-handle-btn {
position: absolute;
top: 45%;


+ 1
- 1
src/views/cards/components/ImagePreviewZoom/index.vue View File

@ -48,7 +48,7 @@
size="mini"
circle
@click="resetImage"
title="重置"
title="恢复原图"
>
</el-button>


+ 1
- 1
src/views/cards/components/ImagePreviewZoomTemperature/index.vue View File

@ -42,7 +42,7 @@
<!-- 重置按钮 -->
<div class="zoom-controls">
<el-button size="mini" circle @click="resetImage" title="重置">
<el-button size="mini" circle @click="resetImage" title="恢复原图">
</el-button>
</div>


+ 4
- 3
src/views/cards/patrolPlan/notstart.vue View File

@ -392,7 +392,7 @@ export default {
} else if (row.executionStatus == 1) {
return "周期执行";
} else if (row.executionStatus == 2) {
return "定时执行";
return "单次执行";
} else if (row.executionStatus == 3) {
return "已执行";
} else if (row.executionStatus == 4) {
@ -667,10 +667,11 @@ export default {
}
::v-deep .patrol-plan-dialog .el-dialog__body {
height: auto !important;
height: calc(100vh - 160px) !important;
max-height: calc(100vh - 160px);
box-sizing: border-box;
padding: 0 24px 24px !important;
overflow-y: auto !important;
overflow: hidden !important;
}
.table1 {


+ 50
- 44
src/views/cards/patrolPlan/summary_editTask.vue View File

@ -47,24 +47,6 @@
</div>
</div>
<!-- 设备类型 - 复选框组 -->
<div class="form-row checkbox-row">
<div class="form-label">设备类型 <span>*</span></div>
<div class="form-input checkbox-group-wrapper">
<el-checkbox-group
v-model="formData.deviceTypeList"
@change="handleDeviceTypeChange"
>
<el-checkbox
v-for="item in deviceTypeOptions"
:key="item.dictCode"
:label="String(item.dictCode)"
>{{ item.dictLabel }}</el-checkbox
>
</el-checkbox-group>
</div>
</div>
<!-- 时间周期选择 -->
<div class="form-row">
<div class="form-label">执行策略 <span>*</span></div>
@ -72,13 +54,13 @@
v-model="formData.executionType"
class="form-input el-radio-group"
>
<el-radio label="1">定时执行</el-radio>
<el-radio label="1">单次执行</el-radio>
<el-radio label="2">周期执行</el-radio>
<el-radio label="3">间隔执行</el-radio>
</el-radio-group>
</div>
<!-- 定时执行 -->
<!-- 单次执行 -->
<template v-if="formData.executionType == '1'">
<div class="form-row">
<div class="form-label">执行时间<span>*</span></div>
@ -141,7 +123,7 @@
</el-select>
</div>
<div class="form-row">
<div class="form-row cycle-time-row">
<div class="form-label">任务执行时间 <span>*</span></div>
<div class="form-input">
<div class="time-header-with-btn">
@ -316,17 +298,6 @@
</div>
</template>
<!-- 是否开启 -->
<div class="form-row">
<div class="form-label">是否开启 <span>*</span></div>
<el-radio-group
v-model="formData.isEnabled"
class="form-input el-radio-group"
>
<el-radio label="0"></el-radio>
<el-radio label="1"></el-radio>
</el-radio-group>
</div>
</div>
<!-- 右侧巡检区域树 + 按钮 -->
@ -996,11 +967,6 @@ export default {
this.$modal.msgError("请选择点位类型");
return false;
}
if (this.formData.deviceTypeList.length === 0) {
this.$modal.msgError("请选择设备类型");
return false;
}
if (this.formData.executionType == "1") {
if (!this.formData.fixedStartTime) {
this.$modal.msgError("请选择执行时间");
@ -1191,23 +1157,27 @@ export default {
width: 90%;
margin: 0 auto;
padding: 20px 0;
height: calc(100vh - 203px);
overflow: auto;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.ac-on1 {
width: 100%;
height: 100%;
display: flex;
gap: 40px;
min-height: 600px;
min-height: 0;
.ac-on1-box {
flex: 1;
&:first-child {
max-width: 600px;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
}
@ -1275,6 +1245,12 @@ export default {
color: #fff;
padding-left: 8px;
}
.el-checkbox__input.is-checked .el-checkbox__inner,
.el-checkbox__input.is-indeterminate .el-checkbox__inner {
background-color: #2f7cff;
border-color: #2f7cff;
}
}
}
}
@ -1298,15 +1274,41 @@ export default {
width: 100%;
}
.cycle-time-row {
margin-bottom: 8px;
}
.time-table-wrapper {
margin-left: 142px;
margin-bottom: 20px;
flex: 1 1 320px;
min-height: 120px;
max-height: 320px;
overflow-y: auto;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #40486a;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
}
.time-header {
display: flex;
margin-bottom: 12px;
padding: 8px 0;
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 0;
padding: 8px 0 32px;
.header-item {
flex: 1;
@ -1370,9 +1372,13 @@ export default {
padding: 20px;
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
.tree-container {
height: 440px;
height: auto;
min-height: 0;
flex: 1;
overflow: auto;
margin-top: 10px;
margin-left:65px;
@ -1402,7 +1408,7 @@ export default {
}
.button-group-tree {
margin-top: 15px;
margin-top: 35px;
text-align: right;
padding-top: 16px;


+ 52
- 43
src/views/cards/patrolPlan/summary_newTask.vue View File

@ -47,35 +47,17 @@
</div>
</div>
<!-- 设备类型 - 复选框组 -->
<div class="form-row checkbox-row">
<div class="form-label">设备类型 <span>*</span></div>
<div class="form-input checkbox-group-wrapper">
<el-checkbox-group
v-model="deviceTypeList"
@change="handleDeviceTypeChange"
>
<el-checkbox
v-for="item in deviceTypeOptions"
:key="item.dictCode"
:label="item.dictCode"
>{{ item.dictLabel }}</el-checkbox
>
</el-checkbox-group>
</div>
</div>
<!-- 时间周期选择 -->
<div class="form-row">
<div class="form-label">执行策略 <span>*</span></div>
<el-radio-group v-model="radio" class="form-input el-radio-group">
<el-radio label="1">定时执行</el-radio>
<el-radio label="1">单次执行</el-radio>
<el-radio label="2">周期执行</el-radio>
<el-radio label="3">间隔执行</el-radio>
</el-radio-group>
</div>
<!-- 定时执行 -->
<!-- 单次执行 -->
<template v-if="radio == '1'">
<div class="form-row">
<div class="form-label">执行时间<span>*</span></div>
@ -138,7 +120,7 @@
</el-select>
</div>
<div class="form-row">
<div class="form-row cycle-time-row">
<div class="form-label">任务执行时间 <span>*</span></div>
<div class="form-input">
<div class="time-header-with-btn">
@ -314,14 +296,6 @@
</div>
</template>
<!-- 是否开启独立于执行策略 -->
<div class="form-row">
<div class="form-label">是否开启 <span>*</span></div>
<el-radio-group v-model="isEnabled" class="form-input el-radio-group">
<el-radio label="0"></el-radio>
<el-radio label="1"></el-radio>
</el-radio-group>
</div>
</div>
<!-- 右侧巡检区域树 + 按钮 -->
@ -833,11 +807,6 @@ export default {
this.$modal.msgError("请选择点位类型");
return false;
}
if (this.deviceTypeList.length === 0) {
this.$modal.msgError("请选择设备类型");
return false;
}
//
// this.selectAreaIdList = this.$refs.treeForm.getCheckedKeys();
// if (this.selectAreaIdList.length <= 0) {
@ -845,7 +814,7 @@ export default {
// return false;
// }
//
//
if (this.radio == "1") {
if (!this.value11) {
this.$modal.msgError("请选择执行时间");
@ -1035,23 +1004,27 @@ export default {
width: 90%;
margin: 0 auto;
padding: 20px 0;
height: calc(100vh - 203px);
overflow: auto;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.ac-on1 {
width: 100%;
height: 100%;
display: flex;
gap: 40px;
min-height: 600px;
min-height: 0;
.ac-on1-box {
flex: 1;
&:first-child {
max-width: 600px;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
}
@ -1124,6 +1097,12 @@ export default {
color: #fff;
padding-left: 8px;
}
.el-checkbox__input.is-checked .el-checkbox__inner,
.el-checkbox__input.is-indeterminate .el-checkbox__inner {
background-color: #2f7cff;
border-color: #2f7cff;
}
}
}
}
@ -1147,15 +1126,41 @@ export default {
width: 100%;
}
.cycle-time-row {
margin-bottom: 8px;
}
.time-table-wrapper {
margin-left: 142px;
margin-bottom: 20px;
flex: 1 1 320px;
min-height: 120px;
max-height: 320px;
overflow-y: auto;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #40486a;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
}
.time-header {
display: flex;
margin-bottom: 12px;
padding: 8px 0;
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 0;
padding: 8px 0 32px;
.header-item {
flex: 1;
@ -1240,9 +1245,13 @@ export default {
padding: 20px;
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
.tree-container {
height: 440px;
height: auto;
min-height: 0;
flex: 1;
overflow: auto;
margin-top: 10px;
margin-left:65px;
@ -1273,7 +1282,7 @@ export default {
}
.button-group-tree {
margin-top: 15px;
margin-top: 35px;
text-align: right;
padding-top: 16px;
@ -1355,7 +1364,7 @@ export default {
}
.tree-container {
height: 400px !important;
height: 480px !important;
}
}


+ 219
- 27
src/views/cards/site_management/site_management.vue View File

@ -64,6 +64,7 @@
<!-- 中间内容区域 - 保持原有结构不受拖拽影响 -->
<div
class="wid"
:class="{ 'has-right-panel': showRightPanel }"
:style="{ left: leftPanelWidth + 10 + 'px' }"
>
<div class="form" style="padding-top: 10px" ref="form">
@ -175,6 +176,7 @@
@row-click="handleRowClick"
highlight-current-row
stripe
:fit="false"
:height="tableHeight"
:data="filteredTableData"
tooltip-effect="dark"
@ -198,7 +200,6 @@
type="selection"
align="center"
width="55"
fixed="left"
:selectable="checkSelectable"
>
</el-table-column>
@ -208,7 +209,6 @@
align="center"
label="序号"
width="55"
fixed="left"
>
<template slot-scope="scope">
<span>{{ getRowIndex(scope.$index) }}</span>
@ -240,6 +240,7 @@
prop="algSubtypeName"
label="算法类型"
align="center"
width="150"
>
<template slot-scope="scope">
<a
@ -263,6 +264,7 @@
header-align="center"
align="center"
label="设备类型"
width="150"
>
<template slot-scope="scope">
<span>{{ scope.row.pointTypeName || "-" }}</span>
@ -310,7 +312,7 @@
</el-select>
</template>
</el-table-column> -->
<el-table-column label="操作" align="center" min-width="150">
<el-table-column label="操作" align="center" width="150">
<template slot-scope="scope">
<el-button
class="task-row-btn"
@ -527,17 +529,20 @@
align="center"
min-width="100"
prop="patrolDeviceName"
show-overflow-tooltip
></el-table-column>
<el-table-column
label="预置点名称"
align="center"
min-width="100"
min-width="120"
prop="presetPosName"
show-overflow-tooltip
></el-table-column>
<el-table-column
label="操作"
width="150"
fixed="right"
class-name="operation-column"
align="center"
>
<template slot-scope="scope">
@ -957,10 +962,11 @@
</div>
</div>
</el-tab-pane>
<!-- 移动设备绑定暂时停用保留此代码供后续恢复 -->
<el-tab-pane
label="移动设备"
name="solutions"
v-if="monitorType == 'ir'"
v-if="false && monitorType == 'ir'"
>
<div class="box card-page" style="height: 570px">
<div class="middle">
@ -1276,17 +1282,18 @@
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
custom-class="card-dialog card-page"
custom-class="card-dialog card-page image-preview-dialog"
width="70%"
append-to-body
:lock-scroll="false"
>
<ImagePreview
v-if="showPreviewImages"
:imageUrls="currentSrcList"
:selectImgPosition="previewImagePos"
:showTemperatureTool="false"
/>
<div v-if="showPreviewImages" class="image-preview-stage">
<ImagePreview
:imageUrls="currentSrcList"
:selectImgPosition="previewImagePos"
:showTemperatureTool="false"
/>
</div>
</el-dialog>
</div>
</CardBox>
@ -1578,6 +1585,7 @@ export default {
leftPanelWidth: 260, //
minLeftWidth: 200, //
maxLeftWidth: 600, //
tableLayoutFrame: null,
tableHeight: 560,
isResizing: false, //
startX: 0, // X
@ -1699,6 +1707,9 @@ export default {
//
document.removeEventListener("mousemove", this.onResize);
document.removeEventListener("mouseup", this.stopResize);
if (this.tableLayoutFrame) {
cancelAnimationFrame(this.tableLayoutFrame);
}
},
methods: {
handlePatrolpointNameInput(value, target) {
@ -2296,12 +2307,13 @@ export default {
this.leftPanelWidth = newWidth;
//
// Element UI does not observe this absolutely positioned container, so
// recalculate its header/body widths while the tree is being resized.
this.$nextTick(() => {
//
if (this.$refs.areaTree) {
this.$refs.areaTree.$el.style.width = "100%";
}
this.scheduleMainTableLayout();
});
},
@ -2324,6 +2336,20 @@ export default {
// localStorage
this.saveLeftPanelWidth();
this.$nextTick(this.scheduleMainTableLayout);
},
scheduleMainTableLayout() {
if (this.tableLayoutFrame) {
cancelAnimationFrame(this.tableLayoutFrame);
}
this.tableLayoutFrame = requestAnimationFrame(() => {
this.tableLayoutFrame = null;
if (this.$refs.multipleTable) {
this.$refs.multipleTable.doLayout();
}
});
},
//
@ -3108,7 +3134,7 @@ export default {
this.presetData.presetPosName = "";
this.presetData.presetPosCode = "";
this.getUavImg();
//
this.monitorShow = true;
this.monitorType = "ir";
this.titleLightTitle = "绑定热成像监控点";
@ -3924,6 +3950,12 @@ export default {
padding-top: 0 !important;
}
/* Reserve the overlay's footprint so the main table never renders beneath it. */
.wid.has-right-panel .form {
width: auto;
margin-right: clamp(420px, 32%, 520px);
}
.line {
display: flex;
align-items: center;
@ -3937,7 +3969,12 @@ export default {
/* 右侧面板样式 - 完全独立不受影响 */
.right {
width: 27%;
/* Keep the binding table wide enough for its columns when the tree is resized. */
width: clamp(420px, 32%, 520px);
min-width: 420px;
max-width: 52%;
overflow-x: auto;
overflow-y: hidden;
border: 2px solid #0b374e;
position: absolute;
right: 0;
@ -3948,11 +3985,19 @@ export default {
.collect {
position: absolute;
top: 40%;
left: 0px;
left: 0;
cursor: pointer;
z-index: 1;
transform: translateX(-100%);
z-index: 30;
transform: none;
}
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track { background: #071b44; }
&::-webkit-scrollbar-thumb { background: #3988ff; border-radius: 4px; }
}
/* 添加重置按钮样式 */
@ -3988,20 +4033,38 @@ export default {
background: transparent !important;
::v-deep .el-table__body-wrapper {
overflow-x: auto;
overflow-x: auto !important;
background: transparent !important;
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-width: thin;
scrollbar-color: #3988ff #071b44;
}
::v-deep .el-table__body-wrapper::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
width: 8px;
height: 8px;
display: block;
}
::v-deep .el-table__header-wrapper {
overflow-x: hidden;
overflow-x: hidden !important;
}
}
/* Keep the header and body at the same minimum width. The body wrapper then
* owns the single horizontal scrollbar and Element UI synchronizes header scrollLeft. */
.custom-table {
::v-deep .el-table__header,
::v-deep .el-table__body {
min-width: 1055px !important;
}
::v-deep .el-table__body-wrapper::-webkit-scrollbar-track {
background: #071b44;
}
::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb {
background: #3988ff;
border-radius: 4px;
}
}
@ -4028,10 +4091,11 @@ export default {
/* 右侧面板样式 */
.eq {
min-width: 400px;
padding-top: 30px;
padding-left: 10px;
box-sizing: border-box;
background-color: #222f3d;
background-color: #111452;
color: #e0e0e0;
height: 100%;
}
@ -4080,6 +4144,9 @@ export default {
/* 右侧表格样式 */
.right-table {
width: 100%;
min-width: 400px;
table-layout: fixed;
overflow: visible;
border-radius: 4px;
overflow: hidden;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif !important;
@ -4090,6 +4157,12 @@ export default {
border-bottom: 2px solid #0b374e !important;
font-weight: 600 !important;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif !important;
white-space: nowrap !important;
}
::v-deep .el-table__header th .cell {
white-space: nowrap !important;
word-break: keep-all;
}
::v-deep .el-table__row td {
@ -4106,6 +4179,38 @@ export default {
::v-deep .el-table__row td .cell {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif !important;
font-weight: 400 !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
::v-deep .el-table__row td.operation-column .cell,
::v-deep .el-table__fixed-right td.operation-column .cell {
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
::v-deep td.operation-column .el-button {
flex: 0 0 auto;
white-space: nowrap;
}
::v-deep td.operation-column .cell {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
gap: 6px;
}
::v-deep .el-table__fixed-right td.operation-column .cell {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
gap: 6px;
overflow: visible;
}
::v-deep .el-table__row:hover td {
@ -4135,6 +4240,62 @@ export default {
::v-deep .el-loading-spinner .el-loading-text {
color: #43c0ff !important;
}
/* Allow narrow panels to scroll horizontally instead of clipping columns. */
::v-deep .el-table__body-wrapper {
overflow-x: auto !important;
overflow-y: hidden !important;
scrollbar-width: thin;
scrollbar-color: #3988ff #071b44;
}
::v-deep .el-table__header-wrapper {
overflow-x: auto !important;
scrollbar-width: thin;
scrollbar-color: #3988ff #071b44;
}
::v-deep .el-table__header-wrapper::-webkit-scrollbar {
width: 8px;
height: 8px;
display: block;
}
::v-deep .el-table__body-wrapper::-webkit-scrollbar {
width: 8px;
height: 8px;
display: block;
}
::v-deep .el-table__body-wrapper::-webkit-scrollbar-track {
background: #071b44;
}
::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb {
background: #3988ff;
border-radius: 4px;
}
}
/* Keep the image preview stage proportional to the viewport. */
.image-preview-dialog {
::v-deep .el-dialog__body {
height: calc(100vh - 180px) !important;
min-height: 420px;
padding: 12px 18px;
box-sizing: border-box;
overflow: hidden;
}
::v-deep .flex-row {
height: 100%;
min-height: 0;
}
.image-preview-stage {
height: 100%;
min-height: 420px;
}
}
/* 右侧选项卡样式 */
@ -4825,3 +4986,34 @@ export default {
color: #1890ff;
}
</style>
<style lang="scss">
/* The dialog is appended to body, so these sizing rules must be global. */
.image-preview-dialog {
height: 90vh;
margin-top: 5vh !important;
display: flex;
flex-direction: column;
overflow: hidden;
.el-dialog__header {
flex: 0 0 42px;
box-sizing: border-box;
}
.el-dialog__body {
flex: 1 1 auto;
min-height: 0;
height: auto !important;
padding: 12px 18px;
overflow: hidden;
}
.image-preview-stage,
.image-preview-stage > .flex-row {
width: 100%;
height: 100%;
min-height: 0;
}
}
</style>

+ 3
- 2
src/views/point/site_management.vue View File

@ -973,10 +973,11 @@
<!-- 动作列表结束 -->
</el-tab-pane>
<!-- 摄像头结束 -->
<!-- 移动设备绑定暂时停用保留此代码供后续恢复 -->
<el-tab-pane
label="无人机"
name="solutions"
v-if="monitorType == 'ir'"
v-if="false && monitorType == 'ir'"
>
<div class="box card-page" style="height: 570px">
<!-- <div class="left"> -->
@ -2149,7 +2150,7 @@ export default {
},
//
infraredShow1() {
this.getUavImg();
//
// if (this.currentRow == null) {
// this.$modal.msgWarning("!");
// return;


+ 132
- 25
src/views/video/LivePreview.vue View File

@ -6,6 +6,7 @@
:channels="channels"
:selected-code="selectedCode"
:loading="loading"
@channels-loaded="handleTreeChannels"
@select="selectChannel"
@open="openChannel"
/>
@ -49,7 +50,7 @@
</div>
</main>
<aside class="control-panel">
<aside v-show="controlPanelExpanded" class="control-panel">
<section class="control-section">
<div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div>
<div class="ptz-grid">
@ -171,6 +172,10 @@ export default {
return {
config: {},
channels: [],
treeChannelsLoaded: false,
treeChannelCodes: new Set(),
controlPanelExpanded: false,
streamsInitialized: false,
selectedCode: '',
layout: 9,
layouts: [
@ -187,6 +192,7 @@ export default {
ptzSpeed: 4,
ptzActive: false,
ptzCameraCode: '',
ptzRepeatTimer: null,
presets: [],
presetId: null,
newPresetId: null,
@ -240,11 +246,14 @@ export default {
},
created() {
this.$root.$on('video-layout-change', this.changeLayout)
this.$root.$on('video-control-panel-change', this.changeControlPanel)
this.$root.$emit('video-layout-updated', this.layout)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
this.loadConfig()
},
beforeDestroy() {
this.$root.$off('video-layout-change', this.changeLayout)
this.$root.$off('video-control-panel-change', this.changeControlPanel)
this.closeAll()
},
methods: {
@ -254,9 +263,12 @@ export default {
try {
const config = await getNvrConfig()
this.config = config || {}
this.channels = Array.isArray(config.channels) ? config.channels : []
const selected = this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0]
if (selected) this.selectChannel(selected)
// Keep config as metadata only until the device tree has supplied the
// channels that are actually visible/selectable by the user.
const configChannels = Array.isArray(config.channels) ? config.channels : []
this.setAvailableChannels(this.treeChannelsLoaded
? configChannels.filter(channel => channel && this.treeChannelCodes.has(channel.cameraCode))
: configChannels, false)
this.connectionText = '设备服务在线'
this.connectionState = 'online'
} catch (error) {
@ -272,13 +284,79 @@ export default {
}
this.selectedCode = channel.cameraCode
},
setAvailableChannels(channels, initializeStreams = false) {
const knownCodes = new Set(this.channels.map(item => item.cameraCode))
;(channels || []).forEach(channel => {
if (!channel || !channel.cameraCode) return
const channelName = channel.name || channel.label || channel.channelName || channel.cameraName || channel.deviceName || ''
if (knownCodes.has(channel.cameraCode)) {
const existing = this.channels.find(item => item.cameraCode === channel.cameraCode)
if (existing) {
// Device-tree entries contain the authoritative channel metadata
// (NVR address, channel number, display name). Merge it over any
// lightweight config entry with the same camera code.
const existingName = existing.name
Object.assign(existing, channel)
if (this.isCameraCodeName(channelName) && !this.isCameraCodeName(existingName)) {
this.$set(existing, 'name', existingName)
} else if (channelName) {
this.$set(existing, 'name', channelName)
}
}
return
}
knownCodes.add(channel.cameraCode)
this.channels.push({
...channel,
name: channelName || (channel.channelCode && !this.isCameraCodeName(channel.channelCode) ? channel.channelCode : `通道 ${channel.channel || ''}`)
})
})
if (initializeStreams && !this.streamsInitialized && this.channels.length) {
this.streamsInitialized = true
this.populateRandomLayout()
}
},
isCameraCodeName(name) {
if (!name) return true
return /^(camera|cam|channel)[-_ ]?\d+$/i.test(String(name).trim())
},
handleTreeChannels(channels) {
this.treeChannelsLoaded = true
const treeCodes = new Set((channels || []).filter(item => item && item.cameraCode).map(item => item.cameraCode))
this.treeChannelCodes = treeCodes
// Remove config-only channels from the selectable/default pool. They can
// still be returned by the config endpoint, but must not appear in the
// default layout unless present in the device tree.
if (treeCodes.size) this.channels = this.channels.filter(item => treeCodes.has(item.cameraCode))
this.setAvailableChannels(channels, true)
},
shuffleChannels() {
const channels = this.channels.slice()
for (let index = channels.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1))
const current = channels[index]
channels[index] = channels[randomIndex]
channels[randomIndex] = current
}
return channels
},
async populateRandomLayout() {
const channels = this.shuffleChannels().slice(0, this.capacity)
if (!channels.length) return
await Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null))
this.activeIndex = 0
this.controlTarget = null
for (let index = 0; index < channels.length; index += 1) {
await this.startChannel(channels[index], index)
}
},
openChannel(channel) {
this.selectChannel(channel)
this.startChannel(channel)
},
async startChannel(channel) {
async startChannel(channel, targetIndex = this.activeIndex, attemptedCameraCodes = []) {
if (!channel || !channel.cameraCode) return
const index = this.activeIndex
const index = targetIndex
const current = this.slots[index]
if (current && current.cameraCode === channel.cameraCode) {
this.controlTarget = current
@ -291,11 +369,14 @@ export default {
streamSource: { hlsUrl: '', flvUrl: '' },
muted: true,
errorNotified: false,
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
timer: null
}
this.$set(this.slots, index, slot)
this.controlTarget = slot
this.selectedCode = channel.cameraCode
if (index === this.activeIndex) {
this.controlTarget = slot
this.selectedCode = channel.cameraCode
}
this.presets = []
this.presetId = null
try {
@ -303,9 +384,10 @@ export default {
if (this.slots[index] !== slot) return
this.applyLiveResult(index, slot, result)
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
throw new Error('实时视频接口未返回可播放地址')
this.pollLive(index, slot, 0)
return
}
this.loadPresets(true)
if (index === this.activeIndex) this.loadPresets(true)
} catch (error) {
if (this.slots[index] === slot) {
this.$set(this.slots, index, null)
@ -324,14 +406,7 @@ export default {
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
},
replaceStreamHost(url) {
if (!url || !window.location.hostname) return url
try {
const parsed = new URL(url, window.location.origin)
parsed.hostname = window.location.hostname
return parsed.toString()
} catch (_) {
return url
}
return url
},
pollLive(index, slot, attempt) {
if (this.slots[index] !== slot) return
@ -369,13 +444,13 @@ export default {
},
changeLayout(layout) {
if (layout === this.layout) return
if (layout < this.layout) {
this.slots.forEach((slot, index) => { if (slot && index >= layout) this.closeSlot(index, false) })
}
this.layout = layout
this.$root.$emit('video-layout-updated', layout)
if (this.activeIndex >= layout) this.selectSlot(0)
this.placementCursor %= layout
this.populateRandomLayout()
},
changeControlPanel(expanded) {
this.controlPanelExpanded = Boolean(expanded)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
},
selectSlot(index) {
if (index === this.activeIndex) {
@ -399,7 +474,30 @@ export default {
if (!slot || slot.errorNotified) return
slot.loading = false
slot.errorNotified = true
this.$message.error((error && error.message) || '视频播放失败')
this.switchToNextChannel(index, slot, error)
},
async switchToNextChannel(index, failedSlot, error) {
if (this.slots[index] !== failedSlot) return
const attemptedCodes = new Set(failedSlot.attemptedCameraCodes || [failedSlot.cameraCode])
const occupiedCodes = new Set(this.slots
.filter((slot, slotIndex) => slot && slotIndex !== index)
.map(slot => slot.cameraCode))
const currentIndex = this.channels.findIndex(channel => channel.cameraCode === failedSlot.cameraCode)
let nextChannel = null
for (let offset = 1; offset <= this.channels.length; offset += 1) {
const candidate = this.channels[(currentIndex + offset + this.channels.length) % this.channels.length]
if (candidate && !attemptedCodes.has(candidate.cameraCode) && !occupiedCodes.has(candidate.cameraCode)) {
nextChannel = candidate
break
}
}
this.$set(this.slots, index, null)
try { await stopLive(failedSlot.cameraCode) } catch (_) { /* continue switching cameras */ }
if (nextChannel) {
await this.startChannel(nextChannel, index, Array.from(attemptedCodes))
} else {
this.$message.error((error && error.message) || '视频播放失败,暂无其他可用摄像头')
}
},
fullscreen(index) {
const card = this.$refs.cameraCards && this.$refs.cameraCards[index]
@ -419,6 +517,8 @@ export default {
const cameraCode = this.ptzCameraCode || (this.controlTarget && this.controlTarget.cameraCode)
this.ptzActive = false
this.ptzCameraCode = ''
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = null
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
return
}
@ -427,13 +527,20 @@ export default {
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
this.ptzActive = true
this.ptzCameraCode = this.controlTarget.cameraCode
this.sendPtz(code, '2', String(this.ptzSpeed), this.ptzCameraCode).catch(error => this.$message.error(error.message))
const sendMove = () => this.sendPtz(code, '2', String(this.ptzSpeed), this.ptzCameraCode).catch(error => {
if (this.ptzActive) this.$message.error(error.message)
})
sendMove()
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = setInterval(sendMove, 300)
},
stopPtz() {
if (!this.ptzActive) return
const cameraCode = this.ptzCameraCode
this.ptzActive = false
this.ptzCameraCode = ''
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = null
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
},
async loadPresets(silent = false) {


+ 2
- 4
src/views/video/components/NvrDeviceTree.vue View File

@ -51,7 +51,6 @@
<span>没有匹配的设备通道</span>
</div>
</div>
<div class="tree-tip">单击通道打开预览或切换当前控制对象</div>
</aside>
</template>
@ -120,8 +119,7 @@ export default {
const walk = nodes => (nodes || []).forEach(node => { if (node.children && node.children.length) { this.expandedKeys.push(node.id); walk(node.children) } })
walk(this.treeNodes)
this.refreshTree()
const first = this.channelNodes[0]
if (first && !this.selectedCode) this.$emit('open', this.normalizeChannel(first))
this.$emit('channels-loaded', this.channelNodes.map(this.normalizeChannel))
} catch (error) {
this.treeNodes = []
this.$message.error(error.message || '设备树加载失败')
@ -148,7 +146,7 @@ export default {
</script>
<style lang="scss" scoped>
.nvr-tree { width: 280px; min-width: 280px; height: 100%; display: flex; flex-direction: column; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: transparent; color: #d7e9ff; box-sizing: border-box; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif !important; font-weight: 400 !important; }
.nvr-tree { width: 360px; min-width: 360px; height: 100%; display: flex; flex-direction: column; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: transparent; color: #d7e9ff; box-sizing: border-box; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif !important; font-weight: 400 !important; }
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41,139,255,.35); color: #54ffff; font-size: 14px; }
.tree-title i { margin-right: 6px; }
.tree-title strong { min-width: 28px; text-align: center; color: #b7d8ff; }


+ 70
- 30
src/views/video/components/NvrStreamPlayer.vue View File

@ -22,7 +22,7 @@ export default {
placeholder: { type: String, default: '等待打开视频通道' }
},
data() {
return { hls: null, flv: null, ready: false, hlsRetryTimer: null }
return { hls: null, flv: null, ready: false, hlsRecoveries: 0, fallbackUsed: false, loadTimer: null }
},
watch: {
source: {
@ -48,8 +48,6 @@ export default {
if (this.$refs.video) this.$refs.video.pause()
},
destroyPlayer() {
clearTimeout(this.hlsRetryTimer)
this.hlsRetryTimer = null
if (this.hls) {
this.hls.destroy()
this.hls = null
@ -65,6 +63,10 @@ export default {
video.load()
}
this.ready = false
this.hlsRecoveries = 0
this.fallbackUsed = false
clearTimeout(this.loadTimer)
this.loadTimer = null
},
attach() {
this.$nextTick(() => {
@ -75,31 +77,62 @@ export default {
this.destroyPlayer()
video.muted = this.muted
const markReady = () => {
clearTimeout(this.loadTimer)
this.loadTimer = null
this.ready = true
this.$emit('ready')
}
video.onloadeddata = markReady
video.oncanplay = markReady
video.onplaying = markReady
video.onerror = () => this.$emit('error', new Error('视频播放失败'))
if (flvUrl && flvjs.isSupported()) {
this.flv = flvjs.createPlayer(
{ type: 'flv', url: flvUrl, isLive: true },
{ enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true }
)
this.flv.attachMediaElement(video)
this.flv.load()
if (this.autoplay) Promise.resolve(this.flv.play()).catch(() => undefined)
this.flv.on(flvjs.Events.ERROR, () => {
if (hlsUrl) this.attachHls(video, hlsUrl)
else this.$emit('error', new Error('HTTP-FLV 播放失败'))
})
return
if (hlsUrl) {
this.loadTimer = setTimeout(() => {
if (!this.ready) this.fallbackToFlv(video, flvUrl)
}, 10000)
this.attachHls(video, hlsUrl, flvUrl)
}
if (hlsUrl) this.attachHls(video, hlsUrl)
else if (flvUrl) this.attachFlv(video, flvUrl)
else video.onerror = () => this.$emit('error', new Error('视频播放失败'))
})
},
attachHls(video, url) {
attachFlv(video, url) {
if (!url || !flvjs.isSupported()) {
this.$emit('error', new Error('当前浏览器不支持 HTTP-FLV 播放'))
return
}
video.onerror = null
this.flv = flvjs.createPlayer(
{ type: 'flv', url, isLive: true },
{ enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true }
)
this.flv.attachMediaElement(video)
this.flv.load()
clearTimeout(this.loadTimer)
this.loadTimer = setTimeout(() => {
if (!this.ready && this.flv) {
try { this.flv.destroy() } catch (_) { /* ignore */ }
this.flv = null
this.$emit('error', new Error('HTTP-FLV stream timeout'))
}
}, 10000)
if (this.autoplay) Promise.resolve(this.flv.play()).catch(() => undefined)
this.flv.on(flvjs.Events.ERROR, () => this.$emit('error', new Error('HTTP-FLV 播放失败')))
},
fallbackToFlv(video, fallbackFlvUrl) {
clearTimeout(this.loadTimer)
this.loadTimer = null
if (!fallbackFlvUrl || this.fallbackUsed) {
this.$emit('error', new Error('HLS 播放失败'))
return
}
this.fallbackUsed = true
if (this.hls) {
this.hls.destroy()
this.hls = null
}
this.attachFlv(video, fallbackFlvUrl)
},
attachHls(video, url, fallbackFlvUrl = '') {
if (this.flv) {
try { this.flv.destroy() } catch (_) { /* ignore */ }
this.flv = null
@ -109,9 +142,12 @@ export default {
enableWorker: true,
lowLatencyMode: true,
liveDurationInfinity: true,
manifestLoadingMaxRetry: 8,
levelLoadingMaxRetry: 8,
fragLoadingMaxRetry: 8
backBufferLength: 2,
maxBufferLength: 3,
maxMaxBufferLength: 5,
manifestLoadingMaxRetry: 3,
levelLoadingMaxRetry: 3,
fragLoadingMaxRetry: 3
})
this.hls = hls
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
@ -122,23 +158,27 @@ export default {
})
hls.on(Hls.Events.ERROR, (_, detail) => {
if (this.hls !== hls || !detail || !detail.fatal) return
if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) {
clearTimeout(this.hlsRetryTimer)
this.hlsRetryTimer = setTimeout(() => {
if (this.hls === hls) hls.startLoad()
}, 800)
} else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) {
if (detail.type === Hls.ErrorTypes.MEDIA_ERROR && this.hlsRecoveries < 1) {
this.hlsRecoveries += 1
hls.recoverMediaError()
} else {
this.$emit('error', new Error('HLS 播放失败'))
hls.destroy()
this.hls = null
this.fallbackToFlv(video, fallbackFlvUrl)
}
})
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.onerror = () => {
video.removeAttribute('src')
if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl)
else this.$emit('error', new Error('HLS 播放失败'))
}
video.src = url
if (this.autoplay) video.play().catch(() => undefined)
} else {
this.$emit('error', new Error('当前浏览器不支持 HLS 播放'))
if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl)
else this.$emit('error', new Error('当前浏览器不支持 HLS 播放'))
}
}
}


Loading…
Cancel
Save