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" />
|
||||
<title>智慧倉儲物流</title>
|
||||
<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"
|
||||
|
@ -51,7 +51,7 @@
|
||||
"indoor_chart": "室內",
|
||||
"temperature": "温度",
|
||||
"humidity": "湿度",
|
||||
"no_data":"无数据",
|
||||
"no_data": "无数据",
|
||||
"alerts_data": "异常资料"
|
||||
},
|
||||
"history": {
|
||||
@ -418,5 +418,12 @@
|
||||
"system_point_name": "系统点位名称",
|
||||
"json_format_text": "请贴上 JSON 格式数据",
|
||||
"json_click_text": "请在左侧输入JSON并点选转换按钮"
|
||||
},
|
||||
"rtsp": {
|
||||
"title": "影像串流",
|
||||
"start": "开始侦测",
|
||||
"stop": "结束侦测",
|
||||
"selectPath": "选择保存路径",
|
||||
"displayArea": "RTSP 画面显示区域"
|
||||
}
|
||||
}
|
||||
|
@ -418,5 +418,12 @@
|
||||
"system_point_name": "系統點位名稱",
|
||||
"json_format_text": "請貼上 JSON 格式數據",
|
||||
"json_click_text": "請在左側輸入JSON並點選轉換按鈕"
|
||||
},
|
||||
"rtsp": {
|
||||
"title": "影像串流",
|
||||
"start": "開始偵測",
|
||||
"stop": "結束偵測",
|
||||
"selectPath": "選擇儲存位置",
|
||||
"displayArea": "RTSP 畫面顯示區域"
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@
|
||||
"indoor_chart": "Indoor",
|
||||
"temperature": "Temp.",
|
||||
"humidity": "Hum.",
|
||||
"no_data":"No data",
|
||||
"no_data": "No data",
|
||||
"alerts_data": "Abnormal data"
|
||||
},
|
||||
"history": {
|
||||
@ -418,5 +418,13 @@
|
||||
"system_point_name": "System Point Name",
|
||||
"json_format_text": "Please paste JSON format data",
|
||||
"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",
|
||||
navigate: "/Setting",
|
||||
},
|
||||
{
|
||||
authCode: "PF12",
|
||||
icon: "camera",
|
||||
pageName: "rtsp",
|
||||
navigate: "/rtsp",
|
||||
},
|
||||
];
|
||||
|
@ -63,7 +63,8 @@ import {
|
||||
faSave,
|
||||
faCrown,
|
||||
faClock,
|
||||
faCheckCircle
|
||||
faCheckCircle,
|
||||
faCamera
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
|
||||
@ -130,6 +131,7 @@ library.add(
|
||||
faCrown,
|
||||
faClock,
|
||||
faCheckCircle,
|
||||
faCamera,
|
||||
faCircle
|
||||
);
|
||||
|
||||
|
71
src/main.js
71
src/main.js
@ -1,60 +1,83 @@
|
||||
// ---- styles ----
|
||||
import "./assets/index.css";
|
||||
import "./assets/main.css";
|
||||
// import "./assets/table.css";
|
||||
import "./assets/btn.css";
|
||||
import "./assets/pagination.css";
|
||||
|
||||
import { createApp } from "vue";
|
||||
// ---- Vue core ----
|
||||
import { createApp, onErrorCaptured } from "vue";
|
||||
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";
|
||||
|
||||
// ---- App / Router ----
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
// ---- UI / Icons / Global comps ----
|
||||
import Antd from "ant-design-vue";
|
||||
import "virtual:svg-icons-register";
|
||||
// 引入项目中的全部全局组件
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import library from "./fontawsomeIconRegister";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
// ---- Directives ----
|
||||
import { focusPlugin } from "@/directives/focusPlugin";
|
||||
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 i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: storedLanguage,
|
||||
fallbackLocale: 'tw',
|
||||
fallbackLocale: "tw",
|
||||
messages,
|
||||
});
|
||||
|
||||
// 建立 App(確保只 mount 一次)
|
||||
// ===========================================
|
||||
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(router);
|
||||
app.use(Antd);
|
||||
app.use(i18n);
|
||||
|
||||
// 组装成一个对象
|
||||
// 全域元件註冊(維持你的寫法)
|
||||
const allGlobalComponents = { SvgIcon, FontAwesomeIcon };
|
||||
const globalComponent = {
|
||||
app.use({
|
||||
install(app) {
|
||||
// 循环注册所有的全局组件
|
||||
Object.keys(allGlobalComponents).forEach((componentName) => {
|
||||
app.component(componentName, allGlobalComponents[componentName]);
|
||||
Object.keys(allGlobalComponents).forEach((k) => {
|
||||
app.component(k, allGlobalComponents[k]);
|
||||
});
|
||||
},
|
||||
};
|
||||
app.use(globalComponent);
|
||||
});
|
||||
|
||||
// 指令
|
||||
app.use(focusPlugin);
|
||||
app.use(draggable);
|
||||
|
||||
// ===========================================
|
||||
// Mount(保證只呼叫一次)
|
||||
// ===========================================
|
||||
app.mount("#app");
|
||||
|
@ -56,6 +56,12 @@ const router = createRouter({
|
||||
name: "assetManagement",
|
||||
component: () => import("@/views/AssetManagement/AssetManagement.vue"),
|
||||
},
|
||||
{
|
||||
path: "/rtsp",
|
||||
name: "rtsp",
|
||||
component: () => import("@/views/rtsp/Rtsp.vue"),
|
||||
meta: { layout: "map", title: "rtsp" },
|
||||
},
|
||||
{
|
||||
path: "/alert",
|
||||
name: "alert",
|
||||
|
@ -91,12 +91,12 @@ watch(selectedBtn, (newValue) => {
|
||||
:getData="getMainSystems"
|
||||
:formState="formState"
|
||||
/>
|
||||
<!-- <button
|
||||
<button
|
||||
@click.stop.prevent="isEditMode = !isEditMode"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
>
|
||||
{{ isEditMode ? t("button.stop_edit") : t("button.start_edit") }}
|
||||
</button> -->
|
||||
</button>
|
||||
</div>
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
|
@ -51,9 +51,9 @@ const onReset = () => {
|
||||
</script>
|
||||
|
||||
<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") }}
|
||||
</button> -->
|
||||
</button>
|
||||
<Modal
|
||||
id="asset_add_main_item"
|
||||
:title="
|
||||
|
@ -4,6 +4,7 @@ import EffectScatter from "@/components/chart/EffectScatter.vue";
|
||||
import DashboardEffectScatterModal from "./DashboardEffectScatterModal.vue";
|
||||
import useSearchParam from "@/hooks/useSearchParam";
|
||||
import { computed, inject, ref, watch } from "vue";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
@ -91,46 +92,48 @@ const handleItemClick = (params) => {
|
||||
watch(
|
||||
[searchParams, () => asset_floor_chart.value, () => props.data],
|
||||
([newValue, newChart, newData], [oldValue]) => {
|
||||
console.groupCollapsed("[FloorMap] watch fired");
|
||||
console.log("floor_id =", newValue.floor_id);
|
||||
console.log("chart ready =", !!newChart);
|
||||
console.log("data keys =", Object.keys(newData || {}).length);
|
||||
console.groupEnd();
|
||||
const floorId = newValue.floor_id;
|
||||
const chartReady = !!newChart;
|
||||
// (1) 首次/樓層切換:不等 data,先載 SVG
|
||||
if (floorId && chartReady && currentFloorId.value !== floorId) {
|
||||
console.log("[FloorMap] load SVG for floor:", floorId);
|
||||
currentFloorId.value = floorId;
|
||||
if (
|
||||
newValue.floor_id &&
|
||||
newChart &&
|
||||
Object.keys(newData || {}).length > 0
|
||||
) {
|
||||
const isFloorChanged = currentFloorId.value !== newValue.floor_id;
|
||||
|
||||
if (isFloorChanged) {
|
||||
// 樓層切換時才重新載入 SVG
|
||||
console.log(
|
||||
"Floor changed, updating chart with new SVG",
|
||||
newValue.floor_id
|
||||
);
|
||||
currentFloorId.value = newValue.floor_id;
|
||||
newChart.updateSvg(
|
||||
{
|
||||
full_name: floorId,
|
||||
path: `${FILE_BASEURL}/upload/floor_map/${floorId}.svg`,
|
||||
full_name: newValue.floor_id,
|
||||
path: `${FILE_BASEURL}/upload/floor_map/${newValue.floor_id}.svg`,
|
||||
},
|
||||
defaultOption(floorId, []) // 先不帶點位
|
||||
defaultOption(newValue.floor_id, currentIconData.value)
|
||||
);
|
||||
|
||||
// 添加點擊事件監聽
|
||||
setTimeout(() => {
|
||||
if (newChart.chart) {
|
||||
newChart.chart.off("click");
|
||||
newChart.chart.off("click"); // 移除舊的監聽器
|
||||
newChart.chart.on("click", handleItemClick);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// (2) 資料到齊後,再補點位(不重載 SVG)
|
||||
if (
|
||||
floorId &&
|
||||
chartReady &&
|
||||
Object.keys(newData || {}).length > 0 &&
|
||||
newChart.chart
|
||||
) {
|
||||
console.log("[FloorMap] update series only for floor:", floorId);
|
||||
} else if (currentFloorId.value === newValue.floor_id && newChart.chart) {
|
||||
// 只是資料更新時,只更新圖表資料,不重新載入 SVG
|
||||
console.log("Data updated, refreshing chart data only");
|
||||
newChart.chart.setOption(
|
||||
{ series: defaultOption(floorId, currentIconData.value).series },
|
||||
{
|
||||
series: defaultOption(newValue.floor_id, currentIconData.value)
|
||||
.series,
|
||||
},
|
||||
false,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
|
@ -45,72 +45,13 @@ const handleCancel = () => {
|
||||
:onCancel="handleCancel"
|
||||
:width="600"
|
||||
:draggable="true"
|
||||
modalClass="max-h-[80vh]"
|
||||
>
|
||||
<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">
|
||||
<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
|
||||
type="button"
|
||||
class="btn-link btn-text-without-border px-2"
|
||||
@ -123,166 +64,24 @@ const handleCancel = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #modalContent>
|
||||
<div 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>
|
||||
<!-- <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>
|
||||
{{ $t("rtsp.displayArea") }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 可以添加額外的樣式 */
|
||||
/* 專門覆蓋 Modal 裡的 min-h-[200px],讓高度自動撐開 */
|
||||
:deep(.min-h-\[200px\]) {
|
||||
min-height: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
@ -8,6 +8,7 @@ import useBuildingStore from "@/stores/useBuildingStore";
|
||||
const store = useBuildingStore();
|
||||
const { t, locale } = useI18n();
|
||||
const taipower_data = ref([]);
|
||||
const elecUseDayData = ref([]);
|
||||
const carbonValue = ref(null);
|
||||
const carbonData = ref(null);
|
||||
const search_data = computed(() => {
|
||||
@ -107,6 +108,7 @@ watch(
|
||||
JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
||||
) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<a-carousel arrows class="mt-5 shadow-lg">
|
||||
<!-- <a-carousel arrows class="mt-5 shadow-lg">
|
||||
<template #prevArrow>
|
||||
<div class="custom-slick-arrow" style="left: 10px; z-index: 1">
|
||||
<font-awesome-icon
|
||||
@ -42,7 +42,7 @@ watch(
|
||||
<div v-for="(url, index) in imgData" :key="index">
|
||||
<img :src="`${FILE_BASEURL}/${url}`" alt="Image" />
|
||||
</div>
|
||||
</a-carousel>
|
||||
</a-carousel> -->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
File diff suppressed because one or more lines are too long
@ -16,7 +16,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
"/upload": {
|
||||
target: "https://ibms-empower2.production.mjmtech.com.tw",
|
||||
target: "https://ibms-ils.production.mjmtech.com.tw",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
@ -42,6 +42,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
dedupe: ["vue"],
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
"@ASSET": fileURLToPath(new URL("./src/assets", import.meta.url)),
|
||||
|
Loading…
Reference in New Issue
Block a user