feat: 新增 RTSP 分頁
首頁: 建立 RTSP 點位 Modals
This commit is contained in:
parent
f111be05fa
commit
0999f8000a
@ -11,7 +11,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>智慧倉儲物流</title>
|
<title>智慧倉儲物流</title>
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.js"></script>
|
<script src="https://code.jquery.com/jquery-3.7.1.js"></script>
|
||||||
<script src="https://code.jquery.com/ui/1.13.3/jquery-ui.js"></script>
|
<!-- <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.js"></script> -->
|
||||||
<!-- <script type="text/javascript" src="/requirejs/config.js"></script> -->
|
<!-- <script type="text/javascript" src="/requirejs/config.js"></script> -->
|
||||||
<!-- <script
|
<!-- <script
|
||||||
type="text/javascript"
|
type="text/javascript"
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
"indoor_chart": "室內",
|
"indoor_chart": "室內",
|
||||||
"temperature": "温度",
|
"temperature": "温度",
|
||||||
"humidity": "湿度",
|
"humidity": "湿度",
|
||||||
"no_data":"无数据",
|
"no_data": "无数据",
|
||||||
"alerts_data": "异常资料"
|
"alerts_data": "异常资料"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
@ -418,5 +418,12 @@
|
|||||||
"system_point_name": "系统点位名称",
|
"system_point_name": "系统点位名称",
|
||||||
"json_format_text": "请贴上 JSON 格式数据",
|
"json_format_text": "请贴上 JSON 格式数据",
|
||||||
"json_click_text": "请在左侧输入JSON并点选转换按钮"
|
"json_click_text": "请在左侧输入JSON并点选转换按钮"
|
||||||
|
},
|
||||||
|
"rtsp": {
|
||||||
|
"title": "影像串流",
|
||||||
|
"start": "开始侦测",
|
||||||
|
"stop": "结束侦测",
|
||||||
|
"selectPath": "选择保存路径",
|
||||||
|
"displayArea": "RTSP 画面显示区域"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -418,5 +418,12 @@
|
|||||||
"system_point_name": "系統點位名稱",
|
"system_point_name": "系統點位名稱",
|
||||||
"json_format_text": "請貼上 JSON 格式數據",
|
"json_format_text": "請貼上 JSON 格式數據",
|
||||||
"json_click_text": "請在左側輸入JSON並點選轉換按鈕"
|
"json_click_text": "請在左側輸入JSON並點選轉換按鈕"
|
||||||
|
},
|
||||||
|
"rtsp": {
|
||||||
|
"title": "影像串流",
|
||||||
|
"start": "開始偵測",
|
||||||
|
"stop": "結束偵測",
|
||||||
|
"selectPath": "選擇儲存位置",
|
||||||
|
"displayArea": "RTSP 畫面顯示區域"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
"indoor_chart": "Indoor",
|
"indoor_chart": "Indoor",
|
||||||
"temperature": "Temp.",
|
"temperature": "Temp.",
|
||||||
"humidity": "Hum.",
|
"humidity": "Hum.",
|
||||||
"no_data":"No data",
|
"no_data": "No data",
|
||||||
"alerts_data": "Abnormal data"
|
"alerts_data": "Abnormal data"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
@ -418,5 +418,13 @@
|
|||||||
"system_point_name": "System Point Name",
|
"system_point_name": "System Point Name",
|
||||||
"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": {
|
||||||
|
"title": "Media Streaming",
|
||||||
|
"start": "Start Detection",
|
||||||
|
"stop": "Stop Detection",
|
||||||
|
"selectPath": "Select Save Path",
|
||||||
|
"displayArea": "RTSP Display Area"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -67,4 +67,10 @@ export const AUTHPAGES = [
|
|||||||
pageName: "Setting",
|
pageName: "Setting",
|
||||||
navigate: "/Setting",
|
navigate: "/Setting",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
authCode: "PF12",
|
||||||
|
icon: "camera",
|
||||||
|
pageName: "rtsp",
|
||||||
|
navigate: "/rtsp",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
@ -63,7 +63,8 @@ import {
|
|||||||
faSave,
|
faSave,
|
||||||
faCrown,
|
faCrown,
|
||||||
faClock,
|
faClock,
|
||||||
faCheckCircle
|
faCheckCircle,
|
||||||
|
faCamera
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { faCircle } from "@fortawesome/free-regular-svg-icons";
|
import { faCircle } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
|
||||||
@ -130,6 +131,7 @@ library.add(
|
|||||||
faCrown,
|
faCrown,
|
||||||
faClock,
|
faClock,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
|
faCamera,
|
||||||
faCircle
|
faCircle
|
||||||
);
|
);
|
||||||
|
|
||||||
|
71
src/main.js
71
src/main.js
@ -1,60 +1,83 @@
|
|||||||
|
// ---- styles ----
|
||||||
import "./assets/index.css";
|
import "./assets/index.css";
|
||||||
import "./assets/main.css";
|
import "./assets/main.css";
|
||||||
// import "./assets/table.css";
|
|
||||||
import "./assets/btn.css";
|
import "./assets/btn.css";
|
||||||
import "./assets/pagination.css";
|
import "./assets/pagination.css";
|
||||||
|
|
||||||
import { createApp } from "vue";
|
// ---- Vue core ----
|
||||||
|
import { createApp, onErrorCaptured } from "vue";
|
||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
import tw from "./config/tw.json";
|
|
||||||
import cn from "./config/cn.json";
|
|
||||||
import us from "./config/us.json";
|
|
||||||
import Antd from "ant-design-vue";
|
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
|
|
||||||
|
// ---- App / Router ----
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
|
||||||
|
// ---- UI / Icons / Global comps ----
|
||||||
|
import Antd from "ant-design-vue";
|
||||||
import "virtual:svg-icons-register";
|
import "virtual:svg-icons-register";
|
||||||
// 引入项目中的全部全局组件
|
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import library from "./fontawsomeIconRegister";
|
import library from "./fontawsomeIconRegister";
|
||||||
import "flag-icons/css/flag-icons.min.css";
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
|
|
||||||
/* import font awesome icon component */
|
// ---- Directives ----
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
||||||
|
|
||||||
import { focusPlugin } from "@/directives/focusPlugin";
|
import { focusPlugin } from "@/directives/focusPlugin";
|
||||||
import { draggable } from "@/directives/draggable";
|
import { draggable } from "@/directives/draggable";
|
||||||
const messages = {
|
|
||||||
tw,
|
|
||||||
cn,
|
|
||||||
us,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// ---- i18n ----
|
||||||
|
import tw from "./config/tw.json";
|
||||||
|
import cn from "./config/cn.json";
|
||||||
|
import us from "./config/us.json";
|
||||||
|
|
||||||
|
const messages = { tw, cn, us };
|
||||||
const storedLanguage = localStorage.getItem("EmpowerLanguage") || "tw";
|
const storedLanguage = localStorage.getItem("EmpowerLanguage") || "tw";
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: storedLanguage,
|
locale: storedLanguage,
|
||||||
fallbackLocale: 'tw',
|
fallbackLocale: "tw",
|
||||||
messages,
|
messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 建立 App(確保只 mount 一次)
|
||||||
|
// ===========================================
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// 全域錯誤攔截:印出是哪個元件(含檔名)掛掉
|
||||||
|
app.config.errorHandler = (err, instance, info) => {
|
||||||
|
const name =
|
||||||
|
(instance && (instance.type?.name || instance.type?.__file)) ||
|
||||||
|
"(anonymous component)";
|
||||||
|
// 在 patch/update 階段若報 instance.update is not a function,哪顆元件
|
||||||
|
console.error("[VueError]", name, info, err);
|
||||||
|
};
|
||||||
|
|
||||||
|
// (選用)開啟 devtools
|
||||||
|
// app.config.devtools = true;
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// 插件與全域元件
|
||||||
|
// ===========================================
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(Antd);
|
app.use(Antd);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
|
|
||||||
// 组装成一个对象
|
// 全域元件註冊(維持你的寫法)
|
||||||
const allGlobalComponents = { SvgIcon, FontAwesomeIcon };
|
const allGlobalComponents = { SvgIcon, FontAwesomeIcon };
|
||||||
const globalComponent = {
|
app.use({
|
||||||
install(app) {
|
install(app) {
|
||||||
// 循环注册所有的全局组件
|
Object.keys(allGlobalComponents).forEach((k) => {
|
||||||
Object.keys(allGlobalComponents).forEach((componentName) => {
|
app.component(k, allGlobalComponents[k]);
|
||||||
app.component(componentName, allGlobalComponents[componentName]);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
app.use(globalComponent);
|
|
||||||
|
// 指令
|
||||||
app.use(focusPlugin);
|
app.use(focusPlugin);
|
||||||
app.use(draggable);
|
app.use(draggable);
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Mount(保證只呼叫一次)
|
||||||
|
// ===========================================
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
@ -56,6 +56,12 @@ const router = createRouter({
|
|||||||
name: "assetManagement",
|
name: "assetManagement",
|
||||||
component: () => import("@/views/AssetManagement/AssetManagement.vue"),
|
component: () => import("@/views/AssetManagement/AssetManagement.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/rtsp",
|
||||||
|
name: "rtsp",
|
||||||
|
component: () => import("@/views/rtsp/Rtsp.vue"),
|
||||||
|
meta: { layout: "map", title: "rtsp" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/alert",
|
path: "/alert",
|
||||||
name: "alert",
|
name: "alert",
|
||||||
|
@ -91,12 +91,12 @@ watch(selectedBtn, (newValue) => {
|
|||||||
:getData="getMainSystems"
|
:getData="getMainSystems"
|
||||||
:formState="formState"
|
:formState="formState"
|
||||||
/>
|
/>
|
||||||
<!-- <button
|
<button
|
||||||
@click.stop.prevent="isEditMode = !isEditMode"
|
@click.stop.prevent="isEditMode = !isEditMode"
|
||||||
class="btn btn-sm btn-outline-success"
|
class="btn btn-sm btn-outline-success"
|
||||||
>
|
>
|
||||||
{{ isEditMode ? t("button.stop_edit") : t("button.start_edit") }}
|
{{ isEditMode ? t("button.stop_edit") : t("button.start_edit") }}
|
||||||
</button> -->
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ButtonConnectedGroup
|
<ButtonConnectedGroup
|
||||||
:items="items"
|
:items="items"
|
||||||
|
@ -51,9 +51,9 @@ const onReset = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- <button class="btn btn-sm btn-success" @click.stop.prevent="openModal">
|
<button class="btn btn-sm btn-success" @click.stop.prevent="openModal">
|
||||||
<font-awesome-icon :icon="['fas', 'plus']" />{{ $t("button.add") }}
|
<font-awesome-icon :icon="['fas', 'plus']" />{{ $t("button.add") }}
|
||||||
</button> -->
|
</button>
|
||||||
<Modal
|
<Modal
|
||||||
id="asset_add_main_item"
|
id="asset_add_main_item"
|
||||||
:title="
|
:title="
|
||||||
|
@ -4,6 +4,7 @@ import EffectScatter from "@/components/chart/EffectScatter.vue";
|
|||||||
import DashboardEffectScatterModal from "./DashboardEffectScatterModal.vue";
|
import DashboardEffectScatterModal from "./DashboardEffectScatterModal.vue";
|
||||||
import useSearchParam from "@/hooks/useSearchParam";
|
import useSearchParam from "@/hooks/useSearchParam";
|
||||||
import { computed, inject, ref, watch } from "vue";
|
import { computed, inject, ref, watch } from "vue";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -91,46 +92,48 @@ const handleItemClick = (params) => {
|
|||||||
watch(
|
watch(
|
||||||
[searchParams, () => asset_floor_chart.value, () => props.data],
|
[searchParams, () => asset_floor_chart.value, () => props.data],
|
||||||
([newValue, newChart, newData], [oldValue]) => {
|
([newValue, newChart, newData], [oldValue]) => {
|
||||||
console.groupCollapsed("[FloorMap] watch fired");
|
if (
|
||||||
console.log("floor_id =", newValue.floor_id);
|
newValue.floor_id &&
|
||||||
console.log("chart ready =", !!newChart);
|
newChart &&
|
||||||
console.log("data keys =", Object.keys(newData || {}).length);
|
Object.keys(newData || {}).length > 0
|
||||||
console.groupEnd();
|
) {
|
||||||
const floorId = newValue.floor_id;
|
const isFloorChanged = currentFloorId.value !== newValue.floor_id;
|
||||||
const chartReady = !!newChart;
|
|
||||||
// (1) 首次/樓層切換:不等 data,先載 SVG
|
if (isFloorChanged) {
|
||||||
if (floorId && chartReady && currentFloorId.value !== floorId) {
|
// 樓層切換時才重新載入 SVG
|
||||||
console.log("[FloorMap] load SVG for floor:", floorId);
|
console.log(
|
||||||
currentFloorId.value = floorId;
|
"Floor changed, updating chart with new SVG",
|
||||||
|
newValue.floor_id
|
||||||
|
);
|
||||||
|
currentFloorId.value = newValue.floor_id;
|
||||||
newChart.updateSvg(
|
newChart.updateSvg(
|
||||||
{
|
{
|
||||||
full_name: floorId,
|
full_name: newValue.floor_id,
|
||||||
path: `${FILE_BASEURL}/upload/floor_map/${floorId}.svg`,
|
path: `${FILE_BASEURL}/upload/floor_map/${newValue.floor_id}.svg`,
|
||||||
},
|
},
|
||||||
defaultOption(floorId, []) // 先不帶點位
|
defaultOption(newValue.floor_id, currentIconData.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 添加點擊事件監聽
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (newChart.chart) {
|
if (newChart.chart) {
|
||||||
newChart.chart.off("click");
|
newChart.chart.off("click"); // 移除舊的監聽器
|
||||||
newChart.chart.on("click", handleItemClick);
|
newChart.chart.on("click", handleItemClick);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
} else if (currentFloorId.value === newValue.floor_id && newChart.chart) {
|
||||||
|
// 只是資料更新時,只更新圖表資料,不重新載入 SVG
|
||||||
// (2) 資料到齊後,再補點位(不重載 SVG)
|
console.log("Data updated, refreshing chart data only");
|
||||||
if (
|
|
||||||
floorId &&
|
|
||||||
chartReady &&
|
|
||||||
Object.keys(newData || {}).length > 0 &&
|
|
||||||
newChart.chart
|
|
||||||
) {
|
|
||||||
console.log("[FloorMap] update series only for floor:", floorId);
|
|
||||||
newChart.chart.setOption(
|
newChart.chart.setOption(
|
||||||
{ series: defaultOption(floorId, currentIconData.value).series },
|
{
|
||||||
|
series: defaultOption(newValue.floor_id, currentIconData.value)
|
||||||
|
.series,
|
||||||
|
},
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
@ -45,72 +45,13 @@ const handleCancel = () => {
|
|||||||
:onCancel="handleCancel"
|
:onCancel="handleCancel"
|
||||||
:width="600"
|
:width="600"
|
||||||
:draggable="true"
|
:draggable="true"
|
||||||
modalClass="max-h-[80vh]"
|
|
||||||
>
|
>
|
||||||
<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">
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-link btn-text-without-border px-2"
|
class="btn-link btn-text-without-border px-2"
|
||||||
@ -123,166 +64,24 @@ const handleCancel = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #modalContent>
|
<!-- 黑底填滿剩下高度 -->
|
||||||
<div class="space-y-4 py-4">
|
<div class="flex-1">
|
||||||
<!-- Desktop Tab - 基本資訊 -->
|
<div
|
||||||
<div v-if="currentTab === 'desktop'" class="grid grid-cols-1 gap-4">
|
class="bg-black text-xl font-bold rounded h-full flex justify-center items-center"
|
||||||
<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>
|
|
||||||
<!-- <tr v-if="props.data.Online_color" class="hover:bg-gray-600">
|
|
||||||
<td class="p-2 border">Online 顏色</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 rounded border border-gray-300"
|
|
||||||
:style="{ backgroundColor: props.data.Online_color }"
|
|
||||||
></div>
|
|
||||||
<span class="text-gray-100 text-sm font-mono">{{
|
|
||||||
props.data.Online_color
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr> -->
|
|
||||||
<!-- <tr v-if="props.data.Offline_color" class="hover:bg-gray-600">
|
|
||||||
<td class="p-2 border">Offline 顏色</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 rounded border border-gray-300"
|
|
||||||
:style="{ backgroundColor: props.data.Offline_color }"
|
|
||||||
></div>
|
|
||||||
<span class="text-gray-100 text-sm font-mono">{{
|
|
||||||
props.data.Offline_color
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr> -->
|
|
||||||
<!-- <tr v-if="props.data.Error_color" class="hover:bg-gray-600">
|
|
||||||
<td class="p-2 border">Error 顏色</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 rounded border border-gray-300"
|
|
||||||
:style="{ backgroundColor: props.data.Error_color }"
|
|
||||||
></div>
|
|
||||||
<span class="text-gray-100 text-sm font-mono">{{
|
|
||||||
props.data.Error_color
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</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],讓高度自動撐開 */
|
||||||
|
:deep(.min-h-\[200px\]) {
|
||||||
|
min-height: 0 !important;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -8,6 +8,7 @@ import useBuildingStore from "@/stores/useBuildingStore";
|
|||||||
const store = useBuildingStore();
|
const store = useBuildingStore();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const taipower_data = ref([]);
|
const taipower_data = ref([]);
|
||||||
|
const elecUseDayData = ref([]);
|
||||||
const carbonValue = ref(null);
|
const carbonValue = ref(null);
|
||||||
const carbonData = ref(null);
|
const carbonData = ref(null);
|
||||||
const search_data = computed(() => {
|
const search_data = computed(() => {
|
||||||
@ -107,6 +108,7 @@ watch(
|
|||||||
JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
||||||
) {
|
) {
|
||||||
getData(newValue);
|
getData(newValue);
|
||||||
|
getElecUseDayData(newValue);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
283
src/views/rtsp/Rtsp.vue
Normal file
283
src/views/rtsp/Rtsp.vue
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<section class="min-h-[600px] h-screen">
|
||||||
|
<h1 class="text-2xl font-extrabold mb-2">{{ $t("rtsp.title") }}</h1>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{{ $t("rtsp.displayArea") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右側控制區 -->
|
||||||
|
<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 || !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"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="isStopping"
|
||||||
|
class="loading loading-spinner loading-sm mr-2"
|
||||||
|
></span>
|
||||||
|
{{ $t("rtsp.stop") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 選擇儲存資料夾 -->
|
||||||
|
<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 axios from "axios";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
savePath: "",
|
||||||
|
dirHandle: null, // File System Access 的目錄 handle
|
||||||
|
directoryName: "", // 顯示用
|
||||||
|
ws: null, // WebSocket
|
||||||
|
isStarting: false,
|
||||||
|
isStopping: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.connectWS();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.ws) this.ws.close();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// === 1) WebSocket:接收「結束偵測」事件後自動下載 ===
|
||||||
|
connectWS() {
|
||||||
|
// 視你的後端實際位址而定(支援 wss)
|
||||||
|
const WS_URL =
|
||||||
|
location.protocol === "https:"
|
||||||
|
? `wss://${location.host}/ws`
|
||||||
|
: `ws://${location.host}/ws`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
// this.message = "WebSocket 已連線";
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = async (evt) => {
|
||||||
|
try {
|
||||||
|
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)。"
|
||||||
|
);
|
||||||
|
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 axios.post("/api/start", {
|
||||||
|
suggestedName: this.buildSuggestedName(),
|
||||||
|
// ★(可選)把使用者授權的資料夾名稱傳後端作為紀錄/標記
|
||||||
|
// directoryName: this.directoryName,
|
||||||
|
});
|
||||||
|
this.message = "偵測中…";
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("開始偵測失敗");
|
||||||
|
} finally {
|
||||||
|
this.isStarting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopDetection() {
|
||||||
|
this.isStopping = true;
|
||||||
|
try {
|
||||||
|
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>
|
@ -20,7 +20,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-carousel arrows class="mt-5 shadow-lg">
|
<!-- <a-carousel arrows class="mt-5 shadow-lg">
|
||||||
<template #prevArrow>
|
<template #prevArrow>
|
||||||
<div class="custom-slick-arrow" style="left: 10px; z-index: 1">
|
<div class="custom-slick-arrow" style="left: 10px; z-index: 1">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
@ -42,7 +42,7 @@ watch(
|
|||||||
<div v-for="(url, index) in imgData" :key="index">
|
<div v-for="(url, index) in imgData" :key="index">
|
||||||
<img :src="`${FILE_BASEURL}/${url}`" alt="Image" />
|
<img :src="`${FILE_BASEURL}/${url}`" alt="Image" />
|
||||||
</div>
|
</div>
|
||||||
</a-carousel>
|
</a-carousel> -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
File diff suppressed because one or more lines are too long
@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/upload": {
|
"/upload": {
|
||||||
target: "https://ibms-empower2.production.mjmtech.com.tw",
|
target: "https://ibms-ils.production.mjmtech.com.tw",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
@ -42,6 +42,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
dedupe: ["vue"],
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
"@ASSET": fileURLToPath(new URL("./src/assets", import.meta.url)),
|
"@ASSET": fileURLToPath(new URL("./src/assets", import.meta.url)),
|
||||||
|
Loading…
Reference in New Issue
Block a user