init: 智慧农业大数据可视化控制中心 v0.1.0(Electron + React 19 + TS 重写版)

This commit is contained in:
2026-08-23 10:57:14 +08:00
commit 2c90205766
61 changed files with 14564 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
out
dist
*.log
.DS_Store
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+27
View File
@@ -0,0 +1,27 @@
# 绿色版打包配置(electron-builder
# 使用方式:npm run dist(先 electron-vite build,再 electron-builder
# 输出位置:release/ 目录下 zip 压缩包,解压即用(免安装绿色版)
appId: com.bbit.smartagri
productName: 智慧农业大数据可视化控制中心
directories:
output: release
buildResources: build
# 只打包构建产物(main/preload/renderer 已全部打进 out/
files:
- out/**
- package.json
# 应用图标(dev 模式主窗口 icon 与打包均使用)
- build/icon.ico
asar: true
# 关闭重建原生依赖(本应用无原生模块,避免下载耗时)
npmRebuild: false
win:
icon: build/icon.ico
# zip = 免安装绿色版压缩包;如需单文件 exe 可加 portable
target:
- target: zip
arch:
- x64
# 本机网络受限时使用 Electron 镜像下载打包用二进制
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
main: {
build: {
outDir: 'out/main',
rollupOptions: {
external: ['electron']
}
}
},
preload: {
build: {
outDir: 'out/preload',
rollupOptions: {
external: ['electron']
}
}
},
renderer: {
root: 'src/renderer',
build: {
outDir: 'out/renderer',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html')
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer/src')
}
},
plugins: [react()]
}
})
+6390
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "smart-agri-center",
"version": "0.1.0",
"description": "智慧农业大数据可视化控制中心(Electron + React + TypeScript",
"main": "./out/main/index.js",
"author": "FanHongCai",
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"pack": "electron-vite build && electron-builder --dir",
"dist": "electron-vite build && electron-builder"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^43.4.1",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"typescript": "^7.0.2",
"vite": "^7.3.6"
}
}
+38
View File
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
/** 加密文本前缀标记 */
const PREFIX = 'enc:v1:'
function keyFromPassword(pwd: string): Buffer {
return createHash('sha256').update(pwd, 'utf8').digest()
}
/** 用密码加密文本;pwd 为空时原样返回 */
export function encryptText(text: string, pwd: string): string {
if (!pwd || !text) return text
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', keyFromPassword(pwd), iv)
const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return PREFIX + Buffer.concat([iv, tag, enc]).toString('base64')
}
/**
* 用密码解密文本。
* - 非加密文本原样返回
* - 密码错误返回 null(调用方应据此提示)
*/
export function decryptText(text: string, pwd: string): string | null {
if (!text || !text.startsWith(PREFIX)) return text
try {
const raw = Buffer.from(text.slice(PREFIX.length), 'base64')
const iv = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const data = raw.subarray(28)
const decipher = createDecipheriv('aes-256-gcm', keyFromPassword(pwd), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
} catch {
return null
}
}
+129
View File
@@ -0,0 +1,129 @@
import { app, BrowserWindow, dialog, session } from 'electron'
import { join } from 'path'
import { installCloseGuard, registerIpc } from './ipc'
import { closeAllScreens, setBroadcast, setMainWindow } from './screens'
import { loadSettings } from './settings'
import { readProjectFile } from './project'
import type { ProjectJson } from './project'
import { parseArgv } from './shortcuts'
import { pushLoad, setPendingLoad } from './loader'
import type { LoadPayload } from './loader'
let mainWindow: BrowserWindow | null = null
function sendScreensChanged(list: unknown): void {
mainWindow?.webContents.send('screens:changed', list)
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1420,
height: 900,
minWidth: 1120,
minHeight: 700,
show: false,
frame: false,
backgroundColor: '#eef3fa',
icon: join(app.getAppPath(), 'build/icon.ico'),
title: `智慧农业大数据可视化控制中心 v${app.getVersion()}`,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webviewTag: true,
spellcheck: false
}
})
mainWindow.on('ready-to-show', () => mainWindow?.show())
// 页面 <title> 会覆盖构造标题,加载完成后统一为带版本号标题(任务栏/Alt+Tab 显示)
mainWindow.webContents.on('did-finish-load', () => {
mainWindow?.setTitle(`智慧农业大数据可视化控制中心 v${app.getVersion()}`)
})
mainWindow.on('closed', () => {
setMainWindow(null)
mainWindow = null
})
setMainWindow(mainWindow)
mainWindow.on('maximize', () => mainWindow?.webContents.send('win:maximized', true))
mainWindow.on('unmaximize', () => mainWindow?.webContents.send('win:maximized', false))
installCloseGuard(mainWindow)
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
void mainWindow.loadURL(devUrl)
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
/** 快捷方式启动参数:打开指定工程并进入编辑/发布状态 */
async function handleLaunch(parsed: { project?: string; mode?: 'edit' | 'publish' }): Promise<void> {
if (!parsed.project) return
const settings = await loadSettings()
const res = await readProjectFile(parsed.project, settings.jsonPassword)
if (!res.json) {
void dialog.showMessageBox({
type: 'error',
title: '打开项目失败',
message:
res.error === '密码错误'
? '密码错误,无法解密该工程文件。\n请先在工具「配置 → 项目信息」中设置正确的 JSON 读写密码后重试。'
: res.error ?? '无法读取文件'
})
return
}
const payload: LoadPayload = { path: res.path, json: res.json as ProjectJson, mode: parsed.mode ?? 'edit' }
setPendingLoad(payload)
if (mainWindow && !mainWindow.isDestroyed()) {
// second-instance / 已运行场景:实时推送(渲染层就绪时可直接收到)
pushLoad(mainWindow, payload)
}
}
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
app.on('second-instance', (_e, argv) => {
const parsed = parseArgv(argv)
if (parsed.project) void handleLaunch(parsed)
else if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
app.whenReady().then(async () => {
// 麦克风测试权限
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(permission === 'media')
})
setBroadcast(sendScreensChanged)
registerIpc()
createWindow()
// 快捷方式启动:打开指定工程
const parsed = parseArgv(process.argv)
if (parsed.project) await handleLaunch(parsed)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('before-quit', () => {
closeAllScreens()
})
}
+247
View File
@@ -0,0 +1,247 @@
import { app, dialog, ipcMain, shell, BrowserWindow } from 'electron'
import { exec } from 'child_process'
import { dirname, join } from 'path'
import {
closeScreen,
closeAllScreens,
listScreens,
publishScreen,
setPages,
startCarousel,
stopCarousel,
mainScreenIndex,
publishMain,
restoreMain,
probeScreens,
setKeyLock,
isKeyLocked
} from './screens'
import type { CarouselPage } from './screens'
import { loadSettings, saveSettings } from './settings'
import type { AppSettings } from './settings'
import { readProjectFile, writeProjectFile } from './project'
import { createDesktopShortcut, chooseProjectFile } from './shortcuts'
import type { ShortcutKind } from './shortcuts'
import { consumePendingLoad } from './loader'
function beepSystem(): void {
if (process.platform === 'darwin') {
shell.beep()
return
}
// Windows / Linux:调用系统终端提示音
exec('powershell -NoProfile -NonInteractive -Command "[console]::beep(880,400)"', () => undefined)
}
export function registerIpc(): void {
// ===== 窗口控制 =====
ipcMain.on('win:minimize', () => BrowserWindow.getFocusedWindow()?.minimize())
ipcMain.on('win:toggle-maximize', () => {
const w = BrowserWindow.getFocusedWindow()
if (!w) return
if (w.isMaximized()) w.unmaximize()
else w.maximize()
})
ipcMain.on('win:close', () => BrowserWindow.getFocusedWindow()?.close())
// ===== 屏幕 / 多屏发布 =====
ipcMain.handle('screens:list', () => listScreens())
ipcMain.handle('screens:states', () => listScreens())
ipcMain.handle('screen:publish', (_e, payload: { index: number; url: string; title: string }) =>
publishScreen(payload.index, payload.url, payload.title)
)
ipcMain.handle('screen:close', (_e, index: number) => closeScreen(index))
ipcMain.handle('screen:closeAll', () => {
closeAllScreens()
return listScreens()
})
ipcMain.handle('screens:mainIndex', () => mainScreenIndex())
ipcMain.handle('screen:publishMain', (_e, payload: { url: string; title: string }) =>
publishMain(payload.url, payload.title)
)
ipcMain.handle('screen:restoreMain', () => restoreMain())
ipcMain.handle('screens:probe', () => probeScreens())
// ===== 单屏轮播 =====
ipcMain.handle(
'carousel:start',
(_e, payload: { index: number; pages: CarouselPage[] }) => startCarousel(payload.index, payload.pages)
)
ipcMain.handle('carousel:stop', (_e, index: number) => stopCarousel(index))
// 渲染层页面同步(用于屏幕键盘切页)
ipcMain.on('project:sync', (_e, pages: unknown) => {
setPages(
Array.isArray(pages)
? pages.map((p) => p as { id: string; title: string; url: string; screen: number; ctrl: number; display: boolean })
: []
)
})
// ===== 工程文件(带 JSON 读写密码)=====
/** 记住用户最后一次选择/保存工程的文件夹 */
const rememberDir = async (p: string): Promise<void> => {
try {
await saveSettings({ lastDir: dirname(p) })
} catch {
// 记录失败不影响主流程
}
}
ipcMain.handle('project:saveAs', async (_e, payload: { data: string; password?: string }) => {
const settings = await loadSettings()
const pwd = payload.password ?? settings.jsonPassword
const defaultPath = settings.lastDir
? join(settings.lastDir, settings.projectName + '.json')
: settings.projectName + '.json'
const r = await dialog.showSaveDialog({
title: '保存工程文件',
defaultPath,
filters: [{ name: 'JSON 工程文件', extensions: ['json'] }]
})
if (r.canceled || !r.filePath) return null
const res = await writeProjectFile(r.filePath, payload.data, pwd)
if (res.ok) await rememberDir(r.filePath)
return res.ok ? r.filePath : null
})
ipcMain.handle('project:save', async (_e, payload: { path: string; data: string; password?: string }) => {
if (!payload.path) return null
const settings = await loadSettings()
const pwd = payload.password ?? settings.jsonPassword
const res = await writeProjectFile(payload.path, payload.data, pwd)
return res.ok ? payload.path : null
})
// 打开工程:先弹框选文件,用当前配置密码尝试解密
ipcMain.handle('project:open', async (_e, password?: string) => {
const settings = await loadSettings()
const r = await dialog.showOpenDialog({
title: '打开工程文件',
defaultPath: settings.lastDir || undefined,
filters: [{ name: 'JSON 工程文件', extensions: ['json'] }],
properties: ['openFile']
})
if (r.canceled || !r.filePaths[0]) return null
const pwd = password ?? settings.jsonPassword
const res = await readProjectFile(r.filePaths[0], pwd)
await rememberDir(r.filePaths[0])
return res
})
// 打开指定路径工程(用于密码输入重试 / 快捷方式场景)
ipcMain.handle('project:openAt', (_e, payload: { path: string; password?: string }) =>
readProjectFile(payload.path, payload.password ?? '')
)
// ===== 应用设置 =====
ipcMain.handle('settings:get', () => loadSettings())
ipcMain.handle('settings:set', (_e, patch: Partial<AppSettings>) => saveSettings(patch))
// ===== 应用信息 / 启动加载 =====
ipcMain.handle('app:info', () => ({
appVersion: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
platform: process.platform + ' ' + process.arch
}))
ipcMain.handle('app:consumeLoad', () => consumePendingLoad())
// ===== 页面截图(预览图)=====
ipcMain.handle('page:snapshot', async (_e, url: string) => {
if (!url || url === 'about:blank') return null
const win = new BrowserWindow({
width: 1280,
height: 720,
show: false,
frame: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
})
try {
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
await win.loadURL(url)
// 等待页面渲染完成(图表/动画)
await new Promise((r) => setTimeout(r, 1600))
const img = await win.webContents.capturePage()
if (img.isEmpty()) return null
return img.toDataURL()
} catch {
return null
} finally {
if (!win.isDestroyed()) win.destroy()
}
})
// ===== 对话框 =====
ipcMain.handle('dialog:confirm', async (_e, message: string) => {
const r = await dialog.showMessageBox({
type: 'question',
title: '智慧农业大数据可视化控制中心',
message,
buttons: ['确定', '取消'],
defaultId: 0,
cancelId: 1
})
return r.response === 0
})
// ===== 工具 =====
ipcMain.handle('beep', () => {
beepSystem()
})
// 旧接口兼容:直接打开本工具
ipcMain.handle('desktop:shortcut', () => createDesktopShortcut('app').ok)
// 三类快捷方式;edit/publish 时先在主进程弹框选择工程文件
ipcMain.handle('shortcut:create', async (_e, kind: ShortcutKind) => {
if (kind === 'app') return createDesktopShortcut('app')
const path = await chooseProjectFile()
if (!path) return { ok: false, message: '未选择工程文件' }
return createDesktopShortcut(kind, path)
})
// ===== 快捷键锁定(遥控设置)=====
ipcMain.handle('screens:keyLock', (_e, locked: boolean) => setKeyLock(!!locked))
ipcMain.handle('screens:keyLockState', () => isKeyLocked())
// ===== 窗口关闭守卫(未保存提示)=====
ipcMain.on('window:dirty', (_e, d: boolean) => {
setDirtyFlag(!!d)
})
}
let dirty = false
let forceQuit = false
function setDirtyFlag(d: boolean): void {
dirty = d
}
export function installCloseGuard(win: BrowserWindow): void {
win.on('close', (e) => {
if (!dirty || forceQuit) return
e.preventDefault()
void dialog
.showMessageBox(win, {
type: 'warning',
title: '工程尚未保存',
message: '当前工程有未保存的修改,确定要关闭吗?',
buttons: ['仍然关闭', '取消'],
defaultId: 1,
cancelId: 1
})
.then((r) => {
if (r.response === 0) {
forceQuit = true
win.close()
}
})
})
}
+31
View File
@@ -0,0 +1,31 @@
import type { BrowserWindow } from 'electron'
import type { ProjectJson } from './project'
export interface LoadPayload {
path: string
json: ProjectJson
mode: 'edit' | 'publish'
}
let pending: LoadPayload | null = null
/** 渲染层尚未就绪时暂存待加载项目 */
export function setPendingLoad(p: LoadPayload | null): void {
pending = p
}
/** 渲染层主动消费暂存项目(启动时) */
export function consumePendingLoad(): LoadPayload | null {
const p = pending
pending = null
return p
}
/** 实时推送给渲染层并聚焦窗口(second-instance 场景) */
export function pushLoad(win: BrowserWindow, payload: LoadPayload): void {
if (win.isDestroyed()) return
win.webContents.send('app:loadProject', payload)
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
+131
View File
@@ -0,0 +1,131 @@
import { promises as fs } from 'fs'
import { encryptText, decryptText } from './crypto'
export interface ProjectJson {
appName?: string
version?: string
pages?: PageFileJson[]
}
interface PageFileJson {
PageID?: unknown
PageTitle?: unknown
PageDescribe?: unknown
ScreenIndex?: unknown
ControllerIndex?: unknown
IsDisplay?: unknown
PageUrl?: unknown
PageParams?: unknown
PagePreview?: unknown
}
export interface SyncPage {
id: string
title: string
url: string
screen: number
ctrl: number
display: boolean
}
export interface ReadResult {
json: ProjectJson | null
path: string
error?: string
}
/** 解析页面 JSON 为可同步给播放端的页面列表 */
export function pagesToSync(json: ProjectJson): SyncPage[] {
const list = Array.isArray(json.pages) ? json.pages : []
return list.map((f: PageFileJson, i) => ({
id: String(f.PageID ?? 'P-' + String(i + 1).padStart(3, '0')),
title: String(f.PageTitle ?? '页面 ' + (i + 1)),
url: String(f.PageUrl ?? 'about:blank'),
screen: Number(f.ScreenIndex) || 1,
ctrl: Number(f.ControllerIndex) || 0,
display: f.IsDisplay !== false
}))
}
/**
* 读取工程文件并解密敏感字段。
* 密码错误时返回 error='密码错误'
*/
export async function readProjectFile(path: string, password: string): Promise<ReadResult> {
try {
const raw = await fs.readFile(path, 'utf8')
const json = JSON.parse(raw) as ProjectJson
if (Array.isArray(json.pages)) {
for (const p of json.pages) {
if (typeof p.PageUrl === 'string' && p.PageUrl.startsWith('enc:')) {
const dec = decryptText(p.PageUrl, password)
if (dec === null) return { json: null, path, error: '密码错误' }
p.PageUrl = dec
}
if (typeof p.PageParams === 'string' && p.PageParams.startsWith('enc:')) {
const dec = decryptText(p.PageParams, password)
if (dec === null) return { json: null, path, error: '密码错误' }
p.PageParams = dec
}
}
}
return { json, path }
} catch {
return { json: null, path, error: 'JSON 文件无法解析' }
}
}
/** 保存工程文件;设置了密码时对 URL / 参数加密存储 */
export async function writeProjectFile(
path: string,
data: string,
password: string
): Promise<{ ok: boolean; error?: string }> {
try {
let out = data
if (password) {
const json = JSON.parse(data) as ProjectJson
if (Array.isArray(json.pages)) {
for (const p of json.pages) {
if (typeof p.PageUrl === 'string') p.PageUrl = encryptText(p.PageUrl, password)
if (typeof p.PageParams === 'string') p.PageParams = encryptText(p.PageParams, password)
}
}
out = JSON.stringify(json, null, 2)
}
await fs.writeFile(path, out, 'utf8')
return { ok: true }
} catch (e) {
return { ok: false, error: String(e) }
}
}
// ===== URL 构造(播放端自动发布用,与渲染层 pageSrc 逻辑保持一致)=====
let urlBase = 'file://'
export function setUrlBase(base: string): void {
urlBase = base
}
export function buildPageUrl(url: string, params: string, id: string, title: string): string {
if (!url || url === 'about:blank') return 'about:blank'
try {
const u = new URL(url, urlBase)
if (params) {
params.split('\n').forEach((line) => {
const eq = line.indexOf('=')
if (eq > 0) {
const k = line.slice(0, eq).trim()
const v = line.slice(eq + 1).trim()
if (k) u.searchParams.set(k, v)
}
})
}
if (!u.searchParams.has('no')) u.searchParams.set('no', id.replace(/\D/g, '') || '0')
if (!u.searchParams.has('title')) u.searchParams.set('title', title)
return u.href
} catch {
return 'about:blank'
}
}
+574
View File
@@ -0,0 +1,574 @@
import { BrowserWindow, screen, WebContentsView } from 'electron'
import { getSettings } from './settings'
export interface PlayingInfo {
title: string
url: string
}
export interface ScreenInfo {
index: number
name: string
res: string
pos: string
primary: boolean
playing: PlayingInfo | null
}
/** 渲染层同步过来的页面(用于屏幕窗口按键切换) */
export interface SyncPage {
id: string
title: string
url: string
screen: number
ctrl: number
display: boolean
}
export interface CarouselPage {
url: string
title: string
}
const screenWindows = new Map<number, BrowserWindow>()
let lastPages: SyncPage[] = []
let broadcastFn: ((list: ScreenInfo[]) => void) | null = null
/** 全局快捷键识别锁定(遥控设置界面可切换) */
let keyLocked = false
export function setKeyLock(v: boolean): void {
keyLocked = v
}
export function isKeyLocked(): boolean {
return keyLocked
}
/** 单屏轮播状态:屏幕索引 → 该屏叠放的页面窗体列表 */
interface CarouselState {
screenIndex: number
wins: BrowserWindow[]
pages: CarouselPage[]
current: number
timer: NodeJS.Timeout | null
}
const carousels = new Map<number, CarouselState>()
/**
* 统一的屏幕键盘处理:
* - Esc 立即退出 / F5 立即刷新
* - 锁定键切换全局快捷键识别(避免网页交互输入数字误触切屏)
* - 数字键、方向键需要"长按超过阈值"才触发,快速输入不影响网页操作
*/
interface KeyBindings {
onEscape: () => void
onF5: () => void
onPrev: () => void
onNext: () => void
onNumber: (n: number) => void
onLockToggle: (locked: boolean) => void
}
function makeKeyHandler(b: KeyBindings): (event: Electron.Event, input: Electron.Input) => void {
let hold = ''
let holdT = 0
return (event, input) => {
const s = getSettings()
const threshold = Math.max(0, s.keyThreshold)
const lockKey = (s.keyLockKey || 'F9').toLowerCase()
if (input.type === 'keyDown') {
if (input.key === 'Escape') {
event.preventDefault()
b.onEscape()
return
}
if (input.key === 'F5') {
event.preventDefault()
b.onF5()
return
}
if (lockKey && input.key.toLowerCase() === lockKey) {
event.preventDefault()
const nv = !isKeyLocked()
setKeyLock(nv)
b.onLockToggle(nv)
return
}
const isNav =
input.key === 'ArrowUp' ||
input.key === 'ArrowLeft' ||
input.key === 'ArrowDown' ||
input.key === 'ArrowRight'
const n = parseInt(input.key)
if (isNav || (!isNaN(n) && n >= 1 && n <= 9)) {
// 仅在首次按下时记录时间戳;auto-repeat 不刷新,避免长按被持续重置
if (hold !== input.key) {
hold = input.key
holdT = Date.now()
}
}
return
}
if (input.type === 'keyUp' && input.key === hold) {
hold = ''
const dur = Date.now() - holdT
if (dur < threshold || isKeyLocked()) return
event.preventDefault()
if (input.key === 'ArrowUp' || input.key === 'ArrowLeft') {
b.onPrev()
return
}
if (input.key === 'ArrowDown' || input.key === 'ArrowRight') {
b.onNext()
return
}
const n = parseInt(input.key)
if (!isNaN(n) && n >= 1 && n <= 9) b.onNumber(n)
}
}
}
function lockHint(win: BrowserWindow | null, locked: boolean): void {
if (!win || win.isDestroyed()) return
const key = getSettings().keyLockKey || 'F9'
const prev = win.getTitle()
win.setTitle(locked ? `🔒 快捷键已锁定(按 ${key} 解锁)` : `快捷键识别已开启(按 ${key} 锁定)`)
setTimeout(() => {
if (!win.isDestroyed()) win.setTitle(prev)
}, 2000)
}
export function setPages(pages: SyncPage[]): void {
lastPages = pages
}
export function setBroadcast(fn: (list: ScreenInfo[]) => void): void {
broadcastFn = fn
}
function emitChange(): void {
broadcastFn?.(listScreens())
}
function sortedDisplays(): Electron.Display[] {
const primary = screen.getPrimaryDisplay()
return [...screen.getAllDisplays()].sort((a, b) => {
const pa = a.id === primary.id ? 0 : 1
const pb = b.id === primary.id ? 0 : 1
if (pa !== pb) return pa - pb
return a.bounds.x - b.bounds.x || a.bounds.y - b.bounds.y
})
}
export function listScreens(): ScreenInfo[] {
return sortedDisplays().map((d, i) => {
const index = i + 1
const win = screenWindows.get(index)
const car = carousels.get(index)
let playing: PlayingInfo | null = null
if (car && car.wins.some((w) => !w.isDestroyed() && w.isVisible())) {
playing = {
title: `单屏轮播(${car.pages.length} 页 · 第 ${car.current + 1} 页)`,
url: car.pages[car.current]?.url ?? ''
}
} else if (win && !win.isDestroyed() && win.isVisible()) {
playing = win.getTitle() ? { title: win.getTitle(), url: win.webContents.getURL() } : null
}
return {
index,
name: `屏幕 ${index}`,
res: `${d.size.width}×${d.size.height}`,
pos: d.id === screen.getPrimaryDisplay().id ? '主显示器' : '扩展显示器',
primary: d.id === screen.getPrimaryDisplay().id,
playing
}
})
}
function ensureWindow(index: number, display: Electron.Display): BrowserWindow {
let win = screenWindows.get(index)
if (win && !win.isDestroyed()) return win
win = new BrowserWindow({
x: display.bounds.x,
y: display.bounds.y,
width: display.bounds.width,
height: display.bounds.height,
frame: false,
autoHideMenuBar: true,
backgroundColor: '#050b18',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
})
win.setMenuBarVisibility(false)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.once('ready-to-show', () => {
if (win && !win.isDestroyed()) {
win.setFullScreen(true)
win.focus()
}
})
// 屏幕窗口键盘:Esc 退出发布,F5 刷新,数字/方向键需长按超阈值,锁定键 F9 切换识别
win.webContents.on(
'before-input-event',
makeKeyHandler({
onEscape: () => closeScreen(index),
onF5: () => win?.webContents.reload(),
onPrev: () => switchCarousel(index, (carousels.get(index)?.current ?? 0) - 1),
onNext: () => switchCarousel(index, (carousels.get(index)?.current ?? 0) + 1),
onNumber: (n) => {
const page = lastPages.find((p) => p.ctrl === n)
if (page) publishScreen(index, page.url, page.title)
},
onLockToggle: (lv) => lockHint(win, lv)
})
)
win.on('closed', () => {
screenWindows.delete(index)
emitChange()
})
screenWindows.set(index, win)
return win
}
export function publishScreen(index: number, url: string, title: string): ScreenInfo[] {
const displays = sortedDisplays()
const display = displays[index - 1]
if (!display) return listScreens()
const win = ensureWindow(index, display)
if (!win.isDestroyed()) {
win.setBounds(display.bounds)
}
win.setTitle(title)
win.loadURL(url)
emitChange()
return listScreens()
}
export function closeScreen(index: number): ScreenInfo[] {
const win = screenWindows.get(index)
if (win && !win.isDestroyed()) win.close()
screenWindows.delete(index)
emitChange()
return listScreens()
}
export function closeAllScreens(): void {
for (const [index, win] of screenWindows) {
if (win && !win.isDestroyed()) win.destroy()
screenWindows.delete(index)
}
closeAllCarousels()
restoreMain()
closeProbe()
emitChange()
}
export function screenCount(): number {
return sortedDisplays().length
}
// ===== 单屏轮播(多页面窗体叠放) =====
/**
* 在指定屏幕启动单屏轮播:为每个页面创建一个全屏窗体叠放在目标屏幕上,
* 通过键盘 上/下 键循环切换、数字键直达,Esc 退出发布,F5 刷新当前页。
* intervalSec > 0 时自动定时切换。
*/
export function startCarousel(index: number, pages: CarouselPage[], intervalSec = 0): ScreenInfo[] {
const displays = sortedDisplays()
const display = displays[index - 1]
if (!display) return listScreens()
if (!pages || pages.length === 0) return listScreens()
stopCarousel(index)
const b = display.bounds
const wins: BrowserWindow[] = []
pages.forEach((page, pi) => {
const win = new BrowserWindow({
x: b.x,
y: b.y,
width: b.width,
height: b.height,
frame: false,
autoHideMenuBar: true,
backgroundColor: '#050b18',
show: pi === 0,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
})
win.setMenuBarVisibility(false)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.setTitle(page.title)
void win.loadURL(page.url)
win.once('ready-to-show', () => {
if (pi === 0 && !win.isDestroyed()) {
win.setFullScreen(true)
win.focus()
}
})
// 键盘:在可见窗体上捕获,方向/数字键需长按超阈值
win.webContents.on(
'before-input-event',
makeKeyHandler({
onEscape: () => stopCarousel(index),
onF5: () => {
const state = carousels.get(index)
state?.wins[state.current]?.webContents.reload()
},
onPrev: () => {
const state = carousels.get(index)
if (state) switchCarousel(index, state.current - 1)
},
onNext: () => {
const state = carousels.get(index)
if (state) switchCarousel(index, state.current + 1)
},
onNumber: (n) => {
const state = carousels.get(index)
if (state && n >= 1 && n <= state.wins.length) switchCarousel(index, n - 1)
},
onLockToggle: (lv) => lockHint(win, lv)
})
)
win.on('closed', () => {
if (carousels.has(index)) {
carousels.delete(index)
emitChange()
}
})
wins.push(win)
})
const state: CarouselState = { screenIndex: index, wins, pages, current: 0, timer: null }
if (intervalSec > 0 && pages.length > 1) {
state.timer = setInterval(() => {
const st = carousels.get(index)
if (st) switchCarousel(index, st.current + 1)
}, intervalSec * 1000)
}
carousels.set(index, state)
emitChange()
return listScreens()
}
/** 切换到第 next 个窗体(循环),只显示目标窗体 */
function switchCarousel(index: number, next: number): void {
const state = carousels.get(index)
if (!state) return
const n = state.wins.length
if (n === 0) return
state.current = ((next % n) + n) % n
state.wins.forEach((w, i) => {
if (w.isDestroyed()) return
if (i === state.current) {
w.show()
w.setFullScreen(true)
w.focus()
} else {
w.hide()
}
})
emitChange()
}
export function stopCarousel(index: number): ScreenInfo[] {
const state = carousels.get(index)
if (state) {
if (state.timer) clearInterval(state.timer)
for (const w of state.wins) {
if (!w.isDestroyed()) w.destroy()
}
carousels.delete(index)
}
emitChange()
return listScreens()
}
export function closeAllCarousels(): void {
for (const index of [...carousels.keys()]) {
stopCarousel(index)
}
}
// ===== 编辑器主窗口全屏播放(在当前编辑器屏幕也显示页面) =====
let mainWin: BrowserWindow | null = null
let mainView: WebContentsView | null = null
let mainPlayingIndex = -1
export function setMainWindow(win: BrowserWindow | null): void {
mainWin = win
}
/** 主窗口当前所在屏幕编号(从 1 起) */
export function mainScreenIndex(): number {
if (!mainWin || mainWin.isDestroyed()) return 1
try {
const display = screen.getDisplayMatching(mainWin.getContentBounds())
const idx = sortedDisplays().findIndex((d) => d.id === display.id)
return idx >= 0 ? idx + 1 : 1
} catch {
return 1
}
}
/** 编辑器主窗口是否处于全屏播放中 */
export function isMainPlaying(): boolean {
return mainView !== null && !!mainWin && !mainWin.isDestroyed()
}
/**
* 在编辑器主窗口所在屏幕上全屏播放指定页面。
* 使用 WebContentsView 覆盖在主窗口上方,退出时移除,不干扰编辑器运行状态。
* 键盘:Esc 退出,F5 刷新,数字键 1-9 切换到对应 ControllerIndex 的页面。
*/
export function publishMain(url: string, title: string): ScreenInfo[] {
if (!mainWin || mainWin.isDestroyed()) return listScreens()
if (!mainView) {
mainView = new WebContentsView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
})
mainView.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
mainView.webContents.on(
'before-input-event',
makeKeyHandler({
onEscape: () => restoreMain(),
onF5: () => mainView?.webContents.reload(),
onPrev: () => undefined,
onNext: () => undefined,
onNumber: (n) => {
const page = lastPages.find((p) => p.ctrl === n)
if (page) void publishMain(page.url, page.title)
},
onLockToggle: (lv) => lockHint(mainWin, lv)
})
)
mainWin.contentView.addChildView(mainView)
}
const b = mainWin.getContentBounds()
mainView.setBounds({ x: 0, y: 0, width: b.width, height: b.height })
mainPlayingIndex = mainScreenIndex()
mainWin.setTitle(title)
void mainView.webContents.loadURL(url)
mainView.webContents.focus()
if (!mainWin.isFullScreen()) mainWin.setFullScreen(true)
mainWin.show()
emitChange()
return listScreens()
}
/** 退出编辑器主窗口全屏播放,恢复编辑器界面 */
export function restoreMain(): ScreenInfo[] {
const wasPlaying = mainView !== null
mainPlayingIndex = -1
if (mainWin && !mainWin.isDestroyed()) {
mainWin.setFullScreen(false)
if (wasPlaying) mainWin.setTitle('智慧农业大数据可视化控制中心')
}
if (mainView) {
const v = mainView
mainView = null
try {
mainWin?.contentView.removeChildView(v)
} catch {
/* 视图可能已销毁 */
}
if (!v.webContents.isDestroyed()) v.webContents.close()
}
if (wasPlaying) emitChange()
return listScreens()
}
// ===== 检测屏幕(各屏显示编号 + 分辨率标签) =====
let probeWins: BrowserWindow[] = []
let probeTimer: NodeJS.Timeout | null = null
function probeHtml(label: string): string {
return (
'<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;background:#000;overflow:hidden;font-family:Consolas,Microsoft YaHei,sans-serif;">' +
'<div style="position:fixed;top:28px;left:32px;background:#000;border:3px solid #fff;border-radius:12px;padding:20px 30px;color:#fff;font-size:46px;font-weight:700;letter-spacing:2px;line-height:1.3;box-shadow:0 6px 30px rgba(0,0,0,.6);">' +
label +
'</div></body></html>'
)
}
/**
* 在每块屏幕上显示"屏幕编号 + 分辨率"标签(黑底白字、左上角醒目显示),
* 6 秒后自动关闭,便于演示人员查看屏幕布局。
*/
export function probeScreens(): ScreenInfo[] {
closeProbe()
const displays = sortedDisplays()
const primaryId = screen.getPrimaryDisplay().id
for (let i = 0; i < displays.length; i++) {
const d = displays[i]
const label =
`屏幕 ${i + 1} ${d.size.width} × ${d.size.height}` +
(d.id === primaryId ? '<div style="font-size:26px;color:#ffd76a;margin-top:6px;">主显示器</div>' : '')
const win = new BrowserWindow({
x: d.bounds.x,
y: d.bounds.y,
width: d.bounds.width,
height: d.bounds.height,
frame: false,
autoHideMenuBar: true,
backgroundColor: '#000000',
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
})
win.setMenuBarVisibility(false)
win.once('ready-to-show', () => {
if (!win.isDestroyed()) {
win.setFullScreen(true)
win.show()
win.focus()
}
})
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(probeHtml(label))}`)
probeWins.push(win)
}
probeTimer = setTimeout(closeProbe, 6000)
return listScreens()
}
function closeProbe(): void {
if (probeTimer) {
clearTimeout(probeTimer)
probeTimer = null
}
for (const w of probeWins) {
if (!w.isDestroyed()) w.destroy()
}
probeWins = []
}
+67
View File
@@ -0,0 +1,67 @@
import { app } from 'electron'
import { promises as fs } from 'fs'
import { join } from 'path'
export interface PublishDefaults {
/** 快捷方式③默认发布方式 */
mode: 'multi' | 'single' | 'main'
/** 单屏发布时使用的屏幕编号 */
screenIndex: number
/** 多屏发布时是否同时在编辑器屏幕全屏显示 */
alsoMain: boolean
/** 单屏轮播自动切换间隔(秒),0 = 不自动切换 */
intervalSec: number
}
export interface AppSettings {
projectName: string
projectVersion: string
/** JSON 文件读写密码,空 = 不加密 */
jsonPassword: string
/** 快捷键识别阈值(毫秒):数字/方向键需长按超过该值才触发切屏 */
keyThreshold: number
/** 锁定/解锁快捷键识别所使用的按键 */
keyLockKey: string
publish: PublishDefaults
/** 上次打开/另存为工程的文件夹,用于记住上次位置 */
lastDir: string
}
const DEFAULTS: AppSettings = {
projectName: '智慧农业大数据可视化控制中心',
projectVersion: '0.1.0',
jsonPassword: '',
keyThreshold: 350,
keyLockKey: 'F9',
publish: { mode: 'multi', screenIndex: 1, alsoMain: false, intervalSec: 10 },
lastDir: ''
}
let cache: AppSettings | null = null
function settingsPath(): string {
return join(app.getPath('userData'), 'settings.json')
}
export async function loadSettings(): Promise<AppSettings> {
if (cache) return cache
try {
const raw = await fs.readFile(settingsPath(), 'utf8')
const obj = JSON.parse(raw) as Partial<AppSettings>
cache = { ...DEFAULTS, ...obj, publish: { ...DEFAULTS.publish, ...(obj.publish || {}) } }
} catch {
cache = { ...DEFAULTS, publish: { ...DEFAULTS.publish } }
}
return cache!
}
export function getSettings(): AppSettings {
return cache ?? DEFAULTS
}
export async function saveSettings(patch: Partial<AppSettings>): Promise<AppSettings> {
const cur = await loadSettings()
cache = { ...cur, ...patch, publish: { ...cur.publish, ...(patch.publish || {}) } }
await fs.writeFile(settingsPath(), JSON.stringify(cache, null, 2), 'utf8')
return cache
}
+88
View File
@@ -0,0 +1,88 @@
import { app, dialog } from 'electron'
import { execSync } from 'child_process'
import { unlinkSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
export type ShortcutKind = 'app' | 'edit' | 'publish'
export interface ShortcutResult {
ok: boolean
message?: string
}
function esc(s: string): string {
return s.replace(/'/g, "''")
}
/** 弹出文件选择框,返回选中的工程 JSON 路径 */
export async function chooseProjectFile(): Promise<string | null> {
const r = await dialog.showOpenDialog({
title: '选择工程 JSON 文件',
filters: [{ name: 'JSON 工程文件', extensions: ['json'] }],
properties: ['openFile']
})
return r.canceled || !r.filePaths[0] ? null : r.filePaths[0]
}
/**
* 创建桌面快捷方式:
* - app: 直接打开本控制中心工具
* - edit: 打开指定工程 JSON 并进入编辑状态(--project ... --mode edit
* - publish:打开指定工程 JSON 并默认进入发布状态(--project ... --mode publish
*/
export function createDesktopShortcut(kind: ShortcutKind, projectPath?: string): ShortcutResult {
try {
const exe = process.execPath
const appDir = app.getAppPath()
let args: string
let name: string
if (kind === 'app') {
args = `"${esc(appDir)}"`
name = '智慧农业大数据可视化控制中心'
} else {
if (!projectPath) return { ok: false, message: '未指定工程文件' }
const file = projectPath.replace(/^.*[\\/]/, '')
const mode = kind === 'edit' ? 'edit' : 'publish'
args = `"${esc(appDir)}" --project "${esc(projectPath)}" --mode ${mode}`
name = `智慧农业控制中心(${mode === 'edit' ? '编辑' : '发布'}- ${file.replace(/\.json$/i, '')}`
}
const script = [
'$ws = New-Object -ComObject WScript.Shell',
`$s = $ws.CreateShortcut([Environment]::GetFolderPath('Desktop') + '\\${esc(name)}.lnk')`,
`$s.TargetPath = '${esc(exe)}'`,
`$s.Arguments = '${esc(args)}'`,
`$s.WorkingDirectory = '${esc(appDir)}'`,
`$s.IconLocation = '${esc(exe)},0'`,
'$s.Save()'
].join('\r\n')
const psFile = join(tmpdir(), `sagc-shortcut-${Date.now()}.ps1`)
// 写 UTF-8 with BOM,确保 PowerShell 5.1 正确解析中文
writeFileSync(psFile, '\uFEFF' + script, 'utf8')
execSync(`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${psFile}"`, { timeout: 15000 })
unlinkSync(psFile)
return { ok: true, message: name }
} catch (e) {
return { ok: false, message: String(e) }
}
}
/** 解析命令行参数(打包后:exe appDir --project <path> --mode edit/publish */
export function parseArgv(argv: string[]): { project?: string; mode?: 'edit' | 'publish' } {
const r: { project?: string; mode?: 'edit' | 'publish' } = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--project' && argv[i + 1]) {
r.project = argv[i + 1]
i++
} else if (a.startsWith('--project=')) {
r.project = a.slice('--project='.length)
} else if (a === '--mode' && argv[i + 1]) {
r.mode = argv[i + 1] === 'publish' ? 'publish' : 'edit'
i++
} else if (a.startsWith('--mode=')) {
r.mode = a.slice('--mode='.length) === 'publish' ? 'publish' : 'edit'
}
}
return r
}
+132
View File
@@ -0,0 +1,132 @@
export interface PlayingInfo {
title: string
url: string
}
export interface ScreenInfo {
index: number
name: string
res: string
pos: string
primary: boolean
playing: PlayingInfo | null
}
export interface PageFile {
PageID: string
PageTitle: string
PageDescribe: string
ScreenIndex: number
ControllerIndex: number
IsDisplay: boolean
PageUrl: string
PageParams: string
PagePreview?: string
}
export interface ProjectJson {
appName?: string
version?: string
pages?: PageFile[]
}
export interface PublishDefaults {
mode: 'multi' | 'single' | 'main'
screenIndex: number
alsoMain: boolean
intervalSec: number
}
export interface AppSettings {
projectName: string
projectVersion: string
jsonPassword: string
keyThreshold: number
keyLockKey: string
publish: PublishDefaults
}
export type ShortcutKind = 'app' | 'edit' | 'publish'
export interface ShortcutResult {
ok: boolean
message?: string
}
export interface LoadPayload {
path: string
json: ProjectJson | null
mode: 'edit' | 'publish'
}
export interface AppInfo {
appVersion: string
electron: string
chrome: string
node: string
platform: string
}
export interface SmartAgriApi {
win: {
minimize(): void
toggleMaximize(): void
close(): void
onMaximized(cb: (v: boolean) => void): () => void
}
screens: {
list(): Promise<ScreenInfo[]>
states(): Promise<ScreenInfo[]>
publish(payload: { index: number; url: string; title: string }): Promise<ScreenInfo[]>
close(index: number): Promise<ScreenInfo[]>
closeAll(): Promise<ScreenInfo[]>
mainIndex(): Promise<number>
publishMain(payload: { url: string; title: string }): Promise<ScreenInfo[]>
restoreMain(): Promise<ScreenInfo[]>
probe(): Promise<ScreenInfo[]>
keyLock(locked: boolean): Promise<boolean>
keyLockState(): Promise<boolean>
onChanged(cb: (list: ScreenInfo[]) => void): () => void
}
carousel: {
start(payload: { index: number; pages: { url: string; title: string }[]; intervalSec?: number }): Promise<ScreenInfo[]>
stop(index: number): Promise<ScreenInfo[]>
}
project: {
saveAs(data: string, password?: string): Promise<string | null>
save(path: string, data: string, password?: string): Promise<string | null>
open(password?: string): Promise<{ path: string; json: ProjectJson | null; error?: string } | null>
openAt(path: string, password?: string): Promise<{ path: string; json: ProjectJson | null; error?: string }>
}
settings: {
get(): Promise<AppSettings>
set(patch: Partial<AppSettings>): Promise<AppSettings>
}
shortcuts: {
create(kind: ShortcutKind): Promise<ShortcutResult>
}
app: {
info(): Promise<AppInfo>
consumeLoad(): Promise<LoadPayload | null>
onLoadProject(cb: (payload: LoadPayload) => void): () => void
}
key: {
onScreenKey(cb: (n: number) => void): () => void
}
page: {
snapshot(url: string): Promise<string | null>
}
syncProject(pages: unknown[]): void
setDirty(d: boolean): void
beep(): Promise<void>
createShortcut(): Promise<boolean>
confirm(message: string): Promise<boolean>
}
declare global {
interface Window {
api: SmartAgriApi
}
}
export {}
+75
View File
@@ -0,0 +1,75 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { IpcRendererEvent } from 'electron'
function on<T>(channel: string, cb: (payload: T) => void): () => void {
const listener = (_e: IpcRendererEvent, payload: T): void => cb(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
const api = {
win: {
minimize: (): void => ipcRenderer.send('win:minimize'),
toggleMaximize: (): void => ipcRenderer.send('win:toggle-maximize'),
close: (): void => ipcRenderer.send('win:close'),
onMaximized: (cb: (v: boolean) => void): (() => void) => on('win:maximized', cb)
},
screens: {
list: (): Promise<unknown[]> => ipcRenderer.invoke('screens:list'),
states: (): Promise<unknown[]> => ipcRenderer.invoke('screens:states'),
publish: (payload: { index: number; url: string; title: string }): Promise<unknown[]> =>
ipcRenderer.invoke('screen:publish', payload),
close: (index: number): Promise<unknown[]> => ipcRenderer.invoke('screen:close', index),
closeAll: (): Promise<unknown[]> => ipcRenderer.invoke('screen:closeAll'),
mainIndex: (): Promise<number> => ipcRenderer.invoke('screens:mainIndex'),
publishMain: (payload: { url: string; title: string }): Promise<unknown[]> =>
ipcRenderer.invoke('screen:publishMain', payload),
restoreMain: (): Promise<unknown[]> => ipcRenderer.invoke('screen:restoreMain'),
probe: (): Promise<unknown[]> => ipcRenderer.invoke('screens:probe'),
keyLock: (locked: boolean): Promise<boolean> => ipcRenderer.invoke('screens:keyLock', locked),
keyLockState: (): Promise<boolean> => ipcRenderer.invoke('screens:keyLockState'),
onChanged: (cb: (list: unknown[]) => void): (() => void) => on('screens:changed', cb)
},
carousel: {
start: (payload: { index: number; pages: { url: string; title: string }[]; intervalSec?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('carousel:start', payload),
stop: (index: number): Promise<unknown[]> => ipcRenderer.invoke('carousel:stop', index)
},
project: {
saveAs: (data: string, password?: string): Promise<string | null> =>
ipcRenderer.invoke('project:saveAs', { data, password }),
save: (path: string, data: string, password?: string): Promise<string | null> =>
ipcRenderer.invoke('project:save', { path, data, password }),
open: (password?: string): Promise<{ path: string; json: unknown | null; error?: string } | null> =>
ipcRenderer.invoke('project:open', password),
openAt: (path: string, password?: string): Promise<{ path: string; json: unknown | null; error?: string }> =>
ipcRenderer.invoke('project:openAt', { path, password })
},
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
set: (patch: unknown): Promise<unknown> => ipcRenderer.invoke('settings:set', patch)
},
shortcuts: {
create: (kind: unknown): Promise<unknown> => ipcRenderer.invoke('shortcut:create', kind)
},
app: {
info: (): Promise<unknown> => ipcRenderer.invoke('app:info'),
consumeLoad: (): Promise<unknown> => ipcRenderer.invoke('app:consumeLoad'),
onLoadProject: (cb: (payload: unknown) => void): (() => void) => on('app:loadProject', cb)
},
key: {
onScreenKey: (cb: (n: number) => void): (() => void) => on('key:switch', cb)
},
page: {
snapshot: (url: string): Promise<string | null> => ipcRenderer.invoke('page:snapshot', url)
},
syncProject: (pages: unknown[]): void => ipcRenderer.send('project:sync', pages),
setDirty: (d: boolean): void => ipcRenderer.send('window:dirty', d),
beep: (): Promise<void> => ipcRenderer.invoke('beep'),
createShortcut: (): Promise<boolean> => ipcRenderer.invoke('desktop:shortcut'),
confirm: (message: string): Promise<boolean> => ipcRenderer.invoke('dialog:confirm', message)
}
contextBridge.exposeInMainWorld('api', api)
export type Api = typeof api
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: http: ws:"
/>
<title>智慧农业大数据可视化控制中心</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,299 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>智慧农业大屏</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Microsoft YaHei UI', 'Segoe UI', sans-serif;
background: radial-gradient(ellipse at 50% -20%, #12306b 0%, #081226 55%, #050b18 100%);
color: #d8e6ff;
height: 100vh;
overflow: hidden;
position: relative;
}
body::before {
content: ''; position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(rgba(64,140,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(64,140,255,.05) 1px, transparent 1px);
background-size: 40px 40px;
}
.grid-line { position: absolute; left: 0; right: 0; height: 1px; background: linear-gradient(90deg, transparent, rgba(64,180,255,.35), transparent); }
.top {
height: 78px; display: flex; align-items: center; justify-content: center;
position: relative; z-index: 2;
}
.title {
font-size: 34px; font-weight: 700; letter-spacing: 6px;
background: linear-gradient(180deg, #fff 20%, #7fc4ff 80%);
-webkit-background-clip: text; background-clip: text; color: transparent;
text-shadow: 0 0 30px rgba(70,150,255,.35);
}
.title small { font-size: 13px; letter-spacing: 2px; opacity: .75; display: block; text-align: center; margin-top: 4px; font-weight: 400; }
.top::before, .top::after {
content: ''; position: absolute; top: 34px; width: 28vw; height: 2px;
background: linear-gradient(90deg, transparent, #3ea2ff); opacity: .8;
}
.top::after { right: 0; background: linear-gradient(270deg, transparent, #3ea2ff); }
.time { position: absolute; right: 30px; top: 26px; font-size: 12px; color: #6fa8e8; letter-spacing: 1px; }
.wrap {
position: relative; z-index: 2; height: calc(100vh - 130px);
display: grid; grid-template-columns: 24% 1fr 24%; gap: 16px; padding: 0 20px;
}
.col { display: flex; flex-direction: column; gap: 14px; min-height: 0; }
.card {
background: linear-gradient(160deg, rgba(16,44,96,.55), rgba(8,18,40,.65));
border: 1px solid rgba(64,150,255,.28);
border-radius: 8px; padding: 12px 14px; position: relative; overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,.35), inset 0 0 30px rgba(40,120,255,.06);
}
.card::before {
content: ''; position: absolute; top: 0; left: 0; width: 60px; height: 2px;
background: linear-gradient(90deg, #3ea2ff, transparent);
}
.card h3 {
font-size: 14px; font-weight: 600; color: #9fc6f5; margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.card h3::before { content: ''; width: 4px; height: 14px; background: linear-gradient(180deg,#54c4ff,#2f7fd8); border-radius: 2px; }
.kpis { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.kpi { background: rgba(10,26,54,.55); border: 1px solid rgba(64,150,255,.2); border-radius: 6px; padding: 10px; text-align: center; }
.kpi .v { font-size: 24px; font-weight: 700; font-family: Consolas, monospace; color: #7cd7ff; text-shadow: 0 0 14px rgba(70,180,255,.5); }
.kpi .v em { font-style: normal; font-size: 12px; color: #6fa8e8; }
.kpi .l { font-size: 11px; color: #7d9fc9; margin-top: 3px; }
.kpi .trend { font-size: 10px; margin-top: 2px; }
.up { color: #59d99a; } .down { color: #ff7d7d; }
.list-row {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 4px; border-bottom: 1px dashed rgba(64,150,255,.16); font-size: 12px;
}
.list-row:last-child { border-bottom: none; }
.list-row .name { color: #a9c6ea; }
.list-row .bar { flex: 1; height: 4px; background: rgba(64,150,255,.15); border-radius: 2px; margin: 0 10px; overflow: hidden; }
.list-row .bar i { display: block; height: 100%; border-radius: 2px; background: linear-gradient(90deg, #2f7fd8, #54c4ff); transition: width .8s; }
.list-row .val { color: #7cd7ff; font-family: Consolas, monospace; }
.center-card { flex: 1; display: flex; flex-direction: column; }
.center-card .mid-chart { flex: 1; position: relative; min-height: 0; }
.center-card canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.center-legend { display: flex; gap: 16px; justify-content: center; padding-top: 8px; font-size: 11px; color: #8db1de; }
.center-legend i { width: 10px; height: 10px; display: inline-block; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
.bar-row { display: flex; align-items: flex-end; gap: 6px; height: 100%; padding-top: 6px; }
.bar-col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 4px; height: 100%; }
.bar-col .bv { font-size: 10px; color: #7cd7ff; font-family: Consolas, monospace; }
.bar-col .bb { width: 60%; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, #54c4ff, #1f5fae); box-shadow: 0 0 10px rgba(60,150,255,.4); transition: height .8s; }
.bar-col .bl { font-size: 10px; color: #7d9fc9; }
.foot {
position: relative; z-index: 2; height: 52px; display: flex; align-items: center; justify-content: center;
color: #6fa8e8; font-size: 12px; letter-spacing: 1px; gap: 30px;
}
.foot::before { content: ''; position: absolute; top: 0; left: 10%; right: 10%; height: 1px; background: linear-gradient(90deg, transparent, rgba(64,150,255,.4), transparent); }
.marquee { overflow: hidden; white-space: nowrap; max-width: 60%; }
.marquee span { display: inline-block; animation: slide 16s linear infinite; }
@keyframes slide { from { transform: translateX(100%);} to { transform: translateX(-100%);} }
.pulse { animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
</style>
</head>
<body>
<div class="grid-line" style="top:78px"></div>
<div class="top">
<div class="title">智慧农业大数据可视化控制中心<small id="subtitle">AGRICULTURE BIG DATA VISUALIZATION</small></div>
<div class="time" id="time">--</div>
</div>
<div class="wrap">
<div class="col">
<div class="card">
<h3>关键指标</h3>
<div class="kpis" id="kpis"></div>
</div>
<div class="card">
<h3>区域排行</h3>
<div id="ranking"></div>
</div>
</div>
<div class="col">
<div class="card center-card">
<h3 id="centerTitle">核心态势</h3>
<div class="mid-chart"><canvas id="midChart"></canvas></div>
<div class="center-legend" id="legend"></div>
</div>
<div class="card">
<h3>设备状态</h3>
<div class="kpis" id="device"></div>
</div>
</div>
<div class="col">
<div class="card">
<h3>实时趋势</h3>
<div style="height:150px;position:relative"><canvas id="lineChart"></canvas></div>
</div>
<div class="card">
<h3>最新告警</h3>
<div id="alerts"></div>
</div>
</div>
</div>
<div class="foot">
<span>今日数据更新时间:<span id="updateTime">--</span></span>
<span class="marquee"><span id="notice">系统运行正常 | 数据每 2 秒自动刷新 | 全基地传感器在线</span></span>
</div>
<script>
var q = new URLSearchParams(location.search);
var no = parseInt(q.get('no') || '1', 10) || 1;
var title = q.get('title') || '农业数据总览';
var THEMES = {
1: { name: '农业数据总览', kpis: [['总产值', '万元'], ['种植面积', '亩'], ['当日产量', '吨'], ['在线设备', '台']], rank: ['北区基地', '东区基地', '南区基地', '西区基地', '温室群 A'], dev: ['灌溉系统', '温室系统', '监控系统'], main: '产量与面积' },
2: { name: '土壤墒情监测', kpis: [['平均湿度', '%'], ['土壤温度', '℃'], ['pH 值', ''], ['有机质', 'g/kg']], rank: ['1号田块', '2号田块', '3号田块', '4号田块', '5号田块'], dev: ['墒情传感器', 'PH 传感器', '测温探针'], main: '墒情分布' },
3: { name: '温室环境监控', kpis: [['室温', '℃'], ['光照', 'klx'], ['CO₂', 'ppm'], ['湿度', '%']], rank: ['1号温室', '2号温室', '3号温室', '4号温室', '5号温室'], dev: ['温控系统', '补光灯', '通风系统'], main: '环境参数' },
4: { name: '设备运维看板', kpis: [['运行设备', '台'], ['故障数', '台'], ['今日保养', '次'], ['备件库存', '件']], rank: ['水泵 A', '水泵 B', '风机 C', '卷帘机 D', '施肥机 E'], dev: ['水泵', '风机', '卷帘机'], main: '设备健康' }
};
var T = THEMES[no] || THEMES[1];
document.querySelector('.title').childNodes[0].nodeValue = '智慧农业大数据可视化控制中心';
document.title = title;
document.getElementById('subtitle').textContent = T.name.toUpperCase();
function rnd(a, b, d) { return +(a + Math.random() * (b - a)).toFixed(d); }
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
var kpis = [];
function renderKpis() {
var el = document.getElementById('kpis');
kpis = T.kpis.map(function (k) {
var v = rnd(30, 95, 1);
return { label: k[0], unit: k[1], v: v, t: pick([1, -1]) };
});
el.innerHTML = kpis.map(function (k) {
return '<div class="kpi"><div class="v">' + k.v + '<em> ' + k.unit + '</em></div><div class="l">' + k.label + '</div><div class="trend ' + (k.t > 0 ? 'up' : 'down') + '">' + (k.t > 0 ? '▲' : '▼') + ' ' + rnd(0.1, 5, 1) + '%</div></div>';
}).join('');
}
function renderRank() {
var el = document.getElementById('ranking');
var rows = T.rank.map(function (name, i) {
var v = rnd(45, 100, 1);
return { name: name, v: v };
}).sort(function (a, b) { return b.v - a.v; });
el.innerHTML = rows.map(function (r, i) {
return '<div class="list-row"><span class="name">' + (i + 1) + '. ' + r.name + '</span><span class="bar"><i style="width:' + r.v + '%"></i></span><span class="val">' + r.v + '%</span></div>';
}).join('');
}
function renderDevice() {
var el = document.getElementById('device');
el.innerHTML = T.dev.map(function (d) {
var n = rnd(60, 99, 0);
var ok = n > 70;
return '<div class="kpi"><div class="v" style="color:' + (ok ? '#59d99a' : '#ffb54d') + '">' + n + '<em>%</em></div><div class="l">' + d + '</div><div class="trend ' + (ok ? 'up' : 'down') + '">' + (ok ? '正常' : '注意') + '</div></div>';
}).join('');
}
var lineData = [];
function drawLine() {
var c = document.getElementById('lineChart');
if (!c || c.width === 0) { setTimeout(drawLine, 300); return; }
var ctx = c.getContext('2d');
var w = c.width, h = c.height;
ctx.clearRect(0, 0, w, h);
var max = 100;
if (lineData.length < 40) lineData.push(rnd(35, 92, 0));
else { lineData.shift(); lineData.push(rnd(35, 92, 0)); }
// 网格
ctx.strokeStyle = 'rgba(64,150,255,.15)'; ctx.lineWidth = 1;
for (var gy = 0; gy < 4; gy++) {
var y = h - (gy + 1) * h / 4;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
// 填充
ctx.beginPath();
ctx.moveTo(0, h);
for (var i = 0; i < lineData.length; i++) {
var x = i / 39 * w;
var y = h - lineData[i] / max * h;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h); ctx.closePath();
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(60,150,255,.35)'); grad.addColorStop(1, 'rgba(60,150,255,0)');
ctx.fillStyle = grad; ctx.fill();
// 折线
ctx.beginPath();
for (var j = 0; j < lineData.length; j++) {
var x2 = j / 39 * w;
var y2 = h - lineData[j] / max * h;
if (j === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2);
}
ctx.strokeStyle = '#54c4ff'; ctx.lineWidth = 2; ctx.shadowColor = 'rgba(80,180,255,.8)'; ctx.shadowBlur = 8; ctx.stroke();
}
var bars = [];
function drawMid() {
var c = document.getElementById('midChart');
if (!c || c.width === 0) { setTimeout(drawMid, 300); return; }
var ctx = c.getContext('2d');
var w = c.width, h = c.height;
ctx.clearRect(0, 0, w, h);
var labels = T.rank;
if (bars.length !== labels.length) bars = labels.map(function () { return rnd(30, 95, 0); });
bars = bars.map(function (v, i) { return Math.max(20, Math.min(98, v + rnd(-6, 6, 0))); });
var n = labels.length, bw = w / n;
ctx.textAlign = 'center';
for (var i = 0; i < n; i++) {
var bh = bars[i] / 100 * (h - 30);
var x = i * bw + bw / 2;
var grad = ctx.createLinearGradient(0, h - bh, 0, h);
grad.addColorStop(0, '#54c4ff'); grad.addColorStop(1, '#1f5fae');
ctx.fillStyle = grad;
ctx.shadowColor = 'rgba(60,150,255,.5)'; ctx.shadowBlur = 10;
roundRect(ctx, x - bw * 0.28, h - bh, bw * 0.56, bh, 4);
ctx.fill(); ctx.shadowBlur = 0;
ctx.fillStyle = '#9fc6f5'; ctx.font = '11px Consolas';
ctx.fillText(bars[i] + '%', x, h - bh - 8);
ctx.fillStyle = '#7d9fc9'; ctx.font = '11px "Microsoft YaHei UI"';
ctx.fillText(labels[i], x, h - 8);
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function renderAlerts() {
var items = [
'1号温室 室温偏高(30.2℃)', '3号田块 墒情偏低', '水泵 B 运行正常', '卷帘机 D 已完成检修', '2号温室 CO₂ 浓度正常', '北区基地 光照充足'
];
var el = document.getElementById('alerts');
el.innerHTML = items.slice(0, 4).map(function (t, i) {
var cls = i === 0 ? 'down' : 'up';
return '<div class="list-row"><span class="name" style="' + (i === 0 ? 'color:#ff9d9d' : '') + '">' + t + '</span><span class="val ' + cls + '">' + (i === 0 ? '⚠' : '✓') + '</span></div>';
}).join('');
}
function resize() {
var mc = document.getElementById('midChart');
var lc = document.getElementById('lineChart');
function fit(c) { if (!c) return; var r = c.parentElement.getBoundingClientRect(); if (r.width > 10) { c.width = r.width; c.height = r.height; } }
fit(mc); fit(lc);
}
window.addEventListener('resize', resize);
function tick() {
var d = new Date();
var p = function (x) { return x < 10 ? '0' + x : x; };
document.getElementById('time').textContent = d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
document.getElementById('updateTime').textContent = p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
renderKpis(); renderRank(); renderDevice(); drawLine(); drawMid(); renderAlerts();
}
resize(); tick();
setInterval(tick, 2000);
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import TitleBar from './components/TitleBar'
import Ribbon from './components/Ribbon'
import StatusBar from './components/StatusBar'
import Toast from './components/Toast'
import { PasswordModal } from './components/PasswordModal'
import EditView from './views/EditView'
import ConfigView from './views/ConfigView'
import { useStore } from './store'
import { resolvePageUrl } from './utils'
import { applyPublishDefaults } from './publishActions'
import type { LoadPayload } from './types'
import type { ConfigMenu } from './configMenus'
export default function App(): React.JSX.Element {
const view = useStore((s) => s.view)
const pages = useStore((s) => s.pages)
const [configMenu, setConfigMenu] = useState<ConfigMenu>('info')
// 监听屏幕状态变化
useEffect(() => {
const off = window.api.screens.onChanged((list) => {
useStore.getState().setScreens(list)
})
void window.api.screens.list().then((list) => useStore.getState().setScreens(list))
return off
}, [])
// 页面变化同步给主进程(供屏幕窗口键盘切页)
useEffect(() => {
window.api.syncProject(
pages.map((p) => ({
id: p.id,
title: p.title,
url: resolvePageUrl(p),
screen: p.screen,
ctrl: p.ctrl,
display: p.display
}))
)
}, [pages])
// 启动:加载应用设置 + 消费/监听快捷方式打开的项目
useEffect(() => {
const st = useStore.getState
void st().loadSettings()
const handle = (p: unknown): void => {
const lp = p as LoadPayload | null
if (!lp?.json) return
const mode = st().loadProject(lp)
if (mode === 'publish') void applyPublishDefaults()
}
void window.api.app.consumeLoad().then(handle)
const off = window.api.app.onLoadProject(handle)
return off
}, [])
// 主窗口数字键切换页面
useEffect(() => {
const h = (e: KeyboardEvent): void => {
if (e.ctrlKey || e.metaKey || e.altKey) return
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
const n = parseInt(e.key)
if (n >= 1 && n <= 9) {
const st = useStore.getState()
const idx = st.pages.findIndex((p) => p.ctrl === n)
if (idx >= 0) {
st.selectPage(idx)
st.setView('edit')
}
}
}
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [])
return (
<div className="app">
<TitleBar />
<Ribbon configMenu={configMenu} onConfigMenu={setConfigMenu} />
<div className="app-content">
{view === 'edit' && <EditView />}
{view === 'config' && <ConfigView menu={configMenu} />}
</div>
<StatusBar />
<Toast />
<PasswordModal />
</div>
)
}
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
interface ModalProps {
title: string
onClose: () => void
width?: number
children: ReactNode
footer?: ReactNode
}
export default function Modal({ title, onClose, width = 520, children, footer }: ModalProps): React.JSX.Element {
return (
<div
className="modal-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="modal" style={{ width }}>
<div className="modal-head">
<span className="modal-title">{title}</span>
<button className="modal-x" title="关闭" onClick={onClose}>
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
)
}
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react'
import Modal from './Modal'
import { answerPassword, usePwd } from '../passwordState'
export function PasswordModal(): React.JSX.Element | null {
const open = usePwd((s) => s.open)
const title = usePwd((s) => s.title)
const [val, setVal] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setVal('')
const t = setTimeout(() => inputRef.current?.focus(), 40)
return () => clearTimeout(t)
}
return undefined
}, [open])
if (!open) return null
return (
<Modal
title={title}
onClose={() => answerPassword(null)}
width={420}
footer={
<>
<button className="btn" onClick={() => answerPassword(null)}>
</button>
<button className="btn primary" disabled={!val} onClick={() => answerPassword(val)}>
</button>
</>
}
>
<p className="m-hint"> JSON URL </p>
<input
ref={inputRef}
type="password"
className="cfg-input"
value={val}
placeholder="请输入密码"
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && val) answerPassword(val)
}}
/>
</Modal>
)
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
import Modal from './Modal'
import { useStore } from '../store'
import type { ScreenInfo } from '../types'
import { publishCurrentToScreen, startCarouselOnScreen, publishAll, detectScreens } from '../publishActions'
/** 通用屏幕列表行 */
function ScreenRow({ s, selected, onSelect }: { s: ScreenInfo; selected: boolean; onSelect: () => void }): React.JSX.Element {
return (
<div className={'m-screen-row' + (selected ? ' sel' : '')} onClick={onSelect}>
<div className="msr-main">
<span className="msr-name"> {s.index}</span>
{s.primary && <span className="msr-badge"></span>}
</div>
<div className="msr-sub">
<span>
{s.res} · {s.pos}
</span>
{s.playing ? (
<span className="msr-playing"> {s.playing.title}</span>
) : (
<span className="msr-idle"></span>
)}
</div>
</div>
)
}
/** 选择屏幕后执行操作的弹窗(预览本页 / 单屏发布共用) */
export function ScreenSelectModal(props: {
title: string
hint: string
confirmLabel: string
/** 仅单屏发布:显示「自动轮播 + 间隔秒数」选项 */
showCarousel?: boolean
onConfirm: (index: number, opts: { carousel: boolean; intervalSec: number }) => void
onClose: () => void
}): React.JSX.Element {
const screens = useStore((s) => s.screens)
const settings = useStore((s) => s.settings)
const [sel, setSel] = useState<number>(screens[0]?.index ?? 0)
const [carousel, setCarousel] = useState(props.showCarousel ?? false)
const [intervalSec, setIntervalSec] = useState(settings?.publish.intervalSec ?? 10)
return (
<Modal
title={props.title}
onClose={props.onClose}
width={470}
footer={
<>
<button className="btn" onClick={props.onClose}>
</button>
<button
className="btn primary"
disabled={!sel}
onClick={() => props.onConfirm(sel, { carousel, intervalSec: Math.max(1, Math.round(intervalSec)) })}
>
{props.confirmLabel}
</button>
</>
}
>
<p className="m-hint">{props.hint}</p>
{screens.length === 0 ? (
<div className="m-empty"></div>
) : (
<div className="m-screen-list">
{screens.map((s) => (
<ScreenRow key={s.index} s={s} selected={sel === s.index} onSelect={() => setSel(s.index)} />
))}
</div>
)}
{props.showCarousel && (
<div className="m-carousel">
<label className="m-check">
<input type="checkbox" checked={carousel} onChange={(e) => setCarousel(e.target.checked)} />
<span>
<em> / </em>
</span>
</label>
{carousel && (
<div className="m-interval">
<span className="m-interval-label"></span>
<input
type="number"
min={1}
max={3600}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
/>
<span></span>
</div>
)}
</div>
)}
</Modal>
)
}
/** 多屏发布弹窗:预览各屏幕布局与页面分配 + 编辑器屏幕全屏开关 */
export function MultiPublishModal(props: { onClose: () => void }): React.JSX.Element {
const screens = useStore((s) => s.screens)
const pages = useStore((s) => s.pages)
const [alsoMain, setAlsoMain] = useState(false)
const [mainIdx, setMainIdx] = useState(0)
useEffect(() => {
void window.api.screens.mainIndex().then((n) => setMainIdx(n))
}, [])
return (
<Modal
title="多屏发布"
onClose={props.onClose}
width={640}
footer={
<>
<button className="btn" onClick={props.onClose}>
</button>
<button
className="btn primary"
onClick={() => {
void publishAll(alsoMain)
props.onClose()
}}
>
</button>
</>
}
>
<p className="m-hint">
IDEsc 退 · F5 ·
</p>
{screens.length === 0 ? (
<div className="m-empty"></div>
) : (
<div className="m-screen-grid">
{screens.map((s) => {
const list = pages.filter((p) => p.display && p.screen === s.index)
return (
<div key={s.index} className="m-scard">
<div className="m-scard-head">
<span className="msr-name"> {s.index}</span>
{s.primary && <span className="msr-badge"></span>}
<span className="msr-res">{s.res}</span>
</div>
<div className="m-scard-pages">
{list.length === 0 ? (
<span className="m-page-empty"></span>
) : (
list.slice(0, 3).map((p) => (
<span key={p.id} className="m-page-chip" title={p.title}>
{p.title}
</span>
))
)}
{list.length > 3 && <span className="m-page-more">+{list.length - 3}</span>}
</div>
{s.playing && <div className="m-scard-play">{s.playing.title}</div>}
</div>
)
})}
</div>
)}
<label className="m-check">
<input type="checkbox" checked={alsoMain} onChange={(e) => setAlsoMain(e.target.checked)} />
{mainIdx || '—'}
</label>
</Modal>
)
}
/** 检测屏幕弹窗:显示已连接屏幕列表,各屏同时显示编号标签 */
export function DetectModal(props: { onClose: () => void }): React.JSX.Element {
const screens = useStore((s) => s.screens)
useEffect(() => {
void detectScreens()
}, [])
return (
<Modal
title="检测屏幕"
onClose={props.onClose}
width={520}
footer={
<button className="btn primary" onClick={props.onClose}>
</button>
}
>
<p className="m-hint">
+ 6 便
</p>
<div className="m-screen-list">
{screens.length === 0 ? (
<div className="m-empty"></div>
) : (
screens.map((s) => (
<div key={s.index} className="m-screen-row">
<div className="msr-main">
<span className="msr-name"> {s.index}</span>
{s.primary && <span className="msr-badge"></span>}
<span className="msr-res">{s.res}</span>
</div>
<div className="msr-sub">
<span>{s.pos}</span>
{s.playing ? (
<span className="msr-playing"> {s.playing.title}</span>
) : (
<span className="msr-idle"></span>
)}
</div>
</div>
))
)}
</div>
</Modal>
)
}
@@ -0,0 +1,192 @@
import { useState } from 'react'
import { useStore } from '../store'
import type { ViewMode } from '../types'
import { CONFIG_MENUS, type ConfigMenu } from '../configMenus'
import { closeAllScreenOutput, publishCurrentToScreen, startCarouselOnScreen, stopCarouselOnScreen } from '../publishActions'
import { toast } from '../toast'
import { ScreenSelectModal, MultiPublishModal, DetectModal } from './PublishModals'
interface RibbonItem {
id: string
icon: string
label: string
title?: string
action?: () => void
type?: 'primary' | 'accent' | 'danger'
disabled?: boolean
}
const TAB_ICON: Record<ViewMode, string> = {
edit: '🖋️',
config: '⚙️'
}
const TAB_NAME: Record<ViewMode, string> = {
edit: '编辑',
config: '配置'
}
export default function Ribbon(props: {
configMenu: ConfigMenu
onConfigMenu: (m: ConfigMenu) => void
}): React.JSX.Element {
const view = useStore((s) => s.view)
const setView = useStore((s) => s.setView)
const pages = useStore((s) => s.pages)
const curIdx = useStore((s) => s.curIdx)
const screens = useStore((s) => s.screens)
const st = useStore.getState
const [pubModal, setPubModal] = useState<'multi' | 'preview' | 'single' | 'detect' | null>(null)
const curPage = pages[curIdx]
const curScreen = curPage?.screen ?? -1
const carouselRunning = screens.some((s) => s.index === curScreen && /轮播/.test(s.playing?.title ?? ''))
const editItems: RibbonItem[] = [
{ id: 'add', icon: '📄', label: '新建页面', action: () => st().addPage() },
{ id: 'before', icon: '⏫', label: '插入页面', action: () => st().insertBefore() },
{ id: 'dup', icon: '📑', label: '复制页面', action: () => st().dupPage() },
{ id: 'del', icon: '🗑️', label: '删除页面', type: 'danger', disabled: pages.length <= 1, action: () => st().delPage() },
{ id: 'up', icon: '⬆️', label: '上移', action: () => st().movePage(-1) },
{ id: 'down', icon: '⬇️', label: '下移', action: () => st().movePage(1) },
{ id: 'snap', icon: '📷', label: '缩略图', action: () => void st().capturePreview() }
]
const fileItems: RibbonItem[] = [
{ id: 'open', icon: '📂', label: '打开', action: () => void st().open() },
{ id: 'save', icon: '💾', label: '保存', action: () => void st().save() },
{ id: 'saveas', icon: '📝', label: '另存为', action: () => void st().saveAs() },
{ id: 'resetorder', icon: '🔀', label: '页面重排', action: () => st().resetOrder() }
]
const pubItems: RibbonItem[] = [
{
id: 'preview',
icon: '👁️',
label: '预览本页',
disabled: !curPage,
title: '弹窗选择目标屏幕,全屏播放当前页面(Esc 退出 · F5 刷新 · 数字键切页)',
action: () => setPubModal('preview')
},
{
id: 'single',
icon: '🎠',
label: '单屏发布',
disabled: !curPage,
title: '弹窗选择目标屏幕,在该屏幕轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出)',
action: () => setPubModal('single')
},
{ id: 'multi', icon: '🖥️', label: '多屏发布', type: 'primary', action: () => setPubModal('multi') },
{
id: 'stopcar',
icon: '⏹️',
label: '停止轮播',
type: 'danger',
disabled: !carouselRunning,
title: carouselRunning ? '停止当前页所属屏幕(屏 ' + curScreen + ')的轮播' : '目标屏幕当前未在轮播',
action: () => {
if (curPage) void stopCarouselOnScreen(curPage.screen)
}
},
{ id: 'closeall', icon: '🏁', label: '关闭所有', type: 'danger', action: () => void closeAllScreenOutput() },
{ id: 'detect', icon: '📡', label: '检测屏幕', action: () => setPubModal('detect') }
]
const renderGroup = (label: string, items: RibbonItem[]): React.JSX.Element => (
<div className="ribbon-group">
<span className="rg-label">{label}</span>
{items.map((it) => (
<button
key={it.id}
className={'ribbon-btn' + (it.type ? ' ' + it.type : '')}
title={it.title ?? it.label}
disabled={it.disabled}
onClick={it.action}
>
<span className="rb-icon">{it.icon}</span>
<span>{it.label}</span>
</button>
))}
</div>
)
return (
<div className="ribbon">
<div className="ribbon-tabs">
{(Object.keys(TAB_ICON) as ViewMode[]).map((v) => (
<button
key={v}
className={'ribbon-tab' + (view === v ? ' active' : '')}
onClick={() => setView(v)}
>
<span className="rt-icon">{TAB_ICON[v]}</span>
{TAB_NAME[v]}
</button>
))}
</div>
<div className="ribbon-body">
{view === 'edit' && (
<>
{renderGroup('项目', fileItems)}
{renderGroup('页面', editItems)}
{renderGroup('发布', pubItems)}
</>
)}
{view === 'config' && (
<>
{renderGroup(
'配置',
CONFIG_MENUS.map((m) => ({
id: m.id,
icon: m.icon,
label: m.label,
title: m.desc,
type: props.configMenu === m.id ? ('accent' as const) : undefined,
action: () => {
setView('config')
props.onConfigMenu(m.id)
}
}))
)}
{renderGroup('工具', [
{
id: 'shortcut',
icon: '🖱️',
label: '桌面快捷方式',
action: () =>
void window.api.createShortcut().then((ok) => toast(ok ? '已创建桌面快捷方式' : '创建快捷方式失败', ok ? 'ok' : 'warn'))
}
])}
</>
)}
</div>
{pubModal === 'preview' && (
<ScreenSelectModal
title="预览本页"
hint="选择要在哪块屏幕上全屏播放当前页面(Esc 退出 · F5 刷新 · 数字键切页)。"
confirmLabel="开始预览"
onConfirm={(i) => {
void publishCurrentToScreen(i)
setPubModal(null)
}}
onClose={() => setPubModal(null)}
/>
)}
{pubModal === 'single' && (
<ScreenSelectModal
title="单屏发布"
hint="选择要在哪块屏幕上轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出 · F5 刷新)。"
confirmLabel="开始发布"
showCarousel
onConfirm={(i, opts) => {
void startCarouselOnScreen(i, opts.carousel ? opts.intervalSec : 0)
setPubModal(null)
}}
onClose={() => setPubModal(null)}
/>
)}
{pubModal === 'multi' && <MultiPublishModal onClose={() => setPubModal(null)} />}
{pubModal === 'detect' && <DetectModal onClose={() => setPubModal(null)} />}
</div>
)
}
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
import { useStore } from '../store'
export default function StatusBar(): React.JSX.Element {
const pages = useStore((s) => s.pages)
const curIdx = useStore((s) => s.curIdx)
const view = useStore((s) => s.view)
const screens = useStore((s) => s.screens)
const dirty = useStore((s) => s.dirty)
const filePath = useStore((s) => s.filePath)
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(t)
}, [])
const pad = (x: number): string => (x < 10 ? '0' + x : String(x))
const viewName = view === 'edit' ? '编辑' : '配置'
return (
<div className="statusbar">
<span className="sb-item">📁 {filePath ? filePath.split(/[\\/]/).pop() : '未命名工程'}</span>
<span className="sb-item">📄 {pages.length}</span>
<span className="sb-item">
🖥 {screens.length > 0 ? screens.length : '未检测'}
</span>
<span className="sb-item">
<span className={'dirty-dot' + (dirty ? ' on' : '')} />
{dirty ? '已修改' : '已保存'}
</span>
<div className="sb-right">
<span className="sb-item">{pages[curIdx]?.title ?? '—'}</span>
<span className="sb-item">{viewName}</span>
<span className="sb-item">
{now.getFullYear()}-{pad(now.getMonth() + 1)}-{pad(now.getDate())} {pad(now.getHours())}:{pad(now.getMinutes())}:
{pad(now.getSeconds())}
</span>
</div>
</div>
)
}
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { useStore } from '../store'
import type { AppInfo } from '../types'
function MinIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 12 12">
<rect x="0" y="5.5" width="12" height="1.4" rx="0.7" fill="currentColor" />
</svg>
)
}
function MaxIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="1.5" y="1.5" width="9" height="9" rx="1.5" />
</svg>
)
}
function RestoreIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="1.5" y="3.5" width="7" height="7" rx="1.2" />
<path d="M4 1.5h5a1.5 1.5 0 0 1 1.5 1.5v5" />
</svg>
)
}
function CloseIcon(): React.JSX.Element {
return (
<svg viewBox="0 0 12 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<path d="M2 2l8 8M10 2l-8 8" />
</svg>
)
}
export default function TitleBar(): React.JSX.Element {
const [maximized, setMaximized] = useState(false)
const [info, setInfo] = useState<AppInfo | null>(null)
const filePath = useStore((s) => s.filePath)
const dirty = useStore((s) => s.dirty)
useEffect(() => {
const off = window.api.win.onMaximized((v) => setMaximized(v))
return off
}, [])
useEffect(() => {
void window.api.app.info().then(setInfo)
}, [])
return (
<div className="titlebar">
<svg className="tb-logo" viewBox="0 0 24 24" fill="none">
<path
d="M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"
stroke="#8fd19a"
strokeWidth="1.6"
/>
<path d="M7 16l3-4 2.5 3 2-2.5 2.5 3.5" stroke="#8fd19a" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="17" cy="8.5" r="1.6" fill="#8fd19a" />
</svg>
<span className="tb-title"></span>
<span className="tb-ver"> v{info?.appVersion ?? '…'}</span>
<span className="tb-file">
{filePath ? filePath : '未命名工程'}
{dirty ? ' ●' : ''}
</span>
<div className="tb-controls">
<button className="tb-btn" title="最小化" onClick={() => window.api.win.minimize()}>
<MinIcon />
</button>
<button className="tb-btn" title="最大化 / 还原" onClick={() => window.api.win.toggleMaximize()}>
{maximized ? <RestoreIcon /> : <MaxIcon />}
</button>
<button className="tb-btn close" title="关闭" onClick={() => window.api.win.close()}>
<CloseIcon />
</button>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { useToast } from '../toast'
const ICONS = { info: '💡', ok: '✅', warn: '⚠️' }
export default function Toast(): React.JSX.Element {
const items = useToast((s) => s.items)
const remove = useToast((s) => s.remove)
return (
<div className="toast-box">
{items.map((t) => (
<div key={t.id} className={'toast ' + t.type} onClick={() => remove(t.id)}>
<span className="t-ic">{ICONS[t.type]}</span>
<span>{t.msg}</span>
</div>
))}
</div>
)
}
@@ -0,0 +1,50 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
export interface WebFrameHandle {
reload: () => void
}
interface WebFrameProps {
src: string
className?: string
}
const WebFrame = forwardRef<WebFrameHandle, WebFrameProps>(function WebFrame(
{ src, className },
ref
) {
const hostRef = useRef<HTMLDivElement>(null)
const wvRef = useRef<Electron.WebviewTag | null>(null)
useImperativeHandle(ref, () => ({
reload: () => wvRef.current?.reload()
}))
useEffect(() => {
const host = hostRef.current
if (!host) return
const wv = document.createElement('webview') as Electron.WebviewTag
wv.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no')
wv.setAttribute('class', 'webframe-webview')
wv.style.display = 'flex'
wv.style.width = '100%'
wv.style.height = '100%'
host.appendChild(wv)
wvRef.current = wv
wv.src = src
return () => {
wv.remove()
wvRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const wv = wvRef.current
if (wv && wv.getAttribute('src') !== src) wv.setAttribute('src', src)
}, [src])
return <div ref={hostRef} className={'webframe' + (className ? ' ' + className : '')} />
})
export default WebFrame
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { useStore } from '../../store'
import type { AppInfo } from '../../types'
export default function AboutPanel(): React.JSX.Element {
const settings = useStore((s) => s.settings)
const [info, setInfo] = useState<AppInfo | null>(null)
useEffect(() => {
void window.api.app.info().then(setInfo)
}, [])
return (
<div className="cfg-section">
<div className="cfg-card about-hero">
<div className="about-logo">🌾</div>
<div className="about-title">{settings?.projectName ?? '智慧农业大数据可视化控制中心'}</div>
<div className="about-ver">
Version {settings?.projectVersion ?? '0.1.0'}
{info?.appVersion ? ` · 安装包 ${info.appVersion}` : ''}
</div>
<p className="about-desc">
URL
</p>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<table className="cfg-table">
<tbody>
<tr>
<td></td>
<td>{info?.platform ?? '…'}</td>
</tr>
<tr>
<td>Electron</td>
<td>{info?.electron ?? '…'}</td>
</tr>
<tr>
<td>Chromium</td>
<td>{info?.chrome ?? '…'}</td>
</tr>
<tr>
<td>Node.js</td>
<td>{info?.node ?? '…'}</td>
</tr>
<tr>
<td></td>
<td>Electron + React 19 + TypeScript + Zustand</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<table className="cfg-table">
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td></td><td> / / / / / </td></tr>
<tr><td></td><td> URL =</td></tr>
<tr><td></td><td> / ID </td></tr>
<tr><td></td><td> URL 访</td></tr>
<tr><td> / </td><td> JSON </td></tr>
<tr><td></td><td> IDPageID</td></tr>
</tbody>
</table>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<table className="cfg-table">
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td></td><td> + 6 </td></tr>
<tr><td></td><td> ID </td></tr>
<tr><td></td><td></td></tr>
<tr><td></td><td></td></tr>
<tr><td></td><td></td></tr>
<tr><td></td><td> 1-9 Esc 退F5 {settings?.keyLockKey ?? 'F9'} /</td></tr>
<tr><td></td><td>/ {settings?.keyThreshold ?? 350}ms</td></tr>
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,218 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../../store'
import { toast } from '../../toast'
import { detectScreens } from '../../publishActions'
interface KeyLog {
key: string
code: string
t: string
}
const SCALE = [523, 587, 659, 698, 784, 880, 988, 1047] // C5 音阶
export default function DeviceTest(): React.JSX.Element {
const screens = useStore((s) => s.screens)
// ===== 键盘测试 =====
const [keys, setKeys] = useState<KeyLog[]>([])
const [kbdFocus, setKbdFocus] = useState(false)
useEffect(() => {
const h = (e: KeyboardEvent): void => {
if (!kbdFocus) return
setKeys((l) =>
[{ key: e.key, code: e.code, t: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }, ...l].slice(0, 10)
)
e.preventDefault()
}
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [kbdFocus])
// ===== 音箱测试 =====
const [vol, setVol] = useState(0.5)
const beep = (freq = 880, dur = 0.25): void => {
try {
const ctx = new AudioContext()
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = 'sine'
osc.frequency.value = freq
g.gain.setValueAtTime(vol, ctx.currentTime)
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur)
osc.connect(g).connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + dur)
void ctx.close().then(() => undefined)
} catch {
void window.api.beep()
}
}
const playScale = (): void => {
SCALE.forEach((f, i) => setTimeout(() => beep(f, 0.22), i * 220))
toast('已播放 1234567i 音阶', 'ok')
}
// ===== 麦克风测试 =====
const [micOn, setMicOn] = useState(false)
const [level, setLevel] = useState(0)
const micRef = useRef<{ stream: MediaStream; ctx: AudioContext; raf: number } | null>(null)
useEffect(() => {
return () => {
const m = micRef.current
if (m) {
cancelAnimationFrame(m.raf)
void m.ctx.close()
m.stream.getTracks().forEach((t) => t.stop())
micRef.current = null
}
}
}, [])
const toggleMic = async (): Promise<void> => {
if (micOn) {
const m = micRef.current
if (m) {
cancelAnimationFrame(m.raf)
void m.ctx.close()
m.stream.getTracks().forEach((t) => t.stop())
micRef.current = null
}
setMicOn(false)
setLevel(0)
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const ctx = new AudioContext()
const src = ctx.createMediaStreamSource(stream)
const ana = ctx.createAnalyser()
ana.fftSize = 512
src.connect(ana)
const data = new Uint8Array(ana.fftSize)
const loop = (): void => {
ana.getByteTimeDomainData(data)
let sum = 0
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128
sum += v * v
}
setLevel(Math.min(100, Math.round(Math.sqrt(sum / data.length) * 220)))
micRef.current!.raf = requestAnimationFrame(loop)
}
loop()
micRef.current = { stream, ctx, raf: 0 }
setMicOn(true)
toast('麦克风已开启,请对麦克风说话观察电平', 'ok')
} catch {
toast('无法访问麦克风,请检查系统权限', 'warn')
}
}
return (
<div className="cfg-section">
<div className="cfg-card">
<div className="cfg-card-head"> </div>
<div className="cfg-card-body">
<p className="cfg-tip">
使 123456789
</p>
<div
className={'cfg-input rc-test' + (kbdFocus ? ' focus' : '')}
tabIndex={0}
onFocus={() => setKbdFocus(true)}
onBlur={() => setKbdFocus(false)}
>
{keys.length === 0 && <span className="cfg-tip"></span>}
{keys.map((k, i) => (
<span key={i} className="rc-log-item">
<b>{k.key}</b> <i>{k.code}</i> {k.t}
</span>
))}
</div>
<button className="btn" onClick={() => setKeys([])}>
🗑
</button>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head">🔊 </div>
<div className="cfg-card-body">
<p className="cfg-tip">线</p>
<div className="cfg-field">
<label>{Math.round(vol * 100)}%</label>
<div className="cfg-range">
<input type="range" min={0} max={1} step={0.05} value={vol} onChange={(e) => setVol(Number(e.target.value))} />
<span className="cfg-range-val">{Math.round(vol * 100)}%</span>
</div>
</div>
<div className="pub-actions">
<button className="btn accent" onClick={() => beep()}>
🔊
</button>
<button className="btn" onClick={() => void playScale()}>
🎵
</button>
</div>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head">🎙 </div>
<div className="cfg-card-body">
<div className="pub-actions">
<button className={'btn ' + (micOn ? 'danger' : 'primary')} onClick={() => void toggleMic()}>
{micOn ? '⏹ 停止测试' : '🎙 开始测试'}
</button>
</div>
<div className="mic-meter">
<div className="mic-bar" style={{ width: level + '%', background: level > 70 ? '#e5484d' : level > 30 ? '#2f9e63' : '#3b82f6' }} />
</div>
<div className="mic-level">
<b>{level}%</b> {micOn ? '(对麦克风说话测试)' : '(未开启)'}
</div>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head">🖥 </div>
<div className="cfg-card-body">
<div className="pub-actions">
<button className="btn accent" onClick={() => void detectScreens()}>
📡
</button>
</div>
{screens.length === 0 ? (
<p className="cfg-tip"></p>
) : (
<table className="cfg-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{screens.map((s) => (
<tr key={s.index}>
<td>
{s.index}
{s.primary && <span className="cfg-badge"></span>}
</td>
<td>{s.res}</td>
<td>{s.pos}</td>
<td>{s.primary ? '主显示器' : '扩展屏'}</td>
<td>{s.playing ? s.playing.title : '空闲'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../../store'
import { toast } from '../../toast'
export default function ProjectInfo(): React.JSX.Element {
const settings = useStore((s) => s.settings)
const pages = useStore((s) => s.pages)
const screens = useStore((s) => s.screens)
const filePath = useStore((s) => s.filePath)
const st = useStore.getState
const [name, setName] = useState(settings?.projectName ?? '')
const [ver, setVer] = useState(settings?.projectVersion ?? '')
const [pwd, setPwd] = useState(settings?.jsonPassword ?? '')
const [showPwd, setShowPwd] = useState(false)
// 设置异步加载完成后同步一次
const synced = useRef(false)
useEffect(() => {
if (settings && !synced.current) {
synced.current = true
setName(settings.projectName)
setVer(settings.projectVersion)
setPwd(settings.jsonPassword)
}
}, [settings])
const save = async (): Promise<void> => {
await st().updateSettings({ projectName: name.trim() || '智慧农业大数据可视化控制中心', projectVersion: ver.trim() || '0.1.0', jsonPassword: pwd })
toast('项目信息与 JSON 密码已保存', 'ok')
}
return (
<div className="cfg-section">
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<div className="cfg-field">
<label></label>
<input className="cfg-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="例如:智慧农业大数据可视化控制中心" />
</div>
<div className="cfg-field">
<label></label>
<input className="cfg-input" value={ver} onChange={(e) => setVer(e.target.value)} placeholder="例如:0.1.0" />
</div>
<p className="cfg-tip"> JSONappName / version</p>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head">
JSON
<span className="cfg-badge"></span>
</div>
<div className="cfg-card-body">
<div className="cfg-field">
<label></label>
<div className="cfg-pwd">
<input
className="cfg-input"
type={showPwd ? 'text' : 'password'}
value={pwd}
onChange={(e) => setPwd(e.target.value)}
placeholder="留空则不加密"
/>
<button className="btn" onClick={() => setShowPwd(!showPwd)}>
{showPwd ? '隐藏' : '显示'}
</button>
</div>
</div>
<p className="cfg-tip">
<b>URL</b> <b></b> AES-256-GCM
JSON
</p>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<div className="cfg-row">
<span className="cfg-row-label"></span>
<span className="cfg-row-value" title={filePath ?? ''}>
{filePath || '未保存(暂存于内存)'}
</span>
</div>
<div className="cfg-row">
<span className="cfg-row-label"></span>
<span className="cfg-row-value">{pages.length}</span>
</div>
<div className="cfg-row">
<span className="cfg-row-label"></span>
<span className="cfg-row-value">{screens.length > 0 ? screens.length : '未检测'}</span>
</div>
<div className="pub-actions">
<button className="btn primary" onClick={() => void save()}>
💾
</button>
<button className="btn" onClick={() => void st().open()}>
📂
</button>
<button className="btn" onClick={() => void st().save()}>
💾
</button>
<button className="btn" onClick={() => void st().saveAs()}>
📤
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../../store'
import { toast } from '../../toast'
import { publishAll, applyPublishDefaults, startCarouselOnScreen } from '../../publishActions'
export default function PublishSettings(): React.JSX.Element {
const settings = useStore((s) => s.settings)
const screens = useStore((s) => s.screens)
const st = useStore.getState
const [mode, setMode] = useState<'multi' | 'single' | 'main'>(settings?.publish.mode ?? 'multi')
const [screenIndex, setScreenIndex] = useState(settings?.publish.screenIndex ?? 1)
const [alsoMain, setAlsoMain] = useState(settings?.publish.alsoMain ?? false)
const [intervalSec, setIntervalSec] = useState(settings?.publish.intervalSec ?? 10)
// 设置异步加载完成后同步一次
const synced = useRef(false)
useEffect(() => {
if (settings && !synced.current) {
synced.current = true
setMode(settings.publish.mode)
setScreenIndex(settings.publish.screenIndex)
setAlsoMain(settings.publish.alsoMain)
setIntervalSec(settings.publish.intervalSec)
}
}, [settings])
const save = async (): Promise<void> => {
await st().updateSettings({
publish: { mode, screenIndex, alsoMain, intervalSec: Math.max(0, Math.round(intervalSec)) }
})
toast('发布默认值已保存', 'ok')
}
const testPublish = async (): Promise<void> => {
await save()
await applyPublishDefaults()
}
return (
<div className="cfg-section">
<p className="cfg-tip">
使
</p>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<label className="cfg-radio">
<input type="radio" checked={mode === 'multi'} onChange={() => setMode('multi')} />
<b></b> ID
</label>
<label className="cfg-radio">
<input type="radio" checked={mode === 'single'} onChange={() => setMode('single')} />
<b></b>
</label>
<label className="cfg-radio">
<input type="radio" checked={mode === 'main'} onChange={() => setMode('main')} />
<b></b>
</label>
{mode === 'multi' && (
<label className="cfg-check">
<input type="checkbox" checked={alsoMain} onChange={(e) => setAlsoMain(e.target.checked)} />
</label>
)}
{mode === 'single' && (
<div className="cfg-field">
<label></label>
<select className="cfg-input" value={screenIndex} onChange={(e) => setScreenIndex(Number(e.target.value))}>
{screens.length === 0 && <option value={1}> 1</option>}
{screens.map((s) => (
<option key={s.index} value={s.index}>
{s.index}
{s.primary ? '(主显示器)' : ''} {s.res}
</option>
))}
</select>
</div>
)}
{mode === 'single' && (
<div className="cfg-field">
<label>0 = </label>
<input
type="number"
className="cfg-input"
min={0}
max={3600}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
/>
</div>
)}
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<div className="pub-actions">
<button className="btn primary" onClick={() => void save()}>
💾
</button>
<button className="btn" onClick={() => void testPublish()}>
</button>
{mode === 'single' && (
<button className="btn" onClick={() => void startCarouselOnScreen(screenIndex, intervalSec)}>
🎠
</button>
)}
{mode === 'multi' && (
<button className="btn" onClick={() => void publishAll(alsoMain)}>
🖥
</button>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../../store'
import { toast } from '../../toast'
import { closeAllScreenOutput } from '../../publishActions'
const LOCK_KEYS = ['F9', 'F8', 'F10', 'ScrollLock', 'Pause', 'NumLock']
export default function RemoteControl(): React.JSX.Element {
const settings = useStore((s) => s.settings)
const pages = useStore((s) => s.pages)
const st = useStore.getState
const [threshold, setThreshold] = useState(settings?.keyThreshold ?? 350)
const [lockKey, setLockKey] = useState(settings?.keyLockKey ?? 'F9')
const [locked, setLocked] = useState(false)
const [log, setLog] = useState<{ key: string; code: string; t: string }[]>([])
// 设置异步加载完成后同步一次
const synced = useRef(false)
useEffect(() => {
if (settings && !synced.current) {
synced.current = true
setThreshold(settings.keyThreshold)
setLockKey(settings.keyLockKey)
}
}, [settings])
useEffect(() => {
void window.api.screens.keyLockState().then(setLocked)
}, [])
const goCtrl = (n: number): void => {
const i = pages.findIndex((p) => p.ctrl === n)
if (i >= 0) {
st().selectPage(i)
st().setView('edit')
toast(`已切换到 控制器 ${n}${pages[i].title}`, 'ok')
} else {
toast(`没有 控制器 ${n} 对应的页面`, 'warn')
}
}
const goRel = (d: number): void => {
const i = Math.max(0, Math.min(st().curIdx + d, pages.length - 1))
st().selectPage(i)
st().setView('edit')
}
const toggleLock = async (): Promise<void> => {
const nv = !locked
setLocked(nv)
await window.api.screens.keyLock(nv)
toast(
nv ? `已锁定快捷键识别(按 ${lockKey} 解锁),网页内输入数字不会切屏` : '已解锁,数字/方向键识别恢复',
nv ? 'info' : 'ok'
)
}
const save = async (): Promise<void> => {
await st().updateSettings({ keyThreshold: threshold, keyLockKey: lockKey })
toast('遥控设置已保存', 'ok')
}
return (
<div className="cfg-section">
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body rc-body">
<div className="rc-remote">
<div className="rc-name"> · </div>
<div className="rc-grid">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<button key={n} className="rc-btn num" onClick={() => goCtrl(n)}>
{n}
</button>
))}
</div>
<div className="rc-rows">
<button className="rc-btn nav" onClick={() => goRel(-1)} title="切换到上一页">
</button>
<button className="rc-btn nav" onClick={() => goRel(1)} title="切换到下一页">
</button>
<button className="rc-btn esc" onClick={() => void closeAllScreenOutput()} title="关闭所有播放页面">
Esc
</button>
<button
className={'rc-btn lock' + (locked ? ' on' : '')}
onClick={() => void toggleLock()}
title={`${lockKey}:锁定/解锁快捷键识别`}
>
{locked ? '🔒' : '🔓'}
</button>
</div>
<div className="rc-state">
<b className={locked ? 'txt-red' : 'txt-green'}>{locked ? '已锁定' : '正常'}</b>
</div>
</div>
<div className="rc-side">
<div className="cfg-field">
<label></label>
<div className="cfg-range">
<input
type="range"
min={100}
max={1500}
step={50}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
/>
<span className="cfg-range-val">{threshold} ms</span>
</div>
</div>
<div className="cfg-field">
<label>/</label>
<select className="cfg-input" value={lockKey} onChange={(e) => setLockKey(e.target.value)}>
{LOCK_KEYS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
</div>
<p className="cfg-tip">
"切屏"/
<b> </b> <b>{lockKey}</b> /
</p>
<div className="pub-actions">
<button className="btn primary" onClick={() => void save()}>
💾
</button>
</div>
</div>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<p className="cfg-tip">
1-9 <b></b>
</p>
<textarea
className="cfg-input rc-test"
placeholder="在此输入测试,如快速按 123456789 或 长按数字键"
onKeyDown={(e) => {
setLog((l) => [
{ key: e.key, code: e.code, t: new Date().toLocaleTimeString('zh-CN', { hour12: false }) },
...l
].slice(0, 8))
}}
/>
<div className="rc-log">
{log.length === 0 && <span className="cfg-tip"></span>}
{log.map((k, i) => (
<span key={i} className="rc-log-item">
<b>{k.key}</b> <i>{k.code}</i> {k.t}
</span>
))}
</div>
</div>
</div>
<div className="cfg-card">
<div className="cfg-card-head"></div>
<div className="cfg-card-body">
<table className="cfg-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td>1-9</td><td></td><td> / / </td></tr>
<tr><td> </td><td>/</td><td></td></tr>
<tr><td>Esc</td><td>退 / </td><td> / / </td></tr>
<tr><td>F5</td><td></td><td> / / </td></tr>
<tr><td>{lockKey}</td><td> / </td><td> / / </td></tr>
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { toast } from '../../toast'
interface CardDef {
kind: 'app' | 'edit' | 'publish'
icon: string
title: string
desc: string
}
const CARDS: CardDef[] = [
{
kind: 'app',
icon: '🖥️',
title: '打开本控制中心工具',
desc: '在桌面创建一个快捷方式,双击直接打开本工具。'
},
{
kind: 'edit',
icon: '✏️',
title: '打开项目并进入编辑',
desc: '在桌面创建快捷方式,双击后自动打开指定的工程 JSON 并进入编辑状态。'
},
{
kind: 'publish',
icon: '🚀',
title: '打开项目并进入发布',
desc: '在桌面创建快捷方式,双击后自动打开指定的工程 JSON,并按「发布设置」的默认配置自动发布。'
}
]
export default function ShortcutPanel(): React.JSX.Element {
const [busy, setBusy] = useState<string | null>(null)
const create = async (kind: 'app' | 'edit' | 'publish'): Promise<void> => {
setBusy(kind)
try {
const r = await window.api.shortcuts.create(kind)
if (r.ok) toast(`快捷方式已创建到桌面:「${r.message}`, 'ok')
else toast('创建失败:' + (r.message ?? ''), 'warn')
} finally {
setBusy(null)
}
}
return (
<div className="cfg-section">
<p className="cfg-tip">
23 JSON
</p>
<div className="cfg-cards3">
{CARDS.map((c) => (
<div key={c.kind} className="cfg-card">
<div className="cfg-card-head">
{c.icon} {c.title}
</div>
<div className="cfg-card-body">
<p className="cfg-tip">{c.desc}</p>
<div className="pub-actions">
<button className="btn primary" disabled={busy !== null} onClick={() => void create(c.kind)}>
{busy === c.kind ? '创建中…' : '📌 创建桌面快捷方式'}
</button>
</div>
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,83 @@
import type { ReactNode } from 'react'
interface IconProps {
size?: number
}
function Svg({ size = 13, children }: IconProps & { children: ReactNode }): React.JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
)
}
/** 插入(在页面前面插入新页) */
export function IconInsert({ size }: IconProps): React.JSX.Element {
return (
<Svg size={size}>
<path d="M12 5v14" />
<path d="M5 12h14" />
</Svg>
)
}
/** 上移 */
export function IconUp({ size }: IconProps): React.JSX.Element {
return (
<Svg size={size}>
<polyline points="18 15 12 9 6 15" />
</Svg>
)
}
/** 下移 */
export function IconDown({ size }: IconProps): React.JSX.Element {
return (
<Svg size={size}>
<polyline points="6 9 12 15 18 9" />
</Svg>
)
}
/** 复制 */
export function IconCopy({ size }: IconProps): React.JSX.Element {
return (
<Svg size={size}>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</Svg>
)
}
/** 删除 */
export function IconTrash({ size }: IconProps): React.JSX.Element {
return (
<Svg size={size}>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</Svg>
)
}
/** 截图 / 相机 */
export function IconCamera({ size }: IconProps): React.JSX.Element {
return (
<Svg size={size}>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</Svg>
)
}
@@ -0,0 +1,19 @@
/** 配置页菜单项标识 */
export type ConfigMenu = 'info' | 'remote' | 'shortcut' | 'publish' | 'device' | 'about'
export interface ConfigMenuMeta {
id: ConfigMenu
icon: string
label: string
desc: string
}
/** 配置菜单定义(作为 Ribbon 上的按钮排列) */
export const CONFIG_MENUS: ConfigMenuMeta[] = [
{ id: 'info', icon: '📋', label: '项目信息', desc: '项目名称、版本号、JSON 读写密码' },
{ id: 'remote', icon: '🎮', label: '遥控设置', desc: '遥控器模拟、快捷键阈值与锁定' },
{ id: 'shortcut', icon: '🖱️', label: '快捷方式', desc: '桌面快捷方式:打开 / 编辑 / 发布' },
{ id: 'publish', icon: '🚀', label: '发布设置', desc: '快捷方式③的默认发布配置' },
{ id: 'device', icon: '🔌', label: '设备测试', desc: '键盘 / 音箱 / 麦克风 / 屏幕测试' },
{ id: 'about', icon: '️', label: '关于', desc: '版本、简介、运行环境与帮助' }
]
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
declare namespace Electron {
interface WebviewTag extends HTMLElement {
src: string
reload(): void
getURL(): string
getWebContentsId(): number
setAttribute(name: string, value: string): void
getAttribute(name: string): string | null
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
@@ -0,0 +1,20 @@
import { create } from 'zustand'
interface PwdState {
open: boolean
title: string
resolve: ((v: string | null) => void) | null
}
export const usePwd = create<PwdState>(() => ({ open: false, title: '', resolve: null }))
/** 弹出密码输入框,返回用户输入的密码;取消返回 null */
export function askPassword(title = '请输入工程文件密码'): Promise<string | null> {
return new Promise((resolve) => usePwd.setState({ open: true, title, resolve }))
}
export function answerPassword(v: string | null): void {
const r = usePwd.getState().resolve
usePwd.setState({ open: false, resolve: null })
r?.(v)
}
@@ -0,0 +1,126 @@
import { useStore } from './store'
import { toast } from './toast'
import { pageSrc } from './utils'
import type { ScreenInfo } from './types'
export async function refreshScreens(): Promise<ScreenInfo[]> {
const list = await window.api.screens.list()
useStore.getState().setScreens(list)
toast(`已检测到 ${list.length} 块可用显示屏幕`, 'ok')
return list
}
/** 预览本页:将当前选中页面发布到指定屏幕 */
export async function publishCurrentToScreen(index: number): Promise<void> {
const { pages, curIdx } = useStore.getState()
const p = pages[curIdx]
if (!p) return
const url = pageSrc(p)
await window.api.screens.publish({ index, url, title: p.title })
const list = await window.api.screens.states()
useStore.getState().setScreens(list)
toast(`${p.title}」已发布到 屏幕 ${index}(Esc 退出 · F5 刷新 · 数字键切页)`, 'ok')
}
/** 多屏发布:各页面按所在屏幕 ID 全屏播放;alsoMain 时编辑器屏幕也全屏显示对应页面 */
export async function publishAll(alsoMain = false): Promise<void> {
const { pages } = useStore.getState()
const screens = await window.api.screens.list()
if (screens.length === 0) {
toast('未检测到屏幕,请先检测屏幕', 'warn')
return
}
let cnt = 0
for (const s of screens) {
const p = pages.find((x) => x.display && x.screen === s.index)
if (p) {
await window.api.screens.publish({ index: s.index, url: pageSrc(p), title: p.title })
cnt++
}
}
if (alsoMain) {
const idx = await window.api.screens.mainIndex()
const p = pages.find((x) => x.display && x.screen === idx)
if (p) {
await window.api.screens.publishMain({ url: pageSrc(p), title: p.title })
cnt++
} else {
toast(`编辑器屏幕(屏幕 ${idx})无匹配页面,未全屏显示`, 'warn')
}
}
const list = await window.api.screens.states()
useStore.getState().setScreens(list)
toast(
cnt > 0
? `多屏发布完成:${cnt} 个页面分发到 ${screens.length} 块屏幕`
: '没有可发布的页面(请勾选「参与多屏发布」并设置屏幕 ID)',
cnt > 0 ? 'ok' : 'warn'
)
}
/** 关闭所有播放:关闭各屏幕窗口/轮播,并恢复编辑器主窗口 */
export async function closeAllScreenOutput(): Promise<void> {
const list = await window.api.screens.closeAll()
useStore.getState().setScreens(list)
toast('已关闭所有播放页面', 'info')
}
/**
* 单屏发布:在指定屏幕轮播所有「参与多屏发布」的页面。
* 键盘:↑/↓ 或数字键切换,Esc 退出,F5 刷新。
*/
export async function startCarouselOnScreen(index: number, intervalSec = 0): Promise<void> {
const { pages } = useStore.getState()
const items = pages
.filter((p) => p.display)
.map((p) => ({ url: pageSrc(p), title: p.title }))
if (items.length === 0) {
toast('没有可发布的页面(请在编辑中勾选「参与多屏发布」)', 'warn')
return
}
await window.api.carousel.start({ index, pages: items, intervalSec })
const list = await window.api.screens.states()
useStore.getState().setScreens(list)
const auto = intervalSec > 0 ? ` · 自动切换 ${intervalSec}s` : ''
toast(`屏幕 ${index} 单屏发布已启动(${items.length}${auto} · ↑/↓ 或数字键切换 · Esc 退出)`, 'ok')
}
export async function stopCarouselOnScreen(index: number): Promise<void> {
const list = await window.api.carousel.stop(index)
useStore.getState().setScreens(list)
toast(`屏幕 ${index} 轮播已停止`, 'info')
}
/** 检测屏幕:各屏显示编号/分辨率标签 6 秒,并返回屏幕列表 */
export async function detectScreens(): Promise<ScreenInfo[]> {
const list = await window.api.screens.probe()
useStore.getState().setScreens(list)
if (list.length === 0) toast('未检测到屏幕', 'warn')
return list
}
/** 按「发布设置」的默认值发布当前工程(快捷方式③启动后自动调用) */
export async function applyPublishDefaults(): Promise<void> {
const s = useStore.getState().settings
if (!s) {
toast('尚未加载设置,请稍后重试', 'warn')
return
}
const p = s.publish
const { pages } = useStore.getState()
if (p.mode === 'single') {
await startCarouselOnScreen(p.screenIndex, p.intervalSec)
} else if (p.mode === 'main') {
const first = pages.find((x) => x.display) ?? pages[0]
if (!first) {
toast('没有可发布的页面', 'warn')
return
}
await window.api.screens.publishMain({ url: pageSrc(first), title: first.title })
const list = await window.api.screens.states()
useStore.getState().setScreens(list)
toast(`编辑器屏幕已发布「${first.title}`, 'ok')
} else {
await publishAll(p.alsoMain)
}
}
+266
View File
@@ -0,0 +1,266 @@
import { create } from 'zustand'
import type { AppSettings, LoadPayload, Page, ProjectJson, ScreenInfo, ViewMode } from './types'
import { defaultPages, jsonToPages, nextPageId, pageSrc, pagesToJson } from './utils'
import { toast } from './toast'
import { askPassword } from './passwordState'
interface AppState {
pages: Page[]
curIdx: number
view: ViewMode
filePath: string | null
dirty: boolean
screens: ScreenInfo[]
zoom: number
settings: AppSettings | null
// actions
setView: (v: ViewMode) => void
selectPage: (i: number) => void
updatePage: (patch: Partial<Page>) => void
addPage: () => void
insertBefore: (index?: number) => void
dupPage: () => void
movePage: (dir: number) => void
delPage: () => void
capturePreview: (index?: number) => Promise<void>
resetOrder: () => void
setScreens: (s: ScreenInfo[]) => void
setZoom: (z: number) => void
loadSettings: () => Promise<AppSettings>
updateSettings: (patch: Partial<AppSettings>) => Promise<AppSettings>
loadProject: (payload: LoadPayload) => 'edit' | 'publish'
save: () => Promise<void>
saveAs: () => Promise<void>
open: () => Promise<void>
markSaved: () => void
}
/** 应用工程 JSON 到编辑状态 */
function applyProject(get: () => AppState, set: (p: Partial<AppState>) => void, path: string, json: ProjectJson): void {
const pages = jsonToPages(json)
set({ pages, curIdx: 0, filePath: path, dirty: false })
try {
window.api.syncProject(pages.map((p) => ({ ctrl: p.ctrl, title: p.title, url: pageSrc(p) })))
} catch {
/* 无碍 */
}
toast('已打开工程:' + path.split(/[\\/]/).pop(), 'ok')
}
export const useStore = create<AppState>((set, get) => {
const touch = (patch: Partial<AppState>): void => set({ ...patch, dirty: true })
return {
pages: defaultPages(),
curIdx: 0,
view: 'edit',
filePath: null,
dirty: false,
screens: [],
zoom: 100,
settings: null,
setView: (v) => set({ view: v }),
selectPage: (i) => {
const pages = get().pages
const idx = Math.max(0, Math.min(i, pages.length - 1))
set({ curIdx: idx })
},
updatePage: (patch) => {
const { pages, curIdx } = get()
const list = pages.map((p, i) => (i === curIdx ? { ...p, ...patch } : p))
touch({ pages: list })
},
addPage: () => {
const pages = get().pages
const id = nextPageId(pages)
const page: Page = {
id,
title: '新页面 ' + pages.length + 1,
desc: '新建页面',
screen: 1,
ctrl: 0,
display: true,
url: 'sample-screen.html',
params: `no=${id.replace(/\D/g, '')}\ntitle=${id}`
}
const list = [...pages, page]
touch({ pages: list, curIdx: list.length - 1 })
toast('已新建页面 ' + id, 'ok')
},
insertBefore: (index?: number) => {
const { pages, curIdx } = get()
const at = index ?? curIdx
const id = nextPageId(pages)
const page: Page = {
id,
title: '新页面 ' + pages.length + 1,
desc: '新建页面',
screen: 1,
ctrl: 0,
display: true,
url: 'sample-screen.html',
params: `no=${id.replace(/\D/g, '')}\ntitle=${id}`
}
const list = [...pages]
list.splice(at, 0, page)
touch({ pages: list, curIdx: at })
toast('已在页面前插入 ' + id, 'ok')
},
dupPage: () => {
const { pages, curIdx } = get()
const src = pages[curIdx]
if (!src) return
const copy: Page = {
...src,
id: nextPageId(pages),
title: src.title + '(副本)'
}
const list = [...pages]
list.splice(curIdx + 1, 0, copy)
touch({ pages: list, curIdx: curIdx + 1 })
toast('已复制页面 ' + copy.id, 'ok')
},
movePage: (dir) => {
const { pages, curIdx } = get()
const ni = curIdx + dir
if (ni < 0 || ni >= pages.length) {
toast('已在边界,无法移动', 'warn')
return
}
const list = [...pages]
;[list[curIdx], list[ni]] = [list[ni], list[curIdx]]
touch({ pages: list, curIdx: ni })
},
delPage: () => {
const { pages, curIdx } = get()
if (pages.length <= 1) {
toast('至少保留一个页面', 'warn')
return
}
const rm = pages[curIdx]
const list = pages.filter((_, i) => i !== curIdx)
touch({ pages: list, curIdx: Math.min(curIdx, list.length - 1) })
toast('已删除页面 ' + rm.id, 'ok')
},
capturePreview: async (index?: number) => {
const { pages } = get()
const at = index ?? get().curIdx
const p = pages[at]
if (!p) return
const src = pageSrc(p)
if (src === 'about:blank') {
toast('空白页无法截图,请先设置 URL', 'warn')
return
}
toast('正在截图…', 'info')
try {
const data = await window.api.page.snapshot(src)
if (data) {
const list = pages.map((x, i) => (i === at ? { ...x, preview: data } : x))
touch({ pages: list })
toast('页面截图已更新', 'ok')
} else {
toast('截图失败,请确认 URL 可访问', 'warn')
}
} catch (err) {
toast('截图失败:' + String(err), 'warn')
}
},
resetOrder: () => {
const pages = get().pages
const list = pages.map((p, i) => ({
...p,
id: 'P-' + String(i + 1).padStart(3, '0'),
screen: i + 1,
ctrl: i + 1
}))
touch({ pages: list })
toast('页面已重排:屏幕 1-' + list.length + '、快捷键 1-' + list.length + '、PageID 已重排', 'ok')
},
setScreens: (s) => set({ screens: s }),
setZoom: (z) => set({ zoom: Math.min(160, Math.max(50, z)) }),
loadSettings: async () => {
const s = await window.api.settings.get()
set({ settings: s })
return s
},
updateSettings: async (patch) => {
const s = await window.api.settings.set(patch)
set({ settings: s })
return s
},
loadProject: (payload) => {
if (payload?.json) applyProject(get, set, payload.path, payload.json)
return payload?.mode ?? 'edit'
},
save: async () => {
const { filePath, pages, settings } = get()
const meta = { appName: settings?.projectName, version: settings?.projectVersion }
const data = JSON.stringify(pagesToJson(pages, meta), null, 2)
const pwd = settings?.jsonPassword || undefined
try {
if (filePath) {
const r = await window.api.project.save(filePath, data, pwd)
if (r) {
set({ dirty: false })
toast('工程已保存', 'ok')
}
} else {
const path = await window.api.project.saveAs(data, pwd)
if (path) {
set({ filePath: path, dirty: false })
toast('工程已保存为 ' + path.split(/[\\/]/).pop(), 'ok')
}
}
} catch (err) {
toast('保存失败:' + String(err), 'warn')
}
},
saveAs: async () => {
const { pages, settings } = get()
const meta = { appName: settings?.projectName, version: settings?.projectVersion }
const data = JSON.stringify(pagesToJson(pages, meta), null, 2)
const pwd = settings?.jsonPassword || undefined
try {
const path = await window.api.project.saveAs(data, pwd)
if (path) {
set({ filePath: path, dirty: false })
toast('工程已另存为', 'ok')
}
} catch (err) {
toast('保存失败:' + String(err), 'warn')
}
},
open: async () => {
try {
const settings = get().settings ?? (await get().loadSettings())
let r = await window.api.project.open(settings.jsonPassword || undefined)
if (!r) return
if (r.json) {
applyProject(get, set, r.path, r.json)
return
}
if (r.error === '密码错误') {
const pwd = await askPassword('打开工程需要密码')
if (!pwd) return
r = await window.api.project.openAt(r.path, pwd)
if (r?.json) {
applyProject(get, set, r.path, r.json)
// 密码正确后记忆到设置,后续打开免输入
void get().updateSettings({ jsonPassword: pwd })
} else {
toast('密码错误,无法解密该工程文件', 'warn')
}
return
}
toast(r.error || '打开失败', 'warn')
} catch (err) {
toast('打开失败:' + String(err), 'warn')
}
},
markSaved: () => set({ dirty: false })
}
})
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand'
export interface ToastItem {
id: number
msg: string
type: 'info' | 'ok' | 'warn'
}
interface ToastState {
items: ToastItem[]
push: (msg: string, type: ToastItem['type']) => void
remove: (id: number) => void
}
let seq = 0
export const useToast = create<ToastState>((set) => ({
items: [],
push: (msg, type) => {
const id = ++seq
set((s) => ({ items: [...s.items, { id, msg, type }] }))
setTimeout(() => {
set((s) => ({ items: s.items.filter((t) => t.id !== id) }))
}, 2800)
},
remove: (id) => set((s) => ({ items: s.items.filter((t) => t.id !== id) }))
}))
export function toast(msg: string, type: ToastItem['type'] = 'info'): void {
useToast.getState().push(msg, type)
}
+79
View File
@@ -0,0 +1,79 @@
export interface Page {
id: string
title: string
desc: string
screen: number
ctrl: number
display: boolean
url: string
params: string
/** 页面截图预览(Base64 dataURL),来自当前 URL 页面的截图 */
preview?: string
}
export interface PlayingInfo {
title: string
url: string
}
export interface ScreenInfo {
index: number
name: string
res: string
pos: string
primary: boolean
playing: PlayingInfo | null
}
export interface PageFile {
PageID: string
PageTitle: string
PageDescribe: string
ScreenIndex: number
ControllerIndex: number
IsDisplay: boolean
PageUrl: string
PageParams: string
/** 页面截图预览(Base64 dataURL),持久化到 JSON */
PagePreview?: string
}
export interface ProjectJson {
appName?: string
version?: string
pages?: PageFile[]
}
export type ViewMode = 'edit' | 'config'
export interface PublishDefaults {
mode: 'multi' | 'single' | 'main'
screenIndex: number
alsoMain: boolean
intervalSec: number
}
export interface AppSettings {
projectName: string
projectVersion: string
jsonPassword: string
keyThreshold: number
keyLockKey: string
publish: PublishDefaults
/** 上次打开/另存为工程的文件夹(主进程记忆) */
lastDir?: string
}
export interface LoadPayload {
path: string
json: ProjectJson | null
mode: 'edit' | 'publish'
}
export interface AppInfo {
appVersion: string
electron: string
chrome: string
node: string
platform: string
}
+87
View File
@@ -0,0 +1,87 @@
import type { Page, PageFile, ProjectJson } from './types'
/** 生成下一个页面 IDP-001、P-002… */
export function nextPageId(pages: Page[]): string {
const ids = pages.map((p) => p.id)
let n = pages.length + 1
while (ids.includes('P-' + String(n).padStart(3, '0'))) n++
return 'P-' + String(n).padStart(3, '0')
}
/** 解析大屏页面最终 URL(支持 http(s)、file、相对路径、about:blank */
export function resolvePageUrl(p: Page): string {
if (p.url === 'about:blank' || p.url === '') return 'about:blank'
try {
const u = new URL(p.url, window.location.href)
return u.href
} catch {
return 'about:blank'
}
}
/** 生成带参数的大屏页面 URLno / title / 自定义 params */
export function pageSrc(p: Page): string {
const base = resolvePageUrl(p)
if (base === 'about:blank') return 'about:blank'
try {
const u = new URL(base)
if (p.params) {
p.params.split('\n').forEach((line) => {
const eq = line.indexOf('=')
if (eq > 0) {
const k = line.slice(0, eq).trim()
const v = line.slice(eq + 1).trim()
if (k) u.searchParams.set(k, v)
}
})
}
if (!u.searchParams.has('no')) u.searchParams.set('no', p.id.replace(/\D/g, '') || '0')
if (!u.searchParams.has('title')) u.searchParams.set('title', p.title)
return u.href
} catch {
return 'about:blank'
}
}
export function pagesToJson(pages: Page[], meta?: { appName?: string; version?: string }): ProjectJson {
return {
appName: meta?.appName || '智慧农业大数据可视化控制中心',
version: meta?.version || '0.1.0',
pages: pages.map((p) => ({
PageID: p.id,
PageTitle: p.title,
PageDescribe: p.desc,
ScreenIndex: p.screen,
ControllerIndex: p.ctrl,
IsDisplay: p.display,
PageUrl: p.url,
PageParams: p.params,
PagePreview: p.preview || ''
}))
}
}
export function jsonToPages(json: ProjectJson): Page[] {
const list = Array.isArray(json.pages) ? json.pages : []
if (list.length === 0) return []
return list.map((f: PageFile, i) => ({
id: String(f.PageID ?? 'P-' + String(i + 1).padStart(3, '0')),
title: String(f.PageTitle ?? '页面 ' + (i + 1)),
desc: String(f.PageDescribe ?? ''),
screen: Number(f.ScreenIndex) || 1,
ctrl: Number(f.ControllerIndex) || 0,
display: f.IsDisplay !== false,
url: String(f.PageUrl ?? 'about:blank'),
params: String(f.PageParams ?? ''),
preview: f.PagePreview || undefined
}))
}
export function defaultPages(): Page[] {
return [
{ id: 'P-001', title: '农业数据总览', desc: '全基地农业数据总览', screen: 1, ctrl: 1, display: true, url: 'sample-screen.html', params: 'no=1\ntitle=农业数据总览' },
{ id: 'P-002', title: '土壤墒情监测', desc: '土壤湿度与墒情分析', screen: 2, ctrl: 2, display: true, url: 'sample-screen.html', params: 'no=2\ntitle=土壤墒情监测' },
{ id: 'P-003', title: '温室环境监控', desc: '温室内环境参数实时监控', screen: 3, ctrl: 3, display: true, url: 'sample-screen.html', params: 'no=3\ntitle=温室环境监控' },
{ id: 'P-004', title: '设备运维看板', desc: '灌溉设备运维状态', screen: 1, ctrl: 4, display: true, url: 'sample-screen.html', params: 'no=4\ntitle=设备运维看板' }
]
}
@@ -0,0 +1,48 @@
import { useMemo, useRef } from 'react'
import { useStore } from '../store'
import { pageSrc, resolvePageUrl } from '../utils'
import WebFrame, { type WebFrameHandle } from '../components/WebFrame'
export default function Canvas(): React.JSX.Element {
const page = useStore((s) => s.pages[s.curIdx])
const zoom = useStore((s) => s.zoom)
const setZoom = useStore((s) => s.setZoom)
const frameRef = useRef<WebFrameHandle>(null)
const src = useMemo(() => (page ? pageSrc(page) : 'about:blank'), [page])
const baseUrl = useMemo(() => (page ? resolvePageUrl(page) : ''), [page])
const isBlank = src === 'about:blank'
return (
<>
<div className="canvas-toolbar">
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--c-text-soft)' }}>
{page ? page.title : '—'}
</span>
<div className="ct-url" title={baseUrl}>
{baseUrl}
</div>
<button
className="icon-btn"
title="刷新大屏页面"
disabled={isBlank}
onClick={() => frameRef.current?.reload()}
>
🔄
</button>
<button className="icon-btn" title="缩小" onClick={() => setZoom(zoom - 10)}>
</button>
<span className="zoom-val">{zoom}%</span>
<button className="icon-btn" title="放大" onClick={() => setZoom(zoom + 10)}>
+
</button>
</div>
<div className="canvas-wrap">
<div className="canvas-stage" style={{ transform: 'scale(' + zoom / 100 + ')' }}>
{isBlank ? <div className="canvas-empty">about:blank</div> : <WebFrame ref={frameRef} src={src} />}
</div>
</div>
</>
)
}
@@ -0,0 +1,22 @@
import ProjectInfo from '../components/config/ProjectInfo'
import RemoteControl from '../components/config/RemoteControl'
import ShortcutPanel from '../components/config/ShortcutPanel'
import PublishSettings from '../components/config/PublishSettings'
import DeviceTest from '../components/config/DeviceTest'
import AboutPanel from '../components/config/AboutPanel'
import type { ConfigMenu } from '../configMenus'
export default function ConfigView({ menu }: { menu: ConfigMenu }): React.JSX.Element {
return (
<div className="config-shell">
<div className="config-area">
{menu === 'info' && <ProjectInfo />}
{menu === 'remote' && <RemoteControl />}
{menu === 'shortcut' && <ShortcutPanel />}
{menu === 'publish' && <PublishSettings />}
{menu === 'device' && <DeviceTest />}
{menu === 'about' && <AboutPanel />}
</div>
</div>
)
}
@@ -0,0 +1,15 @@
import PageList from './PageList'
import Canvas from './Canvas'
import PropertyPanel from './PropertyPanel'
export default function EditView(): React.JSX.Element {
return (
<div className="edit-view">
<PageList />
<div className="edit-center">
<Canvas />
</div>
<PropertyPanel />
</div>
)
}
@@ -0,0 +1,64 @@
import { useStore } from '../store'
import { IconCopy, IconDown, IconInsert, IconTrash, IconUp, IconCamera } from '../components/icons'
export default function PageList(): React.JSX.Element {
const pages = useStore((s) => s.pages)
const curIdx = useStore((s) => s.curIdx)
const selectPage = useStore((s) => s.selectPage)
const insertBefore = useStore((s) => s.insertBefore)
const addPage = useStore((s) => s.addPage)
const capturePreview = useStore((s) => s.capturePreview)
const movePage = useStore((s) => s.movePage)
const dupPage = useStore((s) => s.dupPage)
const delPage = useStore((s) => s.delPage)
const stop = (e: React.MouseEvent): void => e.stopPropagation()
return (
<div className="panel edit-side">
<div className="panel-head">
<span className="ph-icon">📄</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--c-text-faint)' }}>{pages.length} </span>
</div>
<div className="panel-body">
{pages.map((p, i) => (
<div
key={p.id}
className={'page-card' + (i === curIdx ? ' active' : '')}
onClick={() => selectPage(i)}
>
<div className="pc-thumb">
{p.preview ? (
<img src={p.preview} alt={p.title} />
) : (
<span className="pc-thumb-empty">🖼 </span>
)}
</div>
<div className="pc-top">
<span className="pc-title">{p.title}</span>
<span className="pc-id">{p.id}</span>
</div>
<div className="pc-desc">{p.desc || '(无描述)'}</div>
<div className="pc-meta">
<span className={'tag' + (p.display ? '' : ' gray')}>{p.display ? '显示' : '隐藏'}</span>
<span className="tag"> {p.screen}</span>
<span className={'tag' + (p.ctrl > 0 ? ' orange' : ' gray')}> {p.ctrl || '—'}</span>
</div>
<div className="pc-actions">
<button title="在页面前面插入新页" onClick={(e) => { stop(e); selectPage(i); insertBefore(i) }}><IconInsert /></button>
<button title="上移" disabled={i === 0} onClick={(e) => { stop(e); selectPage(i); movePage(-1) }}><IconUp /></button>
<button title="下移" disabled={i === pages.length - 1} onClick={(e) => { stop(e); selectPage(i); movePage(1) }}><IconDown /></button>
<button title="复制页面" onClick={(e) => { stop(e); selectPage(i); dupPage() }}><IconCopy /></button>
<button title="删除页面" disabled={pages.length <= 1} onClick={(e) => { stop(e); selectPage(i); delPage() }}><IconTrash /></button>
<button title="截图更新预览图" onClick={(e) => { stop(e); void capturePreview(i) }}><IconCamera /></button>
</div>
</div>
))}
{pages.length === 0 && <div className="empty-hint"></div>}
<button className="add-page-btn" onClick={() => addPage()}>
</button>
</div>
</div>
)
}
@@ -0,0 +1,110 @@
import { useStore } from '../store'
import { toast } from '../toast'
export default function PropertyPanel(): React.JSX.Element {
const page = useStore((s) => s.pages[s.curIdx])
const updatePage = useStore((s) => s.updatePage)
const screens = useStore((s) => s.screens)
const screenCount = Math.max(screens.length, 4)
if (!page) {
return (
<div className="panel edit-props">
<div className="panel-head">
<span className="ph-icon"></span>
</div>
<div className="panel-body">
<div className="empty-hint"></div>
</div>
</div>
)
}
const setCtr = (v: string): void => {
const n = parseInt(v, 10)
if (isNaN(n) || n < 0 || n > 9) {
toast('控制器编号范围 0-9', 'warn')
return
}
updatePage({ ctrl: n })
}
return (
<div className="panel edit-props">
<div className="panel-head">
<span className="ph-icon"></span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--c-text-faint)' }}>{page.id}</span>
</div>
<div className="panel-body">
<div className="prop-grid">
<div className="prop-section"></div>
<div className="prop-item">
<label></label>
<input type="text" value={page.title} onChange={(e) => updatePage({ title: e.target.value })} />
</div>
<div className="prop-item">
<label></label>
<input type="text" value={page.desc} onChange={(e) => updatePage({ desc: e.target.value })} />
</div>
<div className="prop-section"></div>
<div className="prop-item">
<label>ScreenIndex</label>
<select value={page.screen} onChange={(e) => updatePage({ screen: parseInt(e.target.value, 10) })}>
{Array.from({ length: Math.max(screenCount, 1) }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span className="hint"></span>
</div>
<div className="prop-item">
<label>ControllerIndex</label>
<input
type="number"
min={0}
max={9}
value={page.ctrl}
onChange={(e) => setCtr(e.target.value)}
/>
<span className="hint"> 1-9 </span>
</div>
<div className="prop-item">
<label className="check-row">
<input
type="checkbox"
checked={page.display}
onChange={(e) => updatePage({ display: e.target.checked })}
/>
IsDisplay
</label>
</div>
<div className="prop-section"></div>
<div className="prop-item">
<label> URLPageUrl</label>
<textarea
className="url-area"
rows={2}
value={page.url}
onChange={(e) => updatePage({ url: e.target.value })}
placeholder={'sample-screen.html\nhttps://…\nabout:blank'}
/>
<span className="hint"></span>
</div>
<div className="prop-item">
<label>PageParams key=value 255 </label>
<textarea
maxLength={255}
value={page.params}
onChange={(e) => updatePage({ params: e.target.value })}
placeholder={'no=1\ntitle=农业数据总览'}
/>
<span className="hint"> {page.params.length} / 255</span>
</div>
</div>
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["node", "electron-vite/node"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"paths": {
"@/*": ["./src/renderer/src/*"]
}
},
"include": [
"electron.vite.config.ts",
"src/main/**/*.ts",
"src/preload/**/*.ts"
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"noEmit": true,
"paths": {
"@/*": ["./src/renderer/src/*"]
}
},
"include": [
"src/renderer/src/**/*.ts",
"src/renderer/src/**/*.tsx",
"src/renderer/src/**/*.d.ts",
"src/preload/index.d.ts"
]
}
File diff suppressed because one or more lines are too long