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

View File

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

View File

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

View File

@ -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",
@ -424,18 +425,7 @@
},
"rtsp": {
"title": "Video Stream",
"start": "Start Detection",
"stop": "Stop Detection",
"selectPath": "Select Folder",
"selectDevice": "Select Device",
"pleaseSelectDevice": "Please select a device first",
"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"
"normalQuery": "Query normal records"
}
}

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,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>
import { getRtspDevices, setRtspEnable } from "@/apis/rtsp";
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 =
"http://192.168.0.219:8026/?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();
return { items, changeActiveBtn, setItems, selectedBtn };
const { searchParams } = useSearchParam();
return { items, changeActiveBtn, setItems, selectedBtn, searchParams };
},
data() {
return {
monitorUrl: DEFAULT_MONITOR_URL,
isStarting: false,
isStopping: false,
message: "",
rtspDevices: [],
selectedMainId: null, // main_id
pollTimer: null, //
selectedMainId: null,
tableLoading: false,
dataSource: [],
};
},
computed: {
@ -106,13 +33,21 @@ export default {
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();
},
beforeUnmount() {
//
this.stopPolling();
// 7
this.setLast7Days();
if (this.selectedMainId) {
await this.searchLogs();
}
},
watch: {
selectedBtn: {
@ -125,6 +60,22 @@ export default {
},
},
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({});
@ -166,82 +117,137 @@ export default {
selectDevice(d) {
this.selectedMainId = d.main_id;
this.monitorUrl = d.rtsp_url || DEFAULT_MONITOR_URL;
this.dataSource = [];
},
// === ===
startPolling() {
//
if (this.pollTimer) return;
// searchParams
async searchLogs() {
if (!this.selectedMainId) 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 {
await setRtspEnable({ main_id: keepId, enable: true });
await this.loadRtspDevices();
const found = this.rtspDevices.find((d) => d.main_id === keepId);
if (found) this.selectDevice(found);
this.message = this.$t("rtsp.startSuccess");
// 5
this.startPolling();
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(e);
this.message = this.$t("rtsp.startFail");
console.error("searchLogs() 失敗", e);
this.dataSource = [];
} finally {
this.isStarting = false;
this.tableLoading = false;
}
},
//
async stopDetection() {
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;
}
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>