Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
feac20d519 | |||
3750e3c124 | |||
f990c302e5 | |||
5ff030dc40 | |||
d01d3e7581 | |||
bb549311df | |||
ba7fda3d07 | |||
2bf5f9042a | |||
aa7a136937 | |||
0999f8000a | |||
f111be05fa |
@ -1,4 +1,4 @@
|
||||
VITE_API_BASEURL = "https://ibms-Empower-api.production.mjmtech.com.tw"
|
||||
VITE_FILE_API_BASEURL = "https://ibms-Empower.production.mjmtech.com.tw"
|
||||
VITE_API_BASEURL = "https://ibms-ils-api.production.mjmtech.com.tw"
|
||||
VITE_FILE_API_BASEURL = "https://ibms-ils.production.mjmtech.com.tw"
|
||||
VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net"
|
||||
VITE_FORGE_BASEURL = "https://ibms-Empower.production.mjmtech.com.tw/dist"
|
@ -1,4 +1,4 @@
|
||||
VITE_API_BASEURL = "https://ibms-Empower-api.production.mjmtech.com.tw"
|
||||
VITE_FILE_API_BASEURL = "https://ibms-Empower.production.mjmtech.com.tw"
|
||||
VITE_API_BASEURL = "https://ibms-ils-api.production.mjmtech.com.tw"
|
||||
VITE_FILE_API_BASEURL = "https://ibms-ils.production.mjmtech.com.tw"
|
||||
VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net"
|
||||
VITE_FORGE_BASEURL = "https://ibms-Empower.production.mjmtech.com.tw/dist"
|
@ -9,9 +9,9 @@
|
||||
href="https://developer.api.autodesk.com/modelderivative/v2/viewers/7.*/style.css"
|
||||
/>
|
||||
<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/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"
|
||||
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -21,6 +21,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"flag-icons": "^7.2.3",
|
||||
"hls.js": "^1.6.12",
|
||||
"jquery-ui": "^1.14.1",
|
||||
"json-schema-generator": "^2.0.6",
|
||||
"mqtt": "^5.10.3",
|
||||
@ -3056,6 +3057,12 @@
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.12",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.12.tgz",
|
||||
"integrity": "sha512-Pz+7IzvkbAht/zXvwLzA/stUHNqztqKvlLbfpq6ZYU68+gZ+CZMlsbQBPUviRap+3IQ41E39ke7Ia+yvhsehEQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
|
@ -22,6 +22,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"flag-icons": "^7.2.3",
|
||||
"hls.js": "^1.6.12",
|
||||
"jquery-ui": "^1.14.1",
|
||||
"json-schema-generator": "^2.0.6",
|
||||
"mqtt": "^5.10.3",
|
||||
|
@ -24,4 +24,6 @@ export const GET_ALERT_SCHEDULE_LIST_API = `api/Alarm/GetAlarmSchedule`;
|
||||
export const POST_ALERT_SCHEDULE = `api/Alarm/SaveAlarmSchedule`;
|
||||
export const DELETE_ALERT_SCHEDULE = `api/Alarm/DeleteAlarmSchedule`;
|
||||
|
||||
export const POST_ALERT_MQTT_REFRESH = `api/Alarm/MQTTRefresh`;
|
||||
export const POST_ALERT_MQTT_REFRESH = `api/Alarm/MQTTRefresh`;
|
||||
|
||||
export const POST_QUERY_ALARM_LOG = `api/alarm/query-alarm-log`; // RTSP 分頁顯示告警 log
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
POST_ALERT_SCHEDULE,
|
||||
DELETE_ALERT_SCHEDULE,
|
||||
POST_ALERT_MQTT_REFRESH,
|
||||
POST_QUERY_ALARM_LOG,
|
||||
} from "./api";
|
||||
import instance from "@/util/request";
|
||||
import apihandler from "@/util/apihandler";
|
||||
@ -236,3 +237,24 @@ export const postMQTTRefresh = async () => {
|
||||
code: res.code,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查詢裝置告警紀錄(支援起訖日)
|
||||
* @param {{ id: number, start_date: string, end_date: string }} payload
|
||||
* - id: main_id(裝置/攝影機/樓層主鍵等)
|
||||
* - start_date: "YYYY-MM-DD"
|
||||
* - end_date: "YYYY-MM-DD"
|
||||
* @returns {Promise<any>} apihandler 標準回傳(成功時回 data 陣列)
|
||||
*/
|
||||
export const postQueryAlarmLog = async ({ id, start_date, end_date }) => {
|
||||
const res = await instance.post(POST_QUERY_ALARM_LOG, {
|
||||
id,
|
||||
start_date,
|
||||
end_date,
|
||||
});
|
||||
|
||||
return apihandler(res.code, res.data, {
|
||||
msg: res.msg,
|
||||
code: res.code,
|
||||
});
|
||||
};
|
||||
|
4
src/apis/rtsp/api.js
Normal file
4
src/apis/rtsp/api.js
Normal file
@ -0,0 +1,4 @@
|
||||
// 開關 RTSP(啟用/停用)
|
||||
export const POST_SET_RTSP_ENABLE = `/api/rtsp/set-rtsp-enable`;
|
||||
|
||||
export const POST_GET_RTSP_DEVICE = `/api/rtsp/get-rtsp-device`;
|
29
src/apis/rtsp/index.js
Normal file
29
src/apis/rtsp/index.js
Normal file
@ -0,0 +1,29 @@
|
||||
// src/apis/rtsp/index.js
|
||||
import { POST_SET_RTSP_ENABLE, POST_GET_RTSP_DEVICE } 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 });
|
||||
};
|
||||
|
||||
/**
|
||||
* 取得 RTSP 裝置清單
|
||||
* Swagger: POST /api/rtsp/get-rtsp-device
|
||||
* body: 可為空物件 {} 或依後端需求帶 building_guid 等參數
|
||||
* response.data: [
|
||||
* { main_id, device_number, full_name, device_ip, device_port, rtsp_url,
|
||||
* enable_traffic, start_btn_enable, stop_btn_enable, alarm_message }
|
||||
* ]
|
||||
*/
|
||||
export const getRtspDevices = async (payload = {}) => {
|
||||
const res = await instance.post(POST_GET_RTSP_DEVICE, payload);
|
||||
// 後端回傳格式如題:{ code, msg, data: [...] }
|
||||
return apihandler(res.code, res.data, { msg: res.msg, code: res.code });
|
||||
};
|
@ -3,8 +3,8 @@ import { defineProps } from "vue";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const props = defineProps({
|
||||
name: String,
|
||||
value: String,
|
||||
value: { type: [String, Number, Object, Array, Boolean, null], default: "" },
|
||||
name: { type: String, required: true },
|
||||
items: Array,
|
||||
isLabelExist: {
|
||||
type: Boolean,
|
||||
|
@ -14,11 +14,11 @@ options: [
|
||||
|
||||
const props = defineProps({
|
||||
options: Array,
|
||||
name: String,
|
||||
value: { type: [String, Number, Object, Array, Boolean, null], default: "" },
|
||||
name: { type: String, required: true },
|
||||
Attribute: String,
|
||||
onChange: Function,
|
||||
selectClass: String,
|
||||
value: String || Number,
|
||||
isTopLabelExist: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
@ -2,8 +2,8 @@
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
name: String,
|
||||
value: String,
|
||||
value: { type: [String, Number, Object, Array, Boolean, null], default: "" },
|
||||
name: { type: String, required: true },
|
||||
placeholder: String,
|
||||
});
|
||||
</script>
|
||||
|
@ -104,9 +104,9 @@ watch(locale, () => {
|
||||
class="lg:pl-2 lg:text-2xl font-bold text-white flex items-center"
|
||||
>
|
||||
<img src="/logo.svg" alt="logo" class="w-6 lg:w-8 me-1" />
|
||||
新創賦能
|
||||
智慧倉儲物流
|
||||
</router-link>
|
||||
<NavbarBuilding class="hidden lg:block ms-8" />
|
||||
<!-- <NavbarBuilding class="hidden lg:block ms-8" /> -->
|
||||
</div>
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<NavbarItem
|
||||
|
@ -51,7 +51,7 @@
|
||||
"indoor_chart": "室內",
|
||||
"temperature": "温度",
|
||||
"humidity": "湿度",
|
||||
"no_data":"无数据",
|
||||
"no_data": "无数据",
|
||||
"alerts_data": "异常资料"
|
||||
},
|
||||
"history": {
|
||||
@ -179,6 +179,7 @@
|
||||
"normal": "已复归",
|
||||
"unacked": "未确认",
|
||||
"acked": "已确认",
|
||||
"7days": "近7天",
|
||||
"30days": "近30天",
|
||||
"start_date": "起始日期",
|
||||
"end_date": "结束日期",
|
||||
@ -192,7 +193,7 @@
|
||||
"time": "发生时间",
|
||||
"error_msg": "异常原因",
|
||||
"ack_state": "Ack 确认",
|
||||
"repair_order_number": "派工 / 维运单号",
|
||||
"repair_order_number": "烟 / 火警处理单",
|
||||
"repair_order": "维修单",
|
||||
"form_number": "表单编号",
|
||||
"start_time": "预计开始时间",
|
||||
@ -207,6 +208,9 @@
|
||||
"completed": "已完成",
|
||||
"worker_id": "工作人员编号",
|
||||
"notice": "注意事项",
|
||||
"video_storage_location": "告警影片儲存位置",
|
||||
"copy": "复制",
|
||||
"copied": "已复制!",
|
||||
"result_description": "结果描述",
|
||||
"upload_file": "上传文件",
|
||||
"enable": "启用",
|
||||
@ -244,7 +248,8 @@
|
||||
"saturday": "星期六",
|
||||
"schedule_name": "时段名称",
|
||||
"schedule_content": "时段内容",
|
||||
"reorganization": "MQTT 告警重整"
|
||||
"reorganization": "MQTT 告警重整",
|
||||
"maintenance": "保养"
|
||||
},
|
||||
"operation": {
|
||||
"title": "运维管理",
|
||||
@ -418,5 +423,11 @@
|
||||
"system_point_name": "系统点位名称",
|
||||
"json_format_text": "请贴上 JSON 格式数据",
|
||||
"json_click_text": "请在左侧输入JSON并点选转换按钮"
|
||||
},
|
||||
"rtsp": {
|
||||
"title": "影像串流",
|
||||
"selectDevice": "选择设备",
|
||||
"pleaseSelectDevice": "请先选择设备",
|
||||
"normalQuery": "已复归查询"
|
||||
}
|
||||
}
|
||||
|
@ -179,6 +179,7 @@
|
||||
"normal": "已復歸",
|
||||
"unacked": "未確認",
|
||||
"acked": "已確認",
|
||||
"7days": "近7天",
|
||||
"30days": "近30天",
|
||||
"start_date": "起始日期",
|
||||
"end_date": "結束日期",
|
||||
@ -192,7 +193,7 @@
|
||||
"time": "發生時間",
|
||||
"error_msg": "異常原因",
|
||||
"ack_state": "Ack 確認",
|
||||
"repair_order_number": "派工 / 維運單號",
|
||||
"repair_order_number": "煙 / 火告警處理單",
|
||||
"repair_order": "維修單",
|
||||
"form_number": "表單編號",
|
||||
"start_time": "預計開始時間",
|
||||
@ -207,6 +208,9 @@
|
||||
"completed": "已完成",
|
||||
"worker_id": "工作人員編號",
|
||||
"notice": "注意事項",
|
||||
"video_storage_location": "告警影片儲存位置",
|
||||
"copy": "複製",
|
||||
"copied": "已複製!",
|
||||
"result_description": "結果描述",
|
||||
"upload_file": "上傳檔案",
|
||||
"enable": "啟用",
|
||||
@ -244,7 +248,8 @@
|
||||
"saturday": "星期六",
|
||||
"schedule_name": "時段名稱",
|
||||
"schedule_content": "時段內容",
|
||||
"reorganization": "MQTT 告警重整"
|
||||
"reorganization": "MQTT 告警重整",
|
||||
"maintenance": "保養"
|
||||
},
|
||||
"operation": {
|
||||
"title": "運維管理",
|
||||
@ -418,5 +423,11 @@
|
||||
"system_point_name": "系統點位名稱",
|
||||
"json_format_text": "請貼上 JSON 格式數據",
|
||||
"json_click_text": "請在左側輸入JSON並點選轉換按鈕"
|
||||
},
|
||||
"rtsp": {
|
||||
"title": "影像串流",
|
||||
"selectDevice": "選擇設備",
|
||||
"pleaseSelectDevice": "請先選擇設備",
|
||||
"normalQuery": "已復歸查詢"
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@
|
||||
"indoor_chart": "Indoor",
|
||||
"temperature": "Temp.",
|
||||
"humidity": "Hum.",
|
||||
"no_data":"No data",
|
||||
"no_data": "No data",
|
||||
"alerts_data": "Abnormal data"
|
||||
},
|
||||
"history": {
|
||||
@ -179,6 +179,7 @@
|
||||
"normal": "Normal",
|
||||
"unacked": "Unacked",
|
||||
"acked": "Acked",
|
||||
"7days": "Last 7 Days",
|
||||
"30days": "Last 30 Days",
|
||||
"start_date": "Start Date",
|
||||
"end_date": "End Date",
|
||||
@ -207,6 +208,9 @@
|
||||
"completed": "Completed",
|
||||
"worker_id": "Worker ID",
|
||||
"notice": "Notice",
|
||||
"video_storage_location": "video storage location",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"result_description": "Result Description",
|
||||
"upload_file": "Upload File",
|
||||
"enable": "Enable",
|
||||
@ -244,7 +248,8 @@
|
||||
"saturday": "Saturday",
|
||||
"schedule_name": "Time period name",
|
||||
"schedule_content": "Time period content",
|
||||
"reorganization": "MQTT Alarm Reorganization"
|
||||
"reorganization": "MQTT Alarm Reorganization",
|
||||
"maintenance": "Upkeep"
|
||||
},
|
||||
"operation": {
|
||||
"title": "Operation And Maintenance Management",
|
||||
@ -418,5 +423,11 @@
|
||||
"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": "Video Stream",
|
||||
"selectDevice": "Select Device",
|
||||
"pleaseSelectDevice": "Please select a device first",
|
||||
"normalQuery": "Query normal records"
|
||||
}
|
||||
}
|
||||
|
@ -67,4 +67,10 @@ export const AUTHPAGES = [
|
||||
pageName: "Setting",
|
||||
navigate: "/Setting",
|
||||
},
|
||||
{
|
||||
authCode: "PF12",
|
||||
icon: "camera",
|
||||
pageName: "rtsp",
|
||||
navigate: "/rtsp",
|
||||
},
|
||||
];
|
||||
|
@ -4,13 +4,16 @@ const moveModal = (elmnt) => {
|
||||
pos2 = 0,
|
||||
pos3 = 0,
|
||||
pos4 = 0;
|
||||
document.body.addEventListener("mousedown", dragMouseDown, {
|
||||
// 只在目標元素上監聽 mousedown,避免全域攔截
|
||||
elmnt.addEventListener("mousedown", dragMouseDown, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
function dragMouseDown(e) {
|
||||
console.log("dragMouseDown", e);
|
||||
e = e || window.event;
|
||||
// 僅當左鍵拖曳才阻止預設(避免影響下拉選單等)
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
// get the mouse cursor position at startup:
|
||||
pos3 = e.clientX;
|
||||
@ -48,7 +51,7 @@ export const draggable = {
|
||||
install(app) {
|
||||
app.directive("draggable", {
|
||||
mounted: (el, binding, vnode, prevVnode) => {
|
||||
console.log("draggable", $(`#${el.id}`).draggable);
|
||||
// console.log("draggable", $(`#${el.id}`).draggable);
|
||||
if (binding.value) {
|
||||
if ($(`#${el.id}`).draggable) {
|
||||
$(`#${el.id}`).draggable({
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
|
89
src/main.js
89
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 { 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";
|
||||
|
||||
// ---- Directives ----
|
||||
import { focusPlugin } from "@/directives/focusPlugin";
|
||||
import { draggable } from "@/directives/draggable";
|
||||
|
||||
// ---- 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 App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "virtual:svg-icons-register";
|
||||
// 引入项目中的全部全局组件
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import library from "./fontawsomeIconRegister";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
import { focusPlugin } from "@/directives/focusPlugin";
|
||||
import { draggable } from "@/directives/draggable";
|
||||
const messages = {
|
||||
tw,
|
||||
cn,
|
||||
us,
|
||||
};
|
||||
|
||||
const messages = { tw, cn, us };
|
||||
const storedLanguage = localStorage.getItem("EmpowerLanguage") || "tw";
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: storedLanguage,
|
||||
fallbackLocale: 'tw',
|
||||
messages,
|
||||
locale: storedLanguage,
|
||||
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="
|
||||
|
@ -57,7 +57,7 @@ const columns = computed(() => [
|
||||
{
|
||||
title: t("assetManagement.device_name"),
|
||||
key: "full_name",
|
||||
filter: true,
|
||||
// filter: true,
|
||||
class: "break-all",
|
||||
},
|
||||
{
|
||||
@ -67,13 +67,13 @@ const columns = computed(() => [
|
||||
{
|
||||
title: t("assetManagement.floor"),
|
||||
key: "floor",
|
||||
filter: true,
|
||||
sort: true,
|
||||
// filter: true,
|
||||
// sort: true,
|
||||
},
|
||||
{
|
||||
title: t("assetManagement.department"),
|
||||
key: "department",
|
||||
filter: true,
|
||||
// filter: true,
|
||||
},
|
||||
{
|
||||
title: t("assetManagement.device_coordinate"),
|
||||
@ -90,7 +90,7 @@ const columns = computed(() => [
|
||||
{
|
||||
title: t("assetManagement.buying_date"),
|
||||
key: "buying_date",
|
||||
sort: true,
|
||||
// sort: true,
|
||||
},
|
||||
// {
|
||||
// title: t("assetManagement.oriFile"),
|
||||
@ -99,7 +99,7 @@ const columns = computed(() => [
|
||||
{
|
||||
title: t("assetManagement.created_at"),
|
||||
key: "created_at",
|
||||
sort: true,
|
||||
// sort: true,
|
||||
},
|
||||
{
|
||||
title: t("assetManagement.operation"),
|
||||
|
@ -15,7 +15,7 @@ import useBuildingStore from "@/stores/useBuildingStore";
|
||||
|
||||
const { searchParams } = useSearchParam();
|
||||
const store = useBuildingStore();
|
||||
const hasSearched = ref(false);
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const dataSource = ref([]);
|
||||
const model_data = ref({
|
||||
@ -25,6 +25,7 @@ const model_data = ref({
|
||||
|
||||
const editRecord = ref(null);
|
||||
|
||||
// -------- Modal: detail 映射與開啟 --------
|
||||
const updateEditRecord = (data) => {
|
||||
if (data?.lorf) {
|
||||
editRecord.value = {
|
||||
@ -63,26 +64,29 @@ const getAllOptions = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
tableLoading.value = true;
|
||||
if (Object.keys(searchParams.value).length !== 0) {
|
||||
const res = await getAlertLog({
|
||||
...searchParams.value,
|
||||
isRecovery: Number(searchParams.value.isRecovery),
|
||||
});
|
||||
dataSource.value = (res.data || []).map((d) => ({ ...d, key: d.id }));
|
||||
tableLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = async (record) => {
|
||||
try {
|
||||
if (record.formId) {
|
||||
const res = await getOperationEditRecord(record.formId);
|
||||
|
||||
const pickVideo = (obj = {}) =>
|
||||
obj.video_url ?? obj.videoUrl ?? obj.video_path ?? obj.videoPath ?? "";
|
||||
|
||||
const videoFromDetail = pickVideo(res.data);
|
||||
const videoFromRecord = pickVideo(record);
|
||||
|
||||
updateEditRecord({
|
||||
...res.data,
|
||||
uuid: res.data.error_code,
|
||||
device_number: record.device_number,
|
||||
device_number: record.device_number ?? res.data.device_number ?? "",
|
||||
...(videoFromDetail
|
||||
? { video_url: videoFromDetail }
|
||||
: videoFromRecord
|
||||
? { video_url: videoFromRecord }
|
||||
: {}),
|
||||
video_path: res.data.video_path ?? record.video_path ?? undefined,
|
||||
videoUrl: res.data.videoUrl ?? record.videoUrl ?? undefined,
|
||||
videoPath: res.data.videoPath ?? record.videoPath ?? undefined,
|
||||
});
|
||||
} else {
|
||||
updateEditRecord({
|
||||
@ -96,33 +100,59 @@ const openModal = async (record) => {
|
||||
}
|
||||
};
|
||||
|
||||
// -------- 查詢 API --------
|
||||
const search = async () => {
|
||||
// 防呆:必要條件才打(避免 undefined 造成多餘請求)
|
||||
const params = searchParams.value || {};
|
||||
const ready =
|
||||
params.isRecovery !== undefined &&
|
||||
!!params.Start_date &&
|
||||
!!params.End_date &&
|
||||
!!params.device_name_tag;
|
||||
|
||||
if (!ready) return;
|
||||
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const res = await getAlertLog({
|
||||
...params,
|
||||
isRecovery: Number(params.isRecovery),
|
||||
});
|
||||
dataSource.value = (res.data || []).map((d) => ({ ...d, key: d.id }));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// -------- 監聽條件 -> 立即搜尋(含 debounce)--------
|
||||
const autoSearchTimer = ref(null);
|
||||
const triggerAutoSearch = () => {
|
||||
if (autoSearchTimer.value) clearTimeout(autoSearchTimer.value);
|
||||
// 輕微 debounce,避免連動欄位連續觸發
|
||||
autoSearchTimer.value = setTimeout(() => {
|
||||
search();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getAllOptions();
|
||||
});
|
||||
|
||||
// 只針對會影響查詢結果的關鍵欄位監聽
|
||||
watch(
|
||||
() => ({
|
||||
isRecovery: searchParams.value.isRecovery,
|
||||
Start_date: searchParams.value.Start_date,
|
||||
End_date: searchParams.value.End_date,
|
||||
device_name_tag: searchParams.value.device_name_tag,
|
||||
isRecovery: searchParams.value?.isRecovery,
|
||||
Start_date: searchParams.value?.Start_date,
|
||||
End_date: searchParams.value?.End_date,
|
||||
device_name_tag: searchParams.value?.device_name_tag,
|
||||
}),
|
||||
(val) => {
|
||||
// 判斷所有必要欄位都已經有值且是初始畫面未搜尋過
|
||||
if (
|
||||
val.isRecovery !== undefined &&
|
||||
val.Start_date &&
|
||||
val.End_date &&
|
||||
val.device_name_tag &&
|
||||
!hasSearched.value
|
||||
) {
|
||||
hasSearched.value = true;
|
||||
search();
|
||||
}
|
||||
() => {
|
||||
triggerAutoSearch();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// -------- 對外提供 --------
|
||||
provide("alert_modal", { model_data, search, updateEditRecord });
|
||||
provide("alert_table", {
|
||||
openModal,
|
||||
|
@ -5,19 +5,15 @@ import AlertSearchTimeRange from "./AlertSearchTimeRange.vue";
|
||||
import AlertSearchTypesButton from "./AlertSearchTypesButton.vue";
|
||||
|
||||
const { search } = inject("alert_table");
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex flex-wrap flex-col custom-border px-4 pt-0 pb-4 mb-4">
|
||||
<div class="w-full flex flex-wrap items-center justify-start gap-4 mt-4 lg:mt-0">
|
||||
<div
|
||||
class="w-full flex flex-wrap items-center justify-start gap-4 mt-4 lg:mt-0"
|
||||
>
|
||||
<AlertSearchNormalBtns />
|
||||
<!-- <AlertSearchAckBtns /> -->
|
||||
<AlertSearchTimeRange />
|
||||
<button class="btn btn-search lg:ml-8" @click.stop.prevent="search">
|
||||
<font-awesome-icon :icon="['fas', 'search']" class=" text-lg" />
|
||||
{{ $t("button.search")}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full flex flex-wrap items-center justify-start">
|
||||
<AlertSearchTypesButton />
|
||||
|
@ -1,63 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, watch } from "vue";
|
||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
import useSearchParam from "@/hooks/useSearchParam";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t, locale } = useI18n();
|
||||
const { searchParams, changeParams } = useSearchParam();
|
||||
const {
|
||||
items,
|
||||
changeActiveBtn,
|
||||
setItems,
|
||||
selectedBtn
|
||||
} = useActiveBtn();
|
||||
|
||||
const initializeItems = () => {
|
||||
setItems([
|
||||
{
|
||||
title: t("alert.unacked"),
|
||||
key: "unacked",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
title: t("alert.acked"),
|
||||
key: "acked",
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeItems();
|
||||
});
|
||||
|
||||
watch(locale, () => {
|
||||
initializeItems();
|
||||
});
|
||||
|
||||
watch(
|
||||
selectedBtn,
|
||||
(newValue) => {
|
||||
changeParams({ ...searchParams.value, isAck: newValue.key });
|
||||
},
|
||||
);
|
||||
|
||||
// 監聽搜尋變化
|
||||
watch(
|
||||
searchParams,
|
||||
(newSearchParams) => {
|
||||
if (!newSearchParams.isAck) {
|
||||
changeParams({ ...newSearchParams, isAck: "unacked" });
|
||||
}
|
||||
},
|
||||
{ immediate: true } // 確保在初始化立即觸發
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonGroup :items="items" :withLine="true" class="mr-5" :onclick="(e, item) => {
|
||||
changeActiveBtn(item);
|
||||
}" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
@ -3,22 +3,14 @@ import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
import { onMounted, watch } from "vue";
|
||||
import useSearchParam from "@/hooks/useSearchParam";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const { searchParams, changeParams } = useSearchParam();
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
|
||||
const initializeItems = () => {
|
||||
setItems([
|
||||
{
|
||||
title: t("alert.offnormal"),
|
||||
key: 1,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
title: t("alert.normal"),
|
||||
key: 2,
|
||||
active: false,
|
||||
},
|
||||
{ title: t("alert.offnormal"), key: 1, active: true }, // 異常(未恢復)
|
||||
{ title: t("alert.normal"), key: 2, active: false }, // 正常(已恢復)
|
||||
]);
|
||||
};
|
||||
|
||||
@ -26,23 +18,24 @@ onMounted(() => {
|
||||
initializeItems();
|
||||
});
|
||||
|
||||
// 監聽按鈕變化
|
||||
// 點擊 tab → 直接寫回 isRecovery;父層會偵測變更自動查詢
|
||||
watch(
|
||||
selectedBtn,
|
||||
(newValue) => {
|
||||
if (!newValue?.key) return;
|
||||
changeParams({ ...searchParams.value, isRecovery: newValue.key });
|
||||
}
|
||||
);
|
||||
|
||||
// 監聽搜尋變化
|
||||
// 初始化若缺 isRecovery,補預設 1,避免父層條件不齊
|
||||
watch(
|
||||
searchParams,
|
||||
(newSearchParams) => {
|
||||
if (!newSearchParams.isRecovery) {
|
||||
changeParams({ ...newSearchParams, isRecovery: 1 });
|
||||
(sp) => {
|
||||
if (!sp.isRecovery) {
|
||||
changeParams({ ...sp, isRecovery: 1 });
|
||||
}
|
||||
},
|
||||
{ immediate: true } // 確保在初始化立即觸發
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
|
@ -1,16 +1,55 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted, watch, computed } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import useSearchParam from "@/hooks/useSearchParam";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t, locale } = useI18n();
|
||||
const { searchParams, changeParams } = useSearchParam();
|
||||
|
||||
/**
|
||||
* 可調整 props:
|
||||
* - showQuickButton:是否顯示快捷按鈕(預設 true,可關掉)
|
||||
* - quickButtonType:快捷類型 '30d' | '7d'(預設 '30d')
|
||||
*/
|
||||
const props = defineProps({
|
||||
showQuickButton: { type: Boolean, default: true },
|
||||
quickButtonType: { type: String, default: "30d" }, // '30d' | '7d'
|
||||
});
|
||||
|
||||
// 統一設定區間的 helper
|
||||
const setRange = (start, end) => {
|
||||
const newRange = [
|
||||
{
|
||||
key: "start_at",
|
||||
value: dayjs(start),
|
||||
dateFormat: "yyyy-MM-dd",
|
||||
placeholder: t("alert.start_date"),
|
||||
},
|
||||
{
|
||||
key: "end_at",
|
||||
value: dayjs(end),
|
||||
dateFormat: "yyyy-MM-dd",
|
||||
placeholder: t("alert.end_date"),
|
||||
},
|
||||
];
|
||||
dateRange.value = newRange;
|
||||
|
||||
// 同步到共用查詢參數(字串格式)
|
||||
changeParams({
|
||||
...searchParams.value,
|
||||
Start_date: dayjs(start).format("YYYY-MM-DD"),
|
||||
End_date: dayjs(end).format("YYYY-MM-DD"),
|
||||
});
|
||||
};
|
||||
|
||||
// 初始 dateRange:若有既有搜尋值就吃既有;否則依 quickButtonType
|
||||
const dateRange = ref([
|
||||
{
|
||||
key: "start_at",
|
||||
value: searchParams.value.Start_date
|
||||
? dayjs(searchParams.value.Start_date)
|
||||
: props.quickButtonType === "7d"
|
||||
? dayjs().subtract(7, "day")
|
||||
: dayjs().subtract(30, "day"),
|
||||
dateFormat: "yyyy-MM-dd",
|
||||
placeholder: t("alert.start_date"),
|
||||
@ -25,42 +64,28 @@ const dateRange = ref([
|
||||
},
|
||||
]);
|
||||
|
||||
// 快捷按鈕:依 quickButtonType 決定區間
|
||||
const changeTimeRange = () => {
|
||||
const newRange = [
|
||||
{
|
||||
key: "start_at",
|
||||
value: dayjs().subtract(30, "day"),
|
||||
dateFormat: "yyyy-MM-dd",
|
||||
placeholder: t("alert.start_date"),
|
||||
},
|
||||
{
|
||||
key: "end_at",
|
||||
value: dayjs().endOf("day"),
|
||||
dateFormat: "yyyy-MM-dd",
|
||||
placeholder: t("alert.end_date"),
|
||||
},
|
||||
];
|
||||
|
||||
dateRange.value = newRange;
|
||||
|
||||
changeParams({
|
||||
...searchParams.value,
|
||||
Start_date: newRange[0].value,
|
||||
End_date: newRange[1].value,
|
||||
});
|
||||
if (props.quickButtonType === "7d") {
|
||||
// 近7天:起始 = 今天往回 7 天,結束 = 今天
|
||||
setRange(dayjs().subtract(7, "day"), dayjs().endOf("day"));
|
||||
} else {
|
||||
// 近30天:起始 = 今天往回 30 天,結束 = 今天
|
||||
setRange(dayjs().subtract(30, "day"), dayjs().endOf("day"));
|
||||
}
|
||||
};
|
||||
|
||||
// 進頁面初始化:若沒有既有 Start/End,依 quickButtonType 設定
|
||||
onMounted(() => {
|
||||
// 初始化日期範圍
|
||||
if (
|
||||
!searchParams.value.Start_date ||
|
||||
!searchParams.value.End_date
|
||||
) {
|
||||
if (!searchParams.value.Start_date || !searchParams.value.End_date) {
|
||||
changeTimeRange();
|
||||
} else {
|
||||
// 若已存在既有值,把區間同步回 dateRange(避免格式落差)
|
||||
setRange(searchParams.value.Start_date, searchParams.value.End_date);
|
||||
}
|
||||
});
|
||||
|
||||
// 監聽搜尋變化
|
||||
// 使用者調整日期 → 同步到共用查詢參數
|
||||
watch(
|
||||
dateRange,
|
||||
() => {
|
||||
@ -70,24 +95,34 @@ watch(
|
||||
End_date: dayjs(dateRange.value[1].value).format("YYYY-MM-DD"),
|
||||
});
|
||||
},
|
||||
{ deep: true } // 確保在初始化立即觸發
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 語系切換時,更新 placeholder
|
||||
watch(locale, () => {
|
||||
dateRange.value[0].placeholder = t("alert.start_date");
|
||||
dateRange.value[1].placeholder = t("alert.end_date");
|
||||
});
|
||||
|
||||
// 快捷按鈕顯示文字
|
||||
const quickBtnLabel = computed(() =>
|
||||
props.quickButtonType === "7d" ? t("alert.7days") : t("alert.30days")
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center">
|
||||
<!-- 快捷按鈕:可關閉;可切換 30天 / 7天 -->
|
||||
<button
|
||||
v-if="showQuickButton"
|
||||
type="button"
|
||||
class="btn btn-outline-success mr-3"
|
||||
@click="changeTimeRange"
|
||||
>
|
||||
{{ $t("alert.30days") }}
|
||||
{{ quickBtnLabel }}
|
||||
</button>
|
||||
|
||||
<!-- 日期群組 -->
|
||||
<DateGroup :items="dateRange" :withLine="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -1,5 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, defineProps, watch, inject } from "vue";
|
||||
import {
|
||||
ref,
|
||||
defineProps,
|
||||
watch,
|
||||
inject,
|
||||
nextTick,
|
||||
onMounted,
|
||||
toRaw,
|
||||
} from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import { postOperationRecord } from "@/apis/alert";
|
||||
import * as yup from "yup";
|
||||
@ -10,7 +18,7 @@ const { t } = useI18n();
|
||||
const FILE_BASEURL = import.meta.env.VITE_FILE_API_BASEURL;
|
||||
|
||||
const props = defineProps({
|
||||
editRecord: Object,
|
||||
editRecord: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const form = ref(null);
|
||||
@ -64,18 +72,41 @@ const updateFileList = (files) => {
|
||||
formState.value.lorf = files;
|
||||
};
|
||||
|
||||
// ---------------------- 告警影片儲存位置:顯示 API 的 video_url ----------------------
|
||||
const videoLocation = ref({ videoLocation: "" });
|
||||
const showTooltip = ref(false);
|
||||
|
||||
async function copyToClipboard() {
|
||||
const text = videoLocation.value.videoLocation || "";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
const formData = new FormData(form.value);
|
||||
formData.delete("oriFile");
|
||||
|
||||
formState.value?.lorf.forEach((file, index) => {
|
||||
formData.append(`lorf[${index}].id`, file.id ? file.id : "");
|
||||
formData.append(`lorf[${index}].file`, file.id ? null : file);
|
||||
(formState.value?.lorf ?? []).forEach((file, index) => {
|
||||
formData.append(`lorf[${index}].id`, file?.id ? file.id : "");
|
||||
// 只有新檔案才上傳檔案本體
|
||||
if (!file?.id && file) {
|
||||
formData.append(`lorf[${index}].file`, file);
|
||||
}
|
||||
formData.append(
|
||||
`lorf[${index}].save_file_name`,
|
||||
file.id ? file.save_file_name : ""
|
||||
file?.id ? file.save_file_name : ""
|
||||
);
|
||||
formData.append(`lorf[${index}].ori_file_name`, file.name);
|
||||
formData.append(`lorf[${index}].ori_file_name`, file?.name ?? "");
|
||||
});
|
||||
|
||||
formData.append(
|
||||
@ -83,9 +114,9 @@ const onOk = async () => {
|
||||
dayjs(dateItem.value[0].value).format("YYYY-MM-DD")
|
||||
);
|
||||
|
||||
props.editRecord.id && formData.append("id", props.editRecord.id);
|
||||
|
||||
props.editRecord.uuid && formData.append("error_code", props.editRecord.uuid);
|
||||
if (props.editRecord.id) formData.append("id", props.editRecord.id);
|
||||
if (props.editRecord.uuid)
|
||||
formData.append("error_code", props.editRecord.uuid);
|
||||
|
||||
formData.append("work_type", 2);
|
||||
formData.append(
|
||||
@ -96,7 +127,7 @@ const onOk = async () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const value = await handleSubmit(alertSchema, formState.value);
|
||||
await handleSubmit(alertSchema, formState.value);
|
||||
const res = await postOperationRecord(formData);
|
||||
if (res.isSuccess) {
|
||||
search?.();
|
||||
@ -123,32 +154,34 @@ const onCancel = () => {
|
||||
description: "",
|
||||
lorf: [],
|
||||
};
|
||||
// 重置顯示用的影片路徑
|
||||
videoLocation.value.videoLocation = "";
|
||||
handleErrorReset();
|
||||
updateEditRecord?.(null);
|
||||
alert_action_item.close();
|
||||
};
|
||||
|
||||
// 同步 props.editRecord -> formState / 日期 / 維修項目 / 影片網址
|
||||
watch(
|
||||
() => props.editRecord,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
for (let [key, value] of Object.entries(newVal)) {
|
||||
// 僅更新 formState 中已有的屬性
|
||||
if (key in formState.value) {
|
||||
formState.value[key] = value;
|
||||
}
|
||||
// 處理 start_time
|
||||
if (key === "start_time") {
|
||||
formState.value.start_time = value
|
||||
? dayjs(value).format("YYYY-MM-DD")
|
||||
: dayjs().format("YYYY-MM-DD");
|
||||
dateItem.value[0].value = value;
|
||||
const d = value ? dayjs(value) : dayjs();
|
||||
formState.value.start_time = d.format("YYYY-MM-DD");
|
||||
dateItem.value[0].value = d; // 一律用 dayjs 物件
|
||||
}
|
||||
// 維修項目
|
||||
if (key === "full_name") {
|
||||
formState.value.fix_do = value;
|
||||
formState.value.fix_do = value ?? "";
|
||||
}
|
||||
}
|
||||
// 取 API 回傳的影片位址(與 device_number 同來源物件)
|
||||
videoLocation.value.videoLocation =
|
||||
newVal?.video_url ?? newVal?.videoUrl ?? newVal?.video_path ?? "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@ -160,10 +193,14 @@ watch(
|
||||
id="alert_action_item"
|
||||
:title="t('alert.repair_order')"
|
||||
:onCancel="onCancel"
|
||||
width="710"
|
||||
:width="710"
|
||||
>
|
||||
<template #modalContent>
|
||||
<form ref="form" class="mt-5 w-full flex flex-wrap justify-between">
|
||||
<form
|
||||
ref="form"
|
||||
class="mt-5 w-full flex flex-wrap justify-between"
|
||||
@submit.prevent
|
||||
>
|
||||
<Input
|
||||
v-if="formState && formState.formId"
|
||||
class="my-2"
|
||||
@ -286,9 +323,48 @@ watch(
|
||||
</span></template
|
||||
>
|
||||
</Select>
|
||||
|
||||
<!-- 注意事項 -->
|
||||
<Textarea :value="formState" name="notice" class="w-full my-2">
|
||||
<template #topLeft>{{ $t("alert.notice") }}</template>
|
||||
</Textarea>
|
||||
|
||||
<!-- 告警影片儲存位置-->
|
||||
<div class="my-4 w-full">
|
||||
<label class="text-lg">
|
||||
{{ $t("alert.video_storage_location") }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
class="flex-1"
|
||||
name="videoLocation"
|
||||
:value="videoLocation"
|
||||
readonly
|
||||
/>
|
||||
<div class="relative inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
@click.stop="copyToClipboard"
|
||||
>
|
||||
{{ $t("alert.copy") }}
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<span
|
||||
v-if="showTooltip"
|
||||
class="absolute left-full ml-4 top-1/2 -translate-y-1/2 text-white text-xs px-2 py-1 bg-gray-800 rounded shadow whitespace-nowrap"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ $t("alert.copied") }}
|
||||
</span>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 結果描述 -->
|
||||
<Textarea :value="formState" name="description" class="w-full my-2">
|
||||
<template #topLeft>{{ $t("alert.result_description") }}</template>
|
||||
</Textarea>
|
||||
|
@ -100,6 +100,8 @@ const getData = async () => {
|
||||
buying_date: device.buying_date,
|
||||
created_at: device.created_at,
|
||||
bgSize: 50,
|
||||
is_rtsp: device.is_rtsp === true,
|
||||
rtsp_url: device.rtsp_url || "",
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -134,20 +136,14 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-between">
|
||||
<div
|
||||
class="order-3 lg:order-1 w-full lg:w-1/4 min-h-screen flex flex-col justify-start z-10 border-dashboard gap-5"
|
||||
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"
|
||||
>
|
||||
<!-- 無資料時:完整隱藏區塊,不留空白 -->
|
||||
|
||||
<!-- <DashboardProduct
|
||||
@visible-change="(v) => (productVisible = v)"
|
||||
v-show="productVisible"
|
||||
/>
|
||||
<DashboardProductComplete
|
||||
@visible-change="(v) => (productCompleteVisible = v)"
|
||||
v-show="productVisible"
|
||||
/> -->
|
||||
<DashboardIndoor />
|
||||
<DashboardRefrig class="mb-10" />
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardIndoor />
|
||||
</div>
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardRefrig />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -156,19 +152,19 @@ onUnmounted(() => {
|
||||
<DashboardFloorBar />
|
||||
<DashboardEffectScatter :data="systemData" />
|
||||
</div>
|
||||
<div class="order-2 w-full lg:hidden my-3">
|
||||
<!-- <div class="order-2 w-full lg:hidden my-3">
|
||||
<DashboardSysCard :data="systemData" />
|
||||
</div>
|
||||
</div> -->
|
||||
<div
|
||||
class="order-last w-full lg:w-1/4 flex flex-col justify-start border-dashboard z-20 gap-5"
|
||||
class="order-last w-full lg:w-1/4 flex flex-col justify-start border-dashboard z-20 gap-12"
|
||||
>
|
||||
<div>
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardElectricity />
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardEmission />
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardAlert />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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,45 +92,47 @@ 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;
|
||||
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 (
|
||||
floorId &&
|
||||
chartReady &&
|
||||
Object.keys(newData || {}).length > 0 &&
|
||||
newChart.chart
|
||||
newValue.floor_id &&
|
||||
newChart &&
|
||||
Object.keys(newData || {}).length > 0
|
||||
) {
|
||||
console.log("[FloorMap] update series only for floor:", floorId);
|
||||
newChart.chart.setOption(
|
||||
{ series: defaultOption(floorId, currentIconData.value).series },
|
||||
false,
|
||||
true
|
||||
);
|
||||
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: 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
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -6,10 +6,7 @@ import { useI18n } from "vue-i18n";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const { t } = useI18n();
|
||||
const currentTab = ref("desktop");
|
||||
const changeOpenKey = (key) => {
|
||||
currentTab.value = key;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
@ -17,6 +14,16 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const isRtsp = computed(() => props.data?.is_rtsp === true);
|
||||
|
||||
const monitorUrl = computed(() => props.data?.rtsp_url || "");
|
||||
|
||||
// ------ tab 狀態 / 控制(非 RTSP 時使用)------
|
||||
const currentTab = ref("desktop");
|
||||
const changeOpenKey = (key) => {
|
||||
currentTab.value = key;
|
||||
};
|
||||
|
||||
const modal = ref(null);
|
||||
|
||||
// 當 data 有值時自動開啟 modal
|
||||
@ -24,15 +31,23 @@ watch(
|
||||
() => props.data,
|
||||
(newData) => {
|
||||
if (newData) {
|
||||
console.log("[props.data] =\n", JSON.stringify(props.data, null, 2));
|
||||
dashboard_effectScatter_modal.showModal();
|
||||
if (!isRtsp.value) currentTab.value = "desktop";
|
||||
console.debug(
|
||||
"[Modal Debug] is_rtsp:",
|
||||
newData.is_rtsp,
|
||||
"monitorUrl:",
|
||||
monitorUrl.value
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 關閉 modal 的處理函數
|
||||
// 關閉 modal
|
||||
const handleCancel = () => {
|
||||
currentTab.value = "desktop"; // 重置到 desktop tab
|
||||
currentTab.value = "desktop";
|
||||
dashboard_effectScatter_modal.close();
|
||||
};
|
||||
</script>
|
||||
@ -47,70 +62,75 @@ const handleCancel = () => {
|
||||
:draggable="true"
|
||||
modalClass="max-h-[80vh]"
|
||||
>
|
||||
<!-- 標題列:RTSP 不顯示分頁鈕;非 RTSP 顯示分頁鈕 -->
|
||||
<template #modalTitle>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<template v-if="!isRtsp">
|
||||
<button
|
||||
type="button"
|
||||
class="text-base btn-link btn-text-without-border px-2"
|
||||
@click="() => changeOpenKey('desktop')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'desktop']"
|
||||
size="lg"
|
||||
:class="
|
||||
twMerge(
|
||||
currentTab === 'desktop' ? 'text-success' : 'text-[#a5abb1]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-base btn-link btn-text-without-border px-2"
|
||||
@click="() => changeOpenKey('image')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'image']"
|
||||
size="lg"
|
||||
:class="
|
||||
twMerge(
|
||||
currentTab === 'image' ? 'text-success' : 'text-[#a5abb1]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-base btn-link btn-text-without-border px-2"
|
||||
@click="() => changeOpenKey('cog')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'cog']"
|
||||
size="lg"
|
||||
:class="
|
||||
twMerge(
|
||||
currentTab === 'cog' ? 'text-success' : 'text-[#a5abb1]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-base btn-link btn-text-without-border px-2"
|
||||
@click="() => changeOpenKey('chart')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'chart-line']"
|
||||
size="lg"
|
||||
:class="
|
||||
twMerge(
|
||||
currentTab === 'chart' ? 'text-success' : 'text-[#a5abb1]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link btn-text-without-border px-2"
|
||||
@ -126,7 +146,20 @@ const handleCancel = () => {
|
||||
</template>
|
||||
|
||||
<template #modalContent>
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- RTSP,顯示 iframe -->
|
||||
<div v-if="isRtsp" class="h-[60vh] py-4">
|
||||
<div class="relative bg-black rounded h-full overflow-hidden">
|
||||
<iframe
|
||||
:src="monitorUrl"
|
||||
class="absolute inset-0 w-full h-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 非 RTSP,顯示四分頁 -->
|
||||
<div v-else class="space-y-4 py-4">
|
||||
<!-- Desktop Tab - 基本資訊 -->
|
||||
<div v-if="currentTab === 'desktop'" class="grid grid-cols-1 gap-4">
|
||||
<table
|
||||
@ -174,48 +207,6 @@ const handleCancel = () => {
|
||||
/>
|
||||
</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>
|
||||
@ -257,24 +248,35 @@ const handleCancel = () => {
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
{{ $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">
|
||||
{{ $t("assetManagement.created_at") }}
|
||||
</td>
|
||||
<td class="p-2 border">{{ props.data.created_at }}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -284,5 +286,9 @@ const handleCancel = () => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 可以添加額外的樣式 */
|
||||
/* 讓 Modal 內容能撐滿高度 */
|
||||
:deep(.min-h-\[200px\]) {
|
||||
min-height: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
@ -56,7 +56,7 @@ const getData = async () => {
|
||||
const getRealTime = async () => {
|
||||
if (store.selectedBuilding.building_guid) {
|
||||
const res = await getRealTimeDemand(store.selectedBuilding.building_guid);
|
||||
realTimeDemand.value = res.data.reverse();
|
||||
realTimeDemand.value = Array.isArray(res.data) ? res.data.reverse() : [];
|
||||
updateChart();
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -2,7 +2,7 @@
|
||||
import LineChart from "@/components/chart/LineChart.vue";
|
||||
import { SECOND_CHART_COLOR } from "@/constant";
|
||||
import dayjs from "dayjs";
|
||||
import { ref, watch, onUnmounted, computed } from "vue";
|
||||
import { ref, watch, onUnmounted, computed, nextTick, onMounted } from "vue";
|
||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
import { getDashboardTemp } from "@/apis/dashboard";
|
||||
import useSearchParams from "@/hooks/useSearchParam";
|
||||
@ -12,16 +12,42 @@ import { useI18n } from "vue-i18n";
|
||||
const { t, locale } = useI18n();
|
||||
const { searchParams } = useSearchParams();
|
||||
const buildingStore = useBuildingStore();
|
||||
const timeoutTimer = ref(null); // 定時器參考
|
||||
|
||||
const timeoutTimer = ref(null);
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
|
||||
const allTempData = ref([]);
|
||||
const currentOptionType = ref(1); // 當前顯示類型:1 = 溫度,2 = 濕度
|
||||
const noData = ref(false); // 無資料顯示控制
|
||||
const currentOptionType = ref(1); // 1=溫度, 2=濕度
|
||||
const noData = ref(false);
|
||||
const indoorChartRef = ref(null);
|
||||
|
||||
// 確認是否有資料,無則不呼叫 getDashboardTemp 也不顯示 chart
|
||||
// 雙類型資料快取
|
||||
const cacheMap = ref({ 1: [], 2: [] });
|
||||
|
||||
function getChart() {
|
||||
return indoorChartRef.value?.chart ?? null;
|
||||
}
|
||||
|
||||
async function resizeChart() {
|
||||
await nextTick();
|
||||
const chart = getChart();
|
||||
if (chart) {
|
||||
try { chart.resize(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", resizeChart);
|
||||
setItems(buttonItems.value);
|
||||
resizeChart();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", resizeChart);
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
});
|
||||
|
||||
// 初次或建物切換 → 先檢查 show_room,再一次載入兩種資料到快取
|
||||
watch(
|
||||
() => buildingStore.selectedBuilding?.building_guid,
|
||||
async (guid) => {
|
||||
@ -34,194 +60,185 @@ watch(
|
||||
|
||||
if (showRoom === false) {
|
||||
noData.value = true;
|
||||
return; // 不呼叫 getData
|
||||
return;
|
||||
}
|
||||
|
||||
// 允許顯示圖表+呼叫資料
|
||||
noData.value = false;
|
||||
getData();
|
||||
timeoutTimer.value = setInterval(getData, 60000); // 每分鐘自動更新
|
||||
|
||||
// 一次預載「溫度+濕度」資料
|
||||
await preloadBothOptions();
|
||||
|
||||
// 預設顯示溫度(立即用快取畫)
|
||||
currentOptionType.value = 1;
|
||||
applyChartFromCache(1); // 立即顯示
|
||||
await resizeChart();
|
||||
|
||||
// 之後每分鐘更新兩種快取,並同步更新目前顯示的那個
|
||||
timeoutTimer.value = setInterval(async () => {
|
||||
await preloadBothOptions(false);
|
||||
applyChartFromCache(currentOptionType.value); // 保持即時
|
||||
}, 60000);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 預設圖表設定
|
||||
// 預設圖表
|
||||
const defaultChartOption = ref({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: {
|
||||
data: [],
|
||||
textStyle: { color: "#ffffff", fontSize: 16 },
|
||||
},
|
||||
grid: {
|
||||
top: "10%",
|
||||
left: "0%",
|
||||
right: "0%",
|
||||
bottom: "0%",
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: "#ffffff" },
|
||||
data: [],
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: "#ffffff" },
|
||||
},
|
||||
legend: { data: [], top: 0, textStyle: { color: "#ffffff", fontSize: 12 } },
|
||||
grid: { top: "35%", left: "0%", right: "0%", bottom: "0%", containLabel: true },
|
||||
xAxis: { type: "category", splitLine: { show: false }, axisLabel: { color: "#ffffff" }, data: [] },
|
||||
yAxis: { type: "value", splitLine: { show: false }, axisLabel: { color: "#ffffff" } },
|
||||
series: [],
|
||||
});
|
||||
|
||||
const getData = async () => {
|
||||
// 取得單一類型資料
|
||||
async function fetchOption(option) {
|
||||
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
|
||||
if (!buildingGuid) return;
|
||||
|
||||
if (!buildingGuid) return [];
|
||||
try {
|
||||
const res = await getDashboardTemp({
|
||||
building_guid: buildingGuid,
|
||||
tempOption: 1, // 室溫區域
|
||||
tempOption: 1,
|
||||
timeInterval: 1,
|
||||
option: currentOptionType.value,
|
||||
option, // 1=溫度, 2=濕度
|
||||
});
|
||||
|
||||
const key = "室溫";
|
||||
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
|
||||
noData.value = allTempData.value.length === 0;
|
||||
return res?.isSuccess ? res?.data?.[key] ?? [] : [];
|
||||
} catch (e) {
|
||||
console.error("getDashboardTemp error", e);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 載入兩種資料到快取(首次 parallel,後續也可 parallel)
|
||||
async function preloadBothOptions(resetNoData = true) {
|
||||
const [tData, hData] = await Promise.all([fetchOption(1), fetchOption(2)]);
|
||||
cacheMap.value[1] = tData;
|
||||
cacheMap.value[2] = hData;
|
||||
|
||||
if (resetNoData) {
|
||||
// 兩邊都空才算無資料
|
||||
noData.value = tData.length === 0 && hData.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 立即用快取資料更新圖表(無需等待 API)
|
||||
function applyChartFromCache(option) {
|
||||
const data = cacheMap.value[option] || [];
|
||||
if (!data.length) {
|
||||
// 沒快取就顯示無資料(或保留上一個畫面也行)
|
||||
noData.value = true;
|
||||
return;
|
||||
}
|
||||
noData.value = false;
|
||||
renderChart(data);
|
||||
}
|
||||
|
||||
// 把資料轉成 ECharts option(抽成函數供快取與即時更新共用)
|
||||
function renderChart(newVal) {
|
||||
const chart = getChart();
|
||||
if (!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 minVal = Math.min(...allValues);
|
||||
const maxVal = Math.max(...allValues);
|
||||
const yMin = Math.floor(minVal) - 1;
|
||||
const yMax = Math.ceil(maxVal) + 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] },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 溫度與濕度切換按鈕
|
||||
const buttonItems = computed(() => [
|
||||
{ key: 1, title: t("dashboard.temperature"), active: true },
|
||||
{ key: 2, title: t("dashboard.humidity"), active: false },
|
||||
]);
|
||||
watch(() => locale.value, () => setItems(buttonItems.value), { immediate: true });
|
||||
|
||||
// 多語系切換時更新按鈕文字
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => setItems(buttonItems.value),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 切換溫度/濕度按鈕後更新資料與啟動定時器
|
||||
// 切換溫度/濕度按鈕
|
||||
// 切換溫/濕:先「立即」用快取畫,再刷新快取(回來會自動更即時)
|
||||
watch(
|
||||
selectedBtn,
|
||||
(newVal) => {
|
||||
if ([1, 2].includes(newVal?.key)) {
|
||||
currentOptionType.value = newVal.key;
|
||||
async (newVal) => {
|
||||
if (![1, 2].includes(newVal?.key)) return;
|
||||
|
||||
// 再次確認 show_room 為 true 才重新取資料
|
||||
if (buildingStore.sysConfig?.value?.show_room) {
|
||||
getData();
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
timeoutTimer.value = setInterval(getData, 60000);
|
||||
}
|
||||
currentOptionType.value = newVal.key;
|
||||
|
||||
// 1) 立刻顯示:直接用快取更新圖表
|
||||
applyChartFromCache(currentOptionType.value);
|
||||
await resizeChart();
|
||||
|
||||
// 2) 之後再更新快取(不阻塞顯示)
|
||||
const fresh = await fetchOption(currentOptionType.value);
|
||||
if (fresh.length) {
|
||||
cacheMap.value[currentOptionType.value] = fresh;
|
||||
// 當前頁籤就地更新到最新版
|
||||
renderChart(fresh);
|
||||
} else {
|
||||
// 若拿不到新資料,不覆寫舊快取,維持畫面
|
||||
}
|
||||
},
|
||||
{ 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]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxCount; i++) sampled.push(data[Math.round(i * step)]);
|
||||
return sampled;
|
||||
}
|
||||
|
||||
// 更新圖表資料
|
||||
watch(
|
||||
allTempData,
|
||||
(newVal) => {
|
||||
if (!newVal?.length || !indoorChartRef.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 minVal = Math.min(...allValues);
|
||||
const maxVal = Math.max(...allValues);
|
||||
|
||||
const yMin = Math.floor(minVal) - 1;
|
||||
const yMax = Math.ceil(maxVal) + 1;
|
||||
|
||||
indoorChartRef.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 }
|
||||
);
|
||||
// 離開元件時清除定時器
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 class="text-info text-xl text-center">
|
||||
{{ $t("dashboard.indoor_chart") }}
|
||||
</h3>
|
||||
<div className="my-3 w-full flex justify-center relative">
|
||||
|
||||
<div class="w-full flex justify-center items-center relative">
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
:onclick="(e, item) => changeActiveBtn(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="noData"
|
||||
class="text-center text-white text-lg min-h-[260px] flex items-center justify-center"
|
||||
>
|
||||
{{ $t("dashboard.no_data") }}
|
||||
</div>
|
||||
|
||||
<LineChart
|
||||
v-if="!noData"
|
||||
v-else
|
||||
id="dashboard_other_real_temp"
|
||||
class="min-h-[260px] max-h-fit"
|
||||
:option="defaultChartOption"
|
||||
ref="indoorChartRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
@ -1,150 +1,61 @@
|
||||
<script setup>
|
||||
import LineChart from "@/components/chart/LineChart.vue";
|
||||
import { SECOND_CHART_COLOR } from "@/constant";
|
||||
import { ref, watch, computed, onUnmounted } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import { ref, watch, onUnmounted, computed } from "vue";
|
||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
import { getDashboardTemp } from "@/apis/dashboard";
|
||||
import useSearchParams from "@/hooks/useSearchParam";
|
||||
import useBuildingStore from "@/stores/useBuildingStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { searchParams } = useSearchParams();
|
||||
const buildingStore = useBuildingStore();
|
||||
const allTempData = ref([]);
|
||||
const timeoutTimer = ref(null); // 定時器
|
||||
|
||||
// 狀態與按鈕邏輯
|
||||
const timeoutTimer = ref(null);
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
const currentOptionType = ref(1); // 1: 溫度, 2: 濕度
|
||||
const noData = ref(true); // 目前顯示「無資料」
|
||||
|
||||
// 確認是否有資料,無則不呼叫 getDashboardTemp 也不顯示 chart
|
||||
const allTempData = ref([]);
|
||||
const currentOptionType = ref(1); // 1 = 溫度,2 = 濕度
|
||||
const noData = ref(true); // 初始先視為無資料,等 API 後再更新
|
||||
const chartRef = ref(null);
|
||||
|
||||
// 監聽建築切換:依 sysConfig 決定是否顯示/取數據
|
||||
watch(
|
||||
() => buildingStore.selectedBuilding?.building_guid,
|
||||
async (guid) => {
|
||||
if (guid) {
|
||||
await buildingStore.getSysConfig(guid);
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
if (!guid) return;
|
||||
|
||||
const showRefrigeration =
|
||||
buildingStore.sysConfig?.value?.show_refrigeration;
|
||||
await buildingStore.getSysConfig(guid);
|
||||
const showRefrigeration =
|
||||
buildingStore.sysConfig?.value?.show_refrigeration;
|
||||
|
||||
if (showRefrigeration === false) {
|
||||
noData.value = true; // 不顯示圖表
|
||||
return; // 不呼叫 getData
|
||||
}
|
||||
|
||||
noData.value = false; // 有資料才進行呼叫
|
||||
getData();
|
||||
timeoutTimer.value = setInterval(getData, 60000); // 每分鐘叫一次
|
||||
if (showRefrigeration === false) {
|
||||
noData.value = true; // 不顯示
|
||||
return;
|
||||
}
|
||||
|
||||
noData.value = false; // 顯示並開始取資料
|
||||
await getData();
|
||||
timeoutTimer.value = setInterval(getData, 60_000); // 每分鐘更新
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 新增 API 資料取得函式
|
||||
const getData = async () => {
|
||||
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
|
||||
if (!buildingGuid) return;
|
||||
|
||||
try {
|
||||
const res = await getDashboardTemp({
|
||||
building_guid: buildingGuid,
|
||||
tempOption: 2, // 冷藏區域(室溫為 tempOption: 1)
|
||||
timeInterval: 1,
|
||||
option: currentOptionType.value, // 1: 溫度,2: 濕度
|
||||
});
|
||||
|
||||
const key = "冷藏"; // 根據實際後端回傳 key
|
||||
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
|
||||
noData.value = allTempData.value.length === 0;
|
||||
} catch (e) {
|
||||
console.error("getDashboardTemp error", e);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 資料更新 watch
|
||||
watch(
|
||||
allTempData,
|
||||
(newVal) => {
|
||||
if (!newVal?.length || !other_real_temp_chart.value?.chart) return;
|
||||
|
||||
const firstValid = newVal.find((d) => d.data?.length);
|
||||
if (!firstValid) return;
|
||||
|
||||
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
|
||||
dayjs(time).format("HH:mm:ss")
|
||||
);
|
||||
|
||||
const allValues = newVal
|
||||
.flatMap((d) => sampleData(d.data))
|
||||
.map((d) => d.value)
|
||||
.filter((v) => v != null);
|
||||
|
||||
if (!allValues.length) return;
|
||||
|
||||
const yMin = Math.floor(Math.min(...allValues)) - 1;
|
||||
const yMax = Math.ceil(Math.max(...allValues)) + 1;
|
||||
|
||||
other_real_temp_chart.value.chart.setOption({
|
||||
legend: {
|
||||
data: newVal.map((d) => d.full_name),
|
||||
},
|
||||
xAxis: {
|
||||
data: sampledXAxis,
|
||||
},
|
||||
yAxis: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
},
|
||||
series: newVal.map((d, i) => ({
|
||||
name: d.full_name,
|
||||
type: "line",
|
||||
data: sampleData(d.data).map(({ value }) => value),
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: SECOND_CHART_COLOR[i % SECOND_CHART_COLOR.length],
|
||||
},
|
||||
})),
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 限制顯示資料點數
|
||||
function sampleData(data = [], maxCount = 30) {
|
||||
const len = data.length;
|
||||
if (len <= maxCount) return data;
|
||||
|
||||
const sampled = [];
|
||||
const step = (len - 1) / (maxCount - 1);
|
||||
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const index = Math.round(i * step);
|
||||
sampled.push(data[index]);
|
||||
}
|
||||
|
||||
return sampled;
|
||||
}
|
||||
|
||||
// 圖表參數預設(暫不使用)
|
||||
const other_real_temp_chart = ref(null);
|
||||
// 預設圖表設定
|
||||
const defaultChartOption = ref({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
},
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: {
|
||||
data: [],
|
||||
textStyle: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
},
|
||||
top: 0, // 靠最上方
|
||||
textStyle: { color: "#ffffff", fontSize: 12 },
|
||||
},
|
||||
grid: {
|
||||
top: "10%",
|
||||
top: "35%",
|
||||
left: "0%",
|
||||
right: "0%",
|
||||
bottom: "0%",
|
||||
@ -164,40 +75,129 @@ const defaultChartOption = ref({
|
||||
series: [],
|
||||
});
|
||||
|
||||
// 按鈕名稱根據語系更新
|
||||
// 取得冷藏溫度/濕度資料
|
||||
const getData = async () => {
|
||||
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
|
||||
if (!buildingGuid) return;
|
||||
|
||||
try {
|
||||
const res = await getDashboardTemp({
|
||||
building_guid: buildingGuid,
|
||||
tempOption: 2, // ⚠️ 冷藏區域
|
||||
timeInterval: 1,
|
||||
option: currentOptionType.value, // 1: 溫度;2: 濕度
|
||||
});
|
||||
|
||||
console.log("[getDashboardTemp] 冷藏回傳:", res);
|
||||
|
||||
const key = "冷藏溫度"; // 依你剛剛確認到的 key
|
||||
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
|
||||
noData.value = allTempData.value.length === 0;
|
||||
|
||||
console.log("[getDashboardTemp] allTempData:", allTempData.value);
|
||||
console.log("[getDashboardTemp] noData:", noData.value);
|
||||
} catch (e) {
|
||||
console.error("getDashboardTemp error", e);
|
||||
allTempData.value = [];
|
||||
noData.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 溫度/濕度切換按鈕
|
||||
const buttonItems = computed(() => [
|
||||
{ key: 1, title: t("dashboard.temperature"), active: true },
|
||||
{ key: 2, title: t("dashboard.humidity"), active: false },
|
||||
]);
|
||||
|
||||
// 語系切換時更新按鈕文案
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => {
|
||||
setItems(buttonItems.value);
|
||||
},
|
||||
() => setItems(buttonItems.value),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 切換 tab(目前無實際資料行為)
|
||||
// 切換溫度/濕度後重取資料並重啟輪詢
|
||||
watch(
|
||||
selectedBtn,
|
||||
(newValue) => {
|
||||
if (timeoutTimer.value) {
|
||||
clearInterval(timeoutTimer.value);
|
||||
}
|
||||
async (newVal) => {
|
||||
if ([1, 2].includes(newVal?.key)) {
|
||||
currentOptionType.value = newVal.key;
|
||||
|
||||
if ([1, 2].includes(newValue?.key)) {
|
||||
currentOptionType.value = newValue.key;
|
||||
if (buildingStore.sysConfig?.value?.show_refrigeration !== false) {
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
await getData();
|
||||
timeoutTimer.value = setInterval(getData, 60_000);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 清除計時器
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer.value) {
|
||||
clearInterval(timeoutTimer.value);
|
||||
// 限制顯示資料點數
|
||||
function sampleData(data = [], maxCount = 30) {
|
||||
const len = data.length;
|
||||
if (len <= maxCount) return data;
|
||||
|
||||
const sampled = [];
|
||||
const step = (len - 1) / (maxCount - 1);
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const index = Math.round(i * step);
|
||||
sampled.push(data[index]);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
|
||||
// 更新圖表
|
||||
watch(
|
||||
allTempData,
|
||||
(newVal) => {
|
||||
const chart = chartRef.value?.chart;
|
||||
if (!chart || !Array.isArray(newVal) || newVal.length === 0) return;
|
||||
|
||||
const firstValid = newVal.find(
|
||||
(d) => Array.isArray(d.data) && d.data.length
|
||||
);
|
||||
if (!firstValid) return;
|
||||
|
||||
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
|
||||
dayjs(time).format("HH:mm:ss")
|
||||
);
|
||||
|
||||
const allValues = newVal
|
||||
.flatMap((d) => sampleData(d.data))
|
||||
.map((d) => d?.value)
|
||||
.filter((v) => typeof v === "number" && !Number.isNaN(v));
|
||||
|
||||
if (!allValues.length) return;
|
||||
|
||||
let yMin = Math.floor(Math.min(...allValues)) - 1;
|
||||
let yMax = Math.ceil(Math.max(...allValues)) + 1;
|
||||
if (yMin === yMax) {
|
||||
yMin -= 1;
|
||||
yMax += 1;
|
||||
}
|
||||
|
||||
chart.setOption({
|
||||
legend: { data: newVal.map((d) => d.full_name) },
|
||||
xAxis: { data: sampledXAxis },
|
||||
yAxis: { min: yMin, max: yMax },
|
||||
series: newVal.map((d, i) => ({
|
||||
name: d.full_name,
|
||||
type: "line",
|
||||
data: sampleData(d.data).map(({ value }) => value),
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: SECOND_CHART_COLOR[i % SECOND_CHART_COLOR.length],
|
||||
},
|
||||
})),
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 離開元件時清除定時器
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -205,24 +205,27 @@ onUnmounted(() => {
|
||||
<h3 class="text-info text-xl text-center">
|
||||
{{ $t("dashboard.refrig_chart") }}
|
||||
</h3>
|
||||
<div className="my-3 w-full flex justify-center relative">
|
||||
|
||||
<div class="w-full flex justify-center relative">
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
:onclick="(e, item) => changeActiveBtn(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="noData"
|
||||
class="text-center text-white text-lg min-h-[260px] flex items-center justify-center"
|
||||
>
|
||||
{{ $t("dashboard.no_data") }}
|
||||
</div>
|
||||
|
||||
<LineChart
|
||||
v-if="!noData"
|
||||
id="dashboard_other_real_temp"
|
||||
v-else
|
||||
id="dashboard_refrigeration_temp"
|
||||
class="min-h-[260px] max-h-fit"
|
||||
:option="defaultChartOption"
|
||||
ref="indoorChartRef"
|
||||
ref="chartRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -49,7 +49,7 @@ const doLogin = async () => {
|
||||
class="flex items-center justify-center w-full mb-5 text-4xl font-bold text-white"
|
||||
>
|
||||
<img src="/logo.svg" alt="logo" class="w-12 me-2" />
|
||||
新創賦能
|
||||
智慧倉儲物流
|
||||
</div>
|
||||
<div class="w-full flex flex-col items-end my-2">
|
||||
<div class="w-full flex justify-between">
|
||||
|
253
src/views/rtsp/Rtsp.vue
Normal file
253
src/views/rtsp/Rtsp.vue
Normal file
@ -0,0 +1,253 @@
|
||||
<script>
|
||||
import { getRtspDevices } from "@/apis/rtsp";
|
||||
import { postQueryAlarmLog } from "@/apis/alert";
|
||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||
import useSearchParam from "@/hooks/useSearchParam";
|
||||
import AlertSearchTimeRange from "@/views/alert/components/AlertQuery/AlertSearchTimeRange.vue";
|
||||
import Table from "@/components/customUI/Table.vue";
|
||||
|
||||
const DEFAULT_MONITOR_URL =
|
||||
"https://ibms-ils-rtsp.production.mjmtech.com.tw/?url=rtsp://admin02:mjmAdmin_99@192.168.0.200:554/stream1";
|
||||
|
||||
export default {
|
||||
name: "Rtsp",
|
||||
components: { Table, AlertSearchTimeRange },
|
||||
setup() {
|
||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||
const { searchParams } = useSearchParam();
|
||||
return { items, changeActiveBtn, setItems, selectedBtn, searchParams };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
monitorUrl: DEFAULT_MONITOR_URL,
|
||||
rtspDevices: [],
|
||||
selectedMainId: null,
|
||||
tableLoading: false,
|
||||
dataSource: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentDevice() {
|
||||
if (!this.selectedMainId) return null;
|
||||
return (
|
||||
this.rtspDevices.find((d) => d.main_id === this.selectedMainId) || null
|
||||
);
|
||||
},
|
||||
columns() {
|
||||
return [
|
||||
{ key: "id", title: this.$t("alert.uuid") || "UUID" },
|
||||
{ key: "created_at", title: this.$t("alert.time") || "Time" },
|
||||
{ key: "reason", title: this.$t("alert.error_msg") || "Reason" },
|
||||
];
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadRtspDevices();
|
||||
// 設定近 7 天並立即查一次
|
||||
this.setLast7Days();
|
||||
if (this.selectedMainId) {
|
||||
await this.searchLogs();
|
||||
}
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 工具:轉成 YYYY-MM-DD(考慮時區)
|
||||
toISO(d) {
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
},
|
||||
// 將查詢區間設為「近 7 天」
|
||||
setLast7Days() {
|
||||
const today = new Date();
|
||||
const start = new Date();
|
||||
// 與你在 AlertSearchTimeRange 的 7d 快捷一致:往回 7 天到今天
|
||||
start.setDate(start.getDate() - 7);
|
||||
this.searchParams.Start_date = this.toISO(start);
|
||||
this.searchParams.End_date = this.toISO(today);
|
||||
},
|
||||
|
||||
async loadRtspDevices() {
|
||||
try {
|
||||
const res = await getRtspDevices({});
|
||||
const list = Array.isArray(res) ? res : res?.data || [];
|
||||
|
||||
this.rtspDevices = list;
|
||||
|
||||
// 建立 Tabs
|
||||
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,
|
||||
}));
|
||||
this.setItems(cate);
|
||||
|
||||
// 預設選第一台
|
||||
if (this.rtspDevices.length > 0) {
|
||||
const chosenId = this.selectedMainId ?? this.rtspDevices[0].main_id;
|
||||
const chosen =
|
||||
this.rtspDevices.find((d) => d.main_id === chosenId) ||
|
||||
this.rtspDevices[0];
|
||||
this.selectDevice(chosen);
|
||||
} else {
|
||||
this.selectedMainId = null;
|
||||
this.monitorUrl = DEFAULT_MONITOR_URL;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("loadRtspDevices() 失敗", err);
|
||||
this.rtspDevices = [];
|
||||
this.setItems([]);
|
||||
this.selectedMainId = null;
|
||||
this.monitorUrl = DEFAULT_MONITOR_URL;
|
||||
}
|
||||
},
|
||||
|
||||
// 切換設備 Tab
|
||||
selectDevice(d) {
|
||||
this.selectedMainId = d.main_id;
|
||||
this.monitorUrl = d.rtsp_url || DEFAULT_MONITOR_URL;
|
||||
this.dataSource = [];
|
||||
},
|
||||
|
||||
// 查詢告警紀錄(讀共用的 searchParams)
|
||||
async searchLogs() {
|
||||
if (!this.selectedMainId) return;
|
||||
const start = this.searchParams?.Start_date;
|
||||
const end = this.searchParams?.End_date;
|
||||
if (!start || !end) return;
|
||||
|
||||
try {
|
||||
this.tableLoading = true;
|
||||
const res = await postQueryAlarmLog({
|
||||
id: this.selectedMainId,
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
});
|
||||
const rows = Array.isArray(res) ? res : res?.data || [];
|
||||
this.dataSource = rows.map((d) => ({
|
||||
...d,
|
||||
key: d.id,
|
||||
// 後端目前未回傳 formId,保留欄位供未來擴充
|
||||
formId: d.formId,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("searchLogs() 失敗", e);
|
||||
this.dataSource = [];
|
||||
} finally {
|
||||
this.tableLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
onRepairOrder(record) {
|
||||
console.log("repair order click:", record);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="min-h-[600px]">
|
||||
<h1 class="text-2xl font-extrabold mb-2">{{ $t("rtsp.title") }}</h1>
|
||||
|
||||
<!-- Tabs:選擇攝影機 -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h2 class="text-lg font-bold whitespace-nowrap">
|
||||
{{ $t("rtsp.selectDevice") }} :
|
||||
</h2>
|
||||
|
||||
<ButtonConnectedGroup
|
||||
:items="items"
|
||||
:onclick="
|
||||
(e, item) => {
|
||||
changeActiveBtn(item);
|
||||
const found = rtspDevices.find((r) => r.main_id === item.key);
|
||||
if (found) selectDevice(found);
|
||||
}
|
||||
"
|
||||
:className="`flex flex-wrap`"
|
||||
size="sm"
|
||||
color="info"
|
||||
>
|
||||
<template #buttonContent="{ item }">
|
||||
<span class="text-base">{{ item.title }}</span>
|
||||
</template>
|
||||
</ButtonConnectedGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-start">
|
||||
<!-- 左側:即時監控 -->
|
||||
<div
|
||||
class="relative w-full flex-1 rounded border overflow-hidden h-[420px] md:h-[520px]"
|
||||
>
|
||||
<iframe
|
||||
:src="monitorUrl"
|
||||
class="absolute inset-0 w-full h-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<!-- 右側:查詢告警紀錄(日期 + 表格) -->
|
||||
<aside class="w-1/2 flex flex-col gap-4 px-4">
|
||||
<div
|
||||
class="w-full flex flex-wrap items-end gap-3 custom-border px-4 pt-3 pb-4"
|
||||
>
|
||||
<div class="w-full flex flex-wrap items-center justify-start gap-4">
|
||||
<h2 class="text-lg font-bold">{{ $t("rtsp.normalQuery") }} :</h2>
|
||||
|
||||
<!-- 選擇開始與結束時間 -->
|
||||
<AlertSearchTimeRange quickButtonType="7d" />
|
||||
<button
|
||||
class="btn btn-search"
|
||||
:disabled="
|
||||
!selectedMainId ||
|
||||
!searchParams?.Start_date ||
|
||||
!searchParams?.End_date ||
|
||||
tableLoading
|
||||
"
|
||||
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''"
|
||||
@click.stop.prevent="searchLogs"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'search']" class="text-lg" />
|
||||
{{ $t("button.search") || "Search" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格(交由 Table 內建分頁處理,給全量 dataSource) -->
|
||||
<Table
|
||||
:loading="tableLoading"
|
||||
:columns="columns"
|
||||
:dataSource="dataSource"
|
||||
rowKey="key"
|
||||
>
|
||||
<template #bodyCell="{ record, column }">
|
||||
<template v-if="column.key === 'repairOrder'">
|
||||
<button
|
||||
class="btn btn-sm btn-success text-white whitespace-nowrap"
|
||||
@click.stop.prevent="onRepairOrder(record)"
|
||||
>
|
||||
<span v-if="record.formId">{{ record.formId }}</span>
|
||||
<span v-else>
|
||||
<font-awesome-icon :icon="['fas', 'plus']" />
|
||||
{{ $t("alert.repair_order_number") || "Repair Order" }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
@ -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