修复单屏发布闪烁+编辑界面增强+新增页面默认值优化

- 修复单屏发布/轮播切页屏幕闪烁与任务栏闪现(所有窗体创建即预设全屏,切换仅 show/hide/focus,先隐旧再显新)
- 轮播取消循环:越界保持当前页并在左上角提示,自动轮播到边界即停
- 编辑窗体:预览区地址栏新增复制按钮、页面属性 URL 输入框加高两行
- 配置窗体:工程路径新增复制按钮、去掉工具按钮组与桌面快捷方式入口
- 新增/插入页面默认值:URL/参数/描述留空,所属屏幕与控制器编号取当前最大值+1
This commit is contained in:
2026-08-23 17:47:26 +08:00
parent aa8f070414
commit e0d96051f1
9 changed files with 130 additions and 50 deletions
+7 -4
View File
@@ -286,20 +286,23 @@ function setDirtyFlag(d: boolean): void {
export function installCloseGuard(win: BrowserWindow): void {
win.on('close', (e) => {
if (!dirty || forceQuit) return
if (forceQuit) return
e.preventDefault()
const warn = dirty ? '当前工程有未保存的修改。\n\n' : ''
void dialog
.showMessageBox(win, {
type: 'warning',
title: '工程尚未保存',
message: '当前工程有未保存的修改,确定要关闭吗?',
buttons: ['仍然关闭', '取消'],
title: '确认退出',
message: `${warn}确定要退出智慧农业大数据可视化控制中心吗?`,
buttons: ['退出', '取消'],
defaultId: 1,
cancelId: 1
})
.then((r) => {
if (r.response === 0) {
forceQuit = true
// 先关闭所有播放中的子页面(屏幕窗体/轮播/探测/主屏播放),再关闭主窗口
closeAllScreens()
win.close()
}
})
+55 -13
View File
@@ -150,6 +150,26 @@ function lockHint(win: BrowserWindow | null, locked: boolean): void {
}, 2000)
}
/** 在窗体页面左上角显示临时提示(2 秒后自动移除) */
function hintAt(win: BrowserWindow, text: string): void {
if (!win || win.isDestroyed()) return
const script = `(() => {
const old = document.getElementById('__sb_hint_root')
if (old) old.remove()
const d = document.createElement('div')
d.id = '__sb_hint_root'
d.textContent = ${JSON.stringify(text)}
d.style.cssText =
'position:fixed;top:16px;left:16px;z-index:2147483647;padding:8px 16px;' +
'border-radius:6px;background:rgba(0,0,0,.65);color:#fff;' +
'font:14px/1.6 "Microsoft YaHei",sans-serif;pointer-events:none;' +
'box-shadow:0 2px 8px rgba(0,0,0,.3);'
document.documentElement.appendChild(d)
setTimeout(() => d.remove(), 2000)
})()`
void win.webContents.executeJavaScript(script).catch(() => undefined)
}
export function setPages(pages: SyncPage[]): void {
lastPages = pages
}
@@ -337,7 +357,7 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
frame: false,
autoHideMenuBar: true,
backgroundColor: '#050b18',
show: pi === 0,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@@ -348,13 +368,21 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
win.setMenuBarVisibility(false)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.setTitle(page.title)
// 全部窗体预先设为全屏:避免首次切到时先从窗口化进入全屏,产生过渡动画/任务栏闪现
win.setFullScreen(true)
void win.loadURL(page.url)
// 全部窗体就绪后保持显示并叠放:任一时刻最顶层都有全屏窗体覆盖任务栏,
// 切换只改置顶顺序不做 hide/show,从根源避免任务栏闪现
win.once('ready-to-show', () => {
if (pi === 0 && !win.isDestroyed()) {
win.show()
win.setFullScreen(true)
win.focus()
if (win.isDestroyed()) return
win.show()
if (!win.isFullScreen()) win.setFullScreen(true)
const st = carousels.get(index)
const first = st?.wins[0]
if (first && !first.isDestroyed() && first !== win) {
first.moveTop()
first.focus()
}
})
@@ -418,8 +446,9 @@ export function startCarousel(index: number, pages: CarouselPage[], intervalSec
const st = carousels.get(index)
const first = st?.wins[0]
if (st && first && !first.isDestroyed()) {
first.show()
first.setFullScreen(true)
if (!first.isVisible()) first.show()
if (!first.isFullScreen()) first.setFullScreen(true)
first.moveTop()
first.focus()
}
}, 400)
@@ -449,25 +478,38 @@ export function startInteractive(req: { mainIndex: number; pagesByScreen: Record
return listScreens()
}
/** 切换到第 next 个窗体(循环),只显示目标窗体 */
/** 切换到第 next 个窗体(循环),越界时保持当前页并在左上角提示 */
function switchCarousel(index: number, next: number): void {
const state = carousels.get(index)
if (!state) return
const n = state.wins.length
if (n === 0) return
state.current = ((next % n) + n) % n
// 取消循环播放:到第一屏/最后一屏时不再回绕,仅提示
if (next < 0 || next >= n) {
const w = state.wins[state.current]
if (w && !w.isDestroyed()) hintAt(w, next < 0 ? '当前已经是第一屏' : '当前已经是最后一屏')
// 自动轮播播到边界后停止定时切换,避免反复提示
if (state.auto && state.timer) {
clearInterval(state.timer)
state.timer = null
state.auto = false
}
return
}
state.current = next
// 全屏窗体同尺寸完全叠放:切换只把目标窗体置顶并聚焦,不做 hide/show,
// 使最顶层始终有全屏窗体覆盖任务栏,避免切页瞬间任务栏闪现
state.wins.forEach((w, i) => {
if (w.isDestroyed()) return
if (i === state.current) {
w.show()
w.setFullScreen(true)
if (!w.isVisible()) w.show()
if (!w.isFullScreen()) w.setFullScreen(true)
w.moveTop()
w.focus()
// 切换后短暂延迟再聚焦一次,确保键盘输入落在当前窗体
setTimeout(() => {
if (!w.isDestroyed() && w.isVisible()) w.focus()
}, 120)
} else {
w.hide()
}
})
emitChange()
@@ -3,7 +3,6 @@ import { useStore } from '../store'
import type { ViewMode } from '../types'
import { CONFIG_MENUS, type ConfigMenu } from '../configMenus'
import { closeAllScreenOutput, publishCurrentToScreen, startCarouselOnScreen, stopCarouselAutoOnScreen } from '../publishActions'
import { toast } from '../toast'
import { ScreenSelectModal, MultiPublishModal, InteractiveModal, DetectModal } from './PublishModals'
interface RibbonItem {
@@ -229,15 +228,6 @@ export default function Ribbon(props: {
}
}))
)}
{renderGroup('工具', [
{
id: 'shortcut',
icon: '🖱️',
label: '桌面快捷方式',
action: () =>
void window.api.createShortcut().then((ok) => toast(ok ? '已创建桌面快捷方式' : '创建快捷方式失败', ok ? 'ok' : 'warn'))
}
])}
</>
)}
</div>
@@ -31,6 +31,16 @@ export default function ProjectInfo(): React.JSX.Element {
toast('项目信息与 JSON 密码已保存', 'ok')
}
const copyPath = async (): Promise<void> => {
if (!filePath) return
try {
await navigator.clipboard.writeText(filePath)
toast('工程路径已复制', 'ok')
} catch {
toast('复制失败,请手动选择复制', 'warn')
}
}
return (
<div className="cfg-section">
<div className="cfg-card">
@@ -81,8 +91,13 @@ export default function ProjectInfo(): React.JSX.Element {
<div className="cfg-card-body">
<div className="cfg-row">
<span className="cfg-row-label"></span>
<span className="cfg-row-value" title={filePath ?? ''}>
{filePath || '未保存(暂存于内存)'}
<span className="cfg-path">
<span className="cfg-row-value" title={filePath ?? ''}>
{filePath || '未保存(暂存于内存)'}
</span>
<button className="btn" disabled={!filePath} onClick={() => void copyPath()}>
</button>
</span>
</div>
<div className="cfg-row">
+18 -20
View File
@@ -38,6 +38,22 @@ interface AppState {
markSaved: () => void
}
/** 新增页面默认值:屏幕/控制器编号取当前最大值 +1,URL/参数/描述留空 */
function blankPage(pages: Page[], id: string, title: string): Page {
const maxScreen = pages.reduce((m, p) => Math.max(m, Number(p.screen) || 0), 0)
const maxCtrl = pages.reduce((m, p) => Math.max(m, Number(p.ctrl) || 0), 0)
return {
id,
title,
desc: '',
screen: maxScreen + 1,
ctrl: maxCtrl + 1,
display: true,
url: '',
params: ''
}
}
/** 应用工程 JSON 到编辑状态 */
function applyProject(get: () => AppState, set: (p: Partial<AppState>) => void, path: string, json: ProjectJson): void {
const pages = jsonToPages(json)
@@ -78,16 +94,7 @@ export const useStore = create<AppState>((set, get) => {
addPage: () => {
const pages = get().pages
const id = nextPageId(pages)
const page: Page = {
id,
title: '新页面 ' + pages.length + 1,
desc: '新建页面',
screen: 1,
ctrl: 0,
display: true,
url: 'sample-screen.html',
params: `no=${id.replace(/\D/g, '')}\ntitle=${id}`
}
const page = blankPage(pages, id, '新页面 ' + (pages.length + 1))
const list = [...pages, page]
touch({ pages: list, curIdx: list.length - 1 })
toast('已新建页面 ' + id, 'ok')
@@ -96,16 +103,7 @@ export const useStore = create<AppState>((set, get) => {
const { pages, curIdx } = get()
const at = index ?? curIdx
const id = nextPageId(pages)
const page: Page = {
id,
title: '新页面 ' + pages.length + 1,
desc: '新建页面',
screen: 1,
ctrl: 0,
display: true,
url: 'sample-screen.html',
params: `no=${id.replace(/\D/g, '')}\ntitle=${id}`
}
const page = blankPage(pages, id, '新页面 ' + (pages.length + 1))
const list = [...pages]
list.splice(at, 0, page)
touch({ pages: list, curIdx: at })
@@ -1282,6 +1282,17 @@ select {
text-overflow: ellipsis;
white-space: nowrap;
}
.cfg-path {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 1;
min-width: 0;
}
.cfg-path .cfg-row-value {
max-width: 70%;
}
.cfg-range {
display: flex;
align-items: center;
@@ -2,6 +2,8 @@ import { useMemo, useRef } from 'react'
import { useStore } from '../store'
import { pageSrc, resolvePageUrl } from '../utils'
import WebFrame, { type WebFrameHandle } from '../components/WebFrame'
import { IconCopy } from '../components/icons'
import { toast } from '../toast'
export default function Canvas(): React.JSX.Element {
const page = useStore((s) => s.pages[s.curIdx])
@@ -13,6 +15,16 @@ export default function Canvas(): React.JSX.Element {
const baseUrl = useMemo(() => (page ? resolvePageUrl(page) : ''), [page])
const isBlank = src === 'about:blank'
const copyUrl = async (): Promise<void> => {
if (!baseUrl) return
try {
await navigator.clipboard.writeText(baseUrl)
toast('地址已复制', 'ok')
} catch {
toast('复制失败,请手动选择复制', 'warn')
}
}
return (
<>
<div className="canvas-toolbar">
@@ -22,6 +34,9 @@ export default function Canvas(): React.JSX.Element {
<div className="ct-url" title={baseUrl}>
{baseUrl}
</div>
<button className="icon-btn" title="复制地址" disabled={isBlank} onClick={() => void copyUrl()}>
<IconCopy size={13} />
</button>
<button
className="icon-btn"
title="刷新大屏页面"
@@ -115,7 +115,7 @@ export default function PropertyPanel(): React.JSX.Element {
<label> URLPageUrl</label>
<textarea
className="url-area"
rows={3}
rows={5}
value={page.url}
onChange={(e) => updatePage({ url: e.target.value })}
onPaste={(e) => {