Compare commits

...

2 Commits

Author SHA1 Message Date
suixy 7eeee6d21b Merge remote-tracking branch 'origin/main' 1 week ago
suixy 8e44ce7c57 添加electron打包
修改报警规则
1 week ago

@ -0,0 +1,33 @@
# 页面标题
VITE_APP_TITLE = HaiWei-Plus能源管理系统
VITE_APP_LOGO_TITLE = RuoYi-Vue-Plus
# Electron 桌面端配置
VITE_APP_ENV = 'electron'
VITE_APP_ELECTRON = true
# Electron 使用 file:// 加载本地页面,静态资源必须使用相对路径
VITE_APP_CONTEXT_PATH = './'
# 桌面端没有 Vite/Nginx 代理,这里必须配置为真实后端地址。
# 如后端部署在其他机器,请改为对应地址,例如 'https://example.com/prod-api'。
VITE_APP_BASE_API = 'http://localhost:8080'
# 监控地址
VITE_APP_MONITOR_ADMIN = '/admin/applications'
# SnailJob 控制台地址
VITE_APP_SNAILJOB_ADMIN = '/snail-job'
# 是否在打包时开启压缩,支持 gzip 和 brotli
VITE_BUILD_COMPRESS = gzip
VITE_APP_PORT = 80
# 接口加密功能开关(如需关闭 后端也必须对应关闭)
VITE_APP_ENCRYPT = true
VITE_APP_RSA_PUBLIC_KEY = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=='
VITE_APP_RSA_PRIVATE_KEY = 'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE='
VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'
VITE_APP_WEBSOCKET = false
VITE_APP_SSE = true

1
.gitignore vendored

@ -2,6 +2,7 @@
.history .history
node_modules/ node_modules/
dist/ dist/
release/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*

@ -0,0 +1,52 @@
const { app, BrowserWindow, shell } = require('electron');
const path = require('path');
const isDev = !app.isPackaged;
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 720,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
if (isDev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

@ -0,0 +1,5 @@
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('electronApp', {
platform: process.platform
});

@ -6,10 +6,14 @@
"author": "LionLi", "author": "LionLi",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "electron/main.cjs",
"scripts": { "scripts": {
"dev": "vite serve --mode development", "dev": "vite serve --mode development",
"build:prod": "vite build --mode production", "build:prod": "vite build --mode production",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"build:electron": "vite build --mode electron",
"electron": "npm run build:electron && electron .",
"electron:win": "npm run build:electron && electron-builder --win --x64",
"preview": "vite preview", "preview": "vite preview",
"lint:eslint": "eslint", "lint:eslint": "eslint",
"lint:eslint:fix": "eslint --fix", "lint:eslint:fix": "eslint --fix",
@ -65,6 +69,8 @@
"@vue/eslint-config-prettier": "10.2.0", "@vue/eslint-config-prettier": "10.2.0",
"@vue/eslint-config-typescript": "14.6.0", "@vue/eslint-config-typescript": "14.6.0",
"autoprefixer": "10.4.27", "autoprefixer": "10.4.27",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-plugin-prettier": "5.5.5", "eslint-plugin-prettier": "5.5.5",
"eslint-plugin-vue": "9.33.0", "eslint-plugin-vue": "9.33.0",
@ -86,6 +92,40 @@
"overrides": { "overrides": {
"quill": "1.3.7" "quill": "1.3.7"
}, },
"build": {
"appId": "com.haiwei.energy.visual.editor",
"productName": "HaiWei Plus",
"asar": true,
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"extraMetadata": {
"main": "electron/main.cjs"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "HaiWei Plus"
}
},
"engines": { "engines": {
"node": ">=20.19.0", "node": ">=20.19.0",
"npm": ">=8.19.0" "npm": ">=8.19.0"

@ -3,33 +3,85 @@
<router-view /> <router-view />
<RealtimeAlarmModal /> <RealtimeAlarmModal />
<div v-if="offlineAlertActive" class="offline-breath-overlay"></div> <div v-if="offlineAlertActive" class="offline-breath-overlay"></div>
<el-dialog
v-model="alarmDetailVisible"
title="报警信息"
width="760px"
:close-on-click-modal="false"
:before-close="handleAlarmDetailClose"
destroy-on-close
>
<el-tabs v-model="alarmDetailTab">
<el-tab-pane label="报警信息" name="alarm">
<div class="alarm-info-list">
<div v-if="!currentAlarmContents.length" class="alarm-empty-text"></div>
<div v-for="(content, index) in currentAlarmContents" :key="`${index}-${content}`" class="alarm-info-item">
{{ index + 1 }}. {{ content }}
</div>
</div>
</el-tab-pane>
<el-tab-pane label="处理流程" name="steps">
<div class="alarm-step-panel">
<div v-if="!currentActionSteps.length" class="alarm-empty-text"></div>
<el-steps v-else direction="vertical" :space="72" :active="currentActionSteps.length">
<el-step
v-for="(step, index) in currentActionSteps"
:key="step.objId || `${step.stepSequence}-${index}`"
:title="`第${step.stepSequence ?? '-'}步`"
:description="step.description || '-'"
/>
</el-steps>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<div class="dialog-footer">
<el-button @click="deferAlarmDetail"></el-button>
<el-button type="primary" :loading="alarmHandling" @click="confirmAlarmDetail"></el-button>
</div>
</template>
</el-dialog>
</el-config-provider> </el-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { listEmsAlarmActionStep } from '@/api/ems/base/emsAlarmActionStep';
import { handleExceptions } from '@/api/ems/record/recordAlarmData';
import RealtimeAlarmModal from '@/components/alarm/RealtimeAlarmModal.vue'; import RealtimeAlarmModal from '@/components/alarm/RealtimeAlarmModal.vue';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import { handleThemeStyle } from '@/utils/theme'; import { handleThemeStyle } from '@/utils/theme';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { closeAlarmReminder, connectAlarmReminder, onAlarmReminderOvertime } from '@/utils/alarmReminder'; import {
closeAlarmReminder,
connectAlarmReminder,
onAlarmReminderExtra,
onAlarmReminderOvertime
} from '@/utils/alarmReminder';
const appStore = useAppStore(); const appStore = useAppStore();
const offlineAlertActive = ref(false); const offlineAlertActive = ref(false);
const alarmDetailVisible = ref(false);
const alarmDetailTab = ref('alarm');
const alarmHandling = ref(false);
const currentActionSteps = ref<any[]>([]);
const currentAlarmContents = ref<string[]>([]);
const alarmDetailQueue = ref<any[]>([]);
const currentAlarmDetail = ref<any | null>(null);
let offlineAlertTimer: ReturnType<typeof setTimeout> | null = null; let offlineAlertTimer: ReturnType<typeof setTimeout> | null = null;
const playOfflineVoice = (objid: string) => { const playAlarmVoice = (message: string) => {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) { if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
return; return;
} }
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(`设备${objid}离线`); const utterance = new SpeechSynthesisUtterance(message);
utterance.lang = 'zh-CN'; utterance.lang = 'zh-CN';
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
}; };
const triggerOfflineEffect = () => { const triggerAlarmEffect = () => {
offlineAlertActive.value = false; offlineAlertActive.value = false;
if (offlineAlertTimer) { if (offlineAlertTimer) {
@ -45,23 +97,138 @@ const triggerOfflineEffect = () => {
}); });
}; };
const showOfflineDialog = (objid: string) => { const showAlarmDialog = (message: string, title: string) => {
ElMessageBox.alert(`设备${objid}离线`, '离线报警', { ElMessageBox.alert(message, title, {
type: 'warning', type: 'warning',
confirmButtonText: '确定' confirmButtonText: '确定'
}).catch(() => undefined); }).catch(() => undefined);
}; };
connectAlarmReminder(() => {}); const fetchActionStepsByRuleObjId = async (ruleObjId: string) => {
onAlarmReminderOvertime((device) => { const response = await listEmsAlarmActionStep({
const objid = String(device?.monitorId ?? ''); pageNum: 1,
if (!objid) { pageSize: 10,
ruleObjId
});
return [...(response.rows ?? [])].sort((a, b) => Number(a.stepSequence || 0) - Number(b.stepSequence || 0));
};
const consumeHandleResult = (result?: any) => {
const updatedCount = Number(result?.updatedCount ?? 0);
const alreadyHandledCount = Number(result?.alreadyHandledCount ?? 0);
return updatedCount > 0 || alreadyHandledCount > 0;
};
const normalizeAlarmContents = (alarmContents: unknown) => {
if (!Array.isArray(alarmContents)) {
return [];
}
return alarmContents.map((item) => String(item ?? '')).filter(Boolean);
};
const openNextAlarmDetail = () => {
if (alarmDetailVisible.value || !alarmDetailQueue.value.length) {
return; return;
} }
playOfflineVoice(objid); const nextDetail = alarmDetailQueue.value.shift() || null;
showOfflineDialog(objid); if (!nextDetail) {
triggerOfflineEffect(); return;
}
currentAlarmDetail.value = nextDetail;
currentAlarmContents.value = nextDetail.alarmContents;
currentActionSteps.value = nextDetail.steps;
alarmDetailTab.value = 'alarm';
alarmDetailVisible.value = true;
};
const closeCurrentAlarmDetail = () => {
currentAlarmDetail.value = null;
currentAlarmContents.value = [];
currentActionSteps.value = [];
alarmDetailVisible.value = false;
};
const enqueueAlarmDetails = async (payload: any) => {
const ruleObjIds = [...new Set((payload?.alarmRules ?? []).map((item) => String(item?.objid ?? '')).filter(Boolean))];
const alarmContents = normalizeAlarmContents(payload?.alarmContents);
for (const ruleObjId of ruleObjIds) {
const steps = await fetchActionStepsByRuleObjId(ruleObjId);
alarmDetailQueue.value.push({
ruleObjId,
monitorId: String(payload?.deviceParam?.monitorId ?? ''),
alarmObjId: String(payload?.deviceParam?.objId ?? payload?.deviceParam?.objid ?? ''),
alarmContents,
steps
});
}
openNextAlarmDetail();
};
const handleGlobalAlarm = (message: string, title: string) => {
playAlarmVoice(message);
showAlarmDialog(message, title);
triggerAlarmEffect();
};
const deferAlarmDetail = () => {
closeCurrentAlarmDetail();
openNextAlarmDetail();
};
const handleAlarmDetailClose = (done: () => void) => {
deferAlarmDetail();
done();
};
const confirmAlarmDetail = async () => {
const alarmObjId = currentAlarmDetail.value?.alarmObjId;
if (!alarmObjId) {
ElMessage.warning('当前报警缺少处理对象 ID');
return;
}
alarmHandling.value = true;
try {
const response = await handleExceptions(alarmObjId);
const success = consumeHandleResult((response as any).data ?? null);
if (!success) {
ElMessage.warning((response as any).msg || '报警状态未更新,请稍后重试');
return;
}
ElMessage.success('报警已处理');
closeCurrentAlarmDetail();
openNextAlarmDetail();
} finally {
alarmHandling.value = false;
}
};
void connectAlarmReminder(() => {});
onAlarmReminderOvertime((device) => {
const monitorId = String(device?.monitorId ?? '');
if (!monitorId) {
return;
}
handleGlobalAlarm(`设备${monitorId}离线`, '离线报警');
});
onAlarmReminderExtra((payload) => {
const monitorId = String(payload?.deviceParam?.monitorId ?? '');
if (!monitorId) {
return;
}
playAlarmVoice(`设备${monitorId}报警`);
triggerAlarmEffect();
void enqueueAlarmDetails(payload);
}); });
onMounted(() => { onMounted(() => {
@ -86,6 +253,26 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped> <style scoped>
.alarm-info-list,
.alarm-step-panel {
max-height: 420px;
overflow-y: auto;
padding: 4px 0;
}
.alarm-info-item {
font-size: 14px;
line-height: 1.8;
color: #303133;
word-break: break-all;
}
.alarm-empty-text {
font-size: 14px;
line-height: 1.8;
color: #909399;
}
.offline-breath-overlay { .offline-breath-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

@ -76,8 +76,12 @@ onMounted(() => {
}); });
onMounted(() => { onMounted(() => {
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const baseApi = import.meta.env.VITE_APP_BASE_API;
initWebSocket(protocol + window.location.host + import.meta.env.VITE_APP_BASE_API + '/resource/websocket'); const isAbsoluteApi = /^https?:\/\//.test(baseApi);
const websocketBase = isAbsoluteApi
? baseApi.replace(/^http/, 'ws')
: `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}${baseApi}`;
initWebSocket(websocketBase + '/resource/websocket');
}); });
onMounted(() => { onMounted(() => {

@ -1,4 +1,4 @@
import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router'; import { createWebHashHistory, createWebHistory, createRouter, RouteRecordRaw } from 'vue-router';
/* Layout */ /* Layout */
import Layout from '@/layout/index.vue'; import Layout from '@/layout/index.vue';
@ -110,7 +110,10 @@ export const dynamicRoutes: RouteRecordRaw[] = [
* *
*/ */
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.VITE_APP_CONTEXT_PATH), history:
import.meta.env.VITE_APP_ELECTRON === 'true'
? createWebHashHistory()
: createWebHistory(import.meta.env.VITE_APP_CONTEXT_PATH),
routes: constantRoutes, routes: constantRoutes,
// 刷新时,滚动条位置还原 // 刷新时,滚动条位置还原
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
@ -122,4 +125,3 @@ const router = createRouter({
}); });
export default router; export default router;

@ -14,6 +14,7 @@ interface ImportMetaEnv {
VITE_APP_MONITOR_ADMIN: string; VITE_APP_MONITOR_ADMIN: string;
VITE_APP_SNAILJOB_ADMIN: string; VITE_APP_SNAILJOB_ADMIN: string;
VITE_APP_ENV: string; VITE_APP_ENV: string;
VITE_APP_ELECTRON: string;
VITE_APP_ENCRYPT: string; VITE_APP_ENCRYPT: string;
VITE_APP_RSA_PUBLIC_KEY: string; VITE_APP_RSA_PUBLIC_KEY: string;
VITE_APP_RSA_PRIVATE_KEY: string; VITE_APP_RSA_PRIVATE_KEY: string;

@ -1,10 +1,14 @@
const websocketUrl = 'ws://119.45.202.115:7181/ws'; import { listBaseMonitorInfo } from '@/api/ems/base/baseMonitorInfo';
const websocketUrl = 'ws://119.45.202.115:7164/ws';
// const websocketUrl = 'ws://119.45.202.115:7181/ws';
const deviceDataTime = {}; const deviceDataTime = {};
const overtime = 1000 * 60 * 10; const overtime = 1000 * 60 * 10;
const intervalTime = 1000 * 30; const intervalTime = 1000 * 30;
let alarmReminderSocket = null; let alarmReminderSocket = null;
let overtimeTimer = null; let overtimeTimer = null;
let overtimeCallback = null; let overtimeCallback = null;
let extraCallback = null;
const resolveMessagePayload = (data) => { const resolveMessagePayload = (data) => {
if (typeof data !== 'string') { if (typeof data !== 'string') {
@ -26,18 +30,33 @@ const resolveMessagePayload = (data) => {
const updateDeviceDataTime = (payload) => { const updateDeviceDataTime = (payload) => {
const monitorId = payload?.deviceParam?.monitorId; const monitorId = payload?.deviceParam?.monitorId;
const objid = payload?.deviceParam?.objid;
if (!monitorId || objid === undefined || objid === null) { if (!monitorId) {
return; return;
} }
deviceDataTime[monitorId] = { deviceDataTime[monitorId] = {
objid: String(objid),
time: Date.now() + overtime time: Date.now() + overtime
}; };
}; };
const initDeviceDataTime = async () => {
const response = await listBaseMonitorInfo();
const deviceList = response?.rows ?? [];
deviceList.forEach((item) => {
const monitorCode = item?.monitorCode;
if (!monitorCode) {
return;
}
deviceDataTime[monitorCode] = {
time: Date.now() + overtime
};
});
};
const startOvertimeTimer = () => { const startOvertimeTimer = () => {
if (overtimeTimer) { if (overtimeTimer) {
return; return;
@ -66,7 +85,7 @@ const startOvertimeTimer = () => {
}, 1000); }, 1000);
}; };
export const connectAlarmReminder = (onMessage, url = websocketUrl) => { export const connectAlarmReminder = async (onMessage, url = websocketUrl) => {
if (typeof onMessage !== 'function') { if (typeof onMessage !== 'function') {
throw new Error('connectAlarmReminder 需要传入消息回调函数'); throw new Error('connectAlarmReminder 需要传入消息回调函数');
} }
@ -75,17 +94,30 @@ export const connectAlarmReminder = (onMessage, url = websocketUrl) => {
alarmReminderSocket.close(); alarmReminderSocket.close();
} }
await initDeviceDataTime();
alarmReminderSocket = new WebSocket(url); alarmReminderSocket = new WebSocket(url);
alarmReminderSocket.onmessage = (event) => { alarmReminderSocket.onmessage = (event) => {
const payload = resolveMessagePayload(event.data); const payload = resolveMessagePayload(event.data);
updateDeviceDataTime(payload); updateDeviceDataTime(payload);
if (String(payload?.isFlag) === '1' && typeof extraCallback === 'function') {
extraCallback(payload, event);
}
onMessage(payload, event); onMessage(payload, event);
}; };
return alarmReminderSocket; return alarmReminderSocket;
}; };
export const onAlarmReminderExtra = (callback) => {
if (typeof callback !== 'function') {
throw new Error('onAlarmReminderExtra 需要传入额外处理回调函数');
}
extraCallback = callback;
};
export const onAlarmReminderOvertime = (callback) => { export const onAlarmReminderOvertime = (callback) => {
if (typeof callback !== 'function') { if (typeof callback !== 'function') {
throw new Error('onAlarmReminderOvertime 需要传入超时回调函数'); throw new Error('onAlarmReminderOvertime 需要传入超时回调函数');
@ -102,6 +134,7 @@ export const closeAlarmReminder = () => {
overtimeTimer = null; overtimeTimer = null;
} }
overtimeCallback = null; overtimeCallback = null;
extraCallback = null;
return; return;
} }
@ -114,6 +147,7 @@ export const closeAlarmReminder = () => {
} }
overtimeCallback = null; overtimeCallback = null;
extraCallback = null;
}; };
export { websocketUrl, deviceDataTime, overtime, intervalTime }; export { websocketUrl, deviceDataTime, overtime, intervalTime };

@ -53,8 +53,14 @@ function createCompressionPlugin(kind: CompressionKind): Plugin {
configResolved(resolvedConfig) { configResolved(resolvedConfig) {
config = resolvedConfig; config = resolvedConfig;
}, },
async closeBundle() { async writeBundle(outputOptions) {
const outputDir = path.resolve(process.cwd(), config?.build.outDir ?? 'dist'); const outputDir = path.resolve(config?.root ?? process.cwd(), outputOptions.dir ?? config?.build.outDir ?? 'dist');
try {
await fs.access(outputDir);
} catch {
config?.logger.warn(`[compression:${kind}] output directory not found: ${outputDir}`);
return;
}
const files = await collectFiles(outputDir); const files = await collectFiles(outputDir);
const compressedEntries: Array<{ file: string; originalKb: string; compressedKb: string }> = []; const compressedEntries: Array<{ file: string; originalKb: string; compressedKb: string }> = [];

Loading…
Cancel
Save