feat: 新增 RTSP 畫面到 Dashboard Modals
This commit is contained in:
parent
0999f8000a
commit
aa7a136937
7
package-lock.json
generated
7
package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
|
"hls.js": "^1.6.12",
|
||||||
"jquery-ui": "^1.14.1",
|
"jquery-ui": "^1.14.1",
|
||||||
"json-schema-generator": "^2.0.6",
|
"json-schema-generator": "^2.0.6",
|
||||||
"mqtt": "^5.10.3",
|
"mqtt": "^5.10.3",
|
||||||
@ -3056,6 +3057,12 @@
|
|||||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.12.tgz",
|
||||||
|
"integrity": "sha512-Pz+7IzvkbAht/zXvwLzA/stUHNqztqKvlLbfpq6ZYU68+gZ+CZMlsbQBPUviRap+3IQ41E39ke7Ia+yvhsehEQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/htmlparser2": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "3.10.1",
|
"version": "3.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
|
"hls.js": "^1.6.12",
|
||||||
"jquery-ui": "^1.14.1",
|
"jquery-ui": "^1.14.1",
|
||||||
"json-schema-generator": "^2.0.6",
|
"json-schema-generator": "^2.0.6",
|
||||||
"mqtt": "^5.10.3",
|
"mqtt": "^5.10.3",
|
||||||
|
@ -207,6 +207,9 @@
|
|||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"worker_id": "工作人员编号",
|
"worker_id": "工作人员编号",
|
||||||
"notice": "注意事项",
|
"notice": "注意事项",
|
||||||
|
"video_storage_location": "告警影片儲存位置",
|
||||||
|
"copy": "复制",
|
||||||
|
"copied": "已复制!",
|
||||||
"result_description": "结果描述",
|
"result_description": "结果描述",
|
||||||
"upload_file": "上传文件",
|
"upload_file": "上传文件",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
|
@ -207,6 +207,9 @@
|
|||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"worker_id": "工作人員編號",
|
"worker_id": "工作人員編號",
|
||||||
"notice": "注意事項",
|
"notice": "注意事項",
|
||||||
|
"video_storage_location": "告警影片儲存位置",
|
||||||
|
"copy": "複製",
|
||||||
|
"copied": "已複製!",
|
||||||
"result_description": "結果描述",
|
"result_description": "結果描述",
|
||||||
"upload_file": "上傳檔案",
|
"upload_file": "上傳檔案",
|
||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
|
@ -207,6 +207,9 @@
|
|||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"worker_id": "Worker ID",
|
"worker_id": "Worker ID",
|
||||||
"notice": "Notice",
|
"notice": "Notice",
|
||||||
|
"video_storage_location": "video storage location",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
"result_description": "Result Description",
|
"result_description": "Result Description",
|
||||||
"upload_file": "Upload File",
|
"upload_file": "Upload File",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
@ -426,5 +429,4 @@
|
|||||||
"selectPath": "Select Save Path",
|
"selectPath": "Select Save Path",
|
||||||
"displayArea": "RTSP Display Area"
|
"displayArea": "RTSP Display Area"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, watch, inject } from "vue";
|
import { ref, defineProps, watch, inject, nextTick } from "vue";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { postOperationRecord } from "@/apis/alert";
|
import { postOperationRecord } from "@/apis/alert";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
@ -153,6 +153,28 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const videoLocation = ref("https://your-video-storage-path.com/alert-video");
|
||||||
|
const showTooltip = ref(false);
|
||||||
|
const hasCopiedOnce = ref(false);
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
const text = videoLocation.value;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
// 確保 tooltip 每次都重新觸發
|
||||||
|
showTooltip.value = false;
|
||||||
|
await nextTick();
|
||||||
|
showTooltip.value = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
showTooltip.value = false;
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("複製失敗:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -163,7 +185,11 @@ watch(
|
|||||||
width="710"
|
width="710"
|
||||||
>
|
>
|
||||||
<template #modalContent>
|
<template #modalContent>
|
||||||
<form ref="form" class="mt-5 w-full flex flex-wrap justify-between">
|
<form
|
||||||
|
ref="form"
|
||||||
|
class="mt-5 w-full flex flex-wrap justify-between"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
v-if="formState && formState.formId"
|
v-if="formState && formState.formId"
|
||||||
class="my-2"
|
class="my-2"
|
||||||
@ -286,9 +312,48 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
>
|
>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<!-- 注意事項 -->
|
||||||
<Textarea :value="formState" name="notice" class="w-full my-2">
|
<Textarea :value="formState" name="notice" class="w-full my-2">
|
||||||
<template #topLeft>{{ $t("alert.notice") }}</template>
|
<template #topLeft>{{ $t("alert.notice") }}</template>
|
||||||
</Textarea>
|
</Textarea>
|
||||||
|
|
||||||
|
<!-- 告警影片儲存位置-->
|
||||||
|
<div class="my-4 w-full">
|
||||||
|
<label class="text-lg">
|
||||||
|
{{ $t("alert.video_storage_location") }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
class="flex-1"
|
||||||
|
name="videoLocation"
|
||||||
|
:value="videoLocation"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<div class="relative inline-flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success"
|
||||||
|
@click.stop="copyToClipboard"
|
||||||
|
>
|
||||||
|
{{ $t("alert.copy") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<span
|
||||||
|
v-if="showTooltip"
|
||||||
|
class="absolute left-full ml-4 top-1/2 -translate-y-1/2 text-white text-xs px-2 py-1 bg-gray-800 rounded shadow whitespace-nowrap"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{{ $t("alert.copied") }}
|
||||||
|
</span>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 結果描述 -->
|
||||||
<Textarea :value="formState" name="description" class="w-full my-2">
|
<Textarea :value="formState" name="description" class="w-full my-2">
|
||||||
<template #topLeft>{{ $t("alert.result_description") }}</template>
|
<template #topLeft>{{ $t("alert.result_description") }}</template>
|
||||||
</Textarea>
|
</Textarea>
|
||||||
|
@ -100,6 +100,8 @@ const getData = async () => {
|
|||||||
buying_date: device.buying_date,
|
buying_date: device.buying_date,
|
||||||
created_at: device.created_at,
|
created_at: device.created_at,
|
||||||
bgSize: 50,
|
bgSize: 50,
|
||||||
|
is_rtsp: device.is_rtsp === true,
|
||||||
|
rtsp_url: device.rtsp_url || "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
@ -6,10 +6,7 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const currentTab = ref("desktop");
|
|
||||||
const changeOpenKey = (key) => {
|
|
||||||
currentTab.value = key;
|
|
||||||
};
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@ -17,6 +14,16 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isRtsp = computed(() => props.data?.is_rtsp === true);
|
||||||
|
|
||||||
|
const monitorUrl = computed(() => props.data?.rtsp_url || "");
|
||||||
|
|
||||||
|
// ------ tab 狀態 / 控制(非 RTSP 時使用)------
|
||||||
|
const currentTab = ref("desktop");
|
||||||
|
const changeOpenKey = (key) => {
|
||||||
|
currentTab.value = key;
|
||||||
|
};
|
||||||
|
|
||||||
const modal = ref(null);
|
const modal = ref(null);
|
||||||
|
|
||||||
// 當 data 有值時自動開啟 modal
|
// 當 data 有值時自動開啟 modal
|
||||||
@ -24,15 +31,23 @@ watch(
|
|||||||
() => props.data,
|
() => props.data,
|
||||||
(newData) => {
|
(newData) => {
|
||||||
if (newData) {
|
if (newData) {
|
||||||
|
console.log("[props.data] =\n", JSON.stringify(props.data, null, 2));
|
||||||
dashboard_effectScatter_modal.showModal();
|
dashboard_effectScatter_modal.showModal();
|
||||||
|
if (!isRtsp.value) currentTab.value = "desktop";
|
||||||
|
console.debug(
|
||||||
|
"[Modal Debug] is_rtsp:",
|
||||||
|
newData.is_rtsp,
|
||||||
|
"monitorUrl:",
|
||||||
|
monitorUrl.value
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 關閉 modal 的處理函數
|
// 關閉 modal
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
currentTab.value = "desktop"; // 重置到 desktop tab
|
currentTab.value = "desktop";
|
||||||
dashboard_effectScatter_modal.close();
|
dashboard_effectScatter_modal.close();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -45,13 +60,77 @@ const handleCancel = () => {
|
|||||||
:onCancel="handleCancel"
|
:onCancel="handleCancel"
|
||||||
:width="600"
|
:width="600"
|
||||||
:draggable="true"
|
:draggable="true"
|
||||||
|
modalClass="max-h-[80vh]"
|
||||||
>
|
>
|
||||||
|
<!-- 標題列:RTSP 不顯示分頁鈕;非 RTSP 顯示分頁鈕 -->
|
||||||
<template #modalTitle>
|
<template #modalTitle>
|
||||||
<section class="flex flex-col h-[60vh] gap-4">
|
<div class="flex items-center justify-between">
|
||||||
<!-- 標題固定高度 -->
|
|
||||||
<div class="flex items-center justify-between h-10">
|
|
||||||
<span>{{ props.data?.full_name }}</span>
|
<span>{{ props.data?.full_name }}</span>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
|
<template v-if="!isRtsp">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-base btn-link btn-text-without-border px-2"
|
||||||
|
@click="() => changeOpenKey('desktop')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'desktop']"
|
||||||
|
size="lg"
|
||||||
|
:class="
|
||||||
|
twMerge(
|
||||||
|
currentTab === 'desktop' ? 'text-success' : 'text-[#a5abb1]'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-base btn-link btn-text-without-border px-2"
|
||||||
|
@click="() => changeOpenKey('image')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'image']"
|
||||||
|
size="lg"
|
||||||
|
:class="
|
||||||
|
twMerge(
|
||||||
|
currentTab === 'image' ? 'text-success' : 'text-[#a5abb1]'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-base btn-link btn-text-without-border px-2"
|
||||||
|
@click="() => changeOpenKey('cog')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'cog']"
|
||||||
|
size="lg"
|
||||||
|
:class="
|
||||||
|
twMerge(
|
||||||
|
currentTab === 'cog' ? 'text-success' : 'text-[#a5abb1]'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-base btn-link btn-text-without-border px-2"
|
||||||
|
@click="() => changeOpenKey('chart')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'chart-line']"
|
||||||
|
size="lg"
|
||||||
|
:class="
|
||||||
|
twMerge(
|
||||||
|
currentTab === 'chart' ? 'text-success' : 'text-[#a5abb1]'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-link btn-text-without-border px-2"
|
class="btn-link btn-text-without-border px-2"
|
||||||
@ -64,22 +143,150 @@ const handleCancel = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 黑底填滿剩下高度 -->
|
<template #modalContent>
|
||||||
<div class="flex-1">
|
<!-- RTSP,顯示 iframe -->
|
||||||
<div
|
<div v-if="isRtsp" class="h-[60vh] py-4">
|
||||||
class="bg-black text-xl font-bold rounded h-full flex justify-center items-center"
|
<div class="relative bg-black rounded h-full overflow-hidden">
|
||||||
|
<iframe
|
||||||
|
:src="monitorUrl"
|
||||||
|
class="absolute inset-0 w-full h-full"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 非 RTSP,顯示四分頁 -->
|
||||||
|
<div v-else class="space-y-4 py-4">
|
||||||
|
<!-- Desktop Tab - 基本資訊 -->
|
||||||
|
<div v-if="currentTab === 'desktop'" class="grid grid-cols-1 gap-4">
|
||||||
|
<table
|
||||||
|
v-if="props.data?.points && props.data.points.length"
|
||||||
|
class="min-w-full bg-gray-700 border text-gray-100"
|
||||||
>
|
>
|
||||||
{{ $t("rtsp.displayArea") }}
|
<thead>
|
||||||
|
<tr class="bg-gray-600">
|
||||||
|
<th class="p-2 border text-left">名稱</th>
|
||||||
|
<th class="p-2 border text-left">點位</th>
|
||||||
|
<th class="p-2 border text-left">數值</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(point, idx) in props.data.points"
|
||||||
|
:key="idx"
|
||||||
|
class="hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<td class="p-2 border">{{ point.full_name }}</td>
|
||||||
|
<td class="p-2 py-1 border">{{ point.points }}</td>
|
||||||
|
<td class="p-2 py-1 border">{{ point.value }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Tab - 可視化資訊 -->
|
||||||
|
<div v-if="currentTab === 'image'" class="grid grid-cols-1 gap-4">
|
||||||
|
<table class="min-w-full bg-gray-700 border text-gray-100">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-600">
|
||||||
|
<th class="p-2 border text-left">項目</th>
|
||||||
|
<th class="p-2 border text-left">內容</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="props.data?.icon" class="hover:bg-gray-600">
|
||||||
|
<td class="p-2 border">設備圖示</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
<img
|
||||||
|
:src="props.data.icon"
|
||||||
|
alt="設備圖示"
|
||||||
|
class="w-12 h-12"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- chart Tab - 圖表資訊 -->
|
||||||
|
<div v-if="currentTab === 'chart'" class="grid grid-cols-1 gap-4">
|
||||||
|
<DashboardEffectScatterModalChart :data="props.data" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cog Tab - 完整資料 -->
|
||||||
|
<div v-if="currentTab === 'cog'" class="grid grid-cols-1 gap-4">
|
||||||
|
<table class="min-w-full bg-gray-700 border text-gray-100">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-600">
|
||||||
|
<th class="p-2 border text-left">項目</th>
|
||||||
|
<th class="p-2 border text-left">內容</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="hover:bg-gray-600">
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.device_number") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">{{ props.data.device_number }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-600">
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.device_name") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">{{ props.data.full_name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-600">
|
||||||
|
<td class="p-2 border">{{ $t("assetManagement.floor") }}</td>
|
||||||
|
<td class="p-2 border">{{ props.data.floor }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-600">
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.device_coordinate") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">{{ props.data.device_coordinate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.brand_and_modal") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ props.data.brand }} / {{ props.data.device_model }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.company_and_contact") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ props.data.operation_name }} /
|
||||||
|
{{ props.data.operation_contact_person }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.buying_date") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">{{ props.data.buying_date }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="p-2 border">
|
||||||
|
{{ $t("assetManagement.created_at") }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">{{ props.data.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* 專門覆蓋 Modal 裡的 min-h-[200px],讓高度自動撐開 */
|
/* 讓 Modal 內容能撐滿高度 */
|
||||||
:deep(.min-h-\[200px\]) {
|
:deep(.min-h-\[200px\]) {
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -1,18 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="min-h-[600px] h-screen">
|
<section class="min-h-[600px] h-screen">
|
||||||
<h1 class="text-2xl font-extrabold mb-2">{{ $t("rtsp.title") }}</h1>
|
<h1 class="text-2xl font-extrabold mb-2">{{ $t("rtsp.title") }}</h1>
|
||||||
|
|
||||||
<div class="flex h-[80%] gap-4">
|
<div class="flex h-[80%] gap-4">
|
||||||
<!-- 左側 RTSP 顯示區域 -->
|
<!-- 左側:即時監控(自動填滿容器) -->
|
||||||
<div class="w-1/2 flex flex-col custom-border p-4 mb-4">
|
|
||||||
<div
|
<div class="relative w-full flex-1 rounded border overflow-hidden">
|
||||||
class="flex-1 text-xl font-bold bg-black text-white flex items-center rounded justify-center"
|
<iframe
|
||||||
>
|
:src="monitorUrl"
|
||||||
{{ $t("rtsp.displayArea") }}
|
class="absolute inset-0 w-full h-full"
|
||||||
</div>
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右側控制區 -->
|
<!-- 右側:開始/結束偵測 + 選擇儲存位置 + 下載進度 -->
|
||||||
<aside class="w-1/2 flex flex-col gap-8 p-4">
|
<aside class="w-1/2 flex flex-col gap-6 p-4">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-add w-40"
|
class="btn btn-add w-40"
|
||||||
@ -20,10 +23,6 @@
|
|||||||
:disabled="isStarting || !dirHandle"
|
:disabled="isStarting || !dirHandle"
|
||||||
:title="!dirHandle ? '請先選擇儲存資料夾' : ''"
|
:title="!dirHandle ? '請先選擇儲存資料夾' : ''"
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
v-if="isStarting"
|
|
||||||
class="loading loading-spinner loading-sm mr-2"
|
|
||||||
></span>
|
|
||||||
{{ $t("rtsp.start") }}
|
{{ $t("rtsp.start") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -31,10 +30,6 @@
|
|||||||
@click="stopDetection"
|
@click="stopDetection"
|
||||||
:disabled="isStopping"
|
:disabled="isStopping"
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
v-if="isStopping"
|
|
||||||
class="loading loading-spinner loading-sm mr-2"
|
|
||||||
></span>
|
|
||||||
{{ $t("rtsp.stop") }}
|
{{ $t("rtsp.stop") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -52,13 +47,15 @@
|
|||||||
class="border border-gray-300 rounded px-3 py-2 flex-1"
|
class="border border-gray-300 rounded px-3 py-2 flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p v-if="!dirHandle" class="text-sm text-red-400">
|
<p v-if="!dirHandle" class="text-sm text-red-400">
|
||||||
請先選擇儲存資料夾。
|
請先選擇儲存資料夾。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 下載進度 -->
|
<!-- 下載進度 -->
|
||||||
<div v-if="downloadProgress > 0 && downloadProgress < 100" class="mt-2">
|
<div
|
||||||
|
v-if="downloadProgress > 0 && downloadProgress < 100"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
<div class="text-sm text-gray-600 mb-1">
|
<div class="text-sm text-gray-600 mb-1">
|
||||||
下載中… {{ Math.floor(downloadProgress) }}%
|
下載中… {{ Math.floor(downloadProgress) }}%
|
||||||
</div>
|
</div>
|
||||||
@ -70,6 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="message" class="text-sm text-gray-700">{{ message }}</p>
|
<p v-if="message" class="text-sm text-gray-700">{{ message }}</p>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -79,14 +77,23 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
name: "Rtsp",
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
savePath: "",
|
// 即時監控(RTSP 轉播)頁面
|
||||||
dirHandle: null, // File System Access 的目錄 handle
|
monitorUrl:
|
||||||
directoryName: "", // 顯示用
|
"http://192.168.0.219:8026/?url=rtsp://admin02:mjmAdmin_99@192.168.0.200:554/stream1?tcp",
|
||||||
ws: null, // WebSocket
|
|
||||||
|
// 儲存位置
|
||||||
|
dirHandle: /** @type {FileSystemDirectoryHandle|null} */ (null),
|
||||||
|
directoryName: "",
|
||||||
|
|
||||||
|
// 錄製控制
|
||||||
|
ws: null,
|
||||||
isStarting: false,
|
isStarting: false,
|
||||||
isStopping: false,
|
isStopping: false,
|
||||||
|
|
||||||
|
// 下載狀態
|
||||||
downloadProgress: 0,
|
downloadProgress: 0,
|
||||||
message: "",
|
message: "",
|
||||||
};
|
};
|
||||||
@ -98,57 +105,35 @@ export default {
|
|||||||
if (this.ws) this.ws.close();
|
if (this.ws) this.ws.close();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// === 1) WebSocket:接收「結束偵測」事件後自動下載 ===
|
// WebSocket:等待後端通知偵測結束 → 下載檔案
|
||||||
connectWS() {
|
connectWS() {
|
||||||
// 視你的後端實際位址而定(支援 wss)
|
|
||||||
const WS_URL =
|
const WS_URL =
|
||||||
location.protocol === "https:"
|
location.protocol === "https:"
|
||||||
? `wss://${location.host}/ws`
|
? `wss://${location.host}/ws`
|
||||||
: `ws://${location.host}/ws`;
|
: `ws://${location.host}/ws`;
|
||||||
|
|
||||||
this.ws = new WebSocket(WS_URL);
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
// this.message = "WebSocket 已連線";
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = async (evt) => {
|
this.ws.onmessage = async (evt) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(evt.data);
|
const payload = JSON.parse(evt.data);
|
||||||
// 後端在 RTSP 偵測結束時,推播例如:
|
|
||||||
// { type: 'detection_end', filename: '2025-09-11_141500.mp4' }
|
|
||||||
if (payload.type === "detection_end") {
|
if (payload.type === "detection_end") {
|
||||||
this.message = "偵測結束,開始下載…";
|
this.message = "偵測結束,開始下載…";
|
||||||
await this.downloadAndSave(payload.filename);
|
await this.downloadAndSave(payload.filename);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
// 也可能是純文字訊息
|
// 非 JSON 訊息忽略
|
||||||
console.log("WS message:", evt.data);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
// this.message = "WebSocket 已關閉";
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (err) => {
|
|
||||||
console.error("WebSocket error", err);
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// === 2) 選擇資料夾(可直接寫入)===
|
// 使用者選擇儲存資料夾
|
||||||
async pickDirectory() {
|
async pickDirectory() {
|
||||||
if (!window.showDirectoryPicker) {
|
if (!window.showDirectoryPicker) return;
|
||||||
alert(
|
|
||||||
"此瀏覽器不支援選擇資料夾寫入(請改用 Chrome/Edge,或使用桌面版 Electron/Tauri)。"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
this.dirHandle = await window.showDirectoryPicker(); // 需 HTTPS 或 localhost
|
this.dirHandle = await window.showDirectoryPicker();
|
||||||
this.directoryName = this.dirHandle.name;
|
this.directoryName = this.dirHandle.name;
|
||||||
|
|
||||||
// 要求寫入權限(可先 query,若不允許就 request)
|
// 申請寫入權限
|
||||||
const perm = await this.dirHandle.queryPermission({
|
const perm = await this.dirHandle.queryPermission({
|
||||||
mode: "readwrite",
|
mode: "readwrite",
|
||||||
});
|
});
|
||||||
@ -159,7 +144,6 @@ export default {
|
|||||||
if (req !== "granted") {
|
if (req !== "granted") {
|
||||||
this.dirHandle = null;
|
this.dirHandle = null;
|
||||||
this.directoryName = "";
|
this.directoryName = "";
|
||||||
alert("未授權寫入權限,無法直接存檔。");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -167,69 +151,61 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// === 3) 控制:開始/停止偵測 ===
|
// 開始/結束偵測(觸發後端錄製流程)
|
||||||
async startDetection() {
|
async startDetection() {
|
||||||
if (!this.dirHandle) {
|
if (!this.dirHandle) {
|
||||||
this.message = "請先選擇儲存資料夾後再開始錄製";
|
this.message = "請先選擇儲存資料夾後再開始錄製";
|
||||||
alert("請先選擇儲存資料夾後再開始錄製");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isStarting = true;
|
this.isStarting = true;
|
||||||
try {
|
try {
|
||||||
await axios.post("/api/start", {
|
await axios.post("/api/start", {
|
||||||
suggestedName: this.buildSuggestedName(),
|
suggestedName: this.buildSuggestedName(),
|
||||||
// ★(可選)把使用者授權的資料夾名稱傳後端作為紀錄/標記
|
|
||||||
// directoryName: this.directoryName,
|
|
||||||
});
|
});
|
||||||
this.message = "偵測中…";
|
this.message = "已開始偵測…";
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
console.error(err);
|
console.error(e);
|
||||||
alert("開始偵測失敗");
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isStarting = false;
|
this.isStarting = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async stopDetection() {
|
async stopDetection() {
|
||||||
this.isStopping = true;
|
this.isStopping = true;
|
||||||
try {
|
try {
|
||||||
await axios.post("/api/stop");
|
await axios.post("/api/stop");
|
||||||
this.message = "已要求結束偵測,等待檔案就緒…";
|
this.message = "已要求結束偵測,等待檔案就緒…";
|
||||||
// 等 WS 推播 detection_end 後會自動下載
|
} catch (e) {
|
||||||
} catch (err) {
|
console.error(e);
|
||||||
console.error(err);
|
|
||||||
alert("結束偵測失敗");
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isStopping = false;
|
this.isStopping = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// === 4) 下載並寫入:優先寫進選擇資料夾;否則退回 a.download ===
|
// 下載並寫入選擇的資料夾(若未選則下載到瀏覽器預設下載夾)
|
||||||
async downloadAndSave(filenameFromServer) {
|
async downloadAndSave(filenameFromServer) {
|
||||||
try {
|
try {
|
||||||
this.downloadProgress = 1;
|
this.downloadProgress = 1;
|
||||||
|
|
||||||
// 先打 /api/download(可接受 ?filename=xxx)
|
|
||||||
const response = await axios.get("/api/download", {
|
const response = await axios.get("/api/download", {
|
||||||
params: filenameFromServer ? { filename: filenameFromServer } : {},
|
params: filenameFromServer ? { filename: filenameFromServer } : {},
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
onDownloadProgress: (evt) => {
|
onDownloadProgress: (evt) => {
|
||||||
if (evt.total) {
|
if (evt.total)
|
||||||
this.downloadProgress = (evt.loaded / evt.total) * 100;
|
this.downloadProgress = (evt.loaded / evt.total) * 100;
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = new Blob([response.data], { type: "video/mp4" });
|
const blob = new Blob([response.data], { type: "video/mp4" });
|
||||||
const finalName = filenameFromServer || this.buildSuggestedName();
|
const finalName = filenameFromServer || this.buildSuggestedName();
|
||||||
|
|
||||||
// 有授權資料夾 → 直接寫檔
|
|
||||||
if (this.dirHandle) {
|
if (this.dirHandle) {
|
||||||
await this.saveBlobToDirectory(blob, finalName);
|
const fileHandle = await this.dirHandle.getFileHandle(finalName, {
|
||||||
|
create: true,
|
||||||
|
});
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
await writable.write(blob);
|
||||||
|
await writable.close();
|
||||||
this.message = `已儲存:${finalName}`;
|
this.message = `已儲存:${finalName}`;
|
||||||
} else {
|
} else {
|
||||||
// 無授權 → 退回 a.download(使用者自行決定位置)
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@ -241,42 +217,21 @@ export default {
|
|||||||
this.message = `已下載:${finalName}(至瀏覽器預設下載夾)`;
|
this.message = `已下載:${finalName}(至瀏覽器預設下載夾)`;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("下載/存檔失敗", err);
|
console.error("下載失敗", err);
|
||||||
alert("下載或存檔失敗");
|
|
||||||
} finally {
|
} finally {
|
||||||
this.downloadProgress = 0;
|
this.downloadProgress = 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// === 5) 寫 blob 到已授權的資料夾 ===
|
// 檔名範本
|
||||||
async saveBlobToDirectory(blob, filename) {
|
|
||||||
// 建立或覆寫檔案
|
|
||||||
const fileHandle = await this.dirHandle.getFileHandle(filename, {
|
|
||||||
create: true,
|
|
||||||
});
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
await writable.write(blob);
|
|
||||||
await writable.close();
|
|
||||||
},
|
|
||||||
|
|
||||||
// UI 舊法顯示用(無法寫入)
|
|
||||||
handlePathSelect(event) {
|
|
||||||
const files = event.target.files;
|
|
||||||
if (files?.length > 0) {
|
|
||||||
const path = files[0].webkitRelativePath.split("/")[0];
|
|
||||||
this.savePath = path; // 只是一個資料夾名稱,不能當成真實路徑寫入
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
buildSuggestedName() {
|
buildSuggestedName() {
|
||||||
const ts = new Date();
|
const ts = new Date();
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
const name = `${ts.getFullYear()}-${pad(ts.getMonth() + 1)}-${pad(
|
return `record_${ts.getFullYear()}-${pad(ts.getMonth() + 1)}-${pad(
|
||||||
ts.getDate()
|
ts.getDate()
|
||||||
)}_${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(
|
)}_${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(
|
||||||
ts.getSeconds()
|
ts.getSeconds()
|
||||||
)}.mp4`;
|
)}.mp4`;
|
||||||
return `record_${name}`;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user