init: 智慧农业大数据可视化控制中心 v0.1.0(Electron + React 19 + TS 重写版)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
out
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -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/
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
Generated
+6390
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
各页面将按「所属屏幕 ID」分发到对应屏幕全屏播放(Esc 退出 · 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>按当前页面顺序自动重排 屏幕 ID、控制器编号、PageID</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">项目名称与版本号会写入工程 JSON(appName / 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">
|
||||
共三种桌面快捷方式。第 2、3 种会先弹出文件选择框,选择后把指定的工程 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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Page, PageFile, ProjectJson } from './types'
|
||||
|
||||
/** 生成下一个页面 ID:P-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'
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成带参数的大屏页面 URL(no / 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>大屏页面 URL(PageUrl)</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user