// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。 /* 一块画面的读数。裁判端有两块,队伍端一块。 */ export struct CameraStats { // 在线状态 online: bool, status-text: string, // 实时测量 fps: string, resolution: string, frame-bytes: string, // 累计 frames-shown: string, frames-dropped: string, loss-percent: string, datagrams: string, // 帧间隔,用于把"卡"和"帧率低"区分开 gap-ms: string, } /* 一边的血量(4 块装甲板)。 * * `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值, * 现场看到 0 会以为对方已经没血了。 */ export struct TeamHealth { team-name: string, available: bool, // 占位符文案 placeholder: string, // 每块装甲板的血量;available 时有效 hps: [int], } /* 首次运行时的摄像头选择。 * * 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */ export struct CameraChoice { id-text: string, addr-text: string, online: bool, } // ---------------------------------------------------------------- 队伍端 /* * 队伍终端:一块画面 + 自己那一边的血量。 * * 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省 * 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种 * 情况。 */ export component TeamApp inherits Window { title: "cam-client — 队伍"; preferred-width: 1000px; preferred-height: 720px; in property frame; in property stats; in property health; // 选择绑定时显示,其余时候隐藏 in property <[CameraChoice]> choices; in property choosing; // 选中了列表里的第 N 项。 // // 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是 // "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整 // 信息——.slint 里的模型只是给界面看的。 callback pick(int); background: #101014; // 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。 if !root.choosing: VerticalLayout { spacing: 0; HorizontalLayout { padding: 0; spacing: 0; Rectangle { background: #0a0a0d; vertical-stretch: 1; frame := Image { source: root.frame; image-fit: contain; width: parent.width; height: parent.height; horizontal-alignment: center; vertical-alignment: center; } } } // 底部一条:左边自己这边的血量,右边这一路的统计。 Rectangle { background: #1c1c22; height: 72px; HorizontalLayout { padding: 14px; spacing: 24px; VerticalLayout { padding: 0; spacing: 2px; horizontal-stretch: 1; Text { text: root.health.team-name; color: #e8e8ec; font-size: 14px; font-weight: 600; } // 装甲板还没接上,所以是一排占位而不是四个数字。 HorizontalLayout { padding: 0; spacing: 6px; for hp in root.health.hps: Rectangle { width: 34px; height: 12px; border-width: 1px; border-color: root.health.available ? #4a4a55 : #33333c; background: root.health.available ? #7ddc7d : #1a1a20; } } } VerticalLayout { padding: 0; spacing: 2px; preferred-width: 320px; Text { text: root.stats.status-text + " " + root.stats.fps + " fps " + root.stats.resolution + " " + root.stats.frame-bytes; color: root.stats.online ? #e8e8ec : #e0a060; font-size: 13px; } Text { text: "shown " + root.stats.frames-shown + " dropped " + root.stats.frames-dropped + " (" + root.stats.loss-percent + ")" + " gap " + root.stats.gap-ms; color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060; font-size: 12px; } } } } // 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。 Rectangle { background: #14141a; height: 26px; Text { text: root.health.placeholder; color: #6a6a75; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } } } // 选择界面 if root.choosing: VerticalLayout { padding: 32px; spacing: 16px; Text { text: "选择要观看的摄像头"; color: #e8e8ec; font-size: 20px; font-weight: 600; } Text { text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。"; color: #8a8a95; font-size: 13px; wrap: word-wrap; } Rectangle { height: 8px; background: transparent; } // TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有 // 点击回调;TouchArea 才是带交互的元素。 for choice[index] in root.choices: Rectangle { background: choice.online ? #1c1c22 : #16161b; border-width: 1px; border-color: #2a2a33; height: 56px; TouchArea { clicked => { root.pick(index); } } HorizontalLayout { padding: 14px; spacing: 16px; Text { text: choice.id-text; color: #e8e8ec; font-size: 14px; font-weight: 600; vertical-alignment: center; horizontal-alignment: left; } Text { text: choice.addr-text; color: #8a8a95; font-size: 12px; vertical-alignment: center; horizontal-alignment: left; horizontal-stretch: 1; } Text { text: choice.online ? "在线" : "离线"; color: choice.online ? #7ddc7d : #8a8a95; font-size: 12px; vertical-alignment: center; } } } // 一块都没发现时给出可操作的提示,而不是一个空列表。 if root.choices.length == 0: Rectangle { background: #1a1418; border-width: 1px; border-color: #4a2a35; height: 64px; Text { text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。"; color: #e0a060; font-size: 13px; wrap: word-wrap; vertical-alignment: center; } } } } // ---------------------------------------------------------------- 裁判端 /* 裁判终端:两块画面左右并排 + 双方血量。 * * 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量 * 并排对称。 */ export component RefereeApp inherits Window { title: "cam-client — 裁判系统"; preferred-width: 1400px; preferred-height: 800px; in property frame-a; in property frame-b; in property stats-a; in property stats-b; in property health-a; in property health-b; background: #101014; VerticalLayout { spacing: 0; // 两路画面等宽并排,各占一半。 HorizontalLayout { padding: 0; spacing: 2px; Rectangle { background: #0a0a0d; vertical-stretch: 1; horizontal-stretch: 1; frame-a := Image { source: root.frame-a; image-fit: contain; width: parent.width; height: parent.height; horizontal-alignment: center; vertical-alignment: center; } } Rectangle { background: #0a0a0d; vertical-stretch: 1; horizontal-stretch: 1; frame-b := Image { source: root.frame-b; image-fit: contain; width: parent.width; height: parent.height; horizontal-alignment: center; vertical-alignment: center; } } } // 每路画面下方的队伍条:名字、血量、统计。 HorizontalLayout { padding: 0; spacing: 2px; Rectangle { background: #1c1c22; height: 78px; horizontal-stretch: 1; VerticalLayout { padding: 10px; spacing: 4px; Text { text: root.health-a.team-name + " " + root.stats-a.status-text + " " + root.stats-a.fps + " fps " + root.stats-a.resolution; color: root.stats-a.online ? #e8e8ec : #e0a060; font-size: 14px; font-weight: 600; } HorizontalLayout { padding: 0; spacing: 6px; for hp in root.health-a.hps: Rectangle { width: 40px; height: 14px; border-width: 1px; border-color: root.health-a.available ? #4a4a55 : #33333c; background: root.health-a.available ? #7ddc7d : #1a1a20; } } Text { text: "shown " + root.stats-a.frames-shown + " dropped " + root.stats-a.frames-dropped + " (" + root.stats-a.loss-percent + ")" + " gap " + root.stats-a.gap-ms; color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060; font-size: 12px; } } } Rectangle { background: #1c1c22; height: 78px; horizontal-stretch: 1; VerticalLayout { padding: 10px; spacing: 4px; Text { text: root.health-b.team-name + " " + root.stats-b.status-text + " " + root.stats-b.fps + " fps " + root.stats-b.resolution; color: root.stats-b.online ? #e8e8ec : #e0a060; font-size: 14px; font-weight: 600; } HorizontalLayout { padding: 0; spacing: 6px; for hp in root.health-b.hps: Rectangle { width: 40px; height: 14px; border-width: 1px; border-color: root.health-b.available ? #4a4a55 : #33333c; background: root.health-b.available ? #7ddc7d : #1a1a20; } } Text { text: "shown " + root.stats-b.frames-shown + " dropped " + root.stats-b.frames-dropped + " (" + root.stats-b.loss-percent + ")" + " gap " + root.stats-b.gap-ms; color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060; font-size: 12px; } } } } // 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。 Rectangle { background: #14141a; height: 26px; Text { text: root.health-a.placeholder; color: #6a6a75; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } } } }