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:
@@ -208,3 +208,32 @@
|
||||
- **修复**:Node 脚本(`fix-msgs.js`,用完已删)用 `git cat-file commit` 读原对象 → 保留 header(tree/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 "中文"`**。
|
||||
- **执行环境备注**: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。typecheck(node+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 产物含 startInteractive,typecheck 通过。坑:同文件多 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。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- 页面 JSON 字段含 `PagePreview`(Base64 截图),项目文件存 `.json`。
|
||||
|
||||
## 关键 UI/交互约定
|
||||
- Ribbon 编辑 Tab 组序:项目 → 页面 → 发布(发布 6 按钮:预览本页👁️/单屏发布🎠/多屏发布🖥️/停止轮播⏹️/关闭所有🏁/检测屏幕📡,全部走弹窗)。
|
||||
- Ribbon 编辑 Tab 组序:项目 → 页面 → 发布(发布 7 按钮:预览本页👁️/单屏发布🎠/多屏发布🖥️/交互发布🔁[主屏+小屏轮流投屏]/停止轮播⏹️/关闭所有🏁/检测屏幕📡,全部走弹窗)。
|
||||
- 页面列表卡片:预览图 + 底部操作组(前插/上移/下移/复制/删除/截图,SVG 扁平图标,仅 active 显示)。
|
||||
- 快捷键:大屏窗口/轮播数字键 1-9 需**长按超过 keyThreshold**(防网页内输入数字误触),Esc 退出、F5 刷新、锁定键(默认 F9)全局开关。
|
||||
- 发布设置可配 multi/single/main 三种默认模式,single 支持 intervalSec 轮播。
|
||||
@@ -35,11 +35,11 @@
|
||||
- **electron-vite dev 修改 src/main/preload 不会自动重启 electron**,需手动杀掉 dev 进程组(npm-cli→electron-vite→electron)再重新启动;仅 renderer 改动走 HMR 无需重启。
|
||||
|
||||
## 版本号与打包(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,✅/⏳ 标注)、`开发进度存档`(**跨会话恢复入口**)、`指令清单`(历史,已全部实现)。
|
||||
- 已实现功能全景(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-23,electron-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` 裁剪 locales(55→3),zip 139.7→128.9MB。
|
||||
- 绿色版打包已完成(2026-08-23,electron-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` 裁剪 locales(55→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。
|
||||
|
||||
## 待办/候选开发点(截至 2026-08-23)
|
||||
|
||||
@@ -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