v0.2.0: 交互发布+多屏发布增强+页面管理优化
- 版本升级 0.1.0 -> 0.2.0(package.json 单一来源,标题栏/设置/关于同步)
- 新增交互发布:指定大屏为主屏,其余屏自行加载本屏页面,主屏可轮流投屏各屏内容
- 单屏发布支持自动轮播(intervalSec)与停止轮播(保留窗体可手动切换)
- 多屏发布支持 alsoMain 主屏同时参与;新增屏幕检测弹窗
- 页面列表缩略图 key 重建避免浏览器复用旧图;「打开」按钮防抖(opening 状态)
- URL 粘贴自动提取网页标题/描述(主进程 fetchMeta)
- 项目组新增「新建」:默认创建 1 个空白页面,保存时选择位置
- 发布组 4 按钮(预览/单屏/多屏/交互)统一为线性 SVG 显示器族图标
- 属性面板 URL 输入框加高 1 行、页面参数加高 2 行并去掉水印
- 编辑预览 WebFrame key={src} 重建,修复切页内容错位
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smart-agri-center",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "智慧农业大数据可视化控制中心(Electron + React + TypeScript)",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "FanHongCai",
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
publishScreen,
|
||||
setPages,
|
||||
startCarousel,
|
||||
startInteractive,
|
||||
stopCarousel,
|
||||
stopCarouselAuto,
|
||||
mainScreenIndex,
|
||||
publishMain,
|
||||
restoreMain,
|
||||
@@ -47,8 +49,10 @@ export function registerIpc(): void {
|
||||
// ===== 屏幕 / 多屏发布 =====
|
||||
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:publish',
|
||||
(_e, payload: { index: number; url: string; title: string; navDisabled?: boolean }) =>
|
||||
publishScreen(payload.index, payload.url, payload.title, { navDisabled: payload.navDisabled })
|
||||
)
|
||||
ipcMain.handle('screen:close', (_e, index: number) => closeScreen(index))
|
||||
ipcMain.handle('screen:closeAll', () => {
|
||||
@@ -65,9 +69,16 @@ export function registerIpc(): void {
|
||||
// ===== 单屏轮播 =====
|
||||
ipcMain.handle(
|
||||
'carousel:start',
|
||||
(_e, payload: { index: number; pages: CarouselPage[] }) => startCarousel(payload.index, payload.pages)
|
||||
(_e, payload: { index: number; pages: CarouselPage[]; intervalSec?: number }) =>
|
||||
startCarousel(payload.index, payload.pages, payload.intervalSec ?? 0)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'carousel:startInteractive',
|
||||
(_e, req: { mainIndex: number; pagesByScreen: Record<number, { url: string; title: string }[]> }) =>
|
||||
startInteractive(req)
|
||||
)
|
||||
ipcMain.handle('carousel:stop', (_e, index: number) => stopCarousel(index))
|
||||
ipcMain.handle('carousel:stopAuto', (_e, index: number) => stopCarouselAuto(index))
|
||||
|
||||
// 渲染层页面同步(用于屏幕键盘切页)
|
||||
ipcMain.on('project:sync', (_e, pages: unknown) => {
|
||||
@@ -178,6 +189,55 @@ export function registerIpc(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 页面元信息提取(标题/描述)=====
|
||||
ipcMain.handle('page:fetchMeta', async (_e, url: string) => {
|
||||
if (!url || !/^https?:\/\//i.test(url)) 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' }))
|
||||
// 加载超时保护(12s),防止不可达 URL 长时间挂起
|
||||
const loadP = win.loadURL(url).catch(() => {})
|
||||
await Promise.race([
|
||||
loadP,
|
||||
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('load timeout')), 12000))
|
||||
])
|
||||
// 轮询等待动态 JS 设置标题(最长 2s),防止 title 为空
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const t: unknown = await win.webContents.executeJavaScript('document.title')
|
||||
if (typeof t === 'string' && t.trim()) break
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
const meta = (await win.webContents.executeJavaScript(`(() => {
|
||||
const d = document.querySelector('meta[name="description"]')
|
||||
|| document.querySelector('meta[property="og:description"]')
|
||||
return {
|
||||
title: (document.title || '').trim(),
|
||||
desc: d ? (d.getAttribute('content') || '').trim() : ''
|
||||
}
|
||||
})()`)) as { title?: unknown; desc?: unknown }
|
||||
if (!meta || typeof meta !== 'object') return null
|
||||
return {
|
||||
title: typeof meta.title === 'string' ? meta.title : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : ''
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
if (!win.isDestroyed()) win.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 对话框 =====
|
||||
ipcMain.handle('dialog:confirm', async (_e, message: string) => {
|
||||
const r = await dialog.showMessageBox({
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ScreenInfo {
|
||||
pos: string
|
||||
primary: boolean
|
||||
playing: PlayingInfo | null
|
||||
/** 单屏轮播是否处于自动切换中(「停止轮播」按钮据此启用) */
|
||||
carouselAuto?: boolean
|
||||
}
|
||||
|
||||
/** 渲染层同步过来的页面(用于屏幕窗口按键切换) */
|
||||
@@ -31,6 +33,8 @@ export interface CarouselPage {
|
||||
}
|
||||
|
||||
const screenWindows = new Map<number, BrowserWindow>()
|
||||
/** 多屏发布模式的窗口集合:仅保留 Esc/F5,禁用数字/方向/锁定键 */
|
||||
const navDisabledWins = new Set<BrowserWindow>()
|
||||
let lastPages: SyncPage[] = []
|
||||
let broadcastFn: ((list: ScreenInfo[]) => void) | null = null
|
||||
|
||||
@@ -50,6 +54,10 @@ interface CarouselState {
|
||||
pages: CarouselPage[]
|
||||
current: number
|
||||
timer: NodeJS.Timeout | null
|
||||
/** 是否自动轮播(有定时器在跑) */
|
||||
auto: boolean
|
||||
/** 状态显示名(单屏轮播 / 交互发布) */
|
||||
label: string
|
||||
}
|
||||
const carousels = new Map<number, CarouselState>()
|
||||
|
||||
@@ -57,7 +65,8 @@ const carousels = new Map<number, CarouselState>()
|
||||
* 统一的屏幕键盘处理:
|
||||
* - Esc 立即退出 / F5 立即刷新
|
||||
* - 锁定键切换全局快捷键识别(避免网页交互输入数字误触切屏)
|
||||
* - 数字键、方向键需要"长按超过阈值"才触发,快速输入不影响网页操作
|
||||
* - 数字键、方向键默认需要"长按超过阈值"才触发,避免网页交互输入误触;
|
||||
* quick 模式下(大屏发布/轮播/主屏播放)阈值强制为 0,按键即时响应
|
||||
*/
|
||||
interface KeyBindings {
|
||||
onEscape: () => void
|
||||
@@ -66,14 +75,16 @@ interface KeyBindings {
|
||||
onNext: () => void
|
||||
onNumber: (n: number) => void
|
||||
onLockToggle: (locked: boolean) => void
|
||||
/** true 时禁用数字/方向/锁定键,仅保留 Esc/F5(多屏发布模式) */
|
||||
navDisabled?: () => boolean
|
||||
}
|
||||
|
||||
function makeKeyHandler(b: KeyBindings): (event: Electron.Event, input: Electron.Input) => void {
|
||||
function makeKeyHandler(b: KeyBindings, quick = false): (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 threshold = quick ? 0 : Math.max(0, s.keyThreshold)
|
||||
const lockKey = (s.keyLockKey || 'F9').toLowerCase()
|
||||
if (input.type === 'keyDown') {
|
||||
if (input.key === 'Escape') {
|
||||
@@ -86,6 +97,8 @@ function makeKeyHandler(b: KeyBindings): (event: Electron.Event, input: Electron
|
||||
b.onF5()
|
||||
return
|
||||
}
|
||||
// 多屏发布模式:仅保留 Esc/F5,其余按键一律忽略
|
||||
if (b.navDisabled?.() ?? false) return
|
||||
if (lockKey && input.key.toLowerCase() === lockKey) {
|
||||
event.preventDefault()
|
||||
const nv = !isKeyLocked()
|
||||
@@ -111,7 +124,7 @@ function makeKeyHandler(b: KeyBindings): (event: Electron.Event, input: Electron
|
||||
if (input.type === 'keyUp' && input.key === hold) {
|
||||
hold = ''
|
||||
const dur = Date.now() - holdT
|
||||
if (dur < threshold || isKeyLocked()) return
|
||||
if (dur < threshold || isKeyLocked() || (b.navDisabled?.() ?? false)) return
|
||||
event.preventDefault()
|
||||
if (input.key === 'ArrowUp' || input.key === 'ArrowLeft') {
|
||||
b.onPrev()
|
||||
@@ -167,7 +180,7 @@ export function listScreens(): ScreenInfo[] {
|
||||
let playing: PlayingInfo | null = null
|
||||
if (car && car.wins.some((w) => !w.isDestroyed() && w.isVisible())) {
|
||||
playing = {
|
||||
title: `单屏轮播(${car.pages.length} 页 · 第 ${car.current + 1} 页)`,
|
||||
title: `${car.label}(${car.pages.length} 页 · 第 ${car.current + 1} 页${car.auto ? ' · 自动' : ''})`,
|
||||
url: car.pages[car.current]?.url ?? ''
|
||||
}
|
||||
} else if (win && !win.isDestroyed() && win.isVisible()) {
|
||||
@@ -179,14 +192,20 @@ export function listScreens(): ScreenInfo[] {
|
||||
res: `${d.size.width}×${d.size.height}`,
|
||||
pos: d.id === screen.getPrimaryDisplay().id ? '主显示器' : '扩展显示器',
|
||||
primary: d.id === screen.getPrimaryDisplay().id,
|
||||
playing
|
||||
playing,
|
||||
carouselAuto: car ? car.auto : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function ensureWindow(index: number, display: Electron.Display): BrowserWindow {
|
||||
function ensureWindow(index: number, display: Electron.Display, opts?: { navDisabled?: boolean }): BrowserWindow {
|
||||
let win = screenWindows.get(index)
|
||||
if (win && !win.isDestroyed()) return win
|
||||
if (win && !win.isDestroyed()) {
|
||||
// 复用窗口时也要刷新“仅 Esc/F5”标记(例如先预览本页再多屏发布,或反之)
|
||||
if (opts?.navDisabled) navDisabledWins.add(win)
|
||||
else navDisabledWins.delete(win)
|
||||
return win
|
||||
}
|
||||
|
||||
win = new BrowserWindow({
|
||||
x: display.bounds.x,
|
||||
@@ -207,31 +226,40 @@ function ensureWindow(index: number, display: Electron.Display): BrowserWindow {
|
||||
win.setMenuBarVisibility(false)
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
|
||||
if (opts?.navDisabled) navDisabledWins.add(win)
|
||||
else navDisabledWins.delete(win)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.show()
|
||||
win.setFullScreen(true)
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// 屏幕窗口键盘:Esc 退出发布,F5 刷新,数字/方向键需长按超阈值,锁定键 F9 切换识别
|
||||
// 屏幕窗口键盘: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)
|
||||
makeKeyHandler(
|
||||
{
|
||||
onEscape: () => closeAllScreens(),
|
||||
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),
|
||||
navDisabled: () => (win ? navDisabledWins.has(win) : false)
|
||||
},
|
||||
onLockToggle: (lv) => lockHint(win, lv)
|
||||
})
|
||||
true
|
||||
)
|
||||
)
|
||||
|
||||
win.on('closed', () => {
|
||||
screenWindows.delete(index)
|
||||
if (win) navDisabledWins.delete(win)
|
||||
emitChange()
|
||||
})
|
||||
|
||||
@@ -239,12 +267,17 @@ function ensureWindow(index: number, display: Electron.Display): BrowserWindow {
|
||||
return win
|
||||
}
|
||||
|
||||
export function publishScreen(index: number, url: string, title: string): ScreenInfo[] {
|
||||
export function publishScreen(
|
||||
index: number,
|
||||
url: string,
|
||||
title: string,
|
||||
opts?: { navDisabled?: boolean }
|
||||
): ScreenInfo[] {
|
||||
const displays = sortedDisplays()
|
||||
const display = displays[index - 1]
|
||||
if (!display) return listScreens()
|
||||
|
||||
const win = ensureWindow(index, display)
|
||||
const win = ensureWindow(index, display, opts)
|
||||
if (!win.isDestroyed()) {
|
||||
win.setBounds(display.bounds)
|
||||
}
|
||||
@@ -284,7 +317,7 @@ export function screenCount(): number {
|
||||
* 通过键盘 上/下 键循环切换、数字键直达,Esc 退出发布,F5 刷新当前页。
|
||||
* intervalSec > 0 时自动定时切换。
|
||||
*/
|
||||
export function startCarousel(index: number, pages: CarouselPage[], intervalSec = 0): ScreenInfo[] {
|
||||
export function startCarousel(index: number, pages: CarouselPage[], intervalSec = 0, label = '单屏轮播'): ScreenInfo[] {
|
||||
const displays = sortedDisplays()
|
||||
const display = displays[index - 1]
|
||||
if (!display) return listScreens()
|
||||
@@ -319,34 +352,38 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (pi === 0 && !win.isDestroyed()) {
|
||||
win.show()
|
||||
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()
|
||||
makeKeyHandler(
|
||||
{
|
||||
onEscape: () => closeAllScreens(),
|
||||
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)
|
||||
},
|
||||
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)
|
||||
})
|
||||
true
|
||||
)
|
||||
)
|
||||
|
||||
win.on('closed', () => {
|
||||
@@ -359,8 +396,16 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
|
||||
wins.push(win)
|
||||
})
|
||||
|
||||
const state: CarouselState = { screenIndex: index, wins, pages, current: 0, timer: null }
|
||||
if (intervalSec > 0 && pages.length > 1) {
|
||||
const state: CarouselState = {
|
||||
screenIndex: index,
|
||||
wins,
|
||||
pages,
|
||||
current: 0,
|
||||
timer: null,
|
||||
auto: intervalSec > 0 && pages.length > 1,
|
||||
label
|
||||
}
|
||||
if (state.auto) {
|
||||
state.timer = setInterval(() => {
|
||||
const st = carousels.get(index)
|
||||
if (st) switchCarousel(index, st.current + 1)
|
||||
@@ -368,6 +413,39 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
|
||||
}
|
||||
carousels.set(index, state)
|
||||
emitChange()
|
||||
// 弹窗关闭后编辑器主窗口可能重新抢走焦点,延迟再次聚焦首个窗体
|
||||
setTimeout(() => {
|
||||
const st = carousels.get(index)
|
||||
const first = st?.wins[0]
|
||||
if (st && first && !first.isDestroyed()) {
|
||||
first.show()
|
||||
first.setFullScreen(true)
|
||||
first.focus()
|
||||
}
|
||||
}, 400)
|
||||
return listScreens()
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互发布:指定 mainIndex 为交互主屏(大屏),其余屏幕按页面所属屏幕编号各自加载本屏页面。
|
||||
* 主屏轮播内容为全部屏幕的页面(按屏幕编号排序拼接),因此在主屏上按 ↑/↓ 或数字键,
|
||||
* 即可轮流把其他屏幕的页面切换到主屏上展示。
|
||||
*/
|
||||
export function startInteractive(req: { mainIndex: number; pagesByScreen: Record<number, CarouselPage[]> }): ScreenInfo[] {
|
||||
const displays = sortedDisplays()
|
||||
if (!displays[req.mainIndex - 1]) return listScreens()
|
||||
const indexes = Object.keys(req.pagesByScreen)
|
||||
.map(Number)
|
||||
.filter((i) => i >= 1 && i <= displays.length)
|
||||
.sort((a, b) => a - b)
|
||||
// 主屏轮播页面 = 所有屏幕页面(按屏幕编号排序)
|
||||
const mainPages: CarouselPage[] = []
|
||||
for (const i of indexes) mainPages.push(...req.pagesByScreen[i])
|
||||
for (const i of indexes) {
|
||||
const pages = i === req.mainIndex ? mainPages : req.pagesByScreen[i]
|
||||
if (pages.length > 0) startCarousel(i, pages, 0, i === req.mainIndex ? '交互发布' : '单屏轮播')
|
||||
}
|
||||
emitChange()
|
||||
return listScreens()
|
||||
}
|
||||
|
||||
@@ -384,6 +462,10 @@ function switchCarousel(index: number, next: number): void {
|
||||
w.show()
|
||||
w.setFullScreen(true)
|
||||
w.focus()
|
||||
// 切换后短暂延迟再聚焦一次,确保键盘输入落在当前窗体
|
||||
setTimeout(() => {
|
||||
if (!w.isDestroyed() && w.isVisible()) w.focus()
|
||||
}, 120)
|
||||
} else {
|
||||
w.hide()
|
||||
}
|
||||
@@ -404,6 +486,20 @@ export function stopCarousel(index: number): ScreenInfo[] {
|
||||
return listScreens()
|
||||
}
|
||||
|
||||
/** 停止自动轮播:仅清除定时切换,保留当前页面窗体显示(手动模式下「停止轮播」不可用) */
|
||||
export function stopCarouselAuto(index: number): ScreenInfo[] {
|
||||
const state = carousels.get(index)
|
||||
if (state) {
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer)
|
||||
state.timer = null
|
||||
}
|
||||
state.auto = false
|
||||
}
|
||||
emitChange()
|
||||
return listScreens()
|
||||
}
|
||||
|
||||
export function closeAllCarousels(): void {
|
||||
for (const index of [...carousels.keys()]) {
|
||||
stopCarousel(index)
|
||||
@@ -456,17 +552,20 @@ export function publishMain(url: string, title: string): ScreenInfo[] {
|
||||
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)
|
||||
makeKeyHandler(
|
||||
{
|
||||
onEscape: () => closeAllScreens(),
|
||||
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)
|
||||
},
|
||||
onLockToggle: (lv) => lockHint(mainWin, lv)
|
||||
})
|
||||
true
|
||||
)
|
||||
)
|
||||
mainWin.contentView.addChildView(mainView)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AppSettings {
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
projectName: '智慧农业大数据可视化控制中心',
|
||||
projectVersion: '0.1.0',
|
||||
projectVersion: '0.2.0',
|
||||
jsonPassword: '',
|
||||
keyThreshold: 350,
|
||||
keyLockKey: 'F9',
|
||||
|
||||
+6
-1
@@ -77,7 +77,7 @@ export interface SmartAgriApi {
|
||||
screens: {
|
||||
list(): Promise<ScreenInfo[]>
|
||||
states(): Promise<ScreenInfo[]>
|
||||
publish(payload: { index: number; url: string; title: string }): Promise<ScreenInfo[]>
|
||||
publish(payload: { index: number; url: string; title: string; navDisabled?: boolean }): Promise<ScreenInfo[]>
|
||||
close(index: number): Promise<ScreenInfo[]>
|
||||
closeAll(): Promise<ScreenInfo[]>
|
||||
mainIndex(): Promise<number>
|
||||
@@ -90,7 +90,11 @@ export interface SmartAgriApi {
|
||||
}
|
||||
carousel: {
|
||||
start(payload: { index: number; pages: { url: string; title: string }[]; intervalSec?: number }): Promise<ScreenInfo[]>
|
||||
/** 交互发布:mainIndex 为交互主屏,各屏加载本屏页面,主屏轮播全部页面,↑/↓/数字键轮流展示 */
|
||||
startInteractive(payload: { mainIndex: number; pagesByScreen: Record<number, { url: string; title: string }[]> }): Promise<ScreenInfo[]>
|
||||
stop(index: number): Promise<ScreenInfo[]>
|
||||
/** 停止自动轮播:仅清除定时切换,保留当前页面窗体显示 */
|
||||
stopAuto(index: number): Promise<ScreenInfo[]>
|
||||
}
|
||||
project: {
|
||||
saveAs(data: string, password?: string): Promise<string | null>
|
||||
@@ -115,6 +119,7 @@ export interface SmartAgriApi {
|
||||
}
|
||||
page: {
|
||||
snapshot(url: string): Promise<string | null>
|
||||
fetchMeta(url: string): Promise<{ title: string; desc: string } | null>
|
||||
}
|
||||
syncProject(pages: unknown[]): void
|
||||
setDirty(d: boolean): void
|
||||
|
||||
@@ -17,7 +17,7 @@ const api = {
|
||||
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[]> =>
|
||||
publish: (payload: { index: number; url: string; title: string; navDisabled?: boolean }): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('screen:publish', payload),
|
||||
close: (index: number): Promise<unknown[]> => ipcRenderer.invoke('screen:close', index),
|
||||
closeAll: (): Promise<unknown[]> => ipcRenderer.invoke('screen:closeAll'),
|
||||
@@ -33,7 +33,12 @@ const api = {
|
||||
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)
|
||||
startInteractive: (payload: {
|
||||
mainIndex: number
|
||||
pagesByScreen: Record<number, { url: string; title: string }[]>
|
||||
}): Promise<unknown[]> => ipcRenderer.invoke('carousel:startInteractive', payload),
|
||||
stop: (index: number): Promise<unknown[]> => ipcRenderer.invoke('carousel:stop', index),
|
||||
stopAuto: (index: number): Promise<unknown[]> => ipcRenderer.invoke('carousel:stopAuto', index)
|
||||
},
|
||||
project: {
|
||||
saveAs: (data: string, password?: string): Promise<string | null> =>
|
||||
@@ -61,7 +66,9 @@ const api = {
|
||||
onScreenKey: (cb: (n: number) => void): (() => void) => on('key:switch', cb)
|
||||
},
|
||||
page: {
|
||||
snapshot: (url: string): Promise<string | null> => ipcRenderer.invoke('page:snapshot', url)
|
||||
snapshot: (url: string): Promise<string | null> => ipcRenderer.invoke('page:snapshot', url),
|
||||
fetchMeta: (url: string): Promise<{ title: string; desc: string } | null> =>
|
||||
ipcRenderer.invoke('page:fetchMeta', url)
|
||||
},
|
||||
syncProject: (pages: unknown[]): void => ipcRenderer.send('project:sync', pages),
|
||||
setDirty: (d: boolean): void => ipcRenderer.send('window:dirty', d),
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import { publishCurrentToScreen, startCarouselOnScreen, startInteractiveOnScreen, publishAll, detectScreens } from '../publishActions'
|
||||
|
||||
/** 通用屏幕列表行 */
|
||||
function ScreenRow({ s, selected, onSelect }: { s: ScreenInfo; selected: boolean; onSelect: () => void }): React.JSX.Element {
|
||||
@@ -39,7 +39,8 @@ export function ScreenSelectModal(props: {
|
||||
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 [carousel, setCarousel] = useState(false)
|
||||
const [intervalSec, setIntervalSec] = useState(settings?.publish.intervalSec ?? 10)
|
||||
return (
|
||||
<Modal
|
||||
@@ -133,7 +134,7 @@ export function MultiPublishModal(props: { onClose: () => void }): React.JSX.Ele
|
||||
}
|
||||
>
|
||||
<p className="m-hint">
|
||||
各页面将按「所属屏幕 ID」分发到对应屏幕全屏播放(Esc 退出 · F5 刷新 · 数字键切页)。
|
||||
各页面将按「所属屏幕 ID」分发到对应屏幕全屏播放(Esc 关闭所有 · F5 刷新)。多屏发布模式下数字/方向/锁定键不生效。
|
||||
</p>
|
||||
{screens.length === 0 ? (
|
||||
<div className="m-empty">未检测到屏幕,请先执行「检测屏幕」</div>
|
||||
@@ -220,3 +221,83 @@ export function DetectModal(props: { onClose: () => void }): React.JSX.Element {
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 交互发布弹窗:选择「交互主屏」(大屏),其余小屏各自加载本屏页面,主屏可轮流展示各屏内容 */
|
||||
export function InteractiveModal(props: { onClose: () => void }): React.JSX.Element {
|
||||
const screens = useStore((s) => s.screens)
|
||||
const pages = useStore((s) => s.pages)
|
||||
const [sel, setSel] = useState<number>(screens.find((s) => s.primary)?.index ?? screens[0]?.index ?? 0)
|
||||
const hasPages = pages.some((p) => p.display)
|
||||
return (
|
||||
<Modal
|
||||
title="交互发布"
|
||||
onClose={props.onClose}
|
||||
width={640}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={props.onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={!sel || !hasPages}
|
||||
onClick={() => {
|
||||
void startInteractiveOnScreen(sel)
|
||||
props.onClose()
|
||||
}}
|
||||
>
|
||||
开始发布
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="m-hint">
|
||||
选择一块屏幕作为「交互主屏」(大屏)。各小屏将按页面所属的屏幕编号自行加载显示;
|
||||
在主屏上按 <b>↑/↓</b> 或<b>数字键</b>,可轮流把各屏页面切换到大屏展示(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 clickable' + (sel === s.index ? ' sel' : '')}
|
||||
onClick={() => setSel(s.index)}
|
||||
title={sel === s.index ? '当前为交互主屏' : '点击设为主屏'}
|
||||
>
|
||||
<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>
|
||||
{sel === s.index ? (
|
||||
<div className="m-scard-play">⬤ 交互主屏</div>
|
||||
) : (
|
||||
s.playing && <div className="m-scard-play">正在播放:{s.playing.title}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!hasPages && <div className="m-empty">暂无「参与多屏发布」的页面,请先在页面编辑中勾选并分配屏幕 ID</div>}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ 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 { closeAllScreenOutput, publishCurrentToScreen, startCarouselOnScreen, stopCarouselAutoOnScreen } from '../publishActions'
|
||||
import { toast } from '../toast'
|
||||
import { ScreenSelectModal, MultiPublishModal, DetectModal } from './PublishModals'
|
||||
import { ScreenSelectModal, MultiPublishModal, InteractiveModal, DetectModal } from './PublishModals'
|
||||
|
||||
interface RibbonItem {
|
||||
id: string
|
||||
icon: string
|
||||
icon: string | React.JSX.Element
|
||||
label: string
|
||||
title?: string
|
||||
action?: () => void
|
||||
@@ -26,6 +26,58 @@ const TAB_NAME: Record<ViewMode, string> = {
|
||||
config: '配置'
|
||||
}
|
||||
|
||||
/** 发布组按钮统一样式的线性图标(显示器族,currentColor 着色) */
|
||||
const svgIconProps = {
|
||||
width: 15,
|
||||
height: 15,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
'aria-hidden': true
|
||||
}
|
||||
|
||||
// 预览本页:显示器 + 播放三角
|
||||
const PreviewIcon = () => (
|
||||
<svg {...svgIconProps}>
|
||||
<rect x="3.5" y="4" width="17" height="11.5" rx="2" />
|
||||
<path d="M12 15.5V19M8.5 19h7" />
|
||||
<path d="M10 8.4l4.7 2.6-4.7 2.6z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 单屏发布:显示器 + 轮播旋转箭头
|
||||
const SingleIcon = () => (
|
||||
<svg {...svgIconProps}>
|
||||
<rect x="4" y="5" width="16" height="11" rx="2" />
|
||||
<path d="M12 16v3M8.5 19h7" />
|
||||
<path d="M9.5 12a4.5 4.5 0 0 1 7.3-3.6" />
|
||||
<path d="M16.5 5.5v3.4h-3.4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 多屏发布:两台显示器
|
||||
const MultiIcon = () => (
|
||||
<svg {...svgIconProps}>
|
||||
<rect x="3" y="4.5" width="9.5" height="8" rx="1.5" />
|
||||
<path d="M7.8 12.5V15M5.2 15h5" />
|
||||
<rect x="13.5" y="8" width="8" height="10" rx="1.5" />
|
||||
<path d="M17.5 18v2M15 20h5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 交互发布:显示器 + 双向切换箭头
|
||||
const InteractiveIcon = () => (
|
||||
<svg {...svgIconProps}>
|
||||
<rect x="4" y="5" width="16" height="11" rx="2" />
|
||||
<path d="M12 16v3M8.5 19h7" />
|
||||
<path d="M8 10.5h8M13.5 8l2.5 2.5-2.5 2.5" />
|
||||
<path d="M16 13.5H8M10.5 12 8 14.5l2.5 2.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Ribbon(props: {
|
||||
configMenu: ConfigMenu
|
||||
onConfigMenu: (m: ConfigMenu) => void
|
||||
@@ -35,14 +87,18 @@ export default function Ribbon(props: {
|
||||
const pages = useStore((s) => s.pages)
|
||||
const curIdx = useStore((s) => s.curIdx)
|
||||
const screens = useStore((s) => s.screens)
|
||||
const opening = useStore((s) => s.opening)
|
||||
|
||||
const st = useStore.getState
|
||||
|
||||
const [pubModal, setPubModal] = useState<'multi' | 'preview' | 'single' | 'detect' | null>(null)
|
||||
const [pubModal, setPubModal] = useState<'multi' | 'preview' | 'single' | 'interactive' | '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 carouseling = screens.filter((s) => s.carouselAuto && /轮播/.test(s.playing?.title ?? ''))
|
||||
const carouselRunning = carouseling.length > 0
|
||||
// 优先停止当前页所属屏幕的轮播;若该屏未在轮播,则停止第一个正在轮播的屏幕
|
||||
const stopTarget = carouseling.find((s) => s.index === curScreen) ?? carouseling[0]
|
||||
|
||||
const editItems: RibbonItem[] = [
|
||||
{ id: 'add', icon: '📄', label: '新建页面', action: () => st().addPage() },
|
||||
@@ -54,7 +110,21 @@ export default function Ribbon(props: {
|
||||
{ id: 'snap', icon: '📷', label: '缩略图', action: () => void st().capturePreview() }
|
||||
]
|
||||
const fileItems: RibbonItem[] = [
|
||||
{ id: 'open', icon: '📂', label: '打开', action: () => void st().open() },
|
||||
{
|
||||
id: 'new',
|
||||
icon: '🆕',
|
||||
label: '新建',
|
||||
title: '创建新的空白工程(默认包含 1 个空白页面,保存时选择位置)',
|
||||
action: () => st().newProject()
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
icon: opening ? '⏳' : '📂',
|
||||
label: opening ? '打开中…' : '打开',
|
||||
disabled: opening,
|
||||
title: opening ? '正在选择并加载工程文件,请稍候…' : '打开工程文件',
|
||||
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() }
|
||||
@@ -62,30 +132,41 @@ export default function Ribbon(props: {
|
||||
const pubItems: RibbonItem[] = [
|
||||
{
|
||||
id: 'preview',
|
||||
icon: '👁️',
|
||||
icon: <PreviewIcon />,
|
||||
label: '预览本页',
|
||||
disabled: !curPage,
|
||||
title: '弹窗选择目标屏幕,全屏播放当前页面(Esc 退出 · F5 刷新 · 数字键切页)',
|
||||
title: '弹窗选择目标屏幕,全屏播放当前页面(Esc 关闭所有 · F5 刷新 · 数字键切页)',
|
||||
action: () => setPubModal('preview')
|
||||
},
|
||||
{
|
||||
id: 'single',
|
||||
icon: '🎠',
|
||||
icon: <SingleIcon />,
|
||||
label: '单屏发布',
|
||||
disabled: !curPage,
|
||||
title: '弹窗选择目标屏幕,在该屏幕轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出)',
|
||||
action: () => setPubModal('single')
|
||||
},
|
||||
{ id: 'multi', icon: '🖥️', label: '多屏发布', type: 'primary', action: () => setPubModal('multi') },
|
||||
{ id: 'multi', icon: <MultiIcon />, label: '多屏发布', type: 'primary', action: () => setPubModal('multi') },
|
||||
{
|
||||
id: 'interactive',
|
||||
icon: <InteractiveIcon />,
|
||||
label: '交互发布',
|
||||
type: 'primary',
|
||||
title: '指定一块屏幕为交互主屏(大屏),各小屏自行加载本屏页面;在主屏按 ↑/↓ 或数字键轮流展示各屏内容',
|
||||
action: () => setPubModal('interactive')
|
||||
},
|
||||
{
|
||||
id: 'stopcar',
|
||||
icon: '⏹️',
|
||||
label: '停止轮播',
|
||||
type: 'danger',
|
||||
disabled: !carouselRunning,
|
||||
title: carouselRunning ? '停止当前页所属屏幕(屏 ' + curScreen + ')的轮播' : '目标屏幕当前未在轮播',
|
||||
title:
|
||||
carouselRunning && stopTarget
|
||||
? `停止屏 ${stopTarget.index} 的自动轮播(保留当前页面窗体,可手动切换)`
|
||||
: '仅「自动轮播」模式可用',
|
||||
action: () => {
|
||||
if (curPage) void stopCarouselOnScreen(curPage.screen)
|
||||
if (stopTarget) void stopCarouselAutoOnScreen(stopTarget.index)
|
||||
}
|
||||
},
|
||||
{ id: 'closeall', icon: '🏁', label: '关闭所有', type: 'danger', action: () => void closeAllScreenOutput() },
|
||||
@@ -163,7 +244,7 @@ export default function Ribbon(props: {
|
||||
{pubModal === 'preview' && (
|
||||
<ScreenSelectModal
|
||||
title="预览本页"
|
||||
hint="选择要在哪块屏幕上全屏播放当前页面(Esc 退出 · F5 刷新 · 数字键切页)。"
|
||||
hint="选择要在哪块屏幕上全屏播放当前页面(Esc 关闭所有 · F5 刷新 · 数字键切页)。"
|
||||
confirmLabel="开始预览"
|
||||
onConfirm={(i) => {
|
||||
void publishCurrentToScreen(i)
|
||||
@@ -175,7 +256,7 @@ export default function Ribbon(props: {
|
||||
{pubModal === 'single' && (
|
||||
<ScreenSelectModal
|
||||
title="单屏发布"
|
||||
hint="选择要在哪块屏幕上轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出 · F5 刷新)。"
|
||||
hint="选择要在哪块屏幕上轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 关闭所有 · F5 刷新)。"
|
||||
confirmLabel="开始发布"
|
||||
showCarousel
|
||||
onConfirm={(i, opts) => {
|
||||
@@ -185,6 +266,7 @@ export default function Ribbon(props: {
|
||||
onClose={() => setPubModal(null)}
|
||||
/>
|
||||
)}
|
||||
{pubModal === 'interactive' && <InteractiveModal onClose={() => setPubModal(null)} />}
|
||||
{pubModal === 'multi' && <MultiPublishModal onClose={() => setPubModal(null)} />}
|
||||
{pubModal === 'detect' && <DetectModal onClose={() => setPubModal(null)} />}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function AboutPanel(): React.JSX.Element {
|
||||
<div className="about-logo">🌾</div>
|
||||
<div className="about-title">{settings?.projectName ?? '智慧农业大数据可视化控制中心'}</div>
|
||||
<div className="about-ver">
|
||||
Version {settings?.projectVersion ?? '0.1.0'}
|
||||
Version {settings?.projectVersion ?? '0.2.0'}
|
||||
{info?.appVersion ? ` · 安装包 ${info.appVersion}` : ''}
|
||||
</div>
|
||||
<p className="about-desc">
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function ProjectInfo(): React.JSX.Element {
|
||||
const pages = useStore((s) => s.pages)
|
||||
const screens = useStore((s) => s.screens)
|
||||
const filePath = useStore((s) => s.filePath)
|
||||
const opening = useStore((s) => s.opening)
|
||||
const st = useStore.getState
|
||||
|
||||
const [name, setName] = useState(settings?.projectName ?? '')
|
||||
@@ -26,7 +27,7 @@ export default function ProjectInfo(): React.JSX.Element {
|
||||
}, [settings])
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
await st().updateSettings({ projectName: name.trim() || '智慧农业大数据可视化控制中心', projectVersion: ver.trim() || '0.1.0', jsonPassword: pwd })
|
||||
await st().updateSettings({ projectName: name.trim() || '智慧农业大数据可视化控制中心', projectVersion: ver.trim() || '0.2.0', jsonPassword: pwd })
|
||||
toast('项目信息与 JSON 密码已保存', 'ok')
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ export default function ProjectInfo(): React.JSX.Element {
|
||||
</div>
|
||||
<div className="cfg-field">
|
||||
<label>版本号</label>
|
||||
<input className="cfg-input" value={ver} onChange={(e) => setVer(e.target.value)} placeholder="例如:0.1.0" />
|
||||
<input className="cfg-input" value={ver} onChange={(e) => setVer(e.target.value)} placeholder="例如:0.2.0" />
|
||||
</div>
|
||||
<p className="cfg-tip">项目名称与版本号会写入工程 JSON(appName / version),并作为「另存为」时的默认文件名。</p>
|
||||
</div>
|
||||
@@ -96,8 +97,8 @@ export default function ProjectInfo(): React.JSX.Element {
|
||||
<button className="btn primary" onClick={() => void save()}>
|
||||
💾 保存配置
|
||||
</button>
|
||||
<button className="btn" onClick={() => void st().open()}>
|
||||
📂 打开工程
|
||||
<button className="btn" disabled={opening} onClick={() => void st().open()}>
|
||||
{opening ? '⏳ 打开中…' : '📂 打开工程'}
|
||||
</button>
|
||||
<button className="btn" onClick={() => void st().save()}>
|
||||
💾 保存工程
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { useStore } from './store'
|
||||
import './styles/global.css'
|
||||
|
||||
// 调试便利:暴露 store 到全局(生产保留无碍)
|
||||
declare global {
|
||||
interface Window {
|
||||
__store: typeof useStore
|
||||
}
|
||||
}
|
||||
window.__store = useStore
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function publishCurrentToScreen(index: number): Promise<void> {
|
||||
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')
|
||||
toast(`「${p.title}」已发布到 屏幕 ${index}(Esc 关闭所有 · F5 刷新 · 数字键切页)`, 'ok')
|
||||
}
|
||||
|
||||
/** 多屏发布:各页面按所在屏幕 ID 全屏播放;alsoMain 时编辑器屏幕也全屏显示对应页面 */
|
||||
@@ -34,7 +34,8 @@ export async function publishAll(alsoMain = false): Promise<void> {
|
||||
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 })
|
||||
// 多屏发布模式:屏幕窗口仅保留 Esc/F5,禁用数字/方向/锁定键
|
||||
await window.api.screens.publish({ index: s.index, url: pageSrc(p), title: p.title, navDisabled: true })
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export async function closeAllScreenOutput(): Promise<void> {
|
||||
|
||||
/**
|
||||
* 单屏发布:在指定屏幕轮播所有「参与多屏发布」的页面。
|
||||
* 键盘:↑/↓ 或数字键切换,Esc 退出,F5 刷新。
|
||||
* 键盘:↑/↓ 或数字键切换,Esc 关闭所有播放,F5 刷新。
|
||||
*/
|
||||
export async function startCarouselOnScreen(index: number, intervalSec = 0): Promise<void> {
|
||||
const { pages } = useStore.getState()
|
||||
@@ -81,14 +82,39 @@ export async function startCarouselOnScreen(index: number, intervalSec = 0): Pro
|
||||
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` : ''
|
||||
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)
|
||||
/** 停止单屏自动轮播:仅停止定时切换,保留当前页面窗体显示 */
|
||||
export async function stopCarouselAutoOnScreen(index: number): Promise<void> {
|
||||
const list = await window.api.carousel.stopAuto(index)
|
||||
useStore.getState().setScreens(list)
|
||||
toast(`屏幕 ${index} 轮播已停止`, 'info')
|
||||
toast(`屏幕 ${index} 已停止自动轮播(当前页面保留显示,可用 ↑/↓ 手动切换)`, 'info')
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互发布:指定 mainIndex 为交互主屏(大屏)。
|
||||
* 各屏(含主屏)按页面所属屏幕编号加载本屏页面;主屏轮播含全部屏的页面,
|
||||
* 在主屏上按 ↑/↓ 或数字键,可轮流把各小屏页面切换到大屏展示。
|
||||
*/
|
||||
export async function startInteractiveOnScreen(mainIndex: number): Promise<void> {
|
||||
const { pages } = useStore.getState()
|
||||
const byScreen: Record<number, { url: string; title: string }[]> = {}
|
||||
for (const p of pages) {
|
||||
if (!p.display) continue
|
||||
byScreen[p.screen] ??= []
|
||||
byScreen[p.screen].push({ url: pageSrc(p), title: p.title })
|
||||
}
|
||||
const indexes = Object.keys(byScreen).map(Number).sort((a, b) => a - b)
|
||||
if (indexes.length === 0) {
|
||||
toast('没有可发布的页面(请在编辑中勾选「参与多屏发布」并设置屏幕 ID)', 'warn')
|
||||
return
|
||||
}
|
||||
await window.api.carousel.startInteractive({ mainIndex, pagesByScreen: byScreen })
|
||||
const list = await window.api.screens.states()
|
||||
useStore.getState().setScreens(list)
|
||||
toast(`交互发布已启动(主屏 ${mainIndex} · ${indexes.length} 块屏幕 · ↑/↓ 或数字键轮流展示 · Esc 退出)`, 'ok')
|
||||
}
|
||||
|
||||
/** 检测屏幕:各屏显示编号/分辨率标签 6 秒,并返回屏幕列表 */
|
||||
|
||||
@@ -13,6 +13,8 @@ interface AppState {
|
||||
screens: ScreenInfo[]
|
||||
zoom: number
|
||||
settings: AppSettings | null
|
||||
/** 工程文件操作进行中(打开/另存等),用于防抖防重入 */
|
||||
opening: boolean
|
||||
// actions
|
||||
setView: (v: ViewMode) => void
|
||||
selectPage: (i: number) => void
|
||||
@@ -28,6 +30,7 @@ interface AppState {
|
||||
setZoom: (z: number) => void
|
||||
loadSettings: () => Promise<AppSettings>
|
||||
updateSettings: (patch: Partial<AppSettings>) => Promise<AppSettings>
|
||||
newProject: () => void
|
||||
loadProject: (payload: LoadPayload) => 'edit' | 'publish'
|
||||
save: () => Promise<void>
|
||||
saveAs: () => Promise<void>
|
||||
@@ -59,6 +62,7 @@ export const useStore = create<AppState>((set, get) => {
|
||||
screens: [],
|
||||
zoom: 100,
|
||||
settings: null,
|
||||
opening: false,
|
||||
|
||||
setView: (v) => set({ view: v }),
|
||||
selectPage: (i) => {
|
||||
@@ -157,6 +161,10 @@ export const useStore = create<AppState>((set, get) => {
|
||||
try {
|
||||
const data = await window.api.page.snapshot(src)
|
||||
if (data) {
|
||||
if (data === p.preview) {
|
||||
toast('页面内容无变化,缩略图已是最新', 'info')
|
||||
return
|
||||
}
|
||||
const list = pages.map((x, i) => (i === at ? { ...x, preview: data } : x))
|
||||
touch({ pages: list })
|
||||
toast('页面截图已更新', 'ok')
|
||||
@@ -191,6 +199,20 @@ export const useStore = create<AppState>((set, get) => {
|
||||
set({ settings: s })
|
||||
return s
|
||||
},
|
||||
newProject: () => {
|
||||
const blank: Page = {
|
||||
id: 'P-001',
|
||||
title: '空白页面',
|
||||
desc: '',
|
||||
screen: 1,
|
||||
ctrl: 0,
|
||||
display: true,
|
||||
url: 'about:blank',
|
||||
params: ''
|
||||
}
|
||||
set({ pages: [blank], curIdx: 0, filePath: null, dirty: true })
|
||||
toast('已新建工程(默认 1 个空白页面),保存时请选择位置', 'ok')
|
||||
},
|
||||
loadProject: (payload) => {
|
||||
if (payload?.json) applyProject(get, set, payload.path, payload.json)
|
||||
return payload?.mode ?? 'edit'
|
||||
@@ -235,6 +257,8 @@ export const useStore = create<AppState>((set, get) => {
|
||||
}
|
||||
},
|
||||
open: async () => {
|
||||
if (get().opening) return
|
||||
set({ opening: true })
|
||||
try {
|
||||
const settings = get().settings ?? (await get().loadSettings())
|
||||
let r = await window.api.project.open(settings.jsonPassword || undefined)
|
||||
@@ -259,6 +283,8 @@ export const useStore = create<AppState>((set, get) => {
|
||||
toast(r.error || '打开失败', 'warn')
|
||||
} catch (err) {
|
||||
toast('打开失败:' + String(err), 'warn')
|
||||
} finally {
|
||||
set({ opening: false })
|
||||
}
|
||||
},
|
||||
markSaved: () => set({ dirty: false })
|
||||
|
||||
@@ -1061,6 +1061,18 @@ select {
|
||||
padding: 9px 11px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.m-scard.clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.m-scard.clickable:hover {
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
.m-scard.sel {
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-accent) 30%, transparent);
|
||||
background: #f2f7ff;
|
||||
}
|
||||
.m-scard-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ScreenInfo {
|
||||
pos: string
|
||||
primary: boolean
|
||||
playing: PlayingInfo | null
|
||||
/** 单屏轮播是否处于自动切换中(「停止轮播」按钮据此启用) */
|
||||
carouselAuto?: boolean
|
||||
}
|
||||
|
||||
export interface PageFile {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function pageSrc(p: Page): string {
|
||||
export function pagesToJson(pages: Page[], meta?: { appName?: string; version?: string }): ProjectJson {
|
||||
return {
|
||||
appName: meta?.appName || '智慧农业大数据可视化控制中心',
|
||||
version: meta?.version || '0.1.0',
|
||||
version: meta?.version || '0.2.0',
|
||||
pages: pages.map((p) => ({
|
||||
PageID: p.id,
|
||||
PageTitle: p.title,
|
||||
|
||||
@@ -40,7 +40,13 @@ export default function Canvas(): React.JSX.Element {
|
||||
</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} />}
|
||||
{isBlank ? (
|
||||
<div className="canvas-empty">空白页(about:blank)</div>
|
||||
) : (
|
||||
// key=src 强制重建 webview:切换页面时保证加载内容与选中页一致,
|
||||
// 避免 webview 复用导致残留旧页面 / hash 被远端改写后显示错位
|
||||
<WebFrame key={src} ref={frameRef} src={src} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import Modal from '../components/Modal'
|
||||
import { IconCopy, IconDown, IconInsert, IconTrash, IconUp, IconCamera } from '../components/icons'
|
||||
|
||||
export default function PageList(): React.JSX.Element {
|
||||
@@ -12,6 +14,8 @@ export default function PageList(): React.JSX.Element {
|
||||
const dupPage = useStore((s) => s.dupPage)
|
||||
const delPage = useStore((s) => s.delPage)
|
||||
|
||||
/** 待删除的页面下标,非 null 时弹出确认框 */
|
||||
const [delIdx, setDelIdx] = useState<number | null>(null)
|
||||
const stop = (e: React.MouseEvent): void => e.stopPropagation()
|
||||
|
||||
return (
|
||||
@@ -29,7 +33,7 @@ export default function PageList(): React.JSX.Element {
|
||||
>
|
||||
<div className="pc-thumb">
|
||||
{p.preview ? (
|
||||
<img src={p.preview} alt={p.title} />
|
||||
<img key={p.preview} src={p.preview} alt={p.title} />
|
||||
) : (
|
||||
<span className="pc-thumb-empty">🖼️ 暂无预览</span>
|
||||
)}
|
||||
@@ -49,7 +53,7 @@ export default function PageList(): React.JSX.Element {
|
||||
<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="删除页面" disabled={pages.length <= 1} onClick={(e) => { stop(e); selectPage(i); setDelIdx(i) }}><IconTrash /></button>
|
||||
<button title="截图更新预览图" onClick={(e) => { stop(e); void capturePreview(i) }}><IconCamera /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,6 +63,36 @@ export default function PageList(): React.JSX.Element {
|
||||
+ 新建页面
|
||||
</button>
|
||||
</div>
|
||||
{delIdx !== null && (
|
||||
<Modal
|
||||
title="删除页面"
|
||||
onClose={() => setDelIdx(null)}
|
||||
width={420}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setDelIdx(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn danger"
|
||||
onClick={() => {
|
||||
selectPage(delIdx)
|
||||
delPage()
|
||||
setDelIdx(null)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: 0, lineHeight: 1.8 }}>
|
||||
确定删除页面「{pages[delIdx]?.title}」({pages[delIdx]?.id})吗?
|
||||
<br />
|
||||
删除后不可恢复。
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { toast } from '../toast'
|
||||
|
||||
@@ -5,7 +6,12 @@ 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)
|
||||
const pages = useStore((s) => s.pages)
|
||||
// 屏幕下拉覆盖:物理屏幕数、最少 4 个、页面数量、页面中已使用的最大屏幕编号
|
||||
const screenCount = Math.max(screens.length, 4, pages.length, ...pages.map((p) => p.screen))
|
||||
|
||||
/** 最近一次已提取的 URL,避免重复提取 */
|
||||
const lastMetaUrl = useRef('')
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
@@ -29,6 +35,29 @@ export default function PropertyPanel(): React.JSX.Element {
|
||||
updatePage({ ctrl: n })
|
||||
}
|
||||
|
||||
/** 粘贴 http(s) URL 后提取网页标题与描述并自动填入属性 */
|
||||
const fillMetaFromUrl = async (url: string): Promise<void> => {
|
||||
const u = url.trim()
|
||||
if (!/^https?:\/\//i.test(u)) return
|
||||
if (lastMetaUrl.current === u) return
|
||||
lastMetaUrl.current = u
|
||||
toast('正在提取网页标题与描述…', 'info')
|
||||
try {
|
||||
const meta = await window.api.page.fetchMeta(u)
|
||||
if (!meta || (!meta.title && !meta.desc)) {
|
||||
toast('未提取到网页标题/描述,请确认 URL 可访问', 'warn')
|
||||
return
|
||||
}
|
||||
const patch: { title?: string; desc?: string } = {}
|
||||
if (meta.title) patch.title = meta.title
|
||||
if (meta.desc) patch.desc = meta.desc
|
||||
updatePage(patch)
|
||||
toast(patch.desc ? '已自动填入网页标题与描述' : '已自动填入网页标题', 'ok')
|
||||
} catch {
|
||||
toast('网页标题提取失败', 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel edit-props">
|
||||
<div className="panel-head">
|
||||
@@ -86,20 +115,24 @@ export default function PropertyPanel(): React.JSX.Element {
|
||||
<label>大屏页面 URL(PageUrl)</label>
|
||||
<textarea
|
||||
className="url-area"
|
||||
rows={2}
|
||||
rows={3}
|
||||
value={page.url}
|
||||
onChange={(e) => updatePage({ url: e.target.value })}
|
||||
onPaste={(e) => {
|
||||
const pasted = e.clipboardData.getData('text').trim()
|
||||
if (/^https?:\/\//i.test(pasted)) void fillMetaFromUrl(pasted)
|
||||
}}
|
||||
placeholder={'sample-screen.html\nhttps://…\nabout:blank'}
|
||||
/>
|
||||
<span className="hint">支持多行文本,相对路径基于应用运行目录解析</span>
|
||||
<span className="hint">支持多行文本,相对路径基于应用运行目录解析;粘贴 http(s) 网址后自动提取网页标题与描述</span>
|
||||
</div>
|
||||
<div className="prop-item">
|
||||
<label>页面参数(PageParams,每行 key=value,最多 255 字符)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user