Compare commits

..

No commits in common. "2bf5f9042ad8212a7ee0d57d3b8e005300321a85" and "0999f8000ac230b4da2c229b5a88432b0f2f7dbe" have entirely different histories.

13 changed files with 421 additions and 763 deletions

7
package-lock.json generated
View File

@ -21,7 +21,6 @@
"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",
@ -3057,12 +3056,6 @@
"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",

View File

@ -22,7 +22,6 @@
"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",

View File

@ -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`;

View File

@ -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 });
};

View File

@ -207,9 +207,6 @@
"completed": "已完成", "completed": "已完成",
"worker_id": "工作人员编号", "worker_id": "工作人员编号",
"notice": "注意事项", "notice": "注意事项",
"video_storage_location": "告警影片儲存位置",
"copy": "复制",
"copied": "已复制!",
"result_description": "结果描述", "result_description": "结果描述",
"upload_file": "上传文件", "upload_file": "上传文件",
"enable": "启用", "enable": "启用",
@ -426,16 +423,7 @@
"title": "影像串流", "title": "影像串流",
"start": "开始侦测", "start": "开始侦测",
"stop": "结束侦测", "stop": "结束侦测",
"selectPath": "选择存储位置", "selectPath": "选择保存路径",
"selectDevice": "选择设备", "displayArea": "RTSP 画面显示区域"
"pleaseSelectDevice": "请先选择设备",
"selectPathFirst": "请先选择存储文件夹",
"startSuccess": "已开始侦测…",
"startFail": "开始侦测失败,请稍后再试",
"stopSuccess": "已请求结束侦测…",
"stopFail": "结束侦测失败,请稍后再试",
"noPermission": "未获得写入权限,请重新选择文件夹并授权",
"selectFolderSuccess": "已选择文件夹:{name}",
"selectFolderFail": "选择文件夹失败,请再试一次"
} }
} }

View File

@ -207,9 +207,6 @@
"completed": "已完成", "completed": "已完成",
"worker_id": "工作人員編號", "worker_id": "工作人員編號",
"notice": "注意事項", "notice": "注意事項",
"video_storage_location": "告警影片儲存位置",
"copy": "複製",
"copied": "已複製!",
"result_description": "結果描述", "result_description": "結果描述",
"upload_file": "上傳檔案", "upload_file": "上傳檔案",
"enable": "啟用", "enable": "啟用",
@ -427,15 +424,6 @@
"start": "開始偵測", "start": "開始偵測",
"stop": "結束偵測", "stop": "結束偵測",
"selectPath": "選擇儲存位置", "selectPath": "選擇儲存位置",
"selectDevice": "選擇設備", "displayArea": "RTSP 畫面顯示區域"
"pleaseSelectDevice": "請先選擇設備",
"selectPathFirst": "請先選擇儲存資料夾",
"startSuccess": "已開始偵測…",
"startFail": "開始偵測失敗,請稍後再試",
"stopSuccess": "已請求結束偵測…",
"stopFail": "結束偵測失敗,請稍後再試",
"noPermission": "沒有取得寫入權限,請重新選擇資料夾並允許",
"selectFolderSuccess": "已選擇資料夾:{name}",
"selectFolderFail": "選擇資料夾失敗,請再試一次"
} }
} }

View File

@ -207,9 +207,6 @@
"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",
@ -422,20 +419,12 @@
"json_format_text": "Please paste JSON format data", "json_format_text": "Please paste JSON format data",
"json_click_text": "Please enter JSON on the left and click the conversion button" "json_click_text": "Please enter JSON on the left and click the conversion button"
}, },
"rtsp": { "rtsp": {
"title": "Video Stream", "title": "Media Streaming",
"start": "Start Detection", "start": "Start Detection",
"stop": "Stop Detection", "stop": "Stop Detection",
"selectPath": "Select Folder", "selectPath": "Select Save Path",
"selectDevice": "Select Device", "displayArea": "RTSP Display Area"
"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"
} }
} }

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, defineProps, watch, inject, nextTick } from "vue"; import { ref, defineProps, watch, inject } 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,28 +153,6 @@ 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>
@ -185,11 +163,7 @@ async function copyToClipboard() {
width="710" width="710"
> >
<template #modalContent> <template #modalContent>
<form <form ref="form" class="mt-5 w-full flex flex-wrap justify-between">
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"
@ -312,48 +286,9 @@ async function copyToClipboard() {
</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>

View File

@ -100,8 +100,6 @@ 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 || "",
}, },
]; ];
}); });
@ -136,14 +134,20 @@ onUnmounted(() => {
<template> <template>
<div class="flex flex-wrap justify-between"> <div class="flex flex-wrap justify-between">
<div <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 /> <DashboardIndoor />
</div> <DashboardRefrig class="mb-10" />
<div class="flex flex-col gap-5">
<DashboardRefrig />
</div>
</div> </div>
<div <div
@ -152,19 +156,19 @@ onUnmounted(() => {
<DashboardFloorBar /> <DashboardFloorBar />
<DashboardEffectScatter :data="systemData" /> <DashboardEffectScatter :data="systemData" />
</div> </div>
<!-- <div class="order-2 w-full lg:hidden my-3"> <div class="order-2 w-full lg:hidden my-3">
<DashboardSysCard :data="systemData" /> <DashboardSysCard :data="systemData" />
</div> --> </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 /> <DashboardElectricity />
</div> </div>
<div class="flex flex-col gap-5"> <div class="mt-10">
<DashboardEmission /> <DashboardEmission />
</div> </div>
<div class="flex flex-col gap-5"> <div class="mt-10">
<DashboardAlert /> <DashboardAlert />
</div> </div>
</div> </div>

View File

@ -6,7 +6,10 @@ 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,
@ -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); const modal = ref(null);
// data modal // data modal
@ -31,23 +24,15 @@ 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"; currentTab.value = "desktop"; // desktop tab
dashboard_effectScatter_modal.close(); dashboard_effectScatter_modal.close();
}; };
</script> </script>
@ -60,77 +45,13 @@ const handleCancel = () => {
:onCancel="handleCancel" :onCancel="handleCancel"
:width="600" :width="600"
:draggable="true" :draggable="true"
modalClass="max-h-[80vh]"
> >
<!-- 標題列RTSP 不顯示分頁鈕 RTSP 顯示分頁鈕 -->
<template #modalTitle> <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> <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"
@ -143,150 +64,22 @@ const handleCancel = () => {
</button> </button>
</div> </div>
</div> </div>
</template>
<template #modalContent> <!-- 黑底填滿剩下高度 -->
<!-- RTSP顯示 iframe --> <div class="flex-1">
<div v-if="isRtsp" class="h-[60vh] py-4"> <div
<div class="relative bg-black rounded h-full overflow-hidden"> class="bg-black text-xl font-bold rounded h-full flex justify-center items-center"
<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"
> >
<thead> {{ $t("rtsp.displayArea") }}
<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 內容能撐滿高度 */ /* 專門覆蓋 Modal 裡的 min-h-[200px],讓高度自動撐開 */
:deep(.min-h-\[200px\]) { :deep(.min-h-\[200px\]) {
min-height: 0 !important; min-height: 0 !important;
height: 100%; height: 100%;

View File

@ -50,11 +50,10 @@ const defaultChartOption = ref({
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
legend: { legend: {
data: [], data: [],
top: 0, // textStyle: { color: "#ffffff", fontSize: 16 },
textStyle: { color: "#ffffff", fontSize: 12 },
}, },
grid: { grid: {
top: "35%", top: "10%",
left: "0%", left: "0%",
right: "0%", right: "0%",
bottom: "0%", bottom: "0%",
@ -204,7 +203,7 @@ onUnmounted(() => {
<h3 class="text-info text-xl text-center"> <h3 class="text-info text-xl text-center">
{{ $t("dashboard.indoor_chart") }} {{ $t("dashboard.indoor_chart") }}
</h3> </h3>
<div class="w-full flex justify-center items-center relative"> <div className="my-3 w-full flex justify-center relative">
<ButtonConnectedGroup <ButtonConnectedGroup
:items="items" :items="items"
:onclick="(e, item) => changeActiveBtn(item)" :onclick="(e, item) => changeActiveBtn(item)"

View File

@ -1,61 +1,150 @@
<script setup> <script setup>
import LineChart from "@/components/chart/LineChart.vue"; import LineChart from "@/components/chart/LineChart.vue";
import { SECOND_CHART_COLOR } from "@/constant"; import { SECOND_CHART_COLOR } from "@/constant";
import dayjs from "dayjs"; import { ref, watch, computed, onUnmounted } from "vue";
import { ref, watch, onUnmounted, computed } from "vue";
import useActiveBtn from "@/hooks/useActiveBtn"; import useActiveBtn from "@/hooks/useActiveBtn";
import { getDashboardTemp } from "@/apis/dashboard"; import { getDashboardTemp } from "@/apis/dashboard";
import useSearchParams from "@/hooks/useSearchParam"; import useSearchParams from "@/hooks/useSearchParam";
import useBuildingStore from "@/stores/useBuildingStore"; import useBuildingStore from "@/stores/useBuildingStore";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import dayjs from "dayjs";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { searchParams } = useSearchParams(); const { searchParams } = useSearchParams();
const buildingStore = useBuildingStore(); const buildingStore = useBuildingStore();
const timeoutTimer = ref(null); //
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
const allTempData = ref([]); 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( watch(
() => buildingStore.selectedBuilding?.building_guid, () => buildingStore.selectedBuilding?.building_guid,
async (guid) => { async (guid) => {
if (timeoutTimer.value) clearInterval(timeoutTimer.value); if (guid) {
allTempData.value = [];
noData.value = true;
if (!guid) return;
await buildingStore.getSysConfig(guid); await buildingStore.getSysConfig(guid);
const showRefrigeration = const showRefrigeration =
buildingStore.sysConfig?.value?.show_refrigeration; buildingStore.sysConfig?.value?.show_refrigeration;
if (showRefrigeration === false) { if (showRefrigeration === false) {
noData.value = true; // noData.value = true; //
return; return; // getData
} }
noData.value = false; // noData.value = false; //
await getData(); getData();
timeoutTimer.value = setInterval(getData, 60_000); // timeoutTimer.value = setInterval(getData, 60000); //
}
}, },
{ immediate: true } { 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({ const defaultChartOption = ref({
tooltip: { trigger: "axis" }, tooltip: {
trigger: "axis",
},
legend: { legend: {
data: [], data: [],
top: 0, // textStyle: {
textStyle: { color: "#ffffff", fontSize: 12 }, color: "#ffffff",
fontSize: 16,
},
}, },
grid: { grid: {
top: "35%", top: "10%",
left: "0%", left: "0%",
right: "0%", right: "0%",
bottom: "0%", bottom: "0%",
@ -75,129 +164,40 @@ const defaultChartOption = ref({
series: [], 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(() => [ const buttonItems = computed(() => [
{ key: 1, title: t("dashboard.temperature"), active: true }, { key: 1, title: t("dashboard.temperature"), active: true },
{ key: 2, title: t("dashboard.humidity"), active: false }, { key: 2, title: t("dashboard.humidity"), active: false },
]); ]);
//
watch( watch(
() => locale.value, () => locale.value,
() => setItems(buttonItems.value), () => {
setItems(buttonItems.value);
},
{ immediate: true } { immediate: true }
); );
// / // tab
watch( watch(
selectedBtn, selectedBtn,
async (newVal) => { (newValue) => {
if ([1, 2].includes(newVal?.key)) { if (timeoutTimer.value) {
currentOptionType.value = newVal.key; clearInterval(timeoutTimer.value);
if (buildingStore.sysConfig?.value?.show_refrigeration !== false) {
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
await getData();
timeoutTimer.value = setInterval(getData, 60_000);
} }
if ([1, 2].includes(newValue?.key)) {
currentOptionType.value = newValue.key;
} }
}, },
{ immediate: true, deep: true } { 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(() => { onUnmounted(() => {
if (timeoutTimer.value) clearInterval(timeoutTimer.value); if (timeoutTimer.value) {
clearInterval(timeoutTimer.value);
}
}); });
</script> </script>
@ -205,27 +205,24 @@ onUnmounted(() => {
<h3 class="text-info text-xl text-center"> <h3 class="text-info text-xl text-center">
{{ $t("dashboard.refrig_chart") }} {{ $t("dashboard.refrig_chart") }}
</h3> </h3>
<div className="my-3 w-full flex justify-center relative">
<div class="w-full flex justify-center relative">
<ButtonConnectedGroup <ButtonConnectedGroup
:items="items" :items="items"
:onclick="(e, item) => changeActiveBtn(item)" :onclick="(e, item) => changeActiveBtn(item)"
/> />
</div> </div>
<div <div
v-if="noData" v-if="noData"
class="text-center text-white text-lg min-h-[260px] flex items-center justify-center" class="text-center text-white text-lg min-h-[260px] flex items-center justify-center"
> >
{{ $t("dashboard.no_data") }} {{ $t("dashboard.no_data") }}
</div> </div>
<LineChart <LineChart
v-else v-if="!noData"
id="dashboard_refrigeration_temp" id="dashboard_other_real_temp"
class="min-h-[260px] max-h-fit" class="min-h-[260px] max-h-fit"
:option="defaultChartOption" :option="defaultChartOption"
ref="chartRef" ref="indoorChartRef"
/> />
</template> </template>

View File

@ -1,276 +1,283 @@
<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">
<!-- Tabs選擇要顯示的攝影機 --> <!-- 左側 RTSP 顯示區域 -->
<div class="flex items-center gap-4 mb-6"> <div class="w-1/2 flex flex-col custom-border p-4 mb-4">
<h2 class="text-lg font-bold whitespace-nowrap"> <div
{{ $t("rtsp.selectDevice") }} : class="flex-1 text-xl font-bold bg-black text-white flex items-center rounded justify-center"
</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"
> >
<template #buttonContent="{ item }"> {{ $t("rtsp.displayArea") }}
<span class="text-base">{{ item.title }}</span> </div>
</template>
</ButtonConnectedGroup>
</div> </div>
<div class="flex h-[70%] gap-4"> <!-- 右側控制區 -->
<!-- 左側即時監控 --> <aside class="w-1/2 flex flex-col gap-8 p-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">
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
class="btn btn-add w-40" class="btn btn-add w-40"
@click="startDetection" @click="startDetection"
:disabled="isStarting || !selectedMainId" :disabled="isStarting || !dirHandle"
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''" :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
class="btn btn-error text-white w-40" class="btn btn-error text-white w-40"
@click="stopDetection" @click="stopDetection"
:disabled="isStopping || !selectedMainId" :disabled="isStopping"
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''"
> >
<span
v-if="isStopping"
class="loading loading-spinner loading-sm mr-2"
></span>
{{ $t("rtsp.stop") }} {{ $t("rtsp.stop") }}
</button> </button>
</div> </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> </aside>
</div> </div>
</section> </section>
</template> </template>
<script> <script>
import { getSystemDevices } from "@/apis/system"; import axios from "axios";
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";
export default { export default {
name: "Rtsp",
setup() {
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
return { items, changeActiveBtn, setItems, selectedBtn };
},
data() { data() {
return { return {
monitorUrl: DEFAULT_MONITOR_URL, savePath: "",
dirHandle: null, // File System Access handle
// directoryName: "", //
ws: null, // WebSocket
isStarting: false, isStarting: false,
isStopping: false, isStopping: false,
downloadProgress: 0,
// UI
message: "", message: "",
//
deviceData: {},
rtspDevices: [], // { main_id, full_name, rtsp_url, ... }
selectedMainId: null, // main_id
}; };
}, },
async mounted() { mounted() {
await this.getData(); this.connectWS();
},
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,
}, },
beforeUnmount() {
if (this.ws) this.ws.close();
}, },
methods: { methods: {
async getData() { // === 1) WebSocket ===
try { connectWS() {
const useBuildingStore = (await import("@/stores/useBuildingStore")) // wss
.default; const WS_URL =
const buildingStore = useBuildingStore(); location.protocol === "https:"
const building_guid = ? `wss://${location.host}/ws`
buildingStore?.selectedBuilding?.building_guid || ""; : `ws://${location.host}/ws`;
const res = await getSystemDevices({ building_guid }); this.ws = new WebSocket(WS_URL);
const transformedData = {};
(res?.data || []).forEach((floor) => { this.ws.onopen = () => {
if (floor?.device_list?.length > 0) { // this.message = "WebSocket ";
const fullUrl = floor.floor_map_name; };
const uuid = fullUrl ? fullUrl.replace(/\.svg$/, "") : "";
transformedData[uuid] = floor.device_list.map((device) => { this.ws.onmessage = async (evt) => {
let x = 0,
y = 0;
try { try {
const coordinates = JSON.parse( const payload = JSON.parse(evt.data);
device?.device_coordinate || "[0,0]" // 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; 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; this.isStarting = true;
try { try {
await setRtspEnable({ await axios.post("/api/start", {
main_id: this.selectedMainId, suggestedName: this.buildSuggestedName(),
enable: true, // 使/
// directoryName: this.directoryName,
}); });
this.message = this.$t("rtsp.startSuccess"); this.message = "偵測中…";
} catch (e) { } catch (err) {
console.error(e); console.error(err);
this.message = this.$t("rtsp.startFail"); alert("開始偵測失敗");
} finally { } finally {
this.isStarting = false; this.isStarting = false;
} }
}, },
// setRtspEnable(false)
async stopDetection() { async stopDetection() {
if (!this.selectedMainId) {
this.message = this.$t("rtsp.pleaseSelectDevice");
return;
}
this.isStopping = true; this.isStopping = true;
try { try {
await setRtspEnable({ await axios.post("/api/stop");
main_id: this.selectedMainId, this.message = "已要求結束偵測,等待檔案就緒…";
enable: false, // WS detection_end
}); } catch (err) {
this.message = this.$t("rtsp.stopSuccess"); console.error(err);
} catch (e) { alert("結束偵測失敗");
console.error(e);
this.message = this.$t("rtsp.stopFail");
} finally { } finally {
this.isStopping = false; 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> </script>