Compare commits
No commits in common. "2bf5f9042ad8212a7ee0d57d3b8e005300321a85" and "0999f8000ac230b4da2c229b5a88432b0f2f7dbe" have entirely different histories.
2bf5f9042a
...
0999f8000a
7
package-lock.json
generated
7
package-lock.json
generated
@ -21,7 +21,6 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"flag-icons": "^7.2.3",
|
||||
"hls.js": "^1.6.12",
|
||||
"jquery-ui": "^1.14.1",
|
||||
"json-schema-generator": "^2.0.6",
|
||||
"mqtt": "^5.10.3",
|
||||
@ -3057,12 +3056,6 @@
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"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": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
|
@ -22,7 +22,6 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"flag-icons": "^7.2.3",
|
||||
"hls.js": "^1.6.12",
|
||||
"jquery-ui": "^1.14.1",
|
||||
"json-schema-generator": "^2.0.6",
|
||||
"mqtt": "^5.10.3",
|
||||
|
@ -1,5 +0,0 @@
|
||||
// 開關 RTSP(啟用/停用)
|
||||
export const POST_SET_RTSP_ENABLE = `/api/rtsp/set-rtsp-enable`;
|
||||
|
||||
// 設定 SAMBA 儲存目錄
|
||||
export const POST_SET_SAMBA_DIRECTORY = `/api/rtsp/set-samba-directory`;
|
@ -1,29 +0,0 @@
|
||||
import {
|
||||
POST_SET_RTSP_ENABLE,
|
||||
POST_SET_SAMBA_DIRECTORY,
|
||||
} from "./api";
|
||||
import instance from "@/util/request";
|
||||
import apihandler from "@/util/apihandler";
|
||||
|
||||
/**
|
||||
* 開關 RTSP
|
||||
* Swagger: POST /api/rtsp/set-rtsp-enable
|
||||
* body: { main_id: number, enable: boolean }
|
||||
*/
|
||||
export const setRtspEnable = async ({ main_id, enable }) => {
|
||||
const res = await instance.post(POST_SET_RTSP_ENABLE, { main_id, enable });
|
||||
return apihandler(res.code, res.data, { msg: res.msg, code: res.code });
|
||||
};
|
||||
|
||||
/**
|
||||
* 設定 SAMBA 儲存目錄
|
||||
* Swagger: POST /api/rtsp/set-samba-directory
|
||||
* body: { main_id: number, directory: string }
|
||||
*/
|
||||
export const setSambaDirectory = async ({ main_id, directory }) => {
|
||||
const res = await instance.post(POST_SET_SAMBA_DIRECTORY, {
|
||||
main_id,
|
||||
directory,
|
||||
});
|
||||
return apihandler(res.code, res.data, { msg: res.msg, code: res.code });
|
||||
};
|
@ -207,9 +207,6 @@
|
||||
"completed": "已完成",
|
||||
"worker_id": "工作人员编号",
|
||||
"notice": "注意事项",
|
||||
"video_storage_location": "告警影片儲存位置",
|
||||
"copy": "复制",
|
||||
"copied": "已复制!",
|
||||
"result_description": "结果描述",
|
||||
"upload_file": "上传文件",
|
||||
"enable": "启用",
|
||||
@ -426,16 +423,7 @@
|
||||
"title": "影像串流",
|
||||
"start": "开始侦测",
|
||||
"stop": "结束侦测",
|
||||
"selectPath": "选择存储位置",
|
||||
"selectDevice": "选择设备",
|
||||
"pleaseSelectDevice": "请先选择设备",
|
||||
"selectPathFirst": "请先选择存储文件夹",
|
||||
"startSuccess": "已开始侦测…",
|
||||
"startFail": "开始侦测失败,请稍后再试",
|
||||
"stopSuccess": "已请求结束侦测…",
|
||||
"stopFail": "结束侦测失败,请稍后再试",
|
||||
"noPermission": "未获得写入权限,请重新选择文件夹并授权",
|
||||
"selectFolderSuccess": "已选择文件夹:{name}",
|
||||
"selectFolderFail": "选择文件夹失败,请再试一次"
|
||||
"selectPath": "选择保存路径",
|
||||
"displayArea": "RTSP 画面显示区域"
|
||||
}
|
||||
}
|
||||
|
@ -207,9 +207,6 @@
|
||||
"completed": "已完成",
|
||||
"worker_id": "工作人員編號",
|
||||
"notice": "注意事項",
|
||||
"video_storage_location": "告警影片儲存位置",
|
||||
"copy": "複製",
|
||||
"copied": "已複製!",
|
||||
"result_description": "結果描述",
|
||||
"upload_file": "上傳檔案",
|
||||
"enable": "啟用",
|
||||
@ -427,15 +424,6 @@
|
||||
"start": "開始偵測",
|
||||
"stop": "結束偵測",
|
||||
"selectPath": "選擇儲存位置",
|
||||
"selectDevice": "選擇設備",
|
||||
"pleaseSelectDevice": "請先選擇設備",
|
||||
"selectPathFirst": "請先選擇儲存資料夾",
|
||||
"startSuccess": "已開始偵測…",
|
||||
"startFail": "開始偵測失敗,請稍後再試",
|
||||
"stopSuccess": "已請求結束偵測…",
|
||||
"stopFail": "結束偵測失敗,請稍後再試",
|
||||
"noPermission": "沒有取得寫入權限,請重新選擇資料夾並允許",
|
||||
"selectFolderSuccess": "已選擇資料夾:{name}",
|
||||
"selectFolderFail": "選擇資料夾失敗,請再試一次"
|
||||
"displayArea": "RTSP 畫面顯示區域"
|
||||
}
|
||||
}
|
||||
|
@ -207,9 +207,6 @@
|
||||
"completed": "Completed",
|
||||
"worker_id": "Worker ID",
|
||||
"notice": "Notice",
|
||||
"video_storage_location": "video storage location",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"result_description": "Result Description",
|
||||
"upload_file": "Upload File",
|
||||
"enable": "Enable",
|
||||
@ -423,19 +420,11 @@
|
||||
"json_click_text": "Please enter JSON on the left and click the conversion button"
|
||||
},
|
||||
"rtsp": {
|
||||
"title": "Video Stream",
|
||||
"title": "Media Streaming",
|
||||
"start": "Start Detection",
|
||||
"stop": "Stop Detection",
|
||||
"selectPath": "Select Folder",
|
||||
"selectDevice": "Select Device",
|
||||
"pleaseSelectDevice": "Please select a device first",
|
||||
"selectPathFirst": "Please select a folder first",
|
||||
"startSuccess": "Detection started…",
|
||||
"startFail": "Failed to start detection, please try again later",
|
||||
"stopSuccess": "Detection stop requested…",
|
||||
"stopFail": "Failed to stop detection, please try again later",
|
||||
"noPermission": "No write permission. Please select a folder again and grant access",
|
||||
"selectFolderSuccess": "Folder selected: {name}",
|
||||
"selectFolderFail": "Failed to select folder, please try again"
|
||||
"selectPath": "Select Save Path",
|
||||
"displayArea": "RTSP Display Area"
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, defineProps, watch, inject, nextTick } from "vue";
|
||||
import { ref, defineProps, watch, inject } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import { postOperationRecord } from "@/apis/alert";
|
||||
import * as yup from "yup";
|
||||
@ -153,28 +153,6 @@ watch(
|
||||
},
|
||||
{ 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>
|
||||
|
||||
<template>
|
||||
@ -185,11 +163,7 @@ async function copyToClipboard() {
|
||||
width="710"
|
||||
>
|
||||
<template #modalContent>
|
||||
<form
|
||||
ref="form"
|
||||
class="mt-5 w-full flex flex-wrap justify-between"
|
||||
@submit.prevent
|
||||
>
|
||||
<form ref="form" class="mt-5 w-full flex flex-wrap justify-between">
|
||||
<Input
|
||||
v-if="formState && formState.formId"
|
||||
class="my-2"
|
||||
@ -312,48 +286,9 @@ async function copyToClipboard() {
|
||||
</span></template
|
||||
>
|
||||
</Select>
|
||||
|
||||
<!-- 注意事項 -->
|
||||
<Textarea :value="formState" name="notice" class="w-full my-2">
|
||||
<template #topLeft>{{ $t("alert.notice") }}</template>
|
||||
</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">
|
||||
<template #topLeft>{{ $t("alert.result_description") }}</template>
|
||||
</Textarea>
|
||||
|
@ -100,8 +100,6 @@ const getData = async () => {
|
||||
buying_date: device.buying_date,
|
||||
created_at: device.created_at,
|
||||
bgSize: 50,
|
||||
is_rtsp: device.is_rtsp === true,
|
||||
rtsp_url: device.rtsp_url || "",
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -136,14 +134,20 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-between">
|
||||
<div
|
||||
class="order-3 lg:order-1 w-full lg:w-1/4 min-h-screen flex flex-col justify-start item-center z-10 border-dashboard gap-12"
|
||||
class="order-3 lg:order-1 w-full lg:w-1/4 min-h-screen flex flex-col justify-start z-10 border-dashboard gap-5"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- 無資料時:完整隱藏區塊,不留空白 -->
|
||||
|
||||
<!-- <DashboardProduct
|
||||
@visible-change="(v) => (productVisible = v)"
|
||||
v-show="productVisible"
|
||||
/>
|
||||
<DashboardProductComplete
|
||||
@visible-change="(v) => (productCompleteVisible = v)"
|
||||
v-show="productVisible"
|
||||
/> -->
|
||||
<DashboardIndoor />
|
||||
</div>
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardRefrig />
|
||||
</div>
|
||||
<DashboardRefrig class="mb-10" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -152,19 +156,19 @@ onUnmounted(() => {
|
||||
<DashboardFloorBar />
|
||||
<DashboardEffectScatter :data="systemData" />
|
||||
</div>
|
||||
<!-- <div class="order-2 w-full lg:hidden my-3">
|
||||
<div class="order-2 w-full lg:hidden my-3">
|
||||
<DashboardSysCard :data="systemData" />
|
||||
</div> -->
|
||||
</div>
|
||||
<div
|
||||
class="order-last w-full lg:w-1/4 flex flex-col justify-start border-dashboard z-20 gap-12"
|
||||
class="order-last w-full lg:w-1/4 flex flex-col justify-start border-dashboard z-20 gap-5"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>
|
||||
<DashboardElectricity />
|
||||
</div>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="mt-10">
|
||||
<DashboardEmission />
|
||||
</div>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="mt-10">
|
||||
<DashboardAlert />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,7 +6,10 @@ import { useI18n } from "vue-i18n";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentTab = ref("desktop");
|
||||
const changeOpenKey = (key) => {
|
||||
currentTab.value = key;
|
||||
};
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
@ -14,16 +17,6 @@ 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);
|
||||
|
||||
// 當 data 有值時自動開啟 modal
|
||||
@ -31,23 +24,15 @@ watch(
|
||||
() => props.data,
|
||||
(newData) => {
|
||||
if (newData) {
|
||||
console.log("[props.data] =\n", JSON.stringify(props.data, null, 2));
|
||||
dashboard_effectScatter_modal.showModal();
|
||||
if (!isRtsp.value) currentTab.value = "desktop";
|
||||
console.debug(
|
||||
"[Modal Debug] is_rtsp:",
|
||||
newData.is_rtsp,
|
||||
"monitorUrl:",
|
||||
monitorUrl.value
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 關閉 modal
|
||||
// 關閉 modal 的處理函數
|
||||
const handleCancel = () => {
|
||||
currentTab.value = "desktop";
|
||||
currentTab.value = "desktop"; // 重置到 desktop tab
|
||||
dashboard_effectScatter_modal.close();
|
||||
};
|
||||
</script>
|
||||
@ -60,77 +45,13 @@ const handleCancel = () => {
|
||||
:onCancel="handleCancel"
|
||||
:width="600"
|
||||
:draggable="true"
|
||||
modalClass="max-h-[80vh]"
|
||||
>
|
||||
<!-- 標題列:RTSP 不顯示分頁鈕;非 RTSP 顯示分頁鈕 -->
|
||||
<template #modalTitle>
|
||||
<div class="flex items-center justify-between">
|
||||
<section class="flex flex-col h-[60vh] gap-4">
|
||||
<!-- 標題固定高度 -->
|
||||
<div class="flex items-center justify-between h-10">
|
||||
<span>{{ props.data?.full_name }}</span>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
class="btn-link btn-text-without-border px-2"
|
||||
@ -143,150 +64,22 @@ const handleCancel = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #modalContent>
|
||||
<!-- RTSP,顯示 iframe -->
|
||||
<div v-if="isRtsp" class="h-[60vh] py-4">
|
||||
<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"
|
||||
<!-- 黑底填滿剩下高度 -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="bg-black text-xl font-bold rounded h-full flex justify-center items-center"
|
||||
>
|
||||
<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>
|
||||
{{ $t("rtsp.displayArea") }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 讓 Modal 內容能撐滿高度 */
|
||||
/* 專門覆蓋 Modal 裡的 min-h-[200px],讓高度自動撐開 */
|
||||
:deep(.min-h-\[200px\]) {
|
||||
min-height: 0 !important;
|
||||
height: 100%;
|
||||
|
@ -50,11 +50,10 @@ const defaultChartOption = ref({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: {
|
||||
data: [],
|
||||
top: 0, // 靠最上方
|
||||
textStyle: { color: "#ffffff", fontSize: 12 },
|
||||
textStyle: { color: "#ffffff", fontSize: 16 },
|
||||
},
|
||||
grid: {
|
||||
top: "35%",
|
||||
top: "10%",
|
||||
left: "0%",
|
||||
right: "0%",
|
||||
bottom: "0%",
|
||||
@ -204,7 +203,7 @@ onUnmounted(() => {
|
||||
<h3 class="text-info text-xl text-center">
|
||||
{{ $t("dashboard.indoor_chart") }}
|
||||
</h3>
|
||||
<div class="w-full flex justify-center items-center relative">
|
||||
<div className="my-3 w-full flex justify-center relative">
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
:onclick="(e, item) => changeActiveBtn(item)"
|
||||
|
@ -1,61 +1,150 @@
|
||||
<script setup>
|
||||
import LineChart from "@/components/chart/LineChart.vue";
|
||||
import { SECOND_CHART_COLOR } from "@/constant";
|
||||
import dayjs from "dayjs";
|
||||
import { ref, watch, onUnmounted, computed } from "vue";
|
||||
import { ref, watch, computed, onUnmounted } from "vue";
|
||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
import { getDashboardTemp } from "@/apis/dashboard";
|
||||
import useSearchParams from "@/hooks/useSearchParam";
|
||||
import useBuildingStore from "@/stores/useBuildingStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { searchParams } = useSearchParams();
|
||||
const buildingStore = useBuildingStore();
|
||||
const timeoutTimer = ref(null); // 定時器
|
||||
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
|
||||
const allTempData = ref([]);
|
||||
const currentOptionType = ref(1); // 1 = 溫度,2 = 濕度
|
||||
const noData = ref(true); // 初始先視為無資料,等 API 後再更新
|
||||
const chartRef = ref(null);
|
||||
|
||||
// 監聽建築切換:依 sysConfig 決定是否顯示/取數據
|
||||
// 狀態與按鈕邏輯
|
||||
const timeoutTimer = ref(null);
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
const currentOptionType = ref(1); // 1: 溫度, 2: 濕度
|
||||
const noData = ref(true); // 目前顯示「無資料」
|
||||
|
||||
// 確認是否有資料,無則不呼叫 getDashboardTemp 也不顯示 chart
|
||||
watch(
|
||||
() => buildingStore.selectedBuilding?.building_guid,
|
||||
async (guid) => {
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
if (!guid) return;
|
||||
|
||||
if (guid) {
|
||||
await buildingStore.getSysConfig(guid);
|
||||
|
||||
const showRefrigeration =
|
||||
buildingStore.sysConfig?.value?.show_refrigeration;
|
||||
|
||||
if (showRefrigeration === false) {
|
||||
noData.value = true; // 不顯示
|
||||
return;
|
||||
noData.value = true; // 不顯示圖表
|
||||
return; // 不呼叫 getData
|
||||
}
|
||||
|
||||
noData.value = false; // 顯示並開始取資料
|
||||
await getData();
|
||||
timeoutTimer.value = setInterval(getData, 60_000); // 每分鐘更新
|
||||
noData.value = false; // 有資料才進行呼叫
|
||||
getData();
|
||||
timeoutTimer.value = setInterval(getData, 60000); // 每分鐘叫一次
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 預設圖表設定
|
||||
// 新增 API 資料取得函式
|
||||
const getData = async () => {
|
||||
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
|
||||
if (!buildingGuid) return;
|
||||
|
||||
try {
|
||||
const res = await getDashboardTemp({
|
||||
building_guid: buildingGuid,
|
||||
tempOption: 2, // 冷藏區域(室溫為 tempOption: 1)
|
||||
timeInterval: 1,
|
||||
option: currentOptionType.value, // 1: 溫度,2: 濕度
|
||||
});
|
||||
|
||||
const key = "冷藏"; // 根據實際後端回傳 key
|
||||
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
|
||||
noData.value = allTempData.value.length === 0;
|
||||
} catch (e) {
|
||||
console.error("getDashboardTemp error", e);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 資料更新 watch
|
||||
watch(
|
||||
allTempData,
|
||||
(newVal) => {
|
||||
if (!newVal?.length || !other_real_temp_chart.value?.chart) return;
|
||||
|
||||
const firstValid = newVal.find((d) => d.data?.length);
|
||||
if (!firstValid) return;
|
||||
|
||||
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
|
||||
dayjs(time).format("HH:mm:ss")
|
||||
);
|
||||
|
||||
const allValues = newVal
|
||||
.flatMap((d) => sampleData(d.data))
|
||||
.map((d) => d.value)
|
||||
.filter((v) => v != null);
|
||||
|
||||
if (!allValues.length) return;
|
||||
|
||||
const yMin = Math.floor(Math.min(...allValues)) - 1;
|
||||
const yMax = Math.ceil(Math.max(...allValues)) + 1;
|
||||
|
||||
other_real_temp_chart.value.chart.setOption({
|
||||
legend: {
|
||||
data: newVal.map((d) => d.full_name),
|
||||
},
|
||||
xAxis: {
|
||||
data: sampledXAxis,
|
||||
},
|
||||
yAxis: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
},
|
||||
series: newVal.map((d, i) => ({
|
||||
name: d.full_name,
|
||||
type: "line",
|
||||
data: sampleData(d.data).map(({ value }) => value),
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: SECOND_CHART_COLOR[i % SECOND_CHART_COLOR.length],
|
||||
},
|
||||
})),
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 限制顯示資料點數
|
||||
function sampleData(data = [], maxCount = 30) {
|
||||
const len = data.length;
|
||||
if (len <= maxCount) return data;
|
||||
|
||||
const sampled = [];
|
||||
const step = (len - 1) / (maxCount - 1);
|
||||
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const index = Math.round(i * step);
|
||||
sampled.push(data[index]);
|
||||
}
|
||||
|
||||
return sampled;
|
||||
}
|
||||
|
||||
// 圖表參數預設(暫不使用)
|
||||
const other_real_temp_chart = ref(null);
|
||||
const defaultChartOption = ref({
|
||||
tooltip: { trigger: "axis" },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
},
|
||||
legend: {
|
||||
data: [],
|
||||
top: 0, // 靠最上方
|
||||
textStyle: { color: "#ffffff", fontSize: 12 },
|
||||
textStyle: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: "35%",
|
||||
top: "10%",
|
||||
left: "0%",
|
||||
right: "0%",
|
||||
bottom: "0%",
|
||||
@ -75,129 +164,40 @@ const defaultChartOption = ref({
|
||||
series: [],
|
||||
});
|
||||
|
||||
// 取得冷藏溫度/濕度資料
|
||||
const getData = async () => {
|
||||
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
|
||||
if (!buildingGuid) return;
|
||||
|
||||
try {
|
||||
const res = await getDashboardTemp({
|
||||
building_guid: buildingGuid,
|
||||
tempOption: 2, // ⚠️ 冷藏區域
|
||||
timeInterval: 1,
|
||||
option: currentOptionType.value, // 1: 溫度;2: 濕度
|
||||
});
|
||||
|
||||
console.log("[getDashboardTemp] 冷藏回傳:", res);
|
||||
|
||||
const key = "冷藏溫度"; // 依你剛剛確認到的 key
|
||||
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
|
||||
noData.value = allTempData.value.length === 0;
|
||||
|
||||
console.log("[getDashboardTemp] allTempData:", allTempData.value);
|
||||
console.log("[getDashboardTemp] noData:", noData.value);
|
||||
} catch (e) {
|
||||
console.error("getDashboardTemp error", e);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 溫度/濕度切換按鈕
|
||||
// 按鈕名稱根據語系更新
|
||||
const buttonItems = computed(() => [
|
||||
{ key: 1, title: t("dashboard.temperature"), active: true },
|
||||
{ key: 2, title: t("dashboard.humidity"), active: false },
|
||||
]);
|
||||
|
||||
// 語系切換時更新按鈕文案
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => setItems(buttonItems.value),
|
||||
() => {
|
||||
setItems(buttonItems.value);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 切換溫度/濕度後重取資料並重啟輪詢
|
||||
// 切換 tab(目前無實際資料行為)
|
||||
watch(
|
||||
selectedBtn,
|
||||
async (newVal) => {
|
||||
if ([1, 2].includes(newVal?.key)) {
|
||||
currentOptionType.value = newVal.key;
|
||||
|
||||
if (buildingStore.sysConfig?.value?.show_refrigeration !== false) {
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
await getData();
|
||||
timeoutTimer.value = setInterval(getData, 60_000);
|
||||
(newValue) => {
|
||||
if (timeoutTimer.value) {
|
||||
clearInterval(timeoutTimer.value);
|
||||
}
|
||||
|
||||
if ([1, 2].includes(newValue?.key)) {
|
||||
currentOptionType.value = newValue.key;
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 限制顯示資料點數
|
||||
function sampleData(data = [], maxCount = 30) {
|
||||
const len = data.length;
|
||||
if (len <= maxCount) return data;
|
||||
|
||||
const sampled = [];
|
||||
const step = (len - 1) / (maxCount - 1);
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const index = Math.round(i * step);
|
||||
sampled.push(data[index]);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
|
||||
// 更新圖表
|
||||
watch(
|
||||
allTempData,
|
||||
(newVal) => {
|
||||
const chart = chartRef.value?.chart;
|
||||
if (!chart || !Array.isArray(newVal) || newVal.length === 0) return;
|
||||
|
||||
const firstValid = newVal.find(
|
||||
(d) => Array.isArray(d.data) && d.data.length
|
||||
);
|
||||
if (!firstValid) return;
|
||||
|
||||
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
|
||||
dayjs(time).format("HH:mm:ss")
|
||||
);
|
||||
|
||||
const allValues = newVal
|
||||
.flatMap((d) => sampleData(d.data))
|
||||
.map((d) => d?.value)
|
||||
.filter((v) => typeof v === "number" && !Number.isNaN(v));
|
||||
|
||||
if (!allValues.length) return;
|
||||
|
||||
let yMin = Math.floor(Math.min(...allValues)) - 1;
|
||||
let yMax = Math.ceil(Math.max(...allValues)) + 1;
|
||||
if (yMin === yMax) {
|
||||
yMin -= 1;
|
||||
yMax += 1;
|
||||
}
|
||||
|
||||
chart.setOption({
|
||||
legend: { data: newVal.map((d) => d.full_name) },
|
||||
xAxis: { data: sampledXAxis },
|
||||
yAxis: { min: yMin, max: yMax },
|
||||
series: newVal.map((d, i) => ({
|
||||
name: d.full_name,
|
||||
type: "line",
|
||||
data: sampleData(d.data).map(({ value }) => value),
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: SECOND_CHART_COLOR[i % SECOND_CHART_COLOR.length],
|
||||
},
|
||||
})),
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 離開元件時清除定時器
|
||||
// 清除計時器
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
if (timeoutTimer.value) {
|
||||
clearInterval(timeoutTimer.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -205,27 +205,24 @@ onUnmounted(() => {
|
||||
<h3 class="text-info text-xl text-center">
|
||||
{{ $t("dashboard.refrig_chart") }}
|
||||
</h3>
|
||||
|
||||
<div class="w-full flex justify-center relative">
|
||||
<div className="my-3 w-full flex justify-center relative">
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
:onclick="(e, item) => changeActiveBtn(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="noData"
|
||||
class="text-center text-white text-lg min-h-[260px] flex items-center justify-center"
|
||||
>
|
||||
{{ $t("dashboard.no_data") }}
|
||||
</div>
|
||||
|
||||
<LineChart
|
||||
v-else
|
||||
id="dashboard_refrigeration_temp"
|
||||
v-if="!noData"
|
||||
id="dashboard_other_real_temp"
|
||||
class="min-h-[260px] max-h-fit"
|
||||
:option="defaultChartOption"
|
||||
ref="chartRef"
|
||||
ref="indoorChartRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -1,276 +1,283 @@
|
||||
<template>
|
||||
<section class="min-h-[600px] h-screen">
|
||||
<h1 class="text-2xl font-extrabold mb-2">{{ $t("rtsp.title") }}</h1>
|
||||
|
||||
<!-- Tabs:選擇要顯示的攝影機 -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h2 class="text-lg font-bold whitespace-nowrap">
|
||||
{{ $t("rtsp.selectDevice") }} :
|
||||
</h2>
|
||||
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
:onclick="
|
||||
(e, item) => {
|
||||
changeActiveBtn(item);
|
||||
const found = rtspDevices.find((r) => r.main_id === item.key);
|
||||
if (found) selectDevice(found);
|
||||
}
|
||||
"
|
||||
:className="`flex flex-wrap`"
|
||||
size="sm"
|
||||
color="info"
|
||||
<div class="flex h-[80%] gap-4">
|
||||
<!-- 左側 RTSP 顯示區域 -->
|
||||
<div class="w-1/2 flex flex-col custom-border p-4 mb-4">
|
||||
<div
|
||||
class="flex-1 text-xl font-bold bg-black text-white flex items-center rounded justify-center"
|
||||
>
|
||||
<template #buttonContent="{ item }">
|
||||
<span class="text-base">{{ item.title }}</span>
|
||||
</template>
|
||||
</ButtonConnectedGroup>
|
||||
{{ $t("rtsp.displayArea") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex h-[70%] gap-4">
|
||||
<!-- 左側:即時監控 -->
|
||||
<div class="relative w-full flex-1 rounded border overflow-hidden">
|
||||
<iframe
|
||||
:src="monitorUrl"
|
||||
class="absolute inset-0 w-full h-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<!-- 右側:開始/結束偵測(已移除選擇資料夾相關 UI) -->
|
||||
<aside class="w-1/2 flex flex-col gap-6 p-4">
|
||||
<!-- 右側控制區 -->
|
||||
<aside class="w-1/2 flex flex-col gap-8 p-4">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="btn btn-add w-40"
|
||||
@click="startDetection"
|
||||
:disabled="isStarting || !selectedMainId"
|
||||
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''"
|
||||
:disabled="isStarting || !dirHandle"
|
||||
:title="!dirHandle ? '請先選擇儲存資料夾' : ''"
|
||||
>
|
||||
<span
|
||||
v-if="isStarting"
|
||||
class="loading loading-spinner loading-sm mr-2"
|
||||
></span>
|
||||
{{ $t("rtsp.start") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error text-white w-40"
|
||||
@click="stopDetection"
|
||||
:disabled="isStopping || !selectedMainId"
|
||||
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''"
|
||||
:disabled="isStopping"
|
||||
>
|
||||
<span
|
||||
v-if="isStopping"
|
||||
class="loading loading-spinner loading-sm mr-2"
|
||||
></span>
|
||||
{{ $t("rtsp.stop") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="text-sm text-info">{{ message }}</p>
|
||||
<!-- 選擇儲存資料夾 -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-4">
|
||||
<button class="btn btn-neutral" @click="pickDirectory">
|
||||
{{ $t("rtsp.selectPath") }}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
:value="directoryName || '尚未選擇資料夾'"
|
||||
readonly
|
||||
class="border border-gray-300 rounded px-3 py-2 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!dirHandle" class="text-sm text-red-400">
|
||||
請先選擇儲存資料夾。
|
||||
</p>
|
||||
|
||||
<!-- 下載進度 -->
|
||||
<div v-if="downloadProgress > 0 && downloadProgress < 100" class="mt-2">
|
||||
<div class="text-sm text-gray-600 mb-1">
|
||||
下載中… {{ Math.floor(downloadProgress) }}%
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:value="downloadProgress"
|
||||
max="100"
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="text-sm text-gray-700">{{ message }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getSystemDevices } from "@/apis/system";
|
||||
import { setRtspEnable } from "@/apis/rtsp"; // 已移除 setSambaDirectory
|
||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
|
||||
const FILE_BASEURL = import.meta.env.VITE_FILE_API_BASEURL;
|
||||
const DEFAULT_MONITOR_URL =
|
||||
"http://192.168.0.219:8026/?url=rtsp://admin02:mjmAdmin_99@192.168.0.200:554/stream1?tcp";
|
||||
import axios from "axios";
|
||||
|
||||
export default {
|
||||
name: "Rtsp",
|
||||
setup() {
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
return { items, changeActiveBtn, setItems, selectedBtn };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
monitorUrl: DEFAULT_MONITOR_URL,
|
||||
|
||||
// 偵測控制
|
||||
savePath: "",
|
||||
dirHandle: null, // File System Access 的目錄 handle
|
||||
directoryName: "", // 顯示用
|
||||
ws: null, // WebSocket
|
||||
isStarting: false,
|
||||
isStopping: false,
|
||||
|
||||
// UI 狀態
|
||||
downloadProgress: 0,
|
||||
message: "",
|
||||
|
||||
// 資料整理
|
||||
deviceData: {},
|
||||
rtspDevices: [], // { main_id, full_name, rtsp_url, ... }
|
||||
selectedMainId: null, // 目前選中的設備 main_id
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.getData();
|
||||
},
|
||||
watch: {
|
||||
selectedBtn: {
|
||||
handler(newVal) {
|
||||
if (!newVal) return;
|
||||
const found = this.rtspDevices.find((r) => r.main_id === newVal.key);
|
||||
if (found) this.selectDevice(found);
|
||||
},
|
||||
deep: true,
|
||||
mounted() {
|
||||
this.connectWS();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.ws) this.ws.close();
|
||||
},
|
||||
methods: {
|
||||
async getData() {
|
||||
try {
|
||||
const useBuildingStore = (await import("@/stores/useBuildingStore"))
|
||||
.default;
|
||||
const buildingStore = useBuildingStore();
|
||||
const building_guid =
|
||||
buildingStore?.selectedBuilding?.building_guid || "";
|
||||
// === 1) WebSocket:接收「結束偵測」事件後自動下載 ===
|
||||
connectWS() {
|
||||
// 視你的後端實際位址而定(支援 wss)
|
||||
const WS_URL =
|
||||
location.protocol === "https:"
|
||||
? `wss://${location.host}/ws`
|
||||
: `ws://${location.host}/ws`;
|
||||
|
||||
const res = await getSystemDevices({ building_guid });
|
||||
const transformedData = {};
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
|
||||
(res?.data || []).forEach((floor) => {
|
||||
if (floor?.device_list?.length > 0) {
|
||||
const fullUrl = floor.floor_map_name;
|
||||
const uuid = fullUrl ? fullUrl.replace(/\.svg$/, "") : "";
|
||||
transformedData[uuid] = floor.device_list.map((device) => {
|
||||
let x = 0,
|
||||
y = 0;
|
||||
this.ws.onopen = () => {
|
||||
// this.message = "WebSocket 已連線";
|
||||
};
|
||||
|
||||
this.ws.onmessage = async (evt) => {
|
||||
try {
|
||||
const coordinates = JSON.parse(
|
||||
device?.device_coordinate || "[0,0]"
|
||||
const payload = JSON.parse(evt.data);
|
||||
// 後端在 RTSP 偵測結束時,推播例如:
|
||||
// { type: 'detection_end', filename: '2025-09-11_141500.mp4' }
|
||||
if (payload.type === "detection_end") {
|
||||
this.message = "偵測結束,開始下載…";
|
||||
await this.downloadAndSave(payload.filename);
|
||||
}
|
||||
} catch (e) {
|
||||
// 也可能是純文字訊息
|
||||
console.log("WS message:", evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
// this.message = "WebSocket 已關閉";
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error("WebSocket error", err);
|
||||
};
|
||||
},
|
||||
|
||||
// === 2) 選擇資料夾(可直接寫入)===
|
||||
async pickDirectory() {
|
||||
if (!window.showDirectoryPicker) {
|
||||
alert(
|
||||
"此瀏覽器不支援選擇資料夾寫入(請改用 Chrome/Edge,或使用桌面版 Electron/Tauri)。"
|
||||
);
|
||||
x = Number(coordinates?.[0] ?? 0);
|
||||
y = Number(coordinates?.[1] ?? 0);
|
||||
} catch (_) {}
|
||||
|
||||
let state = "Online";
|
||||
let bgColor = device?.device_normal_color;
|
||||
if (
|
||||
device?.device_status === "Offline" ||
|
||||
device?.device_status == null
|
||||
) {
|
||||
state = "Offline";
|
||||
bgColor = device?.device_close_color;
|
||||
}
|
||||
if (device?.device_status === "Error") {
|
||||
state = "Error";
|
||||
bgColor = device?.device_error_color;
|
||||
}
|
||||
|
||||
return [
|
||||
x,
|
||||
y,
|
||||
{
|
||||
device_number: device?.device_number || "",
|
||||
device_coordinate: device?.device_coordinate || "",
|
||||
device_image_url: device?.device_image_url,
|
||||
full_name: device?.full_name,
|
||||
main_id: device?.main_id,
|
||||
points: device?.points || [],
|
||||
floor: floor?.full_name,
|
||||
state,
|
||||
icon: device?.device_image
|
||||
? `${FILE_BASEURL}/upload/device_icon/${device.device_image}`
|
||||
: "",
|
||||
bgColor,
|
||||
Online_color: device?.device_normal_color,
|
||||
Offline_color: device?.device_close_color,
|
||||
Error_color: device?.device_error_color,
|
||||
brand: device?.brand || "",
|
||||
device_model: device?.device_model,
|
||||
operation_name: device?.operation_name,
|
||||
operation_contact_person: device?.operation_contact_person,
|
||||
buying_date: device?.buying_date,
|
||||
created_at: device?.created_at,
|
||||
bgSize: 50,
|
||||
is_rtsp: device?.is_rtsp === true,
|
||||
rtsp_url: device?.rtsp_url || "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.deviceData = transformedData;
|
||||
|
||||
const allRows = Object.values(transformedData).flat();
|
||||
this.rtspDevices = allRows
|
||||
.map((row) => row?.[2])
|
||||
.filter((p) => p && p.is_rtsp && p.rtsp_url)
|
||||
.reduce((acc, cur) => {
|
||||
if (!acc.find((x) => x.main_id === cur.main_id)) acc.push(cur);
|
||||
return acc;
|
||||
}, [])
|
||||
.sort((a, b) => (a.full_name || "").localeCompare(b.full_name || ""));
|
||||
|
||||
const cate = this.rtspDevices.map((d, index) => ({
|
||||
title: d.full_name || d.main_id,
|
||||
key: d.main_id,
|
||||
active: this.selectedMainId
|
||||
? this.selectedMainId === d.main_id
|
||||
: index === 0,
|
||||
...d,
|
||||
}));
|
||||
this.setItems(cate);
|
||||
|
||||
if (this.rtspDevices.length > 0) {
|
||||
const first = this.rtspDevices[0];
|
||||
this.selectedMainId = first.main_id;
|
||||
this.monitorUrl = first.rtsp_url;
|
||||
} else {
|
||||
this.selectedMainId = null;
|
||||
this.monitorUrl = DEFAULT_MONITOR_URL;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("getData() 失敗", err);
|
||||
this.setItems([]);
|
||||
this.rtspDevices = [];
|
||||
this.selectedMainId = null;
|
||||
this.monitorUrl = DEFAULT_MONITOR_URL;
|
||||
}
|
||||
},
|
||||
|
||||
// 切換設備 Tab
|
||||
selectDevice(d) {
|
||||
this.selectedMainId = d.main_id;
|
||||
this.monitorUrl = d.rtsp_url || DEFAULT_MONITOR_URL;
|
||||
},
|
||||
|
||||
// 開始偵測:setRtspEnable(true)
|
||||
async startDetection() {
|
||||
if (!this.selectedMainId) {
|
||||
this.message = this.$t("rtsp.pleaseSelectDevice");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.dirHandle = await window.showDirectoryPicker(); // 需 HTTPS 或 localhost
|
||||
this.directoryName = this.dirHandle.name;
|
||||
|
||||
// 要求寫入權限(可先 query,若不允許就 request)
|
||||
const perm = await this.dirHandle.queryPermission({
|
||||
mode: "readwrite",
|
||||
});
|
||||
if (perm !== "granted") {
|
||||
const req = await this.dirHandle.requestPermission({
|
||||
mode: "readwrite",
|
||||
});
|
||||
if (req !== "granted") {
|
||||
this.dirHandle = null;
|
||||
this.directoryName = "";
|
||||
alert("未授權寫入權限,無法直接存檔。");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("選擇資料夾失敗", err);
|
||||
}
|
||||
},
|
||||
|
||||
// === 3) 控制:開始/停止偵測 ===
|
||||
async startDetection() {
|
||||
if (!this.dirHandle) {
|
||||
this.message = "請先選擇儲存資料夾後再開始錄製";
|
||||
alert("請先選擇儲存資料夾後再開始錄製");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isStarting = true;
|
||||
try {
|
||||
await setRtspEnable({
|
||||
main_id: this.selectedMainId,
|
||||
enable: true,
|
||||
await axios.post("/api/start", {
|
||||
suggestedName: this.buildSuggestedName(),
|
||||
// ★(可選)把使用者授權的資料夾名稱傳後端作為紀錄/標記
|
||||
// directoryName: this.directoryName,
|
||||
});
|
||||
this.message = this.$t("rtsp.startSuccess");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.message = this.$t("rtsp.startFail");
|
||||
this.message = "偵測中…";
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("開始偵測失敗");
|
||||
} finally {
|
||||
this.isStarting = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 結束偵測:setRtspEnable(false)
|
||||
async stopDetection() {
|
||||
if (!this.selectedMainId) {
|
||||
this.message = this.$t("rtsp.pleaseSelectDevice");
|
||||
return;
|
||||
}
|
||||
this.isStopping = true;
|
||||
try {
|
||||
await setRtspEnable({
|
||||
main_id: this.selectedMainId,
|
||||
enable: false,
|
||||
});
|
||||
this.message = this.$t("rtsp.stopSuccess");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.message = this.$t("rtsp.stopFail");
|
||||
await axios.post("/api/stop");
|
||||
this.message = "已要求結束偵測,等待檔案就緒…";
|
||||
// 等 WS 推播 detection_end 後會自動下載
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("結束偵測失敗");
|
||||
} finally {
|
||||
this.isStopping = false;
|
||||
}
|
||||
},
|
||||
|
||||
// === 4) 下載並寫入:優先寫進選擇資料夾;否則退回 a.download ===
|
||||
async downloadAndSave(filenameFromServer) {
|
||||
try {
|
||||
this.downloadProgress = 1;
|
||||
|
||||
// 先打 /api/download(可接受 ?filename=xxx)
|
||||
const response = await axios.get("/api/download", {
|
||||
params: filenameFromServer ? { filename: filenameFromServer } : {},
|
||||
responseType: "blob",
|
||||
onDownloadProgress: (evt) => {
|
||||
if (evt.total) {
|
||||
this.downloadProgress = (evt.loaded / evt.total) * 100;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: "video/mp4" });
|
||||
const finalName = filenameFromServer || this.buildSuggestedName();
|
||||
|
||||
// 有授權資料夾 → 直接寫檔
|
||||
if (this.dirHandle) {
|
||||
await this.saveBlobToDirectory(blob, finalName);
|
||||
this.message = `已儲存:${finalName}`;
|
||||
} else {
|
||||
// 無授權 → 退回 a.download(使用者自行決定位置)
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = finalName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
this.message = `已下載:${finalName}(至瀏覽器預設下載夾)`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("下載/存檔失敗", err);
|
||||
alert("下載或存檔失敗");
|
||||
} finally {
|
||||
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() {
|
||||
const ts = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const name = `${ts.getFullYear()}-${pad(ts.getMonth() + 1)}-${pad(
|
||||
ts.getDate()
|
||||
)}_${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(
|
||||
ts.getSeconds()
|
||||
)}.mp4`;
|
||||
return `record_${name}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
Loading…
Reference in New Issue
Block a user