光伏pc端应用
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

608 lines
20 KiB

const { app, BrowserWindow, clipboard, ipcMain, Menu, shell } = require('electron')
const { execFile } = require('child_process')
const path = require('path')
const fs = require('fs')
const { SensorController } = require('./src/business/sensor-controller')
const { UsbSerialBridge } = require('./src/business/usb-serial-bridge')
const pkg = require('./package.json')
if (require('electron-squirrel-startup')) app.quit()
const cacheDir = app.getPath('userData')
const cacheFile = path.join(cacheDir, 'app-cache.json')
const fileRoot = path.join(cacheDir, 'files')
let mainWindow = null
let usbBridge = null
let sensorController = null
let bluetoothSelectWindow = null
const topBarState = {
route: '',
network: null,
pwm: false,
targetUrl: ''
}
const pwmPorts = new Set()
function ensureDir(dir) {
try {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
} catch {}
}
function readCache() {
try {
if (fs.existsSync(cacheFile)) return JSON.parse(fs.readFileSync(cacheFile, 'utf-8'))
} catch {}
return {}
}
function writeCache(data) {
try {
ensureDir(cacheDir)
fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2), 'utf-8')
} catch {}
}
function resolveAppFile(inputPath) {
const raw = String(inputPath || '').trim()
if (!raw) return fileRoot
if (path.isAbsolute(raw) && /^[a-zA-Z]:[\\/]/.test(raw)) return raw
const relative = raw.replace(/^[/\\]+/, '')
return path.join(fileRoot, relative)
}
function fileList(dir) {
try {
const target = resolveAppFile(dir)
if (!fs.existsSync(target)) return ''
const stat = fs.statSync(target)
if (stat.isFile()) return target
return fs.readdirSync(target).map(name => path.join(target, name)).join('\n')
} catch {
return ''
}
}
function emit(channel, ...args) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, ...args)
}
}
function parseBluetoothDevices(raw) {
const lines = String(raw || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
const devices = []
for (const line of lines) {
const parts = line.split('\t')
if (parts.length < 2) continue
const [name, address = '', status = ''] = parts
if (!name || name === 'Name') continue
devices.push({ name, address, status })
}
return devices
}
function listBluetoothDevices() {
const script = [
'$ErrorActionPreference = "SilentlyContinue"',
'Get-PnpDevice -Class Bluetooth |',
'Where-Object { $_.FriendlyName -and $_.InstanceId } |',
'ForEach-Object {',
' $id = $_.InstanceId',
' $addr = ""',
' if ($id -match "DEV_([0-9A-Fa-f]{12})") {',
' $raw = $Matches[1].ToUpper()',
' $addr = (($raw -split "(.{2})" | Where-Object { $_ }) -join ":")',
' }',
' "$($_.FriendlyName)`t$addr`t$($_.Status)"',
'}'
].join('\n')
return new Promise(resolve => {
execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true }, (err, stdout) => {
if (err) {
resolve([])
return
}
resolve(parseBluetoothDevices(stdout))
})
})
}
function bluetoothSelectHtml(namePrefix) {
const prefix = String(namePrefix || '')
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>蓝牙连接</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;color:#1f2937}
.bar{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;background:#fff;border-bottom:1px solid #d8e0eb}
.title{font-size:16px;font-weight:600}
.actions{display:flex;gap:8px}
button{border:1px solid #b9c7d8;background:#fff;color:#1f3a5f;border-radius:6px;padding:7px 10px;cursor:pointer}
button.primary{background:#1677ff;border-color:#1677ff;color:#fff}
.content{padding:12px}
.hint{font-size:12px;color:#667085;margin-bottom:10px}
.list{display:flex;flex-direction:column;gap:8px}
.item{background:#fff;border:1px solid #d8e0eb;border-radius:8px;padding:10px 12px;display:flex;justify-content:space-between;gap:12px;align-items:center}
.name{font-size:14px;font-weight:600}
.meta{font-size:12px;color:#667085;margin-top:4px}
.empty{padding:28px 12px;text-align:center;color:#667085;background:#fff;border:1px dashed #c8d2df;border-radius:8px}
</style>
</head>
<body>
<div class="bar">
<div class="title">蓝牙连接</div>
<div class="actions">
<button id="settings">系统蓝牙设置</button>
<button id="refresh" class="primary">刷新</button>
</div>
</div>
<div class="content">
<div class="hint">选择已配对蓝牙设备${prefix ? `,过滤前缀:${prefix}` : ''}</div>
<div id="list" class="list"><div class="empty">正在扫描已配对设备...</div></div>
</div>
<script>
const prefix = ${JSON.stringify(prefix)}
const list = document.getElementById('list')
function render(devices) {
const filtered = prefix ? devices.filter(d => String(d.name || '').startsWith(prefix)) : devices
if (!filtered.length) {
list.innerHTML = '<div class="empty">未找到已配对设备。请先在 Windows 蓝牙设置中配对设备,然后刷新。</div>'
return
}
list.innerHTML = filtered.map((d, i) => '<div class="item"><div><div class="name">' + escapeHtml(d.name || '未知设备') + '</div><div class="meta">' + escapeHtml(d.address || d.status || '') + '</div></div><button class="primary" data-index="' + i + '">选择</button></div>').join('')
Array.from(list.querySelectorAll('button[data-index]')).forEach(btn => {
btn.onclick = () => window.bluetoothSelect.select(filtered[Number(btn.dataset.index)])
})
}
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]))
}
async function refresh() {
list.innerHTML = '<div class="empty">正在扫描已配对设备...</div>'
render(await window.bluetoothSelect.list())
}
document.getElementById('refresh').onclick = refresh
document.getElementById('settings').onclick = () => window.bluetoothSelect.openSettings()
refresh()
</script>
</body>
</html>`
}
async function openBluetoothSelectWindow(sender, namePrefix) {
if (bluetoothSelectWindow && !bluetoothSelectWindow.isDestroyed()) {
bluetoothSelectWindow.focus()
return
}
bluetoothSelectWindow = new BrowserWindow({
width: 560,
height: 620,
parent: mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined,
modal: false,
title: '蓝牙连接',
webPreferences: {
preload: path.join(__dirname, 'src', 'business', 'bluetooth-select-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
ipcMain.once('bluetooth-select-device', (_event, device) => {
const address = String(device?.address || '')
const success = address.length > 0 ? 'true' : 'false'
if (sender && !sender.isDestroyed()) {
sender.send('bluetooth-device-selected', success, address)
}
if (bluetoothSelectWindow && !bluetoothSelectWindow.isDestroyed()) {
bluetoothSelectWindow.close()
}
})
bluetoothSelectWindow.on('closed', () => {
bluetoothSelectWindow = null
})
await bluetoothSelectWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(bluetoothSelectHtml(namePrefix))}`)
}
function hasSensor(sensorName) {
return !!sensorController?.devices?.some(device => device.sensor === sensorName)
}
function routeSummary(route) {
const raw = String(route || '#/')
try {
const [pathPart, queryPart = ''] = raw.split('?')
const params = new URLSearchParams(queryPart)
const picked = []
for (const key of ['id', 'taskId', 'pageType']) {
const value = params.get(key)
if (!value) continue
const shortValue = value.length > 12 ? `${value.slice(0, 12)}...` : value
picked.push(`${key}=${shortValue}`)
}
return picked.length ? `${pathPart}?${picked.join('&')}` : pathPart
} catch {
return raw.length > 60 ? `${raw.slice(0, 60)}...` : raw
}
}
function connectionLabel(name, connected) {
return `${name}${connected ? '✓' : '×'}`
}
function connectionItems() {
return [
connectionLabel('网络', topBarState.network === true),
connectionLabel('倾角', hasSensor('HWT6053')),
connectionLabel('测距', hasSensor('SW_LDS20DA')),
connectionLabel('全站仪', hasSensor('CHC_CGI_430')),
connectionLabel('PWM', topBarState.pwm)
]
}
function updateAppMenu() {
const fullRoute = topBarState.route || '#/'
const template = [
{
label: '连接',
submenu: connectionItems().map(label => ({
label,
enabled: false
}))
},
{
label: `当前页面 ${routeSummary(fullRoute)}`,
submenu: [
{
label: fullRoute,
enabled: false
},
{
label: '复制当前页面链接',
click: () => clipboard.writeText(fullRoute)
}
]
},
{
label: `壳地址 ${topBarState.targetUrl ? '已设置' : '本地包'}`,
submenu: [
{
label: topBarState.targetUrl || '本地包',
enabled: false
},
{
label: '切换到本地包',
click: () => loadShellTarget('')
},
{
label: '打开地址设置',
click: () => emit('app-shell-open-url-dialog')
}
]
},
{
label: '视图',
submenu: [
{ role: 'reload', label: '刷新' },
{ role: 'toggleDevTools', label: '开发者工具' },
{ type: 'separator' },
{ role: 'resetZoom', label: '实际大小' },
{ role: 'zoomIn', label: '放大' },
{ role: 'zoomOut', label: '缩小' }
]
},
{
label: '窗口',
submenu: [
{ role: 'minimize', label: '最小化' },
{ role: 'close', label: '关闭' }
]
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
function updateWindowTitle() {
if (!mainWindow || mainWindow.isDestroyed()) return
const connections = connectionItems().join(' ')
const route = routeSummary(topBarState.route || '#/')
mainWindow.setTitle(`光伏智能建造调度管控系统 | ${connections} | ${route}`)
updateAppMenu()
}
function setPwmPort(deviceName, connected) {
const port = String(deviceName || '')
if (!port) return
if (connected) pwmPorts.add(port)
else pwmPorts.delete(port)
topBarState.pwm = pwmPorts.size > 0
}
function normalizeShellUrl(input) {
const raw = String(input || '').trim()
if (!raw) return ''
try {
const parsed = new URL(raw)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.toString()
} catch {}
return ''
}
function getStartupTarget() {
const cache = readCache()
return normalizeShellUrl(cache.shellTargetUrl || cache.appTargetUrl || '')
}
async function loadShellTarget(targetUrl) {
if (!mainWindow || mainWindow.isDestroyed()) return
const normalized = normalizeShellUrl(targetUrl)
if (normalized) {
topBarState.targetUrl = normalized
writeCache({ ...readCache(), shellTargetUrl: normalized })
await mainWindow.loadURL(normalized)
updateWindowTitle()
return
}
topBarState.targetUrl = ''
const localIndex = path.join(__dirname, 'dist', 'index.html')
await mainWindow.loadFile(localIndex)
updateWindowTitle()
}
function lastSensorValue(id, opId) {
if (!sensorController) return null
const device = sensorController.devices.find(d => d.id === id) ||
sensorController.devices.find(d => d.sensor === id) ||
sensorController.devices.find(d => d.sensor === String(id || '').split(':')[0])
if (!device) return null
return sensorController.lastValues.get(`${device.sensor}:${opId}`) ?? null
}
function createSensorController() {
sensorController = new SensorController({
onValue: data => emit('sensor-data', data),
onDeviceAttached: device => {
emit('sensor-attached', { id: device.id, name: device.name })
updateWindowTitle()
},
onDeviceDetached: device => {
emit('sensor-detached', { id: device.id, name: device.name })
updateWindowTitle()
},
onLog: (level, msg) => emit('sensor-log', { level, msg })
})
sensorController.refresh()
.then(() => updateWindowTitle())
.catch(err => console.error('[sensor] refresh failed:', err.message))
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
fullscreen: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
createSensorController()
usbBridge = new UsbSerialBridge(mainWindow, {
onDeviceDiscovered: payload => {
if (payload?.probeId === 'remote_control') setPwmPort(payload.deviceName, true)
if (sensorController) sensorController.refresh().catch(() => {})
updateWindowTitle()
},
onDeviceDetached: device => {
if (device?.probeId === 'remote_control') {
setPwmPort(device.deviceName, false)
}
updateWindowTitle()
}
})
usbBridge.startDiscovery({ keepAlive: true }).catch(err => console.error('[usb] startup discovery failed:', err.message))
const startupTarget = getStartupTarget()
if (startupTarget) {
topBarState.targetUrl = startupTarget
mainWindow.loadURL(startupTarget)
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'))
}
updateWindowTitle()
}
function registerAppIpc() {
ipcMain.on('app-clear-cache-sync', (event) => {
writeCache({})
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.reload()
event.returnValue = true
})
ipcMain.on('app-version-code-sync', (event) => {
const major = Number(String(pkg.version || '1').split('.')[0]) || 1
event.returnValue = major
})
ipcMain.on('get-cache-sync', (event, key) => {
const cache = readCache()
event.returnValue = key ? (cache[key] ?? null) : JSON.stringify(cache)
})
ipcMain.on('set-cache-sync', (event, key, value) => {
const cache = readCache()
cache[key] = value
writeCache(cache)
event.returnValue = true
})
ipcMain.handle('get-cache', (_event, key) => {
const cache = readCache()
return key ? (cache[key] ?? null) : cache
})
ipcMain.handle('set-cache', (_event, key, value) => {
const cache = readCache()
cache[key] = value
writeCache(cache)
return true
})
ipcMain.on('app-offline-page', () => emit('app-page-mode', 'offline'))
ipcMain.on('app-online-page', () => emit('app-page-mode', 'online'))
ipcMain.on('app-network-status', (_event, online) => {
topBarState.network = !!online
updateWindowTitle()
})
ipcMain.on('app-route-changed', (_event, route) => {
topBarState.route = String(route || '')
updateWindowTitle()
})
ipcMain.handle('app-shell-get-target-url', () => topBarState.targetUrl || getStartupTarget())
ipcMain.handle('app-shell-set-target-url', async (_event, targetUrl) => {
await loadShellTarget(targetUrl)
return topBarState.targetUrl
})
ipcMain.on('app-shell-open-url', async (_event, targetUrl) => {
await loadShellTarget(targetUrl)
})
ipcMain.on('app-file-list-sync', (event, dir) => {
event.returnValue = fileList(dir)
})
ipcMain.on('app-file-write-string', (event, filePath, content, token) => {
const target = resolveAppFile(filePath)
fs.promises.mkdir(path.dirname(target), { recursive: true })
.then(() => fs.promises.writeFile(target, String(content ?? ''), 'utf-8'))
.then(() => event.sender.send('app-file-write-callback', token, target, true, ''))
.catch(err => event.sender.send('app-file-write-callback', token, target, false, err.message))
})
ipcMain.on('app-file-read-string', (event, filePath, token) => {
const target = resolveAppFile(filePath)
fs.promises.readFile(target, 'utf-8')
.then(content => event.sender.send('app-file-read-callback', token, target, content))
.catch(() => event.sender.send('app-file-read-callback', token, target, null))
})
ipcMain.on('app-file-delete', (event, filePath, token) => {
const target = resolveAppFile(filePath)
fs.promises.unlink(target)
.then(() => event.sender.send('app-file-delete-callback', token, target, true))
.catch(() => event.sender.send('app-file-delete-callback', token, target, false))
})
ipcMain.on('app-export-file', (event, filename, content, token) => {
const target = resolveAppFile(filename || `export-${Date.now()}.txt`)
fs.promises.mkdir(path.dirname(target), { recursive: true })
.then(() => fs.promises.writeFile(target, String(content ?? ''), 'utf-8'))
.then(() => event.sender.send('app-file-write-callback', token || filename, target, true, ''))
.catch(err => event.sender.send('app-file-write-callback', token || filename, target, false, err.message))
})
}
function registerSensorIpc() {
ipcMain.on('app-sensor-open-sync', (event) => {
if (sensorController) sensorController.open().catch(() => {})
event.returnValue = true
})
ipcMain.on('app-sensor-close-sync', (event) => {
event.returnValue = sensorController ? sensorController.close() : true
})
ipcMain.on('app-sensor-is-open-sync', (event) => {
event.returnValue = sensorController ? sensorController.isOpen : false
})
ipcMain.on('sensor-init-all-sync', (event) => {
if (sensorController) sensorController.initAll().catch(() => {})
event.returnValue = true
})
ipcMain.on('sensor-flush-sync', (event) => {
if (sensorController) sensorController.refresh().catch(() => {})
event.returnValue = true
})
ipcMain.on('sensor-list-sync', (event) => {
event.returnValue = sensorController ? sensorController.list() : []
})
ipcMain.on('sensor-init-sync', (event, id) => {
event.returnValue = sensorController ? sensorController.init(id) : false
})
ipcMain.on('sensor-open-sync', (event) => {
if (sensorController) sensorController.open().catch(() => {})
event.returnValue = true
})
ipcMain.on('sensor-close-sync', (event) => {
event.returnValue = sensorController ? sensorController.close() : true
})
ipcMain.on('sensor-operation-sync', (event, id, opId) => {
const cached = lastSensorValue(id, opId)
if (sensorController) {
sensorController.operation(id, opId).catch(() => {})
}
event.returnValue = cached
})
ipcMain.on('sensor-operation-async', (event, id, opId, args) => {
if (!sensorController) {
event.sender.send('sensor-operation-callback', id, opId, null)
return
}
sensorController.operation(id, opId, args)
.then(result => event.sender.send('sensor-operation-callback', id, opId, result))
.catch(err => event.sender.send('sensor-operation-callback', id, opId, err.message))
})
}
function registerLocationAndBluetoothIpc() {
ipcMain.on('app-location-is-support-sync', (event) => { event.returnValue = false })
ipcMain.on('app-location-is-open-sync', (event) => { event.returnValue = false })
ipcMain.on('app-location-open', (event) => { event.sender.send('app-location-opened', 1) })
ipcMain.on('app-location-close', () => {})
ipcMain.on('bluetooth-device-select', (event, namePrefix) => {
openBluetoothSelectWindow(event.sender, namePrefix).catch(err => {
console.error('[bluetooth] select window failed:', err.message)
event.sender.send('bluetooth-device-selected', 'false', '')
})
})
ipcMain.handle('bluetooth-select-list', () => listBluetoothDevices())
ipcMain.on('bluetooth-open-settings', () => {
shell.openExternal('ms-settings:bluetooth')
})
ipcMain.on('bluetooth-device-open', (event, mac) => {
event.sender.send('bluetooth-device-opened', mac ? 'true' : 'false', mac || '')
if (mac) event.sender.send('bluetooth-device-state-changed', mac, 'CONNECTED')
})
ipcMain.on('bluetooth-device-write', () => {})
ipcMain.on('bluetooth-device-close', () => {})
}
function registerIpc() {
registerAppIpc()
registerSensorIpc()
registerLocationAndBluetoothIpc()
}
app.whenReady().then(() => {
registerIpc()
ensureDir(fileRoot)
createWindow()
app.on('before-quit', () => {
if (sensorController) sensorController.close()
if (usbBridge) usbBridge.disconnectAll()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})