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

- 修复单屏发布/轮播切页屏幕闪烁与任务栏闪现(所有窗体创建即预设全屏,切换仅 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
+6
View File
@@ -237,3 +237,9 @@
- **发布组 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。
- **已提交并推送**commit `aa8f070`「v0.2.0: 交互发布+多屏发布增强+页面管理优化」(22 files, +657/-134),`git push` 成功 `8d96f51..aa8f070 main -> main`github.bbitcn.net/fanhongcai/DataViewCenter.git)。提交信息用 `git commit -F`(UTF-8 文件,规避 GBK 乱码)。推送时出现 `credential-manager-core is not a git command` 警告为无害提示(GCM 凭据已缓存)。
- **UI 调整 4 项(未提交)**:① 编辑窗体预览区地址栏 `.ct-url` 右侧新增复制地址按钮(`IconCopy` + `navigator.clipboard` + toastCanvas.tsx);② 页面属性「大屏页面 URL」textarea rows 3→5PropertyPanel.tsx);③ 配置窗体「当前工程」工程路径行新增「复制」按钮(`.cfg-path` flex 布局,ProjectInfo.tsx);④ Ribbon 配置视图删除「工具」组(含桌面快捷方式按钮),renderer 不再引用 `window.api.createShortcut`preload/IPC 保留未删,ShortcutPanel 配置菜单页仍可用)。
- **修复单屏发布切页闪烁/任务栏闪现(screens.ts,未提交)**:第一版(预设全屏+先 hide 后 show+防御性 setFullScreen)用户实测无效。**第二版彻底方案(已实施)**:Windows 上 hide 全屏窗体本身就会导致任务栏重排闪现,故轮播全部窗体就绪后**保持显示叠放、永不 hide**;切换只 `moveTop()+focus()` 把目标窗体置顶(同尺寸全屏窗体完全叠放),最顶层始终有全屏窗体覆盖任务栏。ready-to-show 任一窗体就绪即 show,并把 `wins[0]` 置顶。400ms/120ms 兜底 focus 同步改为 moveTop 语义。经验:**Windows Electron 多全屏窗体切换禁用 hide/show,用 moveTop 置顶切换**。
- **主程序关闭确认+子页面联动退出(ipc.ts,未提交)**installCloseGuard 改为统一弹「确认退出」对话框(合并未保存警告),确认后先 `closeAllScreens()``win.close()``window-all-closed``app.quit()`before-quit 里 closeAllScreens 幂等无副作用)。此前仅 dirty 时弹「工程尚未保存」,且主窗关后子窗口仍存活 app 不退出。
- **轮播取消循环+边界左上角提示(screens.ts,未提交)**switchCarousel 去掉 `%n` 取模回绕,越界(next<0 或 next>=n)时保持当前页,并新增 `hintAt(win,text)` 在页面左上角注入 DOM overlayposition:fixed 深色圆角小条,2 秒自动移除,`executeJavaScript` 注入,不受页面 CSP 限制)提示「当前已经是第一屏/最后一屏」;自动轮播播到边界后 clearInterval 并置 `state.auto=false`(避免反复提示)。**单屏轮播与交互发布主屏共用 switchCarousel,因此两者同时生效**。
- **新增/插入页面默认值调整(store.ts,未提交)**:新增 `blankPage()` 辅助函数:url=''、params=''、desc='' 全留空;screen/ctrl 取现有页面**最大值 +1**(需求更正:先是"取最大值",用户又改为"最大值+1")。addPage 与 insertBefore 共用,同时修正标题拼接 bug `'新页面 ' + pages.length + 1`(字符串拼接得到"新页面 41")→ `(pages.length + 1)`。空 url 经 `pageSrc`/`resolvePageUrl` 安全返回 about:blank。renderer 改动 HMR 生效。
+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) => {