Compare commits

..

11 Commits

42 changed files with 1168 additions and 682 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "已复归查询"
}
}

View File

@ -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": "已復歸查詢"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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