feat: 新增 RTSP 分頁

首頁:
建立 RTSP 點位 Modals
This commit is contained in:
MJM_2025_05\polly 2025-09-12 09:02:50 +08:00
parent f111be05fa
commit 0999f8000a
17 changed files with 460 additions and 313 deletions

View File

@ -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"

View File

@ -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 画面显示区域"
} }
} }

View File

@ -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 畫面顯示區域"
} }
} }

View File

@ -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"
}
} }

View File

@ -67,4 +67,10 @@ export const AUTHPAGES = [
pageName: "Setting", pageName: "Setting",
navigate: "/Setting", navigate: "/Setting",
}, },
{
authCode: "PF12",
icon: "camera",
pageName: "rtsp",
navigate: "/rtsp",
},
]; ];

View File

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

View File

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

View File

@ -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",

View File

@ -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"

View File

@ -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="

View File

@ -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,45 +92,47 @@ 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");
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;
newChart.updateSvg(
{
full_name: floorId,
path: `${FILE_BASEURL}/upload/floor_map/${floorId}.svg`,
},
defaultOption(floorId, []) //
);
setTimeout(() => {
if (newChart.chart) {
newChart.chart.off("click");
newChart.chart.on("click", handleItemClick);
}
}, 100);
}
// (2) SVG
if ( if (
floorId && newValue.floor_id &&
chartReady && newChart &&
Object.keys(newData || {}).length > 0 && Object.keys(newData || {}).length > 0
newChart.chart
) { ) {
console.log("[FloorMap] update series only for floor:", floorId); const isFloorChanged = currentFloorId.value !== newValue.floor_id;
newChart.chart.setOption(
{ series: defaultOption(floorId, currentIconData.value).series }, if (isFloorChanged) {
false, // SVG
true console.log(
); "Floor changed, updating chart with new SVG",
newValue.floor_id
);
currentFloorId.value = newValue.floor_id;
newChart.updateSvg(
{
full_name: newValue.floor_id,
path: `${FILE_BASEURL}/upload/floor_map/${newValue.floor_id}.svg`,
},
defaultOption(newValue.floor_id, currentIconData.value)
);
//
setTimeout(() => {
if (newChart.chart) {
newChart.chart.off("click"); //
newChart.chart.on("click", handleItemClick);
}
}, 100);
} else if (currentFloorId.value === newValue.floor_id && newChart.chart) {
// SVG
console.log("Data updated, refreshing chart data only");
newChart.chart.setOption(
{
series: defaultOption(newValue.floor_id, currentIconData.value)
.series,
},
false,
true
);
}
} }
}, },
{ {

View File

@ -45,244 +45,43 @@ 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">
<span>{{ props.data?.full_name }}</span> <!-- 標題固定高度 -->
<div class="flex items-center space-x-2"> <div class="flex items-center justify-between h-10">
<button <span>{{ props.data?.full_name }}</span>
type="button" <div class="flex items-center space-x-2">
class="text-base btn-link btn-text-without-border px-2" <button
@click="() => changeOpenKey('desktop')" type="button"
> class="btn-link btn-text-without-border px-2"
<font-awesome-icon @click="handleCancel"
:icon="['fas', 'desktop']" >
size="lg" <font-awesome-icon
:class=" :icon="['fas', 'times']"
twMerge( class="text-[#a5abb1]"
currentTab === 'desktop' ? 'text-success' : 'text-[#a5abb1]' />
) </button>
" </div>
/>
</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"
@click="handleCancel"
>
<font-awesome-icon
:icon="['fas', 'times']"
class="text-[#a5abb1]"
/>
</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"
>
<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> </div>
<!-- Image Tab - 可視化資訊 --> <!-- 黑底填滿剩下高度 -->
<div v-if="currentTab === 'image'" class="grid grid-cols-1 gap-4"> <div class="flex-1">
<table class="min-w-full bg-gray-700 border text-gray-100"> <div
<thead> class="bg-black text-xl font-bold rounded h-full flex justify-center items-center"
<tr class="bg-gray-600"> >
<th class="p-2 border text-left">項目</th> {{ $t("rtsp.displayArea") }}
<th class="p-2 border text-left">內容</th> </div>
</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> </div>
</section>
<!-- 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>
</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>

View File

@ -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
View 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>

View File

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

View File

@ -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)),