feat: rtsp 分頁串接 API (query-alarm-log)

This commit is contained in:
MJM_2025_05\polly 2025-09-30 15:05:44 +08:00
parent d01d3e7581
commit 5ff030dc40
7 changed files with 258 additions and 221 deletions

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 POST_ALERT_SCHEDULE = `api/Alarm/SaveAlarmSchedule`;
export const DELETE_ALERT_SCHEDULE = `api/Alarm/DeleteAlarmSchedule`; 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, POST_ALERT_SCHEDULE,
DELETE_ALERT_SCHEDULE, DELETE_ALERT_SCHEDULE,
POST_ALERT_MQTT_REFRESH, POST_ALERT_MQTT_REFRESH,
POST_QUERY_ALARM_LOG,
} from "./api"; } from "./api";
import instance from "@/util/request"; import instance from "@/util/request";
import apihandler from "@/util/apihandler"; import apihandler from "@/util/apihandler";
@ -236,3 +237,24 @@ export const postMQTTRefresh = async () => {
code: res.code, 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,
});
};

View File

@ -179,6 +179,7 @@
"normal": "已复归", "normal": "已复归",
"unacked": "未确认", "unacked": "未确认",
"acked": "已确认", "acked": "已确认",
"7days": "近7天",
"30days": "近30天", "30days": "近30天",
"start_date": "起始日期", "start_date": "起始日期",
"end_date": "结束日期", "end_date": "结束日期",
@ -424,18 +425,9 @@
}, },
"rtsp": { "rtsp": {
"title": "影像串流", "title": "影像串流",
"start": "开始侦测",
"stop": "结束侦测",
"selectPath": "选择存储位置", "selectPath": "选择存储位置",
"selectDevice": "选择设备", "selectDevice": "选择设备",
"pleaseSelectDevice": "请先选择设备", "normalQuery":"已复归查询"
"selectPathFirst": "请先选择存储文件夹",
"startSuccess": "已开始侦测…",
"startFail": "开始侦测失败,请稍后再试",
"stopSuccess": "已请求结束侦测…",
"stopFail": "结束侦测失败,请稍后再试",
"noPermission": "未获得写入权限,请重新选择文件夹并授权",
"selectFolderSuccess": "已选择文件夹:{name}",
"selectFolderFail": "选择文件夹失败,请再试一次"
} }
} }

View File

@ -179,6 +179,7 @@
"normal": "已復歸", "normal": "已復歸",
"unacked": "未確認", "unacked": "未確認",
"acked": "已確認", "acked": "已確認",
"7days": "近7天",
"30days": "近30天", "30days": "近30天",
"start_date": "起始日期", "start_date": "起始日期",
"end_date": "結束日期", "end_date": "結束日期",
@ -424,18 +425,7 @@
}, },
"rtsp": { "rtsp": {
"title": "影像串流", "title": "影像串流",
"start": "開始偵測",
"stop": "結束偵測",
"selectPath": "選擇儲存位置",
"selectDevice": "選擇設備", "selectDevice": "選擇設備",
"pleaseSelectDevice": "請先選擇設備", "normalQuery": "已復歸查詢"
"selectPathFirst": "請先選擇儲存資料夾",
"startSuccess": "已開始偵測…",
"startFail": "開始偵測失敗,請稍後再試",
"stopSuccess": "結束偵測",
"stopFail": "結束偵測失敗,請稍後再試",
"noPermission": "沒有取得寫入權限,請重新選擇資料夾並允許",
"selectFolderSuccess": "已選擇資料夾:{name}",
"selectFolderFail": "選擇資料夾失敗,請再試一次"
} }
} }

View File

@ -179,6 +179,7 @@
"normal": "Normal", "normal": "Normal",
"unacked": "Unacked", "unacked": "Unacked",
"acked": "Acked", "acked": "Acked",
"7days": "Last 7 Days",
"30days": "Last 30 Days", "30days": "Last 30 Days",
"start_date": "Start Date", "start_date": "Start Date",
"end_date": "End Date", "end_date": "End Date",
@ -424,18 +425,7 @@
}, },
"rtsp": { "rtsp": {
"title": "Video Stream", "title": "Video Stream",
"start": "Start Detection",
"stop": "Stop Detection",
"selectPath": "Select Folder",
"selectDevice": "Select Device", "selectDevice": "Select Device",
"pleaseSelectDevice": "Please select a device first", "normalQuery": "Query normal records"
"selectPathFirst": "Please select a folder first",
"startSuccess": "Detection started…",
"startFail": "Failed to start detection, please try again later",
"stopSuccess": "Detection stop requested…",
"stopFail": "Failed to stop detection, please try again later",
"noPermission": "No write permission. Please select a folder again and grant access",
"selectFolderSuccess": "Folder selected: {name}",
"selectFolderFail": "Failed to select folder, please try again"
} }
} }

View File

@ -1,16 +1,55 @@
<script setup> <script setup>
import { ref, onMounted, watch } from "vue"; import { ref, onMounted, watch, computed } from "vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import useSearchParam from "@/hooks/useSearchParam"; import useSearchParam from "@/hooks/useSearchParam";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { searchParams, changeParams } = useSearchParam(); 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([ const dateRange = ref([
{ {
key: "start_at", key: "start_at",
value: searchParams.value.Start_date value: searchParams.value.Start_date
? dayjs(searchParams.value.Start_date) ? dayjs(searchParams.value.Start_date)
: props.quickButtonType === "7d"
? dayjs().subtract(7, "day")
: dayjs().subtract(30, "day"), : dayjs().subtract(30, "day"),
dateFormat: "yyyy-MM-dd", dateFormat: "yyyy-MM-dd",
placeholder: t("alert.start_date"), placeholder: t("alert.start_date"),
@ -25,42 +64,28 @@ const dateRange = ref([
}, },
]); ]);
// quickButtonType
const changeTimeRange = () => { const changeTimeRange = () => {
const newRange = [ if (props.quickButtonType === "7d") {
{ // 7 = 7 =
key: "start_at", setRange(dayjs().subtract(7, "day"), dayjs().endOf("day"));
value: dayjs().subtract(30, "day"), } else {
dateFormat: "yyyy-MM-dd", // 30 = 30 =
placeholder: t("alert.start_date"), setRange(dayjs().subtract(30, "day"), dayjs().endOf("day"));
}, }
{
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,
});
}; };
// Start/End quickButtonType
onMounted(() => { onMounted(() => {
// if (!searchParams.value.Start_date || !searchParams.value.End_date) {
if (
!searchParams.value.Start_date ||
!searchParams.value.End_date
) {
changeTimeRange(); changeTimeRange();
} else {
// dateRange
setRange(searchParams.value.Start_date, searchParams.value.End_date);
} }
}); });
// // 使調
watch( watch(
dateRange, dateRange,
() => { () => {
@ -70,24 +95,34 @@ watch(
End_date: dayjs(dateRange.value[1].value).format("YYYY-MM-DD"), End_date: dayjs(dateRange.value[1].value).format("YYYY-MM-DD"),
}); });
}, },
{ deep: true } // { deep: true }
); );
// placeholder
watch(locale, () => { watch(locale, () => {
dateRange.value[0].placeholder = t("alert.start_date"); dateRange.value[0].placeholder = t("alert.start_date");
dateRange.value[1].placeholder = t("alert.end_date"); dateRange.value[1].placeholder = t("alert.end_date");
}); });
//
const quickBtnLabel = computed(() =>
props.quickButtonType === "7d" ? t("alert.7days") : t("alert.30days")
);
</script> </script>
<template> <template>
<div class="flex flex-wrap items-center"> <div class="flex flex-wrap items-center">
<!-- 快捷按鈕可關閉可切換 30 / 7 -->
<button <button
v-if="showQuickButton"
type="button" type="button"
class="btn btn-outline-success mr-3" class="btn btn-outline-success mr-3"
@click="changeTimeRange" @click="changeTimeRange"
> >
{{ $t("alert.30days") }} {{ quickBtnLabel }}
</button> </button>
<!-- 日期群組 -->
<DateGroup :items="dateRange" :withLine="true" /> <DateGroup :items="dateRange" :withLine="true" />
</div> </div>
</template> </template>

View File

@ -1,102 +1,29 @@
<template>
<section class="min-h-[600px] h-screen">
<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 h-[70%] gap-4">
<!-- 左側即時監控 -->
<div class="relative w-full flex-1 rounded border overflow-hidden">
<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-6 p-4">
<div class="flex gap-3">
<button
class="btn btn-add w-40"
@click="startDetection"
:disabled="
isStarting || !selectedMainId || !currentDevice?.start_btn_enable
"
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''"
>
{{ $t("rtsp.start") }}
</button>
<button
class="btn btn-error text-white w-40"
@click="stopDetection"
:disabled="
isStopping || !selectedMainId || !currentDevice?.stop_btn_enable
"
:title="!selectedMainId ? $t('rtsp.pleaseSelectDevice') : ''"
>
{{ $t("rtsp.stop") }}
</button>
</div>
<!-- 顯示後端傳來的提示文字 -->
<p v-if="currentDevice?.alarm_message" class="text-sm text-info">
{{ currentDevice.alarm_message }}
</p>
<p v-if="message" class="text-sm">{{ message }}</p>
</aside>
</div>
</section>
</template>
<script> <script>
import { getRtspDevices, setRtspEnable } from "@/apis/rtsp"; import { getRtspDevices } from "@/apis/rtsp";
import { postQueryAlarmLog } from "@/apis/alert";
import useActiveBtn from "@/hooks/useActiveBtn"; 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 = const DEFAULT_MONITOR_URL =
"http://192.168.0.219:8026/?url=rtsp://admin02:mjmAdmin_99@192.168.0.200:554/stream1"; "http://192.168.0.219:8026/?url=rtsp://admin02:mjmAdmin_99@192.168.0.200:554/stream1";
export default { export default {
name: "Rtsp", name: "Rtsp",
components: { Table, AlertSearchTimeRange },
setup() { setup() {
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn(); const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
return { items, changeActiveBtn, setItems, selectedBtn }; const { searchParams } = useSearchParam();
return { items, changeActiveBtn, setItems, selectedBtn, searchParams };
}, },
data() { data() {
return { return {
monitorUrl: DEFAULT_MONITOR_URL, monitorUrl: DEFAULT_MONITOR_URL,
isStarting: false,
isStopping: false,
message: "",
rtspDevices: [], rtspDevices: [],
selectedMainId: null, // main_id selectedMainId: null,
pollTimer: null, // tableLoading: false,
dataSource: [],
}; };
}, },
computed: { computed: {
@ -106,13 +33,21 @@ export default {
this.rtspDevices.find((d) => d.main_id === this.selectedMainId) || null 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() { async mounted() {
await this.loadRtspDevices(); await this.loadRtspDevices();
}, // 7
beforeUnmount() { this.setLast7Days();
// if (this.selectedMainId) {
this.stopPolling(); await this.searchLogs();
}
}, },
watch: { watch: {
selectedBtn: { selectedBtn: {
@ -125,6 +60,22 @@ export default {
}, },
}, },
methods: { 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() { async loadRtspDevices() {
try { try {
const res = await getRtspDevices({}); const res = await getRtspDevices({});
@ -166,82 +117,137 @@ export default {
selectDevice(d) { selectDevice(d) {
this.selectedMainId = d.main_id; this.selectedMainId = d.main_id;
this.monitorUrl = d.rtsp_url || DEFAULT_MONITOR_URL; this.monitorUrl = d.rtsp_url || DEFAULT_MONITOR_URL;
this.dataSource = [];
}, },
// === === // searchParams
startPolling() { async searchLogs() {
// if (!this.selectedMainId) return;
if (this.pollTimer) return; const start = this.searchParams?.Start_date;
const end = this.searchParams?.End_date;
if (!start || !end) return;
const tick = async () => {
const keepId = this.selectedMainId;
await this.loadRtspDevices();
//
const found = this.rtspDevices.find((d) => d.main_id === keepId);
if (found) this.selectDevice(found);
};
// 5
tick();
this.pollTimer = setInterval(tick, 5000);
},
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
},
//
async startDetection() {
if (!this.selectedMainId) {
this.message = this.$t("rtsp.pleaseSelectDevice");
return;
}
this.isStarting = true;
const keepId = this.selectedMainId;
try { try {
await setRtspEnable({ main_id: keepId, enable: true }); this.tableLoading = true;
await this.loadRtspDevices(); const res = await postQueryAlarmLog({
const found = this.rtspDevices.find((d) => d.main_id === keepId); id: this.selectedMainId,
if (found) this.selectDevice(found); start_date: start,
this.message = this.$t("rtsp.startSuccess"); end_date: end,
});
// 5 const rows = Array.isArray(res) ? res : res?.data || [];
this.startPolling(); this.dataSource = rows.map((d) => ({
...d,
key: d.id,
// formId
formId: d.formId,
}));
} catch (e) { } catch (e) {
console.error(e); console.error("searchLogs() 失敗", e);
this.message = this.$t("rtsp.startFail"); this.dataSource = [];
} finally { } finally {
this.isStarting = false; this.tableLoading = false;
} }
}, },
// onRepairOrder(record) {
async stopDetection() { console.log("repair order click:", record);
if (!this.selectedMainId) {
this.message = this.$t("rtsp.pleaseSelectDevice");
return;
}
this.isStopping = true;
const keepId = this.selectedMainId;
try {
await setRtspEnable({ main_id: keepId, enable: false });
await this.loadRtspDevices();
const found = this.rtspDevices.find((d) => d.main_id === keepId);
if (found) this.selectDevice(found);
this.message = this.$t("rtsp.stopSuccess");
//
this.stopPolling();
} catch (e) {
console.error(e);
this.message = this.$t("rtsp.stopFail");
} finally {
this.isStopping = false;
}
}, },
}, },
}; };
</script> </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>