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:
2026-08-23 16:31:20 +08:00
parent 8d96f51b05
commit aa8f070414
22 changed files with 661 additions and 138 deletions
+29
View File
@@ -208,3 +208,32 @@
- **修复**Node 脚本(`fix-msgs.js`,用完已删)用 `git cat-file commit` 读原对象 → 保留 headertree/parent/author/committer/时间戳)→ 仅替换 message → `git hash-object -t commit -w --stdin` 重建 → `git update-ref`。新 hash`2c90205`init/`4b3ea03`build/`b11bea1`docs)。已 `git push --force-with-lease` 强推(用户授权),`69d592b...b11bea1 (forced update)`,远程一致、工作区干净。 - **修复**Node 脚本(`fix-msgs.js`,用完已删)用 `git cat-file commit` 读原对象 → 保留 headertree/parent/author/committer/时间戳)→ 仅替换 message → `git hash-object -t commit -w --stdin` 重建 → `git update-ref`。新 hash`2c90205`init/`4b3ea03`build/`b11bea1`docs)。已 `git push --force-with-lease` 强推(用户授权),`69d592b...b11bea1 (forced update)`,远程一致、工作区干净。
- **以后约定**:提交信息用 `git commit -F <utf8消息文件>`write_to_file 生成的 UTF-8 文件),**禁用 `git commit -m "中文"`**。 - **以后约定**:提交信息用 `git commit -F <utf8消息文件>`write_to_file 生成的 UTF-8 文件),**禁用 `git commit -m "中文"`**。
- **执行环境备注**execute_command 实际走 cmd`2>$null` 会被当作重定向生成 `$null` 文件(需手动删);`;` 不分割 cmd 命令;`[Console]::OutputEncoding=UTF8` 对 git 输出无效,验证用 `git log > file` 后 read_file 工具读。 - **执行环境备注**execute_command 实际走 cmd`2>$null` 会被当作重定向生成 `$null` 文件(需手动删);`;` 不分割 cmd 命令;`[Console]::OutputEncoding=UTF8` 对 git 输出无效,验证用 `git log > file` 后 read_file 工具读。
## 2026-08-23 发布/轮播测试问题修复(3 项,typecheck 通过)
用户测试反馈 3 个问题,全部修复:
1. **单屏发布勾选轮播无效(根因实锤)**`ipc.ts` `carousel:start` handler 漏传 `payload.intervalSec`,自动轮播定时器从不启动。已修:`startCarousel(payload.index, payload.pages, payload.intervalSec ?? 0)`
2. **上下键/数字键无效(疑似焦点丢失)**`screens.ts` 增强焦点——轮播首窗 `ready-to-show``win.show()``startCarousel` 结尾 400ms 延迟再次 show+focus 首窗;`switchCarousel` 切页后 120ms 延迟再 focus。注:编辑器主窗口聚焦时大屏窗口收不到按键属 Electron 正常行为,需点击大屏窗口。
3. **Esc 快捷键 =「关闭所有」**`ensureWindow`(单屏预览/多屏发布)、`startCarousel`(轮播)、`publishMain`(主屏 WebContentsView)的 `onEscape` 全部改为 `closeAllScreens()`(原为 closeScreen/stopCarousel/restoreMain)。所有提示文案同步改「Esc 关闭所有」。
4. **多屏发布仅保留 Esc/F5**`makeKeyHandler` 新增 `navDisabled`(跳过数字/方向/锁定键);`navDisabledWins` Set 标记窗口;`publishScreen`/`ensureWindow`/IPC/preload 透传 `navDisabled``publishAll``navDisabled:true``ensureWindow` 复用窗口时也会刷新标记)。
5. **「停止轮播」= 结束窗体**:确认 `stopCarousel` 本就 destroy 全部轮播窗口;`Ribbon.tsx` 改进 `carouselRunning`(任一屏轮播即可用,不再要求当前页 screen 匹配)+ 停止目标优先当前屏、否则第一个轮播屏;toast 改「轮播已结束(窗体已关闭)」。
改动文件:`src/main/ipc.ts``src/main/screens.ts``src/preload/index.ts`+`index.d.ts``src/renderer/src/publishActions.ts``components/Ribbon.tsx``components/PublishModals.tsx`。typecheck 通过;**主进程改动需重启 dev 才生效**。
- 已重启 dev 并验证:主窗口标题「智慧农业大数据可视化控制中心 v0.1.0」,dev-err.log 无错误,vite 5173 就绪,5 个 electron 进程。
- 新增页面删除确认弹窗:`views/PageList.tsx` 删除按钮改为 `setDelIdx(i)` 弹出 Modal 确认(标题=删除页面,内容含页面标题+id,footer=取消/删除),确认回调先 `selectPage(delIdx)``delPage()` 防弹窗期间切换选中页误删;复用 `Modal.tsx` + `.btn.danger` 样式,纯 renderer 改动走 HMR。
- 排查"重新截图缩略图不刷新"**用 CDP 实测(dev 以 `npm run dev -- -- --remote-debugging-port=9223` 启动,Node 24 全局 WebSocket 直连)证明截图后 img.src 每次都变化(1390514→1403322→1397278)、toast 链路正常,UI 刷新其实一直正常**,用户"没看到刷新"是因为静态页面像素几乎不变。改进:① `PageList.tsx``<img>``key={p.preview}` 强制重建(杜绝浏览器复用旧图);② `store.ts` capturePreview 区分反馈——数据变化 toast「✅页面截图已更新」/数据相同 toast「💡页面内容无变化,缩略图已是最新」;toast 类名是 `.toast`(不是 .toast-item)。CDP 临时脚本已删。
- 新增"粘贴 URL 自动提取网页标题/描述":主进程 `ipc.ts` 新增 `page:fetchMeta` handler(隐藏 BrowserWindow 加载 URL`Promise.race` 12s 超时,轮询最长 2s 等 JS 动态 title,提取 `document.title` + `meta[name=description]`/`og:description`,仅接受 http/https);preload `page.fetchMeta` 暴露 + `index.d.ts` 类型;`PropertyPanel.tsx` URL textarea `onPaste` 时用 `e.clipboardData.getData('text')` 判断 http(s) 即调用 `fillMetaFromUrl``useRef` 去重),提取成功覆盖填入 title/desc,toast 区分「已自动填入网页标题与描述」/「已自动填入网页标题」。CDP 实测 `fetchMeta('http://localhost:5173/sample-screen.html')``{"title":"农业数据总览","desc":""}`。注意 `LoadURLOptions``timeout` 字段,超时须用 `Promise.race`。已重启 dev 生效。
- 「打开」按钮防抖:`store.ts` AppState 新增 `opening: boolean`create 初值 false),`open()` 开头 `if (get().opening) return` + `set({opening:true})``finally set({opening:false})`(防重入锁,覆盖取消/失败/密码错误全流程);`Ribbon.tsx` 打开按钮订阅 `opening` → disabled + 图标⏳/文案「打开中…」;`ProjectInfo.tsx` 配置页「打开工程」按钮同样 disabled+「⏳ 打开中…」。`.ribbon-btn:disabled`/`.btn:disabled` 样式已存在。纯 renderer 改动 HMR 生效。
- 单屏发布三项修复:①键盘控制:`screens.ts``makeKeyHandler(b, quick=false)` 新增 quick 参数,quick 时 threshold 强制 0(原默认 keyThreshold=350ms 导致快速按键被吞);`ensureWindow`/`startCarousel`/`publishMain` 三处调用均传 `true`。②停止轮播:`CarouselState``auto` 字段(=intervalSec>0 && pages.length>1);`ScreenInfo``carouselAuto?`main screens.ts + renderer types.ts);`listScreens` 返回 `carouselAuto: car.auto`、轮播标题含「· 自动」;新增主进程 `stopCarouselAuto(index)`(清 timer、auto=false、**保留窗体**+ IPC `carousel:stopAuto` + preload `carousel.stopAuto``publishActions.stopCarouselOnScreen` 改名 `stopCarouselAutoOnScreen`(toast 提示可 ↑/↓ 手动切换);`Ribbon` 的「停止轮播」`carouseling` 过滤条件改为 `s.carouselAuto && /轮播/`(仅自动模式可用)。③`PublishModals.tsx` ScreenSelectModal 默认不勾选自动轮播:`useState(false)`(原来 `props.showCarousel ?? false` 导致 single 模式默认勾选)。注:多屏发布窗口 navDisabled 仍禁用数字/方向键,quick 不影响。改主进程+preload,已杀旧实例重启 dev 至 5173。typechecknode+web)通过。
- 新增「交互发布」:演示场景(1 大屏 + 多小屏轮流投屏讲解)。`screens.ts` 新增 `startInteractive({mainIndex, pagesByScreen})`:主屏轮播内容=**所有屏页面按屏号排序拼接**(label='交互发布'),其余屏轮播本屏页面(label='单屏轮播'),复用 startCarousel`CarouselState``label` 字段、`startCarousel` 加第 4 参 `label='单屏轮播'``listScreens` 标题用 `car.label`。IPC `carousel:startInteractive` + preload `carousel.startInteractive`。渲染层:`publishActions.startInteractiveOnScreen(mainIndex)`(按 page.screen 分组收集 display 页面);`PublishModals.InteractiveModal`(卡片点击选主屏,默认主显示器,显示各屏页面分布,`m-scard.clickable/.sel` 新样式);`Ribbon` 发布组 multi 后加「交互发布🔁」按钮(pubModal 类型加 'interactive')。主屏上 ↑/↓/数字键即轮流把各屏页面切到大屏。dev 5173 运行中,preload 产物含 startInteractivetypecheck 通过。坑:同文件多 replace 并行会写锁超时,须每批每文件只改一处。
- **版本升级 0.1.0 → 0.2.0 + 两个用户问题修复**:
- **问题1(属性面板所属屏幕最多屏4)**:`PropertyPanel.tsx` `screenCount = Math.max(screens.length, 4, pages.length, ...pages.map(p=>p.screen))`。CDP 实测 6 页项目下拉显示 1-6。
- **问题2(编辑 P-005 预览显示 P-006**`Canvas.tsx` 的 WebFrame 加 **`key={src}` 强制重建 webview**(切页销毁重建,杜绝 webview 复用残留/远端 hash 被改写导致内容错位)。实测:本地模拟(同 URL 不同 hash+query)切换 attr/url 均正确;远端实测 P-005(`#/zhihuiyq`)→「智慧蚕桑·园区数据可视化平台」、P-006(`#/paihangbang`)→「排行榜·蚕茧交易音视频督导中心」,内容一一对应。注:WebFrame src 更新逻辑(setAttribute)本身正确,但复用 webview 在远端加载时序/导航残留下可能显示旧页,key 重建为根治。`main.tsx``window.__store = useStore` 调试接口(保留无碍)。
- 版本号改动:`package.json`(主来源)→0.2.0、`main/settings.ts` 默认 projectVersion、`renderer/utils.ts` 兜底 version、`AboutPanel.tsx`/`ProjectInfo.tsx` fallback+placeholder。**项目 JSON 文件内 version 字段是用户数据不动**。标题栏版本 = app.getVersion() 读 package.json,重启后显示 v0.2.0。
- 调试技巧沉淀:CDP 动态 `import('/src/store.ts')` 不稳定(偶发 "Promise was collected",因 context 销毁),改走 `window.__store` 全局或纯 DOM(点击 .page-card)最稳;`npx tsc --noEmit -p tsconfig.web.json` 单独验渲染层。临时测试脚本/本地模拟页已删,dev 已重启。
- **新增「新建」+ 发布图标统一 + 属性面板高度(v0.2.0 并入,未发布)**:
- **「新建」按钮**(项目组第一个 🆕):`store.ts` AppState 加 `newProject`——重置为单个空白页 `{id:'P-001',title:'空白页面',desc:'',screen:1,ctrl:0,display:true,url:'about:blank',params:''}``filePath:null, dirty:true`(保存走另存为);syncProject 由 App.tsx pages effect 自动处理,无需手动调。CDP 实测(build 产物 + electron.exe `--remote-debugging-port=9223`):点击后 pages=[P-001:空白页面:about:blank]、预览显示空白页。
- **发布组 4 按钮图标统一**:预览本页/单屏发布/多屏发布/交互发布 由 emoji 换成**统一样式线性 SVG**(显示器族:24 viewBox、stroke currentColor 1.8、15px、在 `.rb-icon` flex 容器内自动居中着色):预览=显示器+播放三角、单屏=显示器+轮播旋转箭头、多屏=前后双显示器、交互=显示器+双向箭头。`Ribbon.tsx` 顶部定义 `svgIconProps` + 4 个图标组件,`RibbonItem.icon` 类型改为 `string | React.JSX.Element`(emoji 字符串兼容)。CDP 实测发布组 `svgs=4`
- **属性面板**URL 输入框 `rows 2→3`;页面参数 textarea `rows=4`(默认 2 加 2 行)并**删除 placeholder 水印**URL 的 placeholder 保留)。`.prop-item textarea``min-height:46px`+`resize:vertical`,无固定高度,rows 生效。
- **CDP 验证 build 产物**注意:`npm run build` 后必须用 `node_modules\electron\dist\electron.exe . --remote-debugging-port=9223``electron.cmd` 不在 PATH 会报 file not found),页面 URL 是 `file://...out/renderer/index.html`,脚本匹配 page 类型须排除 chrome:// 与 devtools。验证完已杀实例重启 dev 至 5173。
+4 -4
View File
@@ -24,7 +24,7 @@
- 页面 JSON 字段含 `PagePreview`Base64 截图),项目文件存 `.json` - 页面 JSON 字段含 `PagePreview`Base64 截图),项目文件存 `.json`
## 关键 UI/交互约定 ## 关键 UI/交互约定
- Ribbon 编辑 Tab 组序:项目 → 页面 → 发布(发布 6 按钮:预览本页👁️/单屏发布🎠/多屏发布🖥️/停止轮播⏹️/关闭所有🏁/检测屏幕📡,全部走弹窗)。 - Ribbon 编辑 Tab 组序:项目 → 页面 → 发布(发布 7 按钮:预览本页👁️/单屏发布🎠/多屏发布🖥️/交互发布🔁[主屏+小屏轮流投屏]/停止轮播⏹️/关闭所有🏁/检测屏幕📡,全部走弹窗)。
- 页面列表卡片:预览图 + 底部操作组(前插/上移/下移/复制/删除/截图,SVG 扁平图标,仅 active 显示)。 - 页面列表卡片:预览图 + 底部操作组(前插/上移/下移/复制/删除/截图,SVG 扁平图标,仅 active 显示)。
- 快捷键:大屏窗口/轮播数字键 1-9 需**长按超过 keyThreshold**(防网页内输入数字误触),Esc 退出、F5 刷新、锁定键(默认 F9)全局开关。 - 快捷键:大屏窗口/轮播数字键 1-9 需**长按超过 keyThreshold**(防网页内输入数字误触),Esc 退出、F5 刷新、锁定键(默认 F9)全局开关。
- 发布设置可配 multi/single/main 三种默认模式,single 支持 intervalSec 轮播。 - 发布设置可配 multi/single/main 三种默认模式,single 支持 intervalSec 轮播。
@@ -35,11 +35,11 @@
- **electron-vite dev 修改 src/main/preload 不会自动重启 electron**,需手动杀掉 dev 进程组(npm-cli→electron-vite→electron)再重新启动;仅 renderer 改动走 HMR 无需重启。 - **electron-vite dev 修改 src/main/preload 不会自动重启 electron**,需手动杀掉 dev 进程组(npm-cli→electron-vite→electron)再重新启动;仅 renderer 改动走 HMR 无需重启。
## 版本号与打包(2026-08-23 新增) ## 版本号与打包(2026-08-23 新增)
- 版本号单一来源 = `package.json version`(当前 0.1.0);主进程 `app.getVersion()`;标题栏显示「系统版本号 vX.Y.Z」;任务栏标题由 `did-finish-load``setTitle` 兜底(页面 `<title>` 会覆盖构造标题)。 - 版本号单一来源 = `package.json version`(当前 **0.2.0**2026-08-23 从 0.1.0 升级,因新增交互发布+多项 bug 修复);主进程 `app.getVersion()`;标题栏显示「系统版本号 vX.Y.Z」;任务栏标题由 `did-finish-load``setTitle` 兜底(页面 `<title>` 会覆盖构造标题)。版本号其他兜底引用:`main/settings.ts` 默认 projectVersion、`renderer/utils.ts` fallback、`AboutPanel`/`ProjectInfo` fallback+placeholder,升级时需同步;**项目 JSON 文件内 version 字段是用户数据不动**。
- 文档三件套(均在根目录,无扩展名):`项目需求`v3.0,✅/⏳ 标注)、`开发进度存档`**跨会话恢复入口**)、`指令清单`(历史,已全部实现)。 - 文档三件套(均在根目录,无扩展名):`项目需求`v3.0,✅/⏳ 标注)、`开发进度存档`**跨会话恢复入口**)、`指令清单`(历史,已全部实现)。
- 已实现功能全景(v0.1.0):Ribbon 三组(项目/页面/发布)、发布 6 按钮(预览本页/单屏发布[可轮播]/多屏发布[alsoMain]/停止轮播/关闭所有/检测屏幕)、配置 6 菜单(项目信息/遥控设置[阈值+锁定键]/快捷方式×3/发布设置/设备测试[键/音/麦/屏]/关于)、快捷键(1-9 长按>阈值、↑↓、Esc、F5、锁定键)、AES-256-GCM 密码加密、标题栏版本号(package.json 单一来源)。 - 已实现功能全景(v0.2.0):Ribbon 三组(项目/页面/发布)、项目组含**新建**(默认 1 个空白页面,另存为才落地)、发布 7 按钮(预览本页/单屏发布[可轮播]/多屏发布[alsoMain]/交互发布[主屏轮流投屏]/停止轮播/关闭所有/检测屏幕,**其中预览/单屏/多屏/交互 4 按钮为统一线性 SVG 显示器族图标**)、配置 6 菜单(项目信息/遥控设置[阈值+锁定键]/快捷方式×3/发布设置/设备测试[键/音/麦/屏]/关于)、快捷键(1-9 长按>阈值、↑↓、Esc、F5、锁定键)、AES-256-GCM 密码加密、标题栏版本号(package.json 单一来源)、编辑预览 WebFrame `key={src}` 重建(防内容错位)、`window.__store` 全局调试接口、属性面板 URL 输入框 3 行/页面参数 4 行无水印
- **未实现/规划**:项目导入/导出、代码视图/页面视图切换、遥控器硬件对接、帮助文档入口。 - **未实现/规划**:项目导入/导出、代码视图/页面视图切换、遥控器硬件对接、帮助文档入口。
- 绿色版打包已完成(2026-08-23electron-builder@26.15.3 已装):`npm run dist``release\智慧农业大数据可视化控制中心-0.1.0-win.zip`(绿色免安装,当前 **128.9MB**+ `release\win-unpacked\`。应用图标 `build/icon.ico` 已配置。瘦身:`compression: maximum` + `scripts/afterPack.js` 裁剪 locales55→3),zip 139.7→128.9MB。 - 绿色版打包已完成(2026-08-23electron-builder@26.15.3 已装**产物为 v0.1.0**):`npm run dist``release\智慧农业大数据可视化控制中心-0.1.0-win.zip`(绿色免安装,当前 **128.9MB**+ `release\win-unpacked\`。应用图标 `build/icon.ico` 已配置。瘦身:`compression: maximum` + `scripts/afterPack.js` 裁剪 locales55→3),zip 139.7→128.9MB。**升级到 0.2.0 后需重新 `npm run dist`**。
- **⚠️ 打包必读**:每次 `npm run dist` 前必须用 `[System.IO.Directory]::Delete(...,$true)` 删掉旧 `release/`SafeDelete 守卫会拦批量删除并导致打包卡死);打包前设 `$env:ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"`。详见根目录 `开发进度存档` §4。 - **⚠️ 打包必读**:每次 `npm run dist` 前必须用 `[System.IO.Directory]::Delete(...,$true)` 删掉旧 `release/`SafeDelete 守卫会拦批量删除并导致打包卡死);打包前设 `$env:ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"`。详见根目录 `开发进度存档` §4。
## 待办/候选开发点(截至 2026-08-23 ## 待办/候选开发点(截至 2026-08-23
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "smart-agri-center", "name": "smart-agri-center",
"version": "0.1.0", "version": "0.2.0",
"description": "智慧农业大数据可视化控制中心(Electron + React + TypeScript", "description": "智慧农业大数据可视化控制中心(Electron + React + TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "FanHongCai", "author": "FanHongCai",
+63 -3
View File
@@ -8,7 +8,9 @@ import {
publishScreen, publishScreen,
setPages, setPages,
startCarousel, startCarousel,
startInteractive,
stopCarousel, stopCarousel,
stopCarouselAuto,
mainScreenIndex, mainScreenIndex,
publishMain, publishMain,
restoreMain, restoreMain,
@@ -47,8 +49,10 @@ export function registerIpc(): void {
// ===== 屏幕 / 多屏发布 ===== // ===== 屏幕 / 多屏发布 =====
ipcMain.handle('screens:list', () => listScreens()) ipcMain.handle('screens:list', () => listScreens())
ipcMain.handle('screens:states', () => listScreens()) ipcMain.handle('screens:states', () => listScreens())
ipcMain.handle('screen:publish', (_e, payload: { index: number; url: string; title: string }) => ipcMain.handle(
publishScreen(payload.index, payload.url, payload.title) '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:close', (_e, index: number) => closeScreen(index))
ipcMain.handle('screen:closeAll', () => { ipcMain.handle('screen:closeAll', () => {
@@ -65,9 +69,16 @@ export function registerIpc(): void {
// ===== 单屏轮播 ===== // ===== 单屏轮播 =====
ipcMain.handle( ipcMain.handle(
'carousel:start', '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:stop', (_e, index: number) => stopCarousel(index))
ipcMain.handle('carousel:stopAuto', (_e, index: number) => stopCarouselAuto(index))
// 渲染层页面同步(用于屏幕键盘切页) // 渲染层页面同步(用于屏幕键盘切页)
ipcMain.on('project:sync', (_e, pages: unknown) => { 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) => { ipcMain.handle('dialog:confirm', async (_e, message: string) => {
const r = await dialog.showMessageBox({ const r = await dialog.showMessageBox({
+153 -54
View File
@@ -13,6 +13,8 @@ export interface ScreenInfo {
pos: string pos: string
primary: boolean primary: boolean
playing: PlayingInfo | null playing: PlayingInfo | null
/** 单屏轮播是否处于自动切换中(「停止轮播」按钮据此启用) */
carouselAuto?: boolean
} }
/** 渲染层同步过来的页面(用于屏幕窗口按键切换) */ /** 渲染层同步过来的页面(用于屏幕窗口按键切换) */
@@ -31,6 +33,8 @@ export interface CarouselPage {
} }
const screenWindows = new Map<number, BrowserWindow>() const screenWindows = new Map<number, BrowserWindow>()
/** 多屏发布模式的窗口集合:仅保留 Esc/F5,禁用数字/方向/锁定键 */
const navDisabledWins = new Set<BrowserWindow>()
let lastPages: SyncPage[] = [] let lastPages: SyncPage[] = []
let broadcastFn: ((list: ScreenInfo[]) => void) | null = null let broadcastFn: ((list: ScreenInfo[]) => void) | null = null
@@ -50,6 +54,10 @@ interface CarouselState {
pages: CarouselPage[] pages: CarouselPage[]
current: number current: number
timer: NodeJS.Timeout | null timer: NodeJS.Timeout | null
/** 是否自动轮播(有定时器在跑) */
auto: boolean
/** 状态显示名(单屏轮播 / 交互发布) */
label: string
} }
const carousels = new Map<number, CarouselState>() const carousels = new Map<number, CarouselState>()
@@ -57,7 +65,8 @@ const carousels = new Map<number, CarouselState>()
* 统一的屏幕键盘处理: * 统一的屏幕键盘处理:
* - Esc 立即退出 / F5 立即刷新 * - Esc 立即退出 / F5 立即刷新
* - 锁定键切换全局快捷键识别(避免网页交互输入数字误触切屏) * - 锁定键切换全局快捷键识别(避免网页交互输入数字误触切屏)
* - 数字键、方向键需要"长按超过阈值"才触发,快速输入不影响网页操作 * - 数字键、方向键默认需要"长按超过阈值"才触发,避免网页交互输入误触;
* quick 模式下(大屏发布/轮播/主屏播放)阈值强制为 0,按键即时响应
*/ */
interface KeyBindings { interface KeyBindings {
onEscape: () => void onEscape: () => void
@@ -66,14 +75,16 @@ interface KeyBindings {
onNext: () => void onNext: () => void
onNumber: (n: number) => void onNumber: (n: number) => void
onLockToggle: (locked: boolean) => 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 hold = ''
let holdT = 0 let holdT = 0
return (event, input) => { return (event, input) => {
const s = getSettings() 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() const lockKey = (s.keyLockKey || 'F9').toLowerCase()
if (input.type === 'keyDown') { if (input.type === 'keyDown') {
if (input.key === 'Escape') { if (input.key === 'Escape') {
@@ -86,6 +97,8 @@ function makeKeyHandler(b: KeyBindings): (event: Electron.Event, input: Electron
b.onF5() b.onF5()
return return
} }
// 多屏发布模式:仅保留 Esc/F5,其余按键一律忽略
if (b.navDisabled?.() ?? false) return
if (lockKey && input.key.toLowerCase() === lockKey) { if (lockKey && input.key.toLowerCase() === lockKey) {
event.preventDefault() event.preventDefault()
const nv = !isKeyLocked() const nv = !isKeyLocked()
@@ -111,7 +124,7 @@ function makeKeyHandler(b: KeyBindings): (event: Electron.Event, input: Electron
if (input.type === 'keyUp' && input.key === hold) { if (input.type === 'keyUp' && input.key === hold) {
hold = '' hold = ''
const dur = Date.now() - holdT const dur = Date.now() - holdT
if (dur < threshold || isKeyLocked()) return if (dur < threshold || isKeyLocked() || (b.navDisabled?.() ?? false)) return
event.preventDefault() event.preventDefault()
if (input.key === 'ArrowUp' || input.key === 'ArrowLeft') { if (input.key === 'ArrowUp' || input.key === 'ArrowLeft') {
b.onPrev() b.onPrev()
@@ -167,7 +180,7 @@ export function listScreens(): ScreenInfo[] {
let playing: PlayingInfo | null = null let playing: PlayingInfo | null = null
if (car && car.wins.some((w) => !w.isDestroyed() && w.isVisible())) { if (car && car.wins.some((w) => !w.isDestroyed() && w.isVisible())) {
playing = { 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 ?? '' url: car.pages[car.current]?.url ?? ''
} }
} else if (win && !win.isDestroyed() && win.isVisible()) { } else if (win && !win.isDestroyed() && win.isVisible()) {
@@ -179,14 +192,20 @@ export function listScreens(): ScreenInfo[] {
res: `${d.size.width}×${d.size.height}`, res: `${d.size.width}×${d.size.height}`,
pos: d.id === screen.getPrimaryDisplay().id ? '主显示器' : '扩展显示器', pos: d.id === screen.getPrimaryDisplay().id ? '主显示器' : '扩展显示器',
primary: 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) 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({ win = new BrowserWindow({
x: display.bounds.x, x: display.bounds.x,
@@ -207,31 +226,40 @@ function ensureWindow(index: number, display: Electron.Display): BrowserWindow {
win.setMenuBarVisibility(false) win.setMenuBarVisibility(false)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
if (opts?.navDisabled) navDisabledWins.add(win)
else navDisabledWins.delete(win)
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.show()
win.setFullScreen(true) win.setFullScreen(true)
win.focus() win.focus()
} }
}) })
// 屏幕窗口键盘:Esc 退出发布F5 刷新,数字/方向键需长按超阈值,锁定键 F9 切换识别 // 屏幕窗口键盘:Esc 关闭所有播放F5 刷新,数字/方向键即时切换,锁定键 F9 切换识别
win.webContents.on( win.webContents.on(
'before-input-event', 'before-input-event',
makeKeyHandler({ makeKeyHandler(
onEscape: () => closeScreen(index), {
onF5: () => win?.webContents.reload(), onEscape: () => closeAllScreens(),
onPrev: () => switchCarousel(index, (carousels.get(index)?.current ?? 0) - 1), onF5: () => win?.webContents.reload(),
onNext: () => switchCarousel(index, (carousels.get(index)?.current ?? 0) + 1), onPrev: () => switchCarousel(index, (carousels.get(index)?.current ?? 0) - 1),
onNumber: (n) => { onNext: () => switchCarousel(index, (carousels.get(index)?.current ?? 0) + 1),
const page = lastPages.find((p) => p.ctrl === n) onNumber: (n) => {
if (page) publishScreen(index, page.url, page.title) 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', () => { win.on('closed', () => {
screenWindows.delete(index) screenWindows.delete(index)
if (win) navDisabledWins.delete(win)
emitChange() emitChange()
}) })
@@ -239,12 +267,17 @@ function ensureWindow(index: number, display: Electron.Display): BrowserWindow {
return win 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 displays = sortedDisplays()
const display = displays[index - 1] const display = displays[index - 1]
if (!display) return listScreens() if (!display) return listScreens()
const win = ensureWindow(index, display) const win = ensureWindow(index, display, opts)
if (!win.isDestroyed()) { if (!win.isDestroyed()) {
win.setBounds(display.bounds) win.setBounds(display.bounds)
} }
@@ -284,7 +317,7 @@ export function screenCount(): number {
* 通过键盘 上/下 键循环切换、数字键直达,Esc 退出发布,F5 刷新当前页。 * 通过键盘 上/下 键循环切换、数字键直达,Esc 退出发布,F5 刷新当前页。
* intervalSec > 0 时自动定时切换。 * 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 displays = sortedDisplays()
const display = displays[index - 1] const display = displays[index - 1]
if (!display) return listScreens() if (!display) return listScreens()
@@ -319,34 +352,38 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
if (pi === 0 && !win.isDestroyed()) { if (pi === 0 && !win.isDestroyed()) {
win.show()
win.setFullScreen(true) win.setFullScreen(true)
win.focus() win.focus()
} }
}) })
// 键盘:在可见窗体上捕获,方向/数字键需长按超阈值 // 键盘:在可见窗体上捕获,方向/数字键即时切换(不要求长按)
win.webContents.on( win.webContents.on(
'before-input-event', 'before-input-event',
makeKeyHandler({ makeKeyHandler(
onEscape: () => stopCarousel(index), {
onF5: () => { onEscape: () => closeAllScreens(),
const state = carousels.get(index) onF5: () => {
state?.wins[state.current]?.webContents.reload() 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: () => { true
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', () => { win.on('closed', () => {
@@ -359,8 +396,16 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
wins.push(win) wins.push(win)
}) })
const state: CarouselState = { screenIndex: index, wins, pages, current: 0, timer: null } const state: CarouselState = {
if (intervalSec > 0 && pages.length > 1) { screenIndex: index,
wins,
pages,
current: 0,
timer: null,
auto: intervalSec > 0 && pages.length > 1,
label
}
if (state.auto) {
state.timer = setInterval(() => { state.timer = setInterval(() => {
const st = carousels.get(index) const st = carousels.get(index)
if (st) switchCarousel(index, st.current + 1) if (st) switchCarousel(index, st.current + 1)
@@ -368,6 +413,39 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
} }
carousels.set(index, state) carousels.set(index, state)
emitChange() 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() return listScreens()
} }
@@ -384,6 +462,10 @@ function switchCarousel(index: number, next: number): void {
w.show() w.show()
w.setFullScreen(true) w.setFullScreen(true)
w.focus() w.focus()
// 切换后短暂延迟再聚焦一次,确保键盘输入落在当前窗体
setTimeout(() => {
if (!w.isDestroyed() && w.isVisible()) w.focus()
}, 120)
} else { } else {
w.hide() w.hide()
} }
@@ -404,6 +486,20 @@ export function stopCarousel(index: number): ScreenInfo[] {
return listScreens() 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 { export function closeAllCarousels(): void {
for (const index of [...carousels.keys()]) { for (const index of [...carousels.keys()]) {
stopCarousel(index) stopCarousel(index)
@@ -456,17 +552,20 @@ export function publishMain(url: string, title: string): ScreenInfo[] {
mainView.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) mainView.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
mainView.webContents.on( mainView.webContents.on(
'before-input-event', 'before-input-event',
makeKeyHandler({ makeKeyHandler(
onEscape: () => restoreMain(), {
onF5: () => mainView?.webContents.reload(), onEscape: () => closeAllScreens(),
onPrev: () => undefined, onF5: () => mainView?.webContents.reload(),
onNext: () => undefined, onPrev: () => undefined,
onNumber: (n) => { onNext: () => undefined,
const page = lastPages.find((p) => p.ctrl === n) onNumber: (n) => {
if (page) void publishMain(page.url, page.title) 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) mainWin.contentView.addChildView(mainView)
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export interface AppSettings {
const DEFAULTS: AppSettings = { const DEFAULTS: AppSettings = {
projectName: '智慧农业大数据可视化控制中心', projectName: '智慧农业大数据可视化控制中心',
projectVersion: '0.1.0', projectVersion: '0.2.0',
jsonPassword: '', jsonPassword: '',
keyThreshold: 350, keyThreshold: 350,
keyLockKey: 'F9', keyLockKey: 'F9',
+6 -1
View File
@@ -77,7 +77,7 @@ export interface SmartAgriApi {
screens: { screens: {
list(): Promise<ScreenInfo[]> list(): Promise<ScreenInfo[]>
states(): 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[]> close(index: number): Promise<ScreenInfo[]>
closeAll(): Promise<ScreenInfo[]> closeAll(): Promise<ScreenInfo[]>
mainIndex(): Promise<number> mainIndex(): Promise<number>
@@ -90,7 +90,11 @@ export interface SmartAgriApi {
} }
carousel: { carousel: {
start(payload: { index: number; pages: { url: string; title: string }[]; intervalSec?: number }): Promise<ScreenInfo[]> 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[]> stop(index: number): Promise<ScreenInfo[]>
/** 停止自动轮播:仅清除定时切换,保留当前页面窗体显示 */
stopAuto(index: number): Promise<ScreenInfo[]>
} }
project: { project: {
saveAs(data: string, password?: string): Promise<string | null> saveAs(data: string, password?: string): Promise<string | null>
@@ -115,6 +119,7 @@ export interface SmartAgriApi {
} }
page: { page: {
snapshot(url: string): Promise<string | null> snapshot(url: string): Promise<string | null>
fetchMeta(url: string): Promise<{ title: string; desc: string } | null>
} }
syncProject(pages: unknown[]): void syncProject(pages: unknown[]): void
setDirty(d: boolean): void setDirty(d: boolean): void
+10 -3
View File
@@ -17,7 +17,7 @@ const api = {
screens: { screens: {
list: (): Promise<unknown[]> => ipcRenderer.invoke('screens:list'), list: (): Promise<unknown[]> => ipcRenderer.invoke('screens:list'),
states: (): Promise<unknown[]> => ipcRenderer.invoke('screens:states'), 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), ipcRenderer.invoke('screen:publish', payload),
close: (index: number): Promise<unknown[]> => ipcRenderer.invoke('screen:close', index), close: (index: number): Promise<unknown[]> => ipcRenderer.invoke('screen:close', index),
closeAll: (): Promise<unknown[]> => ipcRenderer.invoke('screen:closeAll'), closeAll: (): Promise<unknown[]> => ipcRenderer.invoke('screen:closeAll'),
@@ -33,7 +33,12 @@ const api = {
carousel: { carousel: {
start: (payload: { index: number; pages: { url: string; title: string }[]; intervalSec?: number }): Promise<unknown[]> => start: (payload: { index: number; pages: { url: string; title: string }[]; intervalSec?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('carousel:start', payload), 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: { project: {
saveAs: (data: string, password?: string): Promise<string | null> => saveAs: (data: string, password?: string): Promise<string | null> =>
@@ -61,7 +66,9 @@ const api = {
onScreenKey: (cb: (n: number) => void): (() => void) => on('key:switch', cb) onScreenKey: (cb: (n: number) => void): (() => void) => on('key:switch', cb)
}, },
page: { 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), syncProject: (pages: unknown[]): void => ipcRenderer.send('project:sync', pages),
setDirty: (d: boolean): void => ipcRenderer.send('window:dirty', d), setDirty: (d: boolean): void => ipcRenderer.send('window:dirty', d),
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import Modal from './Modal' import Modal from './Modal'
import { useStore } from '../store' import { useStore } from '../store'
import type { ScreenInfo } from '../types' 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 { 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 screens = useStore((s) => s.screens)
const settings = useStore((s) => s.settings) const settings = useStore((s) => s.settings)
const [sel, setSel] = useState<number>(screens[0]?.index ?? 0) 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) const [intervalSec, setIntervalSec] = useState(settings?.publish.intervalSec ?? 10)
return ( return (
<Modal <Modal
@@ -133,7 +134,7 @@ export function MultiPublishModal(props: { onClose: () => void }): React.JSX.Ele
} }
> >
<p className="m-hint"> <p className="m-hint">
IDEsc 退 · F5 · IDEsc · F5 //
</p> </p>
{screens.length === 0 ? ( {screens.length === 0 ? (
<div className="m-empty"></div> <div className="m-empty"></div>
@@ -220,3 +221,83 @@ export function DetectModal(props: { onClose: () => void }): React.JSX.Element {
</Modal> </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 { useStore } from '../store'
import type { ViewMode } from '../types' import type { ViewMode } from '../types'
import { CONFIG_MENUS, type ConfigMenu } from '../configMenus' 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 { toast } from '../toast'
import { ScreenSelectModal, MultiPublishModal, DetectModal } from './PublishModals' import { ScreenSelectModal, MultiPublishModal, InteractiveModal, DetectModal } from './PublishModals'
interface RibbonItem { interface RibbonItem {
id: string id: string
icon: string icon: string | React.JSX.Element
label: string label: string
title?: string title?: string
action?: () => void action?: () => void
@@ -26,6 +26,58 @@ const TAB_NAME: Record<ViewMode, string> = {
config: '配置' 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: { export default function Ribbon(props: {
configMenu: ConfigMenu configMenu: ConfigMenu
onConfigMenu: (m: ConfigMenu) => void onConfigMenu: (m: ConfigMenu) => void
@@ -35,14 +87,18 @@ export default function Ribbon(props: {
const pages = useStore((s) => s.pages) const pages = useStore((s) => s.pages)
const curIdx = useStore((s) => s.curIdx) const curIdx = useStore((s) => s.curIdx)
const screens = useStore((s) => s.screens) const screens = useStore((s) => s.screens)
const opening = useStore((s) => s.opening)
const st = useStore.getState 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 curPage = pages[curIdx]
const curScreen = curPage?.screen ?? -1 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[] = [ const editItems: RibbonItem[] = [
{ id: 'add', icon: '📄', label: '新建页面', action: () => st().addPage() }, { id: 'add', icon: '📄', label: '新建页面', action: () => st().addPage() },
@@ -54,7 +110,21 @@ export default function Ribbon(props: {
{ id: 'snap', icon: '📷', label: '缩略图', action: () => void st().capturePreview() } { id: 'snap', icon: '📷', label: '缩略图', action: () => void st().capturePreview() }
] ]
const fileItems: RibbonItem[] = [ 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: 'save', icon: '💾', label: '保存', action: () => void st().save() },
{ id: 'saveas', icon: '📝', label: '另存为', action: () => void st().saveAs() }, { id: 'saveas', icon: '📝', label: '另存为', action: () => void st().saveAs() },
{ id: 'resetorder', icon: '🔀', label: '页面重排', action: () => st().resetOrder() } { id: 'resetorder', icon: '🔀', label: '页面重排', action: () => st().resetOrder() }
@@ -62,30 +132,41 @@ export default function Ribbon(props: {
const pubItems: RibbonItem[] = [ const pubItems: RibbonItem[] = [
{ {
id: 'preview', id: 'preview',
icon: '👁️', icon: <PreviewIcon />,
label: '预览本页', label: '预览本页',
disabled: !curPage, disabled: !curPage,
title: '弹窗选择目标屏幕,全屏播放当前页面(Esc 退出 · F5 刷新 · 数字键切页)', title: '弹窗选择目标屏幕,全屏播放当前页面(Esc 关闭所有 · F5 刷新 · 数字键切页)',
action: () => setPubModal('preview') action: () => setPubModal('preview')
}, },
{ {
id: 'single', id: 'single',
icon: '🎠', icon: <SingleIcon />,
label: '单屏发布', label: '单屏发布',
disabled: !curPage, disabled: !curPage,
title: '弹窗选择目标屏幕,在该屏幕轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出)', title: '弹窗选择目标屏幕,在该屏幕轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出)',
action: () => setPubModal('single') 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', id: 'stopcar',
icon: '⏹️', icon: '⏹️',
label: '停止轮播', label: '停止轮播',
type: 'danger', type: 'danger',
disabled: !carouselRunning, disabled: !carouselRunning,
title: carouselRunning ? '停止当前页所属屏幕(屏 ' + curScreen + ')的轮播' : '目标屏幕当前未在轮播', title:
carouselRunning && stopTarget
? `停止屏 ${stopTarget.index} 的自动轮播(保留当前页面窗体,可手动切换)`
: '仅「自动轮播」模式可用',
action: () => { action: () => {
if (curPage) void stopCarouselOnScreen(curPage.screen) if (stopTarget) void stopCarouselAutoOnScreen(stopTarget.index)
} }
}, },
{ id: 'closeall', icon: '🏁', label: '关闭所有', type: 'danger', action: () => void closeAllScreenOutput() }, { id: 'closeall', icon: '🏁', label: '关闭所有', type: 'danger', action: () => void closeAllScreenOutput() },
@@ -163,7 +244,7 @@ export default function Ribbon(props: {
{pubModal === 'preview' && ( {pubModal === 'preview' && (
<ScreenSelectModal <ScreenSelectModal
title="预览本页" title="预览本页"
hint="选择要在哪块屏幕上全屏播放当前页面(Esc 退出 · F5 刷新 · 数字键切页)。" hint="选择要在哪块屏幕上全屏播放当前页面(Esc 关闭所有 · F5 刷新 · 数字键切页)。"
confirmLabel="开始预览" confirmLabel="开始预览"
onConfirm={(i) => { onConfirm={(i) => {
void publishCurrentToScreen(i) void publishCurrentToScreen(i)
@@ -175,7 +256,7 @@ export default function Ribbon(props: {
{pubModal === 'single' && ( {pubModal === 'single' && (
<ScreenSelectModal <ScreenSelectModal
title="单屏发布" title="单屏发布"
hint="选择要在哪块屏幕上轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 退出 · F5 刷新)。" hint="选择要在哪块屏幕上轮播所有「参与多屏发布」的页面(↑/↓ 或数字键切换 · Esc 关闭所有 · F5 刷新)。"
confirmLabel="开始发布" confirmLabel="开始发布"
showCarousel showCarousel
onConfirm={(i, opts) => { onConfirm={(i, opts) => {
@@ -185,6 +266,7 @@ export default function Ribbon(props: {
onClose={() => setPubModal(null)} onClose={() => setPubModal(null)}
/> />
)} )}
{pubModal === 'interactive' && <InteractiveModal onClose={() => setPubModal(null)} />}
{pubModal === 'multi' && <MultiPublishModal onClose={() => setPubModal(null)} />} {pubModal === 'multi' && <MultiPublishModal onClose={() => setPubModal(null)} />}
{pubModal === 'detect' && <DetectModal onClose={() => setPubModal(null)} />} {pubModal === 'detect' && <DetectModal onClose={() => setPubModal(null)} />}
</div> </div>
@@ -16,7 +16,7 @@ export default function AboutPanel(): React.JSX.Element {
<div className="about-logo">🌾</div> <div className="about-logo">🌾</div>
<div className="about-title">{settings?.projectName ?? '智慧农业大数据可视化控制中心'}</div> <div className="about-title">{settings?.projectName ?? '智慧农业大数据可视化控制中心'}</div>
<div className="about-ver"> <div className="about-ver">
Version {settings?.projectVersion ?? '0.1.0'} Version {settings?.projectVersion ?? '0.2.0'}
{info?.appVersion ? ` · 安装包 ${info.appVersion}` : ''} {info?.appVersion ? ` · 安装包 ${info.appVersion}` : ''}
</div> </div>
<p className="about-desc"> <p className="about-desc">
@@ -7,6 +7,7 @@ export default function ProjectInfo(): React.JSX.Element {
const pages = useStore((s) => s.pages) const pages = useStore((s) => s.pages)
const screens = useStore((s) => s.screens) const screens = useStore((s) => s.screens)
const filePath = useStore((s) => s.filePath) const filePath = useStore((s) => s.filePath)
const opening = useStore((s) => s.opening)
const st = useStore.getState const st = useStore.getState
const [name, setName] = useState(settings?.projectName ?? '') const [name, setName] = useState(settings?.projectName ?? '')
@@ -26,7 +27,7 @@ export default function ProjectInfo(): React.JSX.Element {
}, [settings]) }, [settings])
const save = async (): Promise<void> => { 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') toast('项目信息与 JSON 密码已保存', 'ok')
} }
@@ -41,7 +42,7 @@ export default function ProjectInfo(): React.JSX.Element {
</div> </div>
<div className="cfg-field"> <div className="cfg-field">
<label></label> <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> </div>
<p className="cfg-tip"> JSONappName / version</p> <p className="cfg-tip"> JSONappName / version</p>
</div> </div>
@@ -96,8 +97,8 @@ export default function ProjectInfo(): React.JSX.Element {
<button className="btn primary" onClick={() => void save()}> <button className="btn primary" onClick={() => void save()}>
💾 💾
</button> </button>
<button className="btn" onClick={() => void st().open()}> <button className="btn" disabled={opening} onClick={() => void st().open()}>
📂 {opening ? '⏳ 打开中…' : '📂 打开工程'}
</button> </button>
<button className="btn" onClick={() => void st().save()}> <button className="btn" onClick={() => void st().save()}>
💾 💾
@@ -1,8 +1,17 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { useStore } from './store'
import './styles/global.css' import './styles/global.css'
// 调试便利:暴露 store 到全局(生产保留无碍)
declare global {
interface Window {
__store: typeof useStore
}
}
window.__store = useStore
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
@@ -19,7 +19,7 @@ export async function publishCurrentToScreen(index: number): Promise<void> {
await window.api.screens.publish({ index, url, title: p.title }) await window.api.screens.publish({ index, url, title: p.title })
const list = await window.api.screens.states() const list = await window.api.screens.states()
useStore.getState().setScreens(list) useStore.getState().setScreens(list)
toast(`${p.title}」已发布到 屏幕 ${index}Esc 退出 · F5 刷新 · 数字键切页)`, 'ok') toast(`${p.title}」已发布到 屏幕 ${index}Esc 关闭所有 · F5 刷新 · 数字键切页)`, 'ok')
} }
/** 多屏发布:各页面按所在屏幕 ID 全屏播放;alsoMain 时编辑器屏幕也全屏显示对应页面 */ /** 多屏发布:各页面按所在屏幕 ID 全屏播放;alsoMain 时编辑器屏幕也全屏显示对应页面 */
@@ -34,7 +34,8 @@ export async function publishAll(alsoMain = false): Promise<void> {
for (const s of screens) { for (const s of screens) {
const p = pages.find((x) => x.display && x.screen === s.index) const p = pages.find((x) => x.display && x.screen === s.index)
if (p) { 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++ 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> { export async function startCarouselOnScreen(index: number, intervalSec = 0): Promise<void> {
const { pages } = useStore.getState() 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 }) await window.api.carousel.start({ index, pages: items, intervalSec })
const list = await window.api.screens.states() const list = await window.api.screens.states()
useStore.getState().setScreens(list) useStore.getState().setScreens(list)
const auto = intervalSec > 0 ? ` · 自动切换 ${intervalSec}s` : '' const auto = intervalSec > 0 ? ` · 自动切换 ${intervalSec}s(点「停止轮播」可停用)` : ''
toast(`屏幕 ${index} 单屏发布已启动(${items.length}${auto} · ↑/↓ 或数字键切换 · Esc 退出)`, 'ok') 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) 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 秒,并返回屏幕列表 */ /** 检测屏幕:各屏显示编号/分辨率标签 6 秒,并返回屏幕列表 */
+26
View File
@@ -13,6 +13,8 @@ interface AppState {
screens: ScreenInfo[] screens: ScreenInfo[]
zoom: number zoom: number
settings: AppSettings | null settings: AppSettings | null
/** 工程文件操作进行中(打开/另存等),用于防抖防重入 */
opening: boolean
// actions // actions
setView: (v: ViewMode) => void setView: (v: ViewMode) => void
selectPage: (i: number) => void selectPage: (i: number) => void
@@ -28,6 +30,7 @@ interface AppState {
setZoom: (z: number) => void setZoom: (z: number) => void
loadSettings: () => Promise<AppSettings> loadSettings: () => Promise<AppSettings>
updateSettings: (patch: Partial<AppSettings>) => Promise<AppSettings> updateSettings: (patch: Partial<AppSettings>) => Promise<AppSettings>
newProject: () => void
loadProject: (payload: LoadPayload) => 'edit' | 'publish' loadProject: (payload: LoadPayload) => 'edit' | 'publish'
save: () => Promise<void> save: () => Promise<void>
saveAs: () => Promise<void> saveAs: () => Promise<void>
@@ -59,6 +62,7 @@ export const useStore = create<AppState>((set, get) => {
screens: [], screens: [],
zoom: 100, zoom: 100,
settings: null, settings: null,
opening: false,
setView: (v) => set({ view: v }), setView: (v) => set({ view: v }),
selectPage: (i) => { selectPage: (i) => {
@@ -157,6 +161,10 @@ export const useStore = create<AppState>((set, get) => {
try { try {
const data = await window.api.page.snapshot(src) const data = await window.api.page.snapshot(src)
if (data) { if (data) {
if (data === p.preview) {
toast('页面内容无变化,缩略图已是最新', 'info')
return
}
const list = pages.map((x, i) => (i === at ? { ...x, preview: data } : x)) const list = pages.map((x, i) => (i === at ? { ...x, preview: data } : x))
touch({ pages: list }) touch({ pages: list })
toast('页面截图已更新', 'ok') toast('页面截图已更新', 'ok')
@@ -191,6 +199,20 @@ export const useStore = create<AppState>((set, get) => {
set({ settings: s }) set({ settings: s })
return 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) => { loadProject: (payload) => {
if (payload?.json) applyProject(get, set, payload.path, payload.json) if (payload?.json) applyProject(get, set, payload.path, payload.json)
return payload?.mode ?? 'edit' return payload?.mode ?? 'edit'
@@ -235,6 +257,8 @@ export const useStore = create<AppState>((set, get) => {
} }
}, },
open: async () => { open: async () => {
if (get().opening) return
set({ opening: true })
try { try {
const settings = get().settings ?? (await get().loadSettings()) const settings = get().settings ?? (await get().loadSettings())
let r = await window.api.project.open(settings.jsonPassword || undefined) 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') toast(r.error || '打开失败', 'warn')
} catch (err) { } catch (err) {
toast('打开失败:' + String(err), 'warn') toast('打开失败:' + String(err), 'warn')
} finally {
set({ opening: false })
} }
}, },
markSaved: () => set({ dirty: false }) markSaved: () => set({ dirty: false })
@@ -1061,6 +1061,18 @@ select {
padding: 9px 11px; padding: 9px 11px;
background: #fbfcfe; 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 { .m-scard-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -23,6 +23,8 @@ export interface ScreenInfo {
pos: string pos: string
primary: boolean primary: boolean
playing: PlayingInfo | null playing: PlayingInfo | null
/** 单屏轮播是否处于自动切换中(「停止轮播」按钮据此启用) */
carouselAuto?: boolean
} }
export interface PageFile { export interface PageFile {
+1 -1
View File
@@ -46,7 +46,7 @@ export function pageSrc(p: Page): string {
export function pagesToJson(pages: Page[], meta?: { appName?: string; version?: string }): ProjectJson { export function pagesToJson(pages: Page[], meta?: { appName?: string; version?: string }): ProjectJson {
return { return {
appName: meta?.appName || '智慧农业大数据可视化控制中心', appName: meta?.appName || '智慧农业大数据可视化控制中心',
version: meta?.version || '0.1.0', version: meta?.version || '0.2.0',
pages: pages.map((p) => ({ pages: pages.map((p) => ({
PageID: p.id, PageID: p.id,
PageTitle: p.title, PageTitle: p.title,
@@ -40,7 +40,13 @@ export default function Canvas(): React.JSX.Element {
</div> </div>
<div className="canvas-wrap"> <div className="canvas-wrap">
<div className="canvas-stage" style={{ transform: 'scale(' + zoom / 100 + ')' }}> <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>
</div> </div>
</> </>
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { useStore } from '../store' import { useStore } from '../store'
import Modal from '../components/Modal'
import { IconCopy, IconDown, IconInsert, IconTrash, IconUp, IconCamera } from '../components/icons' import { IconCopy, IconDown, IconInsert, IconTrash, IconUp, IconCamera } from '../components/icons'
export default function PageList(): React.JSX.Element { 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 dupPage = useStore((s) => s.dupPage)
const delPage = useStore((s) => s.delPage) const delPage = useStore((s) => s.delPage)
/** 待删除的页面下标,非 null 时弹出确认框 */
const [delIdx, setDelIdx] = useState<number | null>(null)
const stop = (e: React.MouseEvent): void => e.stopPropagation() const stop = (e: React.MouseEvent): void => e.stopPropagation()
return ( return (
@@ -29,7 +33,7 @@ export default function PageList(): React.JSX.Element {
> >
<div className="pc-thumb"> <div className="pc-thumb">
{p.preview ? ( {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> <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 === 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="下移" 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="复制页面" 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> <button title="截图更新预览图" onClick={(e) => { stop(e); void capturePreview(i) }}><IconCamera /></button>
</div> </div>
</div> </div>
@@ -59,6 +63,36 @@ export default function PageList(): React.JSX.Element {
</button> </button>
</div> </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> </div>
) )
} }
@@ -1,3 +1,4 @@
import { useRef } from 'react'
import { useStore } from '../store' import { useStore } from '../store'
import { toast } from '../toast' import { toast } from '../toast'
@@ -5,7 +6,12 @@ export default function PropertyPanel(): React.JSX.Element {
const page = useStore((s) => s.pages[s.curIdx]) const page = useStore((s) => s.pages[s.curIdx])
const updatePage = useStore((s) => s.updatePage) const updatePage = useStore((s) => s.updatePage)
const screens = useStore((s) => s.screens) 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) { if (!page) {
return ( return (
@@ -29,6 +35,29 @@ export default function PropertyPanel(): React.JSX.Element {
updatePage({ ctrl: n }) 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 ( return (
<div className="panel edit-props"> <div className="panel edit-props">
<div className="panel-head"> <div className="panel-head">
@@ -86,20 +115,24 @@ export default function PropertyPanel(): React.JSX.Element {
<label> URLPageUrl</label> <label> URLPageUrl</label>
<textarea <textarea
className="url-area" className="url-area"
rows={2} rows={3}
value={page.url} value={page.url}
onChange={(e) => updatePage({ url: e.target.value })} 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'} placeholder={'sample-screen.html\nhttps://…\nabout:blank'}
/> />
<span className="hint"></span> <span className="hint"> http(s) </span>
</div> </div>
<div className="prop-item"> <div className="prop-item">
<label>PageParams key=value 255 </label> <label>PageParams key=value 255 </label>
<textarea <textarea
rows={4}
maxLength={255} maxLength={255}
value={page.params} value={page.params}
onChange={(e) => updatePage({ params: e.target.value })} onChange={(e) => updatePage({ params: e.target.value })}
placeholder={'no=1\ntitle=农业数据总览'}
/> />
<span className="hint"> {page.params.length} / 255</span> <span className="hint"> {page.params.length} / 255</span>
</div> </div>
File diff suppressed because one or more lines are too long