feat: 增加HLS视频协议支持并优化招标项目管理
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"element-plus": "^2.8.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"less": "^4.2.0",
|
||||
"naive-ui": "^2.44.1",
|
||||
"vue": "^3.4.37",
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
'menu-entry--children': Boolean(item.children?.length),
|
||||
}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-strip__item"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<button type="button" class="menu-strip__item" @click="handleMenuClick(item)">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
|
||||
@@ -49,22 +45,11 @@
|
||||
</nav>
|
||||
|
||||
<div class="action-strip">
|
||||
<button
|
||||
type="button"
|
||||
class="ai-badge"
|
||||
aria-label="AI 助手"
|
||||
@click="handlePrototypeNotice"
|
||||
>
|
||||
<button type="button" class="ai-badge" aria-label="AI 助手" @click="handlePrototypeNotice">
|
||||
<span class="ai-badge__icon">AI</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="log-button"
|
||||
@click="handlePrototypeNotice"
|
||||
>
|
||||
导出工作日志
|
||||
</button>
|
||||
<button type="button" class="log-button" @click="handlePrototypeNotice">导出工作日志</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -142,7 +127,12 @@ const menuItems = [
|
||||
{
|
||||
label: '招投标信息管理',
|
||||
routeName: 'TenderProjectManagement',
|
||||
activeNames: ['TenderProjectManagement', 'AwardProjectManagement', 'SectorManagement'],
|
||||
activeNames: [
|
||||
'TenderProjectManagement',
|
||||
'AwardProjectManagement',
|
||||
'SectorManagement',
|
||||
'TenderTaskConfig',
|
||||
],
|
||||
children: [
|
||||
{
|
||||
label: '招标项目管理',
|
||||
@@ -158,6 +148,10 @@ const menuItems = [
|
||||
label: '板块管理',
|
||||
routeName: 'SectorManagement',
|
||||
},
|
||||
{
|
||||
label: '任务配置',
|
||||
routeName: 'TenderTaskConfig',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: '系统管理' },
|
||||
|
||||
@@ -23,4 +23,12 @@ export default [
|
||||
title: '板块管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/tendering/task-config',
|
||||
name: 'TenderTaskConfig',
|
||||
component: () => import('@/views/tendering/task/index.vue'),
|
||||
meta: {
|
||||
title: '任务配置',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,6 +47,19 @@
|
||||
class="filter-field filter-field--code"
|
||||
placeholder="国标号"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.videoProtocol"
|
||||
clearable
|
||||
class="filter-field"
|
||||
placeholder="视频协议"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in protocolOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
clearable
|
||||
@@ -63,10 +76,8 @@
|
||||
<div class="panel-card">
|
||||
<div class="table-toolbar">
|
||||
<div class="table-toolbar__group">
|
||||
<el-button type="primary" @click="openCreateDialog">新增视频点位</el-button>
|
||||
<el-button :disabled="!selectedIds.length" @click="deleteSelected">
|
||||
批量删除
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">新增监控视频</el-button>
|
||||
<el-button :disabled="!selectedIds.length" @click="deleteSelected">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-toolbar__summary">
|
||||
@@ -83,19 +94,29 @@
|
||||
>
|
||||
<el-table-column type="selection" width="52" />
|
||||
<el-table-column label="工程名称" prop="projectName" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="主要工程地点" prop="projectLocation" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="摄像机点位" prop="cameraPoint" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="通道号" prop="channelCode" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
label="国标号"
|
||||
prop="nationalStandardCode"
|
||||
min-width="210"
|
||||
label="主要工程地点"
|
||||
prop="projectLocation"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
/>
|
||||
<el-table-column label="摄像机点位" prop="cameraPoint" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="通道号" prop="channelCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="国标号" prop="nationalStandardCode" min-width="210" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="code-badge">{{ row.nationalStandardCode }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="视频协议" prop="videoProtocol" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="protocol-badge">{{ formatProtocol(row.videoProtocol) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="播放链接" prop="playUrl" min-width="280" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="link-text">{{ row.playUrl }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
@@ -115,11 +136,7 @@
|
||||
直播
|
||||
</button>
|
||||
<button type="button" class="row-action" @click="openEditDialog(row)">编辑</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-action row-action--danger"
|
||||
@click="deleteRecord(row)"
|
||||
>
|
||||
<button type="button" class="row-action row-action--danger" @click="deleteRecord(row)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
@@ -147,7 +164,7 @@
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="`${editingRecordId ? '编辑' : '新增'}监控视频`"
|
||||
width="760px"
|
||||
width="820px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="110px">
|
||||
@@ -169,10 +186,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="主要工程地点">
|
||||
<el-input
|
||||
:model-value="formModel.projectLocation || '请选择工程名称后自动带出'"
|
||||
disabled
|
||||
/>
|
||||
<el-input :model-value="formModel.projectLocation || '选择项目后自动带出'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="摄像机点位" prop="cameraPoint">
|
||||
<el-input v-model="formModel.cameraPoint" placeholder="请输入摄像机点位" />
|
||||
@@ -183,6 +197,19 @@
|
||||
<el-form-item label="国标号" prop="nationalStandardCode">
|
||||
<el-input v-model="formModel.nationalStandardCode" placeholder="请输入国标号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="视频协议" prop="videoProtocol">
|
||||
<el-select v-model="formModel.videoProtocol" placeholder="请选择视频协议" class="select-field">
|
||||
<el-option
|
||||
v-for="option in protocolOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="播放链接" prop="playUrl" class="form-grid__span-2">
|
||||
<el-input v-model="formModel.playUrl" placeholder="请输入播放链接" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formModel.status" placeholder="请选择状态" class="select-field">
|
||||
<el-option label="在线" value="在线" />
|
||||
@@ -206,7 +233,7 @@
|
||||
<el-dialog
|
||||
v-model="liveDialogVisible"
|
||||
title="视频直播"
|
||||
width="900px"
|
||||
width="960px"
|
||||
destroy-on-close
|
||||
@closed="handleLiveDialogClosed"
|
||||
>
|
||||
@@ -236,6 +263,10 @@
|
||||
<span class="live-meta-item__label">通道号</span>
|
||||
<strong class="live-meta-item__value">{{ liveRecord.channelCode || '-' }}</strong>
|
||||
</div>
|
||||
<div class="live-meta-item">
|
||||
<span class="live-meta-item__label">视频协议</span>
|
||||
<strong class="live-meta-item__value">{{ formatProtocol(liveRecord.videoProtocol) }}</strong>
|
||||
</div>
|
||||
<div class="live-meta-item">
|
||||
<span class="live-meta-item__label">国标号</span>
|
||||
<strong class="live-meta-item__value">{{ liveRecord.nationalStandardCode }}</strong>
|
||||
@@ -258,7 +289,7 @@
|
||||
></video>
|
||||
<div class="live-screen__overlay"></div>
|
||||
<div v-if="isStartingLive" class="live-screen__mask">
|
||||
<span>正在连接摄像头...</span>
|
||||
<span>正在连接视频流...</span>
|
||||
</div>
|
||||
<div v-else-if="liveError" class="live-screen__mask live-screen__mask--error">
|
||||
<p>{{ liveError }}</p>
|
||||
@@ -277,6 +308,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Hls from 'hls.js';
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import storage from '@/utils/storage';
|
||||
@@ -284,6 +316,8 @@ import storage from '@/utils/storage';
|
||||
const STORAGE_KEY = 'admin-pc:supervision-videos';
|
||||
const DEFAULT_WEBRTC_STREAM_URL =
|
||||
'webrtc://104.168.15.133:15123/proxy/sms/index/api/webrtc?app=rtp&stream=chhv1os&type=play';
|
||||
const DEFAULT_HLS_STREAM_URL = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
|
||||
const locationOptions = [
|
||||
'江苏省/南京市',
|
||||
'江苏省/无锡市',
|
||||
@@ -300,6 +334,11 @@ const locationOptions = [
|
||||
'江苏省/宿迁市',
|
||||
];
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'WebRTC', value: 'webrtc' },
|
||||
{ label: 'HLS', value: 'hls' },
|
||||
];
|
||||
|
||||
const initialRecords = [
|
||||
{
|
||||
id: 'video_1',
|
||||
@@ -308,6 +347,8 @@ const initialRecords = [
|
||||
cameraPoint: '1号塔吊西南侧',
|
||||
channelCode: 'CH-001',
|
||||
nationalStandardCode: 'GB34020000001320000001',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '王敏',
|
||||
updatedAt: '2026-04-27 09:12:00',
|
||||
@@ -319,6 +360,8 @@ const initialRecords = [
|
||||
cameraPoint: '南门车辆通道',
|
||||
channelCode: 'CH-002',
|
||||
nationalStandardCode: 'GB34020000001320000002',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '李峰',
|
||||
updatedAt: '2026-04-27 09:18:00',
|
||||
@@ -330,6 +373,8 @@ const initialRecords = [
|
||||
cameraPoint: '主设备吊装区',
|
||||
channelCode: 'CH-003',
|
||||
nationalStandardCode: 'GB34020000001320000003',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '陈宇',
|
||||
updatedAt: '2026-04-27 09:26:00',
|
||||
@@ -341,6 +386,8 @@ const initialRecords = [
|
||||
cameraPoint: '材料堆场东口',
|
||||
channelCode: 'CH-004',
|
||||
nationalStandardCode: 'GB34020000001320000004',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '赵晴',
|
||||
updatedAt: '2026-04-27 09:33:00',
|
||||
@@ -352,6 +399,8 @@ const initialRecords = [
|
||||
cameraPoint: '外立面登高作业区',
|
||||
channelCode: 'CH-005',
|
||||
nationalStandardCode: 'GB34020000001320000005',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '顾晨',
|
||||
updatedAt: '2026-04-27 09:44:00',
|
||||
@@ -363,6 +412,8 @@ const initialRecords = [
|
||||
cameraPoint: '钢结构拼装场',
|
||||
channelCode: 'CH-006',
|
||||
nationalStandardCode: 'GB34020000001320000006',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '刘颖',
|
||||
updatedAt: '2026-04-27 09:55:00',
|
||||
@@ -374,6 +425,8 @@ const initialRecords = [
|
||||
cameraPoint: '桥面摊铺区',
|
||||
channelCode: 'CH-007',
|
||||
nationalStandardCode: 'GB34020000001320000007',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '王敏',
|
||||
updatedAt: '2026-04-27 10:08:00',
|
||||
@@ -385,6 +438,8 @@ const initialRecords = [
|
||||
cameraPoint: '访客登记口',
|
||||
channelCode: 'CH-008',
|
||||
nationalStandardCode: 'GB34020000001320000008',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '李峰',
|
||||
updatedAt: '2026-04-27 10:14:00',
|
||||
@@ -396,6 +451,8 @@ const initialRecords = [
|
||||
cameraPoint: 'AGV调度主通道',
|
||||
channelCode: 'CH-009',
|
||||
nationalStandardCode: 'GB34020000001320000009',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '陈宇',
|
||||
updatedAt: '2026-04-27 10:27:00',
|
||||
@@ -407,6 +464,8 @@ const initialRecords = [
|
||||
cameraPoint: '冷机吊装通道',
|
||||
channelCode: 'CH-010',
|
||||
nationalStandardCode: 'GB34020000001320000010',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '赵晴',
|
||||
updatedAt: '2026-04-27 10:36:00',
|
||||
@@ -425,6 +484,7 @@ const liveRecord = ref(null);
|
||||
const isStartingLive = ref(false);
|
||||
const liveError = ref('');
|
||||
const rtcPeerConnection = ref(null);
|
||||
const hlsPlayer = ref(null);
|
||||
|
||||
const filters = reactive({
|
||||
projectName: '',
|
||||
@@ -432,6 +492,7 @@ const filters = reactive({
|
||||
cameraPoint: '',
|
||||
channelCode: '',
|
||||
nationalStandardCode: '',
|
||||
videoProtocol: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
@@ -441,18 +502,27 @@ const createEmptyForm = () => ({
|
||||
cameraPoint: '',
|
||||
channelCode: '',
|
||||
nationalStandardCode: '',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '管理员',
|
||||
updatedAt: '',
|
||||
});
|
||||
|
||||
const normalizeRecord = (record) => ({
|
||||
const normalizeRecord = (record) => {
|
||||
const protocol = normalizeProtocol(record?.videoProtocol);
|
||||
const playUrl = normalizePlayUrl(protocol, record?.playUrl);
|
||||
|
||||
return {
|
||||
...createEmptyForm(),
|
||||
...record,
|
||||
projectLocation: normalizeProjectLocation(record?.projectLocation),
|
||||
videoProtocol: protocol,
|
||||
playUrl,
|
||||
creator: record?.creator || '管理员',
|
||||
updatedAt: record?.updatedAt || formatCurrentDateTime(),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const buildSeededRecords = () => {
|
||||
const storedRecords = storage.getLocalStorage(STORAGE_KEY);
|
||||
@@ -474,21 +544,20 @@ const buildSeededRecords = () => {
|
||||
|
||||
const records = ref(buildSeededRecords());
|
||||
const formModel = reactive(createEmptyForm());
|
||||
|
||||
const projectOptions = computed(() => {
|
||||
const projectMap = new Map();
|
||||
|
||||
records.value.forEach((record) => {
|
||||
if (!record.projectName) {
|
||||
if (!record.projectName || projectMap.has(record.projectName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectMap.has(record.projectName)) {
|
||||
projectMap.set(record.projectName, {
|
||||
label: record.projectName,
|
||||
value: record.projectName,
|
||||
location: normalizeProjectLocation(record.projectLocation),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...projectMap.values()];
|
||||
@@ -499,34 +568,32 @@ const formRules = {
|
||||
cameraPoint: [{ required: true, message: '请输入摄像机点位', trigger: 'blur' }],
|
||||
channelCode: [{ required: true, message: '请输入通道号', trigger: 'blur' }],
|
||||
nationalStandardCode: [{ required: true, message: '请输入国标号', trigger: 'blur' }],
|
||||
videoProtocol: [{ required: true, message: '请选择视频协议', trigger: 'change' }],
|
||||
playUrl: [{ required: true, message: '请输入播放链接', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
creator: [{ required: true, message: '请输入创建人', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const filteredRecords = computed(() =>
|
||||
records.value.filter((record) => {
|
||||
const matchesProjectName = filters.projectName
|
||||
? record.projectName === filters.projectName
|
||||
: true;
|
||||
|
||||
const matchesProjectName = filters.projectName ? record.projectName === filters.projectName : true;
|
||||
const matchesLocation = filters.projectLocation
|
||||
? record.projectLocation === filters.projectLocation
|
||||
: true;
|
||||
|
||||
const matchesCameraPoint = filters.cameraPoint
|
||||
? record.cameraPoint.toLowerCase().includes(filters.cameraPoint.trim().toLowerCase())
|
||||
: true;
|
||||
|
||||
const matchesChannelCode = filters.channelCode
|
||||
? record.channelCode.toLowerCase().includes(filters.channelCode.trim().toLowerCase())
|
||||
: true;
|
||||
|
||||
const matchesCode = filters.nationalStandardCode
|
||||
? record.nationalStandardCode
|
||||
.toLowerCase()
|
||||
.includes(filters.nationalStandardCode.trim().toLowerCase())
|
||||
: true;
|
||||
|
||||
const matchesProtocol = filters.videoProtocol
|
||||
? record.videoProtocol === filters.videoProtocol
|
||||
: true;
|
||||
const matchesStatus = filters.status ? record.status === filters.status : true;
|
||||
|
||||
return (
|
||||
@@ -535,6 +602,7 @@ const filteredRecords = computed(() =>
|
||||
matchesCameraPoint &&
|
||||
matchesChannelCode &&
|
||||
matchesCode &&
|
||||
matchesProtocol &&
|
||||
matchesStatus
|
||||
);
|
||||
}),
|
||||
@@ -562,6 +630,7 @@ const resetFilters = () => {
|
||||
filters.cameraPoint = '';
|
||||
filters.channelCode = '';
|
||||
filters.nationalStandardCode = '';
|
||||
filters.videoProtocol = '';
|
||||
filters.status = '';
|
||||
currentPage.value = 1;
|
||||
};
|
||||
@@ -574,7 +643,7 @@ const openCreateDialog = () => {
|
||||
|
||||
const openEditDialog = (record) => {
|
||||
editingRecordId.value = record.id;
|
||||
Object.assign(formModel, createEmptyForm(), record);
|
||||
Object.assign(formModel, createEmptyForm(), normalizeRecord(record));
|
||||
syncProjectLocation(record.projectName);
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
@@ -589,13 +658,15 @@ const submitForm = async () => {
|
||||
? records.value.find((record) => record.id === editingRecordId.value) || null
|
||||
: null;
|
||||
|
||||
const payload = {
|
||||
const protocol = normalizeProtocol(formModel.videoProtocol);
|
||||
const payload = normalizeRecord({
|
||||
...(existingRecord || {}),
|
||||
...createEmptyForm(),
|
||||
...formModel,
|
||||
id: editingRecordId.value || `video_${Date.now()}`,
|
||||
videoProtocol: protocol,
|
||||
playUrl: normalizePlayUrl(protocol, formModel.playUrl),
|
||||
updatedAt: formatCurrentDateTime(),
|
||||
};
|
||||
});
|
||||
|
||||
if (editingRecordId.value) {
|
||||
records.value = records.value.map((record) =>
|
||||
@@ -676,7 +747,7 @@ const openLiveDialog = async (record) => {
|
||||
return;
|
||||
}
|
||||
|
||||
liveRecord.value = record;
|
||||
liveRecord.value = normalizeRecord(record);
|
||||
liveDialogVisible.value = true;
|
||||
await startLiveStream();
|
||||
};
|
||||
@@ -693,7 +764,7 @@ const handleLiveDialogClosed = () => {
|
||||
};
|
||||
|
||||
const startLiveStream = async () => {
|
||||
if (!liveDialogVisible.value) {
|
||||
if (!liveDialogVisible.value || !liveRecord.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -708,6 +779,57 @@ const startLiveStream = async () => {
|
||||
throw new Error('video element unavailable');
|
||||
}
|
||||
|
||||
const protocol = normalizeProtocol(liveRecord.value.videoProtocol);
|
||||
const playUrl = normalizePlayUrl(protocol, liveRecord.value.playUrl);
|
||||
|
||||
if (protocol === 'hls') {
|
||||
await startHlsStream(playUrl);
|
||||
} else {
|
||||
await startWebRtcStream(playUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
liveError.value = error?.message || '视频流连接失败,请稍后重试。';
|
||||
stopLiveStream();
|
||||
} finally {
|
||||
isStartingLive.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startHlsStream = async (url) => {
|
||||
if (!url) {
|
||||
throw new Error('HLS 播放链接不能为空');
|
||||
}
|
||||
|
||||
const video = liveVideoRef.value;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hlsPlayer.value = hls;
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data?.fatal) {
|
||||
liveError.value = 'HLS 视频流播放失败,请检查播放链接。';
|
||||
stopLiveStream();
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
} else {
|
||||
throw new Error('当前浏览器不支持 HLS 播放');
|
||||
}
|
||||
|
||||
const playTask = video.play?.();
|
||||
if (playTask?.catch) {
|
||||
await playTask.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const startWebRtcStream = async (url) => {
|
||||
if (!url) {
|
||||
throw new Error('WebRTC 播放链接不能为空');
|
||||
}
|
||||
|
||||
const peerConnection = new RTCPeerConnection();
|
||||
rtcPeerConnection.value = peerConnection;
|
||||
|
||||
@@ -741,7 +863,7 @@ const startLiveStream = async () => {
|
||||
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
|
||||
const response = await fetch(resolveWebRtcEndpoint(DEFAULT_WEBRTC_STREAM_URL), {
|
||||
const response = await fetch(resolveWebRtcEndpoint(url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
@@ -765,15 +887,14 @@ const startLiveStream = async () => {
|
||||
if (playTask?.catch) {
|
||||
await playTask.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
liveError.value = error?.message || 'WebRTC 视频流连接失败,请稍后重试。';
|
||||
stopLiveStream();
|
||||
} finally {
|
||||
isStartingLive.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopLiveStream = () => {
|
||||
if (hlsPlayer.value) {
|
||||
hlsPlayer.value.destroy();
|
||||
hlsPlayer.value = null;
|
||||
}
|
||||
|
||||
if (rtcPeerConnection.value) {
|
||||
rtcPeerConnection.value.ontrack = null;
|
||||
rtcPeerConnection.value.onconnectionstatechange = null;
|
||||
@@ -797,6 +918,7 @@ watch(
|
||||
filters.cameraPoint,
|
||||
filters.channelCode,
|
||||
filters.nationalStandardCode,
|
||||
filters.videoProtocol,
|
||||
filters.status,
|
||||
],
|
||||
() => {
|
||||
@@ -814,12 +936,16 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
liveDialogVisible,
|
||||
(visible) => {
|
||||
watch(liveDialogVisible, (visible) => {
|
||||
if (!visible) {
|
||||
stopLiveStream();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => formModel.videoProtocol,
|
||||
(protocol) => {
|
||||
formModel.playUrl = normalizePlayUrl(protocol, formModel.playUrl);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -839,6 +965,20 @@ function formatCurrentDateTime() {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function normalizeProtocol(value) {
|
||||
return String(value || 'webrtc').toLowerCase() === 'hls' ? 'hls' : 'webrtc';
|
||||
}
|
||||
|
||||
function normalizePlayUrl(protocol, value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return normalizeProtocol(protocol) === 'hls' ? DEFAULT_HLS_STREAM_URL : DEFAULT_WEBRTC_STREAM_URL;
|
||||
}
|
||||
|
||||
function normalizeProjectLocation(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
@@ -873,6 +1013,10 @@ function syncProjectLocation(projectName) {
|
||||
formModel.projectLocation = matchedProject?.location || '';
|
||||
}
|
||||
|
||||
function formatProtocol(value) {
|
||||
return normalizeProtocol(value) === 'hls' ? 'HLS' : 'WebRTC';
|
||||
}
|
||||
|
||||
function resolveWebRtcEndpoint(url) {
|
||||
if (!url) {
|
||||
return '';
|
||||
@@ -1041,6 +1185,21 @@ function extractAnswerSdp(payload, rawText) {
|
||||
background: linear-gradient(135deg, rgba(226, 239, 255, 0.92), rgba(242, 248, 255, 0.98));
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 64px;
|
||||
padding: 5px 10px;
|
||||
color: #1557b0;
|
||||
font-size: 12px;
|
||||
background: rgba(21, 87, 176, 0.1);
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: #385372;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1103,6 +1262,10 @@ function extractAnswerSdp(payload, rawText) {
|
||||
gap: 4px 18px;
|
||||
}
|
||||
|
||||
.form-grid__span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.live-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1183,6 +1346,7 @@ function extractAnswerSdp(payload, rawText) {
|
||||
color: #182b44;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.live-screen {
|
||||
@@ -1274,6 +1438,10 @@ function extractAnswerSdp(payload, rawText) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid__span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.live-screen,
|
||||
.live-screen__video {
|
||||
min-height: 280px;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
title="中标项目管理"
|
||||
storage-key="admin-pc:award-projects"
|
||||
date-range-prop="awardDate"
|
||||
amount-range-prop="awardAmount"
|
||||
amount-range-unit="万元"
|
||||
:amount-scale="10000"
|
||||
:columns="columns"
|
||||
:form-fields="formFields"
|
||||
:initial-records="initialRecords"
|
||||
@@ -19,7 +22,7 @@ const columns = [
|
||||
{ prop: 'sectionName', label: '标段名称', minWidth: 220 },
|
||||
{ prop: 'constructionCompany', label: '建设单位名称', minWidth: 220 },
|
||||
{ prop: 'awardCompany', label: '中标人名称', minWidth: 220 },
|
||||
{ prop: 'awardAmount', label: '中标价', minWidth: 160, type: 'currency' },
|
||||
{ prop: 'awardAmount', label: '中标金额', minWidth: 160, type: 'wanAmount' },
|
||||
{ prop: 'awardDate', label: '中标时间', minWidth: 140 },
|
||||
{ prop: 'detailLink', label: '详情链接', minWidth: 96, width: 96, type: 'link' },
|
||||
];
|
||||
@@ -31,7 +34,7 @@ const formFields = [
|
||||
{ prop: 'sectionName', label: '标段名称', type: 'text' },
|
||||
{ prop: 'constructionCompany', label: '建设单位名称', type: 'text' },
|
||||
{ prop: 'awardCompany', label: '中标人名称', type: 'text' },
|
||||
{ prop: 'awardAmount', label: '中标价', type: 'amount' },
|
||||
{ prop: 'awardAmount', label: '中标金额', type: 'amount' },
|
||||
{ prop: 'awardDate', label: '中标时间', type: 'date' },
|
||||
{ prop: 'detailLink', label: '详情链接', type: 'url' },
|
||||
];
|
||||
@@ -48,7 +51,7 @@ const initialRecords = [
|
||||
awardAmount: 17980000,
|
||||
awardDate: '2026-04-22',
|
||||
detailLink: 'https://example.com/award/1',
|
||||
provinceCity: '江苏省南京市',
|
||||
provinceCity: '江苏省/南京市',
|
||||
sectorType: '基础设施',
|
||||
projectType: '工程类',
|
||||
},
|
||||
@@ -63,7 +66,7 @@ const initialRecords = [
|
||||
awardAmount: 8860000,
|
||||
awardDate: '2026-04-20',
|
||||
detailLink: 'https://example.com/award/2',
|
||||
provinceCity: '江苏省苏州市',
|
||||
provinceCity: '江苏省/苏州市',
|
||||
sectorType: '智慧园区',
|
||||
projectType: '服务类',
|
||||
},
|
||||
@@ -78,7 +81,7 @@ const initialRecords = [
|
||||
awardAmount: 13250000,
|
||||
awardDate: '2026-04-19',
|
||||
detailLink: 'https://example.com/award/3',
|
||||
provinceCity: '江苏省徐州市',
|
||||
provinceCity: '江苏省/徐州市',
|
||||
sectorType: '能源环保',
|
||||
projectType: '采购类',
|
||||
},
|
||||
@@ -93,7 +96,7 @@ const initialRecords = [
|
||||
awardAmount: 5980000,
|
||||
awardDate: '2026-04-17',
|
||||
detailLink: 'https://example.com/award/4',
|
||||
provinceCity: '江苏省常州市',
|
||||
provinceCity: '江苏省/常州市',
|
||||
sectorType: '智慧园区',
|
||||
projectType: '采购类',
|
||||
},
|
||||
@@ -108,7 +111,7 @@ const initialRecords = [
|
||||
awardAmount: 11360000,
|
||||
awardDate: '2026-04-16',
|
||||
detailLink: 'https://example.com/award/5',
|
||||
provinceCity: '江苏省南通市',
|
||||
provinceCity: '江苏省/南通市',
|
||||
sectorType: '公共服务',
|
||||
projectType: '服务类',
|
||||
},
|
||||
@@ -123,7 +126,7 @@ const initialRecords = [
|
||||
awardAmount: 14750000,
|
||||
awardDate: '2026-04-14',
|
||||
detailLink: 'https://example.com/award/6',
|
||||
provinceCity: '江苏省扬州市',
|
||||
provinceCity: '江苏省/扬州市',
|
||||
sectorType: '智能制造',
|
||||
projectType: '采购类',
|
||||
},
|
||||
@@ -138,7 +141,7 @@ const initialRecords = [
|
||||
awardAmount: 26880000,
|
||||
awardDate: '2026-04-12',
|
||||
detailLink: 'https://example.com/award/7',
|
||||
provinceCity: '江苏省镇江市',
|
||||
provinceCity: '江苏省/镇江市',
|
||||
sectorType: '基础设施',
|
||||
projectType: '工程类',
|
||||
},
|
||||
@@ -153,7 +156,7 @@ const initialRecords = [
|
||||
awardAmount: 3620000,
|
||||
awardDate: '2026-04-10',
|
||||
detailLink: 'https://example.com/award/8',
|
||||
provinceCity: '江苏省盐城市',
|
||||
provinceCity: '江苏省/盐城市',
|
||||
sectorType: '能源环保',
|
||||
projectType: '服务类',
|
||||
},
|
||||
@@ -168,7 +171,7 @@ const initialRecords = [
|
||||
awardAmount: 8450000,
|
||||
awardDate: '2026-04-08',
|
||||
detailLink: 'https://example.com/award/9',
|
||||
provinceCity: '江苏省连云港市',
|
||||
provinceCity: '江苏省/连云港市',
|
||||
sectorType: '物流仓储',
|
||||
projectType: '其他',
|
||||
},
|
||||
@@ -183,7 +186,7 @@ const initialRecords = [
|
||||
awardAmount: 21400000,
|
||||
awardDate: '2026-04-06',
|
||||
detailLink: 'https://example.com/award/10',
|
||||
provinceCity: '江苏省宿迁市',
|
||||
provinceCity: '江苏省/宿迁市',
|
||||
sectorType: '文旅配套',
|
||||
projectType: '工程类',
|
||||
},
|
||||
|
||||
1281
src/views/tendering/task/index.vue
Normal file
1281
src/views/tendering/task/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user