同步屏客户端

master
cysamurai 2 months ago
parent 63232e89c1
commit eded921144

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
src-tauri/target/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Broadcast Client</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,29 @@
{
"name": "broadcast-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"test": "vitest",
"test:run": "vitest run",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2",
"element-plus": "^2.11.4",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^3.2.4",
"vue-tsc": "^2.1.10"
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,20 @@
[package]
name = "broadcast-client"
version = "0.1.0"
description = "Broadcast Ruler Client"
authors = ["team"]
edition = "2021"
[lib]
name = "broadcast_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-store = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

@ -0,0 +1,4 @@
fn main() {
println!("cargo:rustc-check-cfg=cfg(mobile)");
tauri_build::build()
}

@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for broadcast window",
"windows": ["main", "sync-config"],
"permissions": [
"core:default",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-start-dragging",
"opener:default",
"store:default"
]
}

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability for broadcast window","local":true,"windows":["main","sync-config"],"permissions":["core:default","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-start-dragging","opener:default","store:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,437 @@
use std::{
fs::{create_dir_all, OpenOptions},
io::Read,
net::TcpListener,
io::Write,
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
const SOCKET_PORT: u16 = 9501;
const SOCKET_STATUS_EVENT: &str = "socket-status";
const SOCKET_CALL_EVENT: &str = "socket-call-message";
const LOG_FILE_NAME: &str = "socket-service.log";
#[derive(Default)]
struct SocketServiceState {
runtime: Mutex<Option<SocketServiceRuntime>>,
}
struct SocketServiceRuntime {
running: Arc<AtomicBool>,
}
#[derive(Debug, Serialize, Clone)]
struct SocketStatusPayload {
running: bool,
port: u16,
}
#[derive(Debug, Deserialize)]
struct IncomingSocketPayload {
#[serde(rename = "ticketNumber")]
ticket_number: Option<String>,
#[serde(rename = "displayText")]
display_text: Option<String>,
#[serde(rename = "voiceText")]
voice_text: Option<String>,
flash: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct IncomingSocketMessage {
action: Option<String>,
#[serde(rename = "windowId")]
window_id: Option<u32>,
#[serde(rename = "Index", alias = "index")]
index: Option<u32>,
#[serde(rename = "windowName")]
window_name: Option<String>,
#[serde(rename = "WindowText", alias = "windowText")]
window_text: Option<String>,
#[serde(rename = "BizText", alias = "bizText")]
biz_text: Option<String>,
#[serde(rename = "ledAddress")]
led_address: Option<String>,
timestamp: Option<i64>,
#[serde(rename = "StatusText", alias = "statusText")]
status_text: Option<String>,
payload: Option<IncomingSocketPayload>,
}
#[derive(Debug, Serialize, Clone)]
struct SocketCallEventPayload {
#[serde(rename = "windowId")]
window_id: u32,
#[serde(rename = "displayText")]
display_text: String,
}
/// 应用入口:注册插件、菜单事件与前端命令。
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::default().build())
.manage(SocketServiceState::default())
.on_menu_event(|app, event| match event.id().as_ref() {
"open_sync_config" => {
let _ = ensure_config_window(app);
}
"quit_app" => app.exit(0),
_ => {}
})
.invoke_handler(tauri::generate_handler![
show_context_menu,
start_socket_service,
stop_socket_service,
get_socket_service_status
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// 弹出同步屏窗口的右键菜单。
#[tauri::command]
fn show_context_menu(window: tauri::Window) -> Result<(), String> {
use tauri::menu::{Menu, MenuItem};
let config_item = MenuItem::with_id(
window.app_handle(),
"open_sync_config",
"配置同步屏窗口",
true,
None::<&str>,
)
.map_err(|error| format!("创建菜单项失败: {error}"))?;
let quit_item = MenuItem::with_id(
window.app_handle(),
"quit_app",
"退出",
true,
None::<&str>,
)
.map_err(|error| format!("创建菜单项失败: {error}"))?;
let menu = Menu::with_items(window.app_handle(), &[&config_item, &quit_item])
.map_err(|error| format!("创建菜单失败: {error}"))?;
window
.popup_menu(&menu)
.map_err(|error| format!("弹出菜单失败: {error}"))
}
/// 启动本地 socket 服务9501并推送服务状态事件。
#[tauri::command]
fn start_socket_service(
app: tauri::AppHandle,
state: tauri::State<'_, SocketServiceState>,
) -> Result<SocketStatusPayload, String> {
let mut guard = state
.runtime
.lock()
.map_err(|_| "socket 状态锁获取失败".to_string())?;
if let Some(runtime) = guard.as_ref() {
if runtime.running.load(Ordering::SeqCst) {
append_socket_log(&app, "INFO", "socket 服务已在运行,忽略重复启动请求");
return Ok(SocketStatusPayload {
running: true,
port: SOCKET_PORT,
});
}
}
let listener = TcpListener::bind(("0.0.0.0", SOCKET_PORT))
.map_err(|error| format!("socket 服务启动失败: {error}"))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("socket 非阻塞配置失败: {error}"))?;
let running = Arc::new(AtomicBool::new(true));
let running_in_thread = Arc::clone(&running);
let app_in_thread = app.clone();
append_socket_log(
&app,
"INFO",
format!("socket 服务启动成功,开始监听端口 {}", SOCKET_PORT),
);
thread::spawn(move || {
emit_socket_status(&app_in_thread, true);
loop {
if !running_in_thread.load(Ordering::SeqCst) {
break;
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut buffer = Vec::<u8>::new();
if stream.read_to_end(&mut buffer).is_ok() {
append_socket_log(
&app_in_thread,
"INFO",
format!("socket 接收消息,字节长度 {}", buffer.len()),
);
dispatch_socket_messages(&app_in_thread, &buffer);
} else {
append_socket_log(&app_in_thread, "WARN", "socket 消息读取失败");
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(30));
}
Err(_) => {
thread::sleep(Duration::from_millis(50));
}
}
}
append_socket_log(&app_in_thread, "INFO", "socket 服务线程退出");
emit_socket_status(&app_in_thread, false);
});
*guard = Some(SocketServiceRuntime { running });
Ok(SocketStatusPayload {
running: true,
port: SOCKET_PORT,
})
}
/// 停止本地 socket 服务并推送状态事件。
#[tauri::command]
fn stop_socket_service(
app: tauri::AppHandle,
state: tauri::State<'_, SocketServiceState>,
) -> Result<SocketStatusPayload, String> {
let mut guard = state
.runtime
.lock()
.map_err(|_| "socket 状态锁获取失败".to_string())?;
if let Some(runtime) = guard.as_ref() {
runtime.running.store(false, Ordering::SeqCst);
append_socket_log(&app, "INFO", "收到停止请求socket 服务即将停止");
}
*guard = None;
emit_socket_status(&app, false);
Ok(SocketStatusPayload {
running: false,
port: SOCKET_PORT,
})
}
/// 查询 socket 服务当前状态(用于配置窗口初始化展示)。
#[tauri::command]
fn get_socket_service_status(state: tauri::State<'_, SocketServiceState>) -> SocketStatusPayload {
let running = state
.runtime
.lock()
.ok()
.and_then(|runtime| {
runtime
.as_ref()
.map(|svc| svc.running.load(Ordering::SeqCst))
})
.unwrap_or(false);
SocketStatusPayload {
running,
port: SOCKET_PORT,
}
}
fn emit_socket_status(app: &tauri::AppHandle, running: bool) {
let _ = app.emit(
SOCKET_STATUS_EVENT,
SocketStatusPayload {
running,
port: SOCKET_PORT,
},
);
}
fn dispatch_socket_messages(app: &tauri::AppHandle, bytes: &[u8]) {
if bytes.is_empty() {
append_socket_log(app, "WARN", "收到空消息,忽略处理");
return;
}
if let Ok(text) = std::str::from_utf8(bytes) {
append_socket_log(
app,
"DEBUG",
format!(
"收到原始报文: {}",
truncate_for_log(text.trim(), 2000)
),
);
// 先尝试整包 JSON再尝试按行解析多条消息。
if try_dispatch_one_message(app, text) {
return;
}
for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
append_socket_log(
app,
"DEBUG",
format!("拆分报文行: {}", truncate_for_log(line, 1200)),
);
let _ = try_dispatch_one_message(app, line);
}
} else {
append_socket_log(app, "WARN", "消息非 UTF-8 文本,忽略处理");
}
}
fn try_dispatch_one_message(app: &tauri::AppHandle, text: &str) -> bool {
let Ok(message) = serde_json::from_str::<IncomingSocketMessage>(text) else {
append_socket_log(
app,
"WARN",
format!("消息 JSON 解析失败,报文: {}", truncate_for_log(text, 1200)),
);
return false;
};
append_socket_log(
app,
"INFO",
format!(
"消息解析成功 action={:?} windowId={:?} index={:?} windowName={:?} windowText={:?} bizText={:?} ledAddress={:?} timestamp={:?} statusText={:?}",
message.action,
message.window_id,
message.index,
message.window_name,
message.window_text,
message.biz_text,
message.led_address,
message.timestamp,
message.status_text
),
);
let Some(window_id) = message.window_id.or(message.index) else {
append_socket_log(app, "WARN", "消息缺少 windowId 字段");
return true;
};
let payload = message.payload;
let display_text = payload
.as_ref()
.and_then(|p| p.display_text.clone())
.or(message.status_text.clone());
let Some(display_text) = display_text else {
append_socket_log(
app,
"WARN",
format!("窗口 {} 消息缺少 displayText/StatusText 字段", window_id),
);
return true;
};
append_socket_log(
app,
"INFO",
format!(
"窗口 {} payload 详情 ticketNumber={:?} displayText={} voiceText={:?} flash={:?}",
window_id,
payload.as_ref().and_then(|p| p.ticket_number.clone()),
truncate_for_log(&display_text, 500),
payload.as_ref().and_then(|p| p.voice_text.clone()),
payload.as_ref().and_then(|p| p.flash)
),
);
let _ = app.emit(
SOCKET_CALL_EVENT,
SocketCallEventPayload {
window_id,
display_text,
},
);
append_socket_log(app, "INFO", format!("窗口 {} 动态文本已更新", window_id));
true
}
fn append_socket_log<S: AsRef<str>>(app: &tauri::AppHandle, level: &str, message: S) {
let Ok(log_file_path) = resolve_log_file_path(app) else {
return;
};
if let Some(parent) = log_file_path.parent() {
let _ = create_dir_all(parent);
}
let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&log_file_path)
else {
return;
};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let line = format!("[{}][{}] {}\n", now_ms, level, message.as_ref());
let _ = file.write_all(line.as_bytes());
}
fn resolve_log_file_path(app: &tauri::AppHandle) -> tauri::Result<PathBuf> {
let log_dir = app.path().app_log_dir()?;
Ok(log_dir.join(LOG_FILE_NAME))
}
fn truncate_for_log(source: &str, max_chars: usize) -> String {
let mut chars = source.chars();
let mut output = String::new();
for _ in 0..max_chars {
let Some(ch) = chars.next() else {
return output;
};
output.push(ch);
}
if chars.next().is_some() {
output.push_str("...(truncated)");
}
output
}
/// 确保配置窗口存在:
/// - 已存在则激活聚焦;
/// - 不存在则创建后显示。
fn ensure_config_window(app: &tauri::AppHandle) -> Result<(), String> {
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
if let Some(window) = app.get_webview_window("sync-config") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return Ok(());
}
let window = WebviewWindowBuilder::new(app, "sync-config", WebviewUrl::App("/#/config".into()))
.title("配置同步屏窗口")
.inner_size(720.0, 460.0)
.resizable(true)
.decorations(true)
.always_on_top(true)
.build()
.map_err(|error| format!("创建配置窗口失败: {error}"))?;
let _ = window.show();
let _ = window.set_focus();
Ok(())
}

@ -0,0 +1,6 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// 原生启动入口,转交到库内 run()。
fn main() {
broadcast_client_lib::run()
}

@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "broadcast-client",
"version": "0.1.0",
"identifier": "com.ziyun.broadcastclient",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Broadcast Client",
"width": 1280,
"height": 256,
"x": 0,
"y": 0,
"decorations": false,
"transparent": false,
"shadow": false,
"alwaysOnTop": true,
"resizable": false,
"maximizable": false,
"fullscreen": false,
"visible": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all"
}
}

@ -0,0 +1,30 @@
<template>
<ConfigView v-if="isConfigPage" />
<BroadcastView v-else />
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import BroadcastView from "./views/BroadcastView.vue";
import ConfigView from "./views/ConfigView.vue";
// hash
const hash = ref(window.location.hash);
/**
* 同步当前 URL hash用于页面切换
*/
function syncHash() {
hash.value = window.location.hash;
}
onMounted(() => {
window.addEventListener("hashchange", syncHash);
});
onUnmounted(() => {
window.removeEventListener("hashchange", syncHash);
});
const isConfigPage = computed(() => hash.value === "#/config");
</script>

@ -0,0 +1,48 @@
<template>
<section
class="segment"
:style="{
left: `${segment.left}px`,
top: `${segment.top}px`,
width: `${segment.sliceWidth}px`,
height: `${segment.height}px`,
}"
>
<div
class="segment-track"
:style="{
width: `${totalWidth}px`,
height: `${segment.height}px`,
transform: `translateX(${-segment.sourceX}px)`,
}"
>
<RulerTicks
v-if="showRuler"
:ticks="ticks"
:total-width="totalWidth"
:segment-height="segment.height"
/>
</div>
<WindowAreasLayer :slices="segmentAreaSlices" />
</section>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Segment, Tick } from "../models/ruler";
import type { WindowAreaSlice } from "../services/windowAreaSliceService";
import RulerTicks from "./RulerTicks.vue";
import WindowAreasLayer from "./WindowAreasLayer.vue";
const props = defineProps<{
segment: Segment;
ticks: Tick[];
totalWidth: number;
showRuler: boolean;
windowAreaSlices: WindowAreaSlice[];
}>();
const segmentAreaSlices = computed(() =>
props.windowAreaSlices.filter((item) => item.segmentIndex === props.segment.index),
);
</script>

@ -0,0 +1,24 @@
<template>
<div class="ruler-track" :style="{ width: `${totalWidth}px`, height: `${segmentHeight}px` }">
<div
v-for="tick in ticks"
:key="`${tick.type}-${tick.x}`"
class="tick"
:class="tick.type"
:style="{ left: `${tick.x}px` }"
>
<span v-if="tick.type === 'major'" class="tick-label">{{ tick.label }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Tick } from "../models/ruler";
//
defineProps<{
ticks: Tick[];
totalWidth: number;
segmentHeight: number;
}>();
</script>

@ -0,0 +1,25 @@
<template>
<div class="children-layer">
<div
v-for="slice in slices"
:key="`${slice.childId}-${slice.segmentIndex}-${slice.renderLeft}`"
class="child-slice"
:class="slice.className"
:style="{
left: `${slice.renderLeft}px`,
top: `${slice.renderTop}px`,
width: `${slice.renderWidth}px`,
height: `${slice.renderHeight}px`,
}"
/>
</div>
</template>
<script setup lang="ts">
import type { ChildSlice } from "../models/ruler";
//
defineProps<{
slices: ChildSlice[];
}>();
</script>

@ -0,0 +1,68 @@
<template>
<div class="window-areas-layer">
<section
v-for="slice in slices"
:key="`${slice.area.id}-${slice.segmentIndex}-${slice.renderLeft}`"
class="window-area"
:style="{
left: `${slice.renderLeft}px`,
top: `${slice.renderTop}px`,
width: `${slice.renderWidth}px`,
height: `${slice.renderHeight}px`,
}"
>
<div
class="window-area-inner"
:style="{
width: `${slice.area.width}px`,
height: `${slice.area.height}px`,
transform: `translateX(${-slice.clipOffset}px)`,
}"
>
<div class="window-no-region">
<span
class="window-no-text"
:class="{ circle: slice.area.windowNumberCircle }"
:style="{
fontSize: `${slice.area.windowNumberStyle.fontSize}px`,
color: slice.area.windowNumberStyle.color,
fontWeight: slice.area.windowNumberStyle.fontWeight,
}"
>
{{ slice.area.windowNumber }}
</span>
</div>
<div class="window-text-region">
<div
class="window-text-line"
:style="{
fontSize: `${slice.area.staticTextStyle.fontSize}px`,
color: slice.area.staticTextStyle.color,
fontWeight: slice.area.staticTextStyle.fontWeight,
}"
>
{{ slice.area.staticText }}
</div>
<div
class="window-text-line"
:style="{
fontSize: `${slice.area.dynamicTextStyle.fontSize}px`,
color: slice.area.dynamicTextStyle.color,
fontWeight: slice.area.dynamicTextStyle.fontWeight,
}"
>
{{ slice.area.dynamicText }}
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { WindowAreaSlice } from "../services/windowAreaSliceService";
defineProps<{
slices: WindowAreaSlice[];
}>();
</script>

@ -0,0 +1,56 @@
import { onMounted, onUnmounted, ref } from "vue";
import type { BroadcastConfig } from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import {
loadBroadcastConfig,
onBroadcastConfigUpdated,
saveBroadcastConfig,
} from "../services/configStore";
/**
*
* -
* -
*/
export function useBroadcastConfig() {
const config = ref<BroadcastConfig>({ ...DEFAULT_BROADCAST_CONFIG });
let unlisten: (() => void) | null = null;
/**
*
*/
async function refreshConfig() {
config.value = await loadBroadcastConfig();
}
/**
*
*/
async function patchConfig(patch: Partial<BroadcastConfig>) {
const nextConfig = { ...config.value, ...patch };
const saved = await saveBroadcastConfig(nextConfig);
config.value = saved;
return saved;
}
onMounted(() => {
void refreshConfig();
void onBroadcastConfigUpdated((nextConfig) => {
config.value = nextConfig;
}).then((off) => {
unlisten = off;
});
});
onUnmounted(() => {
unlisten?.();
unlisten = null;
});
return {
config,
refreshConfig,
patchConfig,
};
}

@ -0,0 +1,10 @@
import { computed, type Ref } from "vue";
import { buildTicks } from "../services/tickService";
/**
*
*/
export function useRulerTicks(totalWidth: Ref<number>) {
const ticks = computed(() => buildTicks(totalWidth.value));
return { ticks };
}

@ -0,0 +1,110 @@
import { onMounted, onUnmounted, ref, watch, type Ref } from "vue";
import {
LogicalSize,
PhysicalPosition,
currentMonitor,
getCurrentWindow,
} from "@tauri-apps/api/window";
import { buildSegments, normalizeScreenWidth } from "../services/segmentService";
import type { Segment } from "../models/ruler";
import { createDebouncedAsyncTask } from "../utils/timing";
const FALLBACK_SCREEN_WIDTH = 1920;
const RESIZE_DEBOUNCE_MS = 100;
/**
*
*
*/
export function useScreenInfo(
totalWidth: Ref<number>,
segmentHeight: Ref<number>,
segmentsRef?: Ref<Segment[]>,
configuredWindowHeight?: Ref<number | undefined>,
) {
const screenWidth = ref(FALLBACK_SCREEN_WIDTH);
/**
* 使退
*/
async function resolveScreenWidth() {
try {
const monitor = await currentMonitor();
const scaleFactor = monitor?.scaleFactor ?? 1;
const widthRaw = monitor?.size?.width ?? window.screen.width ?? FALLBACK_SCREEN_WIDTH;
const width = widthRaw / scaleFactor;
screenWidth.value = normalizeScreenWidth(width);
} catch {
screenWidth.value = normalizeScreenWidth(window.screen.width || FALLBACK_SCREEN_WIDTH);
}
}
/**
* Tauri
*/
async function syncWindowBounds() {
try {
const currentWindow = getCurrentWindow();
const segments = segmentsRef?.value?.length
? segmentsRef.value
: buildSegments(screenWidth.value, totalWidth.value, segmentHeight.value);
const calculatedHeight = Math.max(
segmentHeight.value,
...segments.map((item) => item.top + item.height),
);
const targetHeight =
configuredWindowHeight?.value && configuredWindowHeight.value > 0
? Math.floor(configuredWindowHeight.value)
: calculatedHeight;
await currentWindow.setPosition(new PhysicalPosition(0, 0));
await currentWindow.setSize(new LogicalSize(screenWidth.value, targetHeight));
} catch {
// Tauri API 不可用时使用浏览器默认行为。
}
}
/**
*
*/
async function refreshLayout() {
await resolveScreenWidth();
await syncWindowBounds();
}
const resizeTask = createDebouncedAsyncTask(refreshLayout, RESIZE_DEBOUNCE_MS);
/**
* resize 使
*/
function handleResize() {
resizeTask.trigger();
}
onMounted(() => {
void refreshLayout();
window.addEventListener("resize", handleResize);
});
watch(
[
totalWidth,
segmentHeight,
...(segmentsRef ? [segmentsRef] : []),
...(configuredWindowHeight ? [configuredWindowHeight] : []),
],
() => {
void refreshLayout();
},
);
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
resizeTask.cancel();
});
return {
screenWidth,
refreshLayout,
};
}

@ -0,0 +1,17 @@
import { computed, type Ref } from "vue";
import { buildSegments } from "../services/segmentService";
/**
*
*/
export function useSegmentLayout(
screenWidth: Ref<number>,
totalWidth: Ref<number>,
segmentHeight: Ref<number>,
) {
const segments = computed(() =>
buildSegments(screenWidth.value, totalWidth.value, segmentHeight.value),
);
const containerHeight = computed(() => segments.value.length * segmentHeight.value);
return { segments, containerHeight };
}

@ -0,0 +1 @@
/// <reference types="vite/client" />

@ -0,0 +1,8 @@
import { createApp } from "vue";
import ElementPlus from "element-plus/es";
import "element-plus/theme-chalk/index.css";
import App from "./App.vue";
import "./styles.css";
// 前端入口:挂载 Vue 根组件。
createApp(App).use(ElementPlus).mount("#app");

@ -0,0 +1,50 @@
import { DEFAULT_SEGMENT_HEIGHT, DEFAULT_TOTAL_WIDTH } from "./ruler";
// 单个分段可配置项(长度与窗口内定位坐标)。
export interface SegmentConfigItem {
length: number;
x: number;
y: number;
}
// 文本样式配置。
export interface TextStyleConfig {
fontSize: number;
color: string;
fontWeight: number;
}
// 子div窗口区域配置模型。
export interface ChildWindowAreaConfig {
id: string;
windowId: number;
width: number;
height: number;
x: number;
y: number;
windowNumber: string;
windowNumberCircle: boolean;
windowNumberStyle: TextStyleConfig;
staticText: string;
staticTextStyle: TextStyleConfig;
dynamicText: string;
dynamicTextStyle: TextStyleConfig;
}
// 广播渲染配置模型。
export interface BroadcastConfig {
totalWidth: number;
segmentHeight: number;
showRuler: boolean;
segments: SegmentConfigItem[];
windowAreas: ChildWindowAreaConfig[];
}
// 应用启动时使用的默认配置。
export const DEFAULT_BROADCAST_CONFIG: BroadcastConfig = {
totalWidth: DEFAULT_TOTAL_WIDTH,
segmentHeight: DEFAULT_SEGMENT_HEIGHT,
showRuler: true,
segments: [],
windowAreas: [],
};

@ -0,0 +1,48 @@
// 默认主容器宽度(可被配置覆盖)。
export const DEFAULT_TOTAL_WIDTH = 800;
// 默认分段高度(单位 px
export const DEFAULT_SEGMENT_HEIGHT = 64;
// 小刻度间隔(单位 px
export const MINOR_TICK_GAP = 10;
// 大刻度间隔(单位 px
export const MAJOR_TICK_GAP = 100;
export type TickType = "minor" | "major";
export interface Tick {
x: number;
type: TickType;
label?: string;
}
// 分段渲染数据模型。
export interface Segment {
index: number;
sourceX: number;
sliceWidth: number;
left: number;
top: number;
height: number;
}
// 主容器内可跨段显示的子元素模型。
export interface ChildElement {
id: string;
left: number;
width: number;
top: number;
height: number;
className?: string;
}
// 子元素切片后的渲染模型。
export interface ChildSlice {
childId: string;
segmentIndex: number;
renderLeft: number;
renderTop: number;
renderWidth: number;
renderHeight: number;
clipOffset: number;
className?: string;
}

@ -0,0 +1,39 @@
import type { ChildElement, ChildSlice, Segment } from "../models/ruler";
/**
*
*/
export function buildChildSlices(children: ChildElement[], segments: Segment[]): ChildSlice[] {
const slices: ChildSlice[] = [];
for (const child of children) {
const childStart = child.left;
const childEnd = child.left + child.width;
for (const segment of segments) {
const segmentStart = segment.sourceX;
const segmentEnd = segmentStart + segment.sliceWidth;
const visibleStart = Math.max(childStart, segmentStart);
const visibleEnd = Math.min(childEnd, segmentEnd);
const visibleWidth = visibleEnd - visibleStart;
if (visibleWidth <= 0) {
continue;
}
slices.push({
childId: child.id,
segmentIndex: segment.index,
renderLeft: visibleStart - segmentStart,
renderTop: child.top,
renderWidth: visibleWidth,
renderHeight: child.height,
clipOffset: Math.max(0, segmentStart - childStart),
className: child.className,
});
}
}
return slices;
}

@ -0,0 +1,200 @@
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
import { load, type Store } from "@tauri-apps/plugin-store";
import type { BroadcastConfig, ChildWindowAreaConfig, TextStyleConfig } from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import {
normalizeSegmentConfigItem,
normalizeSegmentHeight,
normalizeScreenWidth,
normalizeTotalWidth,
} from "./segmentService";
const STORE_PATH = "broadcast-config.json";
const CONFIG_KEY = "runtime_broadcast_config";
const CONFIG_EVENT = "broadcast-config-updated";
const LOCAL_STORAGE_KEY = "broadcast_config_local_fallback";
let storePromise: Promise<Store> | null = null;
function normalizeFontSize(raw: unknown, fallback: number): number {
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
}
function normalizeFontWeight(raw: unknown, fallback: number): number {
return typeof raw === "number" && Number.isFinite(raw) && raw >= 100 ? Math.floor(raw) : fallback;
}
function normalizeColor(raw: unknown, fallback: string): string {
if (typeof raw !== "string") {
return fallback;
}
const value = raw.trim();
return value.length > 0 ? value : fallback;
}
function normalizeTextStyle(raw: unknown, fallback: TextStyleConfig): TextStyleConfig {
const source = (raw ?? {}) as Partial<TextStyleConfig>;
return {
fontSize: normalizeFontSize(source.fontSize, fallback.fontSize),
color: normalizeColor(source.color, fallback.color),
fontWeight: normalizeFontWeight(source.fontWeight, fallback.fontWeight),
};
}
function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig {
const source = (raw ?? {}) as Partial<ChildWindowAreaConfig>;
return {
id: typeof source.id === "string" && source.id.trim() ? source.id : `area-${index + 1}`,
windowId:
typeof source.windowId === "number" && Number.isFinite(source.windowId) && source.windowId > 0
? Math.floor(source.windowId)
: index + 1,
width: normalizeFontSize(source.width, 220),
height: normalizeFontSize(source.height, 48),
x: typeof source.x === "number" && Number.isFinite(source.x) ? Math.max(0, Math.floor(source.x)) : 0,
y: typeof source.y === "number" && Number.isFinite(source.y) ? Math.max(0, Math.floor(source.y)) : 0,
windowNumber:
typeof source.windowNumber === "string" && source.windowNumber.trim()
? source.windowNumber
: String(index + 1),
windowNumberCircle: source.windowNumberCircle === true,
windowNumberStyle: normalizeTextStyle(source.windowNumberStyle, {
fontSize: 16,
color: "#ffffff",
fontWeight: 700,
}),
staticText: typeof source.staticText === "string" ? source.staticText : "静态文本",
staticTextStyle: normalizeTextStyle(source.staticTextStyle, {
fontSize: 14,
color: "#ffffff",
fontWeight: 500,
}),
dynamicText: typeof source.dynamicText === "string" ? source.dynamicText : "动态文本",
dynamicTextStyle: normalizeTextStyle(source.dynamicTextStyle, {
fontSize: 14,
color: "#ffffff",
fontWeight: 500,
}),
};
}
/**
*
*/
function normalizeConfig(raw: unknown): BroadcastConfig {
const source = (raw ?? {}) as Partial<BroadcastConfig>;
const segmentHeight = normalizeSegmentHeight(
source.segmentHeight ?? DEFAULT_BROADCAST_CONFIG.segmentHeight,
);
const totalWidth = normalizeTotalWidth(source.totalWidth ?? DEFAULT_BROADCAST_CONFIG.totalWidth);
const screenWidth = normalizeScreenWidth(window.screen.width || 1920);
const segmentsRaw = Array.isArray(source.segments) ? source.segments : [];
const windowAreasRaw = Array.isArray(source.windowAreas) ? source.windowAreas : [];
return {
totalWidth: Math.min(totalWidth, screenWidth),
segmentHeight,
showRuler:
typeof source.showRuler === "boolean" ? source.showRuler : DEFAULT_BROADCAST_CONFIG.showRuler,
segments: segmentsRaw.map((item, index) =>
normalizeSegmentConfigItem(item, index, screenWidth, segmentHeight),
),
windowAreas: windowAreasRaw.map((item, index) => normalizeWindowArea(item, index)),
};
}
/**
* store
*/
async function getStore(): Promise<Store> {
if (storePromise === null) {
storePromise = load(STORE_PATH, { defaults: {}, autoSave: false });
}
return storePromise;
}
/**
* store 退
*/
function getFallbackConfig(): BroadcastConfig {
try {
const raw = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (!raw) {
return DEFAULT_BROADCAST_CONFIG;
}
return normalizeConfig(JSON.parse(raw));
} catch {
return DEFAULT_BROADCAST_CONFIG;
}
}
/**
* store 退
*/
function setFallbackConfig(config: BroadcastConfig) {
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config));
} catch {
// 本地存储失败时忽略,保持应用可用。
}
}
/**
* 广
*/
async function emitConfigUpdated(config: BroadcastConfig) {
try {
await emit<BroadcastConfig>(CONFIG_EVENT, config);
} catch {
// 浏览器模式下无宿主事件总线,忽略即可。
}
}
/**
*
*/
export async function loadBroadcastConfig(): Promise<BroadcastConfig> {
try {
const store = await getStore();
const saved = await store.get<BroadcastConfig>(CONFIG_KEY);
const config = saved ? normalizeConfig(saved) : DEFAULT_BROADCAST_CONFIG;
if (!saved) {
await store.set(CONFIG_KEY, config);
await store.save();
}
return config;
} catch {
return getFallbackConfig();
}
}
/**
* store广
*/
export async function saveBroadcastConfig(payload: BroadcastConfig): Promise<BroadcastConfig> {
const config = normalizeConfig(payload);
try {
const store = await getStore();
await store.set(CONFIG_KEY, config);
await store.save();
} catch {
setFallbackConfig(config);
}
await emitConfigUpdated(config);
return config;
}
/**
*
*/
export async function onBroadcastConfigUpdated(
handler: (config: BroadcastConfig) => void,
): Promise<UnlistenFn> {
try {
return await listen<BroadcastConfig>(CONFIG_EVENT, (event) => {
handler(normalizeConfig(event.payload));
});
} catch {
return () => {};
}
}

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SEGMENT_HEIGHT } from "../models/ruler";
import { buildSegments, normalizeScreenWidth } from "./segmentService";
const TOTAL_WIDTH = 5000;
// 分段算法核心用例。
describe("segmentService", () => {
// 非法屏宽应回退默认值。
it("normalizes invalid screen width", () => {
expect(normalizeScreenWidth(0)).toBe(1920);
expect(normalizeScreenWidth(-1)).toBe(1920);
expect(normalizeScreenWidth(Number.NaN)).toBe(1920);
});
// 分段数量与前两段布局应符合规则。
it("builds segments with correct count and size", () => {
const width = 1080;
const segments = buildSegments(width, TOTAL_WIDTH, DEFAULT_SEGMENT_HEIGHT);
expect(segments.length).toBe(Math.ceil(TOTAL_WIDTH / width));
expect(segments[0]).toMatchObject({
index: 0,
sourceX: 0,
sliceWidth: 1080,
top: 0,
height: DEFAULT_SEGMENT_HEIGHT,
});
expect(segments[1]).toMatchObject({
index: 1,
sourceX: 1080,
sliceWidth: 1080,
top: DEFAULT_SEGMENT_HEIGHT,
height: DEFAULT_SEGMENT_HEIGHT,
});
});
// 最后一段应被裁剪到剩余宽度。
it("clips the last segment width correctly", () => {
const width = 1080;
const segments = buildSegments(width, TOTAL_WIDTH, DEFAULT_SEGMENT_HEIGHT);
const last = segments[segments.length - 1];
expect(last.sliceWidth).toBe(TOTAL_WIDTH - 1080 * 4);
expect(last.sourceX + last.sliceWidth).toBe(TOTAL_WIDTH);
});
});

@ -0,0 +1,125 @@
import type { SegmentConfigItem } from "../models/config";
import { DEFAULT_SEGMENT_HEIGHT, DEFAULT_TOTAL_WIDTH, type Segment } from "../models/ruler";
/**
*
*/
export function normalizeScreenWidth(rawWidth: number): number {
return Number.isFinite(rawWidth) && rawWidth > 0 ? Math.floor(rawWidth) : 1920;
}
/**
* 退
*/
export function normalizeTotalWidth(rawWidth: number): number {
return Number.isFinite(rawWidth) && rawWidth > 0 ? Math.floor(rawWidth) : DEFAULT_TOTAL_WIDTH;
}
/**
*
*/
export function normalizeSegmentHeight(rawHeight: number): number {
return Number.isFinite(rawHeight) && rawHeight > 0 ? Math.floor(rawHeight) : DEFAULT_SEGMENT_HEIGHT;
}
/**
* +
*/
export function buildSegments(
screenWidth: number,
totalWidth: number = DEFAULT_TOTAL_WIDTH,
segmentHeight: number = DEFAULT_SEGMENT_HEIGHT,
): Segment[] {
const safeWidth = normalizeScreenWidth(screenWidth);
const safeTotalWidth = normalizeTotalWidth(totalWidth);
const safeSegmentHeight = normalizeSegmentHeight(segmentHeight);
const count = Math.ceil(safeTotalWidth / safeWidth);
const segments: Segment[] = [];
for (let i = 0; i < count; i += 1) {
const sourceX = i * safeWidth;
const remainWidth = safeTotalWidth - sourceX;
const sliceWidth = Math.max(0, Math.min(safeWidth, remainWidth));
segments.push({
index: i,
sourceX,
sliceWidth,
left: 0,
top: i * safeSegmentHeight,
height: safeSegmentHeight,
});
}
return segments;
}
/**
*
*/
export function normalizeSegmentConfigItem(
item: Partial<SegmentConfigItem> | undefined,
index: number,
screenWidth: number,
segmentHeight: number,
): SegmentConfigItem {
const safeScreenWidth = normalizeScreenWidth(screenWidth);
const safeSegmentHeight = normalizeSegmentHeight(segmentHeight);
const lengthRaw = item?.length ?? safeScreenWidth;
const xRaw = item?.x ?? 0;
const yRaw = item?.y ?? index * safeSegmentHeight;
return {
length: Math.max(1, Math.min(safeScreenWidth, Math.floor(lengthRaw))),
x: Number.isFinite(xRaw) ? Math.max(0, Math.floor(xRaw)) : 0,
y: Number.isFinite(yRaw) ? Math.max(0, Math.floor(yRaw)) : index * safeSegmentHeight,
};
}
/**
* 使退
*/
export function buildSegmentsFromConfig(
segmentConfigs: SegmentConfigItem[],
screenWidth: number,
totalWidth: number,
segmentHeight: number,
): Segment[] {
const safeScreenWidth = normalizeScreenWidth(screenWidth);
const safeTotalWidth = normalizeTotalWidth(totalWidth);
const safeSegmentHeight = normalizeSegmentHeight(segmentHeight);
const normalized = segmentConfigs.map((item, index) =>
normalizeSegmentConfigItem(item, index, safeScreenWidth, safeSegmentHeight),
);
if (normalized.length === 0) {
return buildSegments(safeScreenWidth, safeTotalWidth, safeSegmentHeight);
}
const segments: Segment[] = [];
let sourceCursor = 0;
for (let i = 0; i < normalized.length; i += 1) {
if (sourceCursor >= safeTotalWidth) {
break;
}
const current = normalized[i];
const remainWidth = safeTotalWidth - sourceCursor;
const sliceWidth = Math.min(current.length, remainWidth);
segments.push({
index: i,
sourceX: sourceCursor,
sliceWidth,
left: current.x,
top: current.y,
height: safeSegmentHeight,
});
sourceCursor += sliceWidth;
}
if (segments.length === 0) {
return buildSegments(safeScreenWidth, safeTotalWidth, safeSegmentHeight);
}
return segments;
}

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { MAJOR_TICK_GAP, MINOR_TICK_GAP } from "../models/ruler";
import { buildTicks } from "./tickService";
const TOTAL_WIDTH = 800;
// 刻度生成规则验证。
describe("tickService", () => {
// 刻度应覆盖整个主容器宽度。
it("builds ticks covering full ruler width", () => {
const ticks = buildTicks(TOTAL_WIDTH);
expect(ticks[0].x).toBe(0);
expect(ticks[ticks.length - 1].x).toBe(TOTAL_WIDTH);
expect(ticks.length).toBe(TOTAL_WIDTH / MINOR_TICK_GAP + 1);
});
// 每 100px 应生成一个大刻度并附带标签。
it("marks major ticks every 100px", () => {
const ticks = buildTicks(TOTAL_WIDTH);
const majors = ticks.filter((tick) => tick.type === "major");
expect(majors[0]).toMatchObject({ x: 0, label: "0" });
expect(majors[1]).toMatchObject({ x: MAJOR_TICK_GAP, label: "100" });
expect(majors.every((tick) => tick.x % MAJOR_TICK_GAP === 0)).toBe(true);
});
});

@ -0,0 +1,32 @@
import {
DEFAULT_TOTAL_WIDTH,
MAJOR_TICK_GAP,
MINOR_TICK_GAP,
type Tick,
} from "../models/ruler";
import { normalizeTotalWidth } from "./segmentService";
const tickCache = new Map<number, Tick[]>();
/**
* totalWidth
*/
export function buildTicks(totalWidth: number = DEFAULT_TOTAL_WIDTH): Tick[] {
const safeTotalWidth = normalizeTotalWidth(totalWidth);
const cachedTicks = tickCache.get(safeTotalWidth);
if (cachedTicks) {
return cachedTicks;
}
const ticks: Tick[] = [];
for (let x = 0; x <= safeTotalWidth; x += MINOR_TICK_GAP) {
if (x % MAJOR_TICK_GAP === 0) {
ticks.push({ x, type: "major", label: String(x) });
} else {
ticks.push({ x, type: "minor" });
}
}
tickCache.set(safeTotalWidth, ticks);
return ticks;
}

@ -0,0 +1,52 @@
import type { ChildWindowAreaConfig } from "../models/config";
import type { Segment } from "../models/ruler";
export interface WindowAreaSlice {
area: ChildWindowAreaConfig;
segmentIndex: number;
renderLeft: number;
renderTop: number;
renderWidth: number;
renderHeight: number;
clipOffset: number;
}
/**
* sourceX
*/
export function buildWindowAreaSlices(
areas: ChildWindowAreaConfig[],
segments: Segment[],
): WindowAreaSlice[] {
const slices: WindowAreaSlice[] = [];
for (const area of areas) {
const areaStart = area.x;
const areaEnd = area.x + area.width;
for (const segment of segments) {
const segmentStart = segment.sourceX;
const segmentEnd = segmentStart + segment.sliceWidth;
const visibleStart = Math.max(areaStart, segmentStart);
const visibleEnd = Math.min(areaEnd, segmentEnd);
const visibleWidth = visibleEnd - visibleStart;
if (visibleWidth <= 0) {
continue;
}
slices.push({
area,
segmentIndex: segment.index,
renderLeft: visibleStart - segmentStart,
renderTop: area.y,
renderWidth: visibleWidth,
renderHeight: area.height,
clipOffset: Math.max(0, segmentStart - areaStart),
});
}
}
return slices;
}

@ -0,0 +1,393 @@
:root {
color-scheme: light;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 0 !important;
background: #000;
}
.broadcast-root {
position: relative;
overflow: hidden;
background: #000;
}
.segment {
position: absolute;
overflow: hidden;
}
.segment-track {
position: absolute;
left: 0;
top: 0;
height: 64px;
}
.ruler-track {
position: relative;
height: 64px;
}
.tick {
position: absolute;
bottom: 0;
width: 1px;
background: #7f8da8;
}
.tick.minor {
height: 16px;
opacity: 0.85;
}
.tick.major {
height: 34px;
background: #c4d2ea;
}
.tick-label {
position: absolute;
top: -20px;
left: 4px;
font-size: 10px;
line-height: 1;
color: #d9e4ff;
opacity: 0.9;
white-space: nowrap;
}
.children-layer {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.child-slice {
position: absolute;
border-radius: 0;
background: linear-gradient(90deg, #27ae60 0%, #2ecc71 100%);
}
.child-banner.alt {
background: linear-gradient(90deg, #8e44ad 0%, #9b59b6 100%);
}
.child-banner.warn {
background: linear-gradient(90deg, #d35400 0%, #f39c12 100%);
}
.config-root {
width: 100%;
height: 100%;
background: #eef1f6;
color: #111;
padding: 0 16px 16px;
overflow: auto;
}
.config-page {
display: grid;
gap: 12px;
}
.config-card {
border: 1px solid #ebeef5;
}
.config-collapse {
border: 1px solid #b8c3d9;
border-radius: 6px;
background: #d9e1f0;
overflow: hidden;
}
.config-collapse .el-collapse-item__header {
padding: 0 14px;
background: #d0dbef;
color: #1f2d3d;
border-bottom: 1px solid #b8c3d9;
}
.config-collapse .el-collapse-item__wrap {
background: #f8fbff;
}
.config-collapse .el-collapse-item__content {
padding: 14px;
}
.panel-scroll {
max-height: 42vh;
overflow: auto;
padding-right: 6px;
}
.panel-scroll--tall {
max-height: 56vh;
}
.card-header {
font-weight: 600;
}
.row-between {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.area-list {
display: grid;
gap: 10px;
}
.area-item {
background: #ffffff;
}
.el-input-number {
width: 100%;
}
.config-title {
margin: 12px 0;
font-size: 16px;
}
.config-desc {
margin: 0;
color: #666;
font-size: 14px;
}
.config-form {
margin-top: 16px;
display: grid;
gap: 10px;
max-width: 480px;
}
.field-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.field-row input[type="number"] {
width: 160px;
padding: 6px 8px;
background: #fff;
border: 1px solid #c8c8c8;
color: #111;
}
.checkbox-row {
justify-content: flex-start;
}
.segments-panel {
margin-top: 20px;
}
.segments-panel h2 {
margin: 0 0 8px;
font-size: 16px;
}
.segment-grid {
margin-top: 8px;
display: grid;
grid-template-columns: 90px 120px 160px;
gap: 6px 10px;
font-size: 13px;
}
.segment-grid-header {
color: #95a5a6;
}
.actions-row {
display: flex;
gap: 8px;
margin-top: 14px;
}
.btn {
border: 1px solid #c8c8c8;
background: #fff;
color: #222;
padding: 6px 10px;
cursor: pointer;
}
.btn.primary {
background: #1677ff;
color: #fff;
border-color: #1677ff;
}
.segment-list {
margin-top: 10px;
display: grid;
gap: 8px;
}
.segment-row {
display: grid;
grid-template-columns: 44px 110px 90px 90px 70px;
gap: 8px;
align-items: center;
}
.segment-row input[type="number"] {
width: 100%;
padding: 4px 6px;
border: 1px solid #c8c8c8;
}
.save-hint {
margin-top: 8px;
color: #666;
font-size: 12px;
}
.socket-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.socket-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.socket-status.running .socket-dot {
background: #2ecc71;
box-shadow: 0 0 6px rgba(46, 204, 113, 0.85);
}
.socket-status.stopped .socket-dot {
background: #e74c3c;
box-shadow: 0 0 6px rgba(231, 76, 60, 0.85);
}
.areas-panel {
margin-top: 18px;
padding-bottom: 18px;
}
.window-area-list {
margin-top: 10px;
display: grid;
gap: 12px;
}
.window-area-card {
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 10px;
}
.window-area-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.window-area-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 12px;
margin-bottom: 8px;
}
.window-area-grid .field-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.window-area-grid input[type="text"],
.window-area-grid input[type="number"] {
width: 100%;
padding: 6px 8px;
border: 1px solid #c8c8c8;
}
.window-areas-layer {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.window-area {
position: absolute;
overflow: hidden;
background: #000;
border: none;
}
.window-area-inner {
display: flex;
}
.window-no-region {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border-right: none;
background: #000;
}
.window-no-text.circle {
width: 2.2em;
height: 2.2em;
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.window-text-region {
flex: 2.5;
display: flex;
flex-direction: column;
background: #000;
}
.window-text-line {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}

@ -0,0 +1,60 @@
/**
*
* -
* -
*/
export function createDebouncedAsyncTask(task: () => Promise<void>, delayMs: number) {
let timer: number | null = null;
let running = false;
let queued = false;
/**
*
*/
async function runTask() {
if (running) {
queued = true;
return;
}
running = true;
try {
await task();
} finally {
running = false;
if (queued) {
queued = false;
await runTask();
}
}
}
/**
*
*/
function trigger() {
if (timer !== null) {
window.clearTimeout(timer);
}
timer = window.setTimeout(() => {
timer = null;
void runTask();
}, delayMs);
}
/**
*
*/
function cancel() {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
}
return {
trigger,
cancel,
};
}

@ -0,0 +1,106 @@
<template>
<main
class="broadcast-root"
:style="{ width: `${screenWidth}px`, height: `${containerHeight}px` }"
@contextmenu.prevent="showWindowMenu"
>
<RulerSegment
v-for="segment in segments"
:key="segment.index"
:segment="segment"
:ticks="ticks"
:total-width="totalWidth"
:show-ruler="showRuler"
:window-area-slices="windowAreaSlices"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import RulerSegment from "../components/RulerSegment.vue";
import { useBroadcastConfig } from "../composables/useBroadcastConfig";
import { useRulerTicks } from "../composables/useRulerTicks";
import { useScreenInfo } from "../composables/useScreenInfo";
import type { ChildWindowAreaConfig } from "../models/config";
import { buildSegmentsFromConfig } from "../services/segmentService";
import { buildWindowAreaSlices } from "../services/windowAreaSliceService";
const { config } = useBroadcastConfig();
const totalWidth = computed(() => config.value.totalWidth);
const segmentHeight = computed(() => config.value.segmentHeight);
const showRuler = computed(() => config.value.showRuler);
const socketDynamicTexts = ref<Record<number, string>>({});
let socketMessageUnlisten: UnlistenFn | null = null;
const windowAreas = computed<ChildWindowAreaConfig[]>(() =>
config.value.windowAreas.map((area) => ({
...area,
dynamicText: socketDynamicTexts.value[area.windowId] ?? area.dynamicText,
})),
);
// 使
const configuredWindowHeight = computed(() => {
if (config.value.segments.length === 0) {
return undefined;
}
const maxY = Math.max(...config.value.segments.map((item) => Math.max(0, item.y)));
return maxY + segmentHeight.value;
});
const { screenWidth } = useScreenInfo(totalWidth, segmentHeight, undefined, configuredWindowHeight);
const segments = computed(() =>
buildSegmentsFromConfig(
config.value.segments,
screenWidth.value,
totalWidth.value,
segmentHeight.value,
),
);
const containerHeight = computed(() => {
if (segments.value.length === 0) {
return segmentHeight.value;
}
return Math.max(...segments.value.map((item) => item.top + item.height));
});
const { ticks } = useRulerTicks(totalWidth);
const windowAreaSlices = computed(() => buildWindowAreaSlices(windowAreas.value, segments.value));
/**
* 右键触发原生菜单配置窗口 / 退出
*/
async function showWindowMenu() {
try {
await invoke("show_context_menu");
} catch {
//
}
}
onMounted(async () => {
try {
socketMessageUnlisten = await listen<{ windowId: number; displayText: string }>(
"socket-call-message",
(event) => {
const payload = event.payload;
if (!payload || typeof payload.windowId !== "number") {
return;
}
socketDynamicTexts.value = {
...socketDynamicTexts.value,
[payload.windowId]: payload.displayText ?? "",
};
},
);
} catch {
socketMessageUnlisten = null;
}
});
onUnmounted(() => {
socketMessageUnlisten?.();
socketMessageUnlisten = null;
});
</script>

@ -0,0 +1,477 @@
<template>
<main class="config-root config-page">
<el-page-header content="配置同步屏窗口" />
<el-collapse v-model="activePanels" class="config-collapse">
<el-collapse-item name="base">
<template #title>
<div class="card-header">基础配置</div>
</template>
<div class="panel-scroll">
<el-form label-width="140px">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="主div总长度(px)">
<el-input-number v-model="draft.totalWidth" :min="1" :max="screenWidth" :step="1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="主div高度(px)">
<el-input-number v-model="draft.segmentHeight" :min="1" :step="1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="标尺显示">
<el-switch v-model="draft.showRuler" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</el-collapse-item>
<el-collapse-item name="segments">
<template #title>
<div class="card-header row-between">
<span>分段列表主屏宽度上限 {{ screenWidth }}px</span>
<el-button type="primary" plain size="small" @click.stop="addSegment">添加分段</el-button>
</div>
</template>
<div class="panel-scroll">
<el-table :data="draft.segments" border size="small">
<el-table-column type="index" label="#" width="56" />
<el-table-column label="段长度(px)" width="140">
<template #default="{ row }">
<el-input-number v-model="row.length" :min="1" :max="screenWidth" :step="1" />
</template>
</el-table-column>
<el-table-column label="X" width="120">
<template #default="{ row }">
<el-input-number v-model="row.x" :min="0" :step="1" />
</template>
</el-table-column>
<el-table-column label="Y" width="120">
<template #default="{ row }">
<el-input-number v-model="row.y" :min="0" :step="1" />
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button type="danger" link @click="removeSegment($index)"></el-button>
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">应用后预览</el-divider>
<el-table :data="appliedSegments" border size="small">
<el-table-column type="index" label="#" width="56" />
<el-table-column prop="sliceWidth" label="段长度(px)" width="140" />
<el-table-column label="左顶点坐标">
<template #default="{ row }">({{ row.left }}, {{ row.top }})</template>
</el-table-column>
</el-table>
</div>
</el-collapse-item>
<el-collapse-item name="areas">
<template #title>
<div class="card-header row-between">
<span>子div窗口区域</span>
<el-button type="primary" plain size="small" @click.stop="addWindowArea">添加子div</el-button>
</div>
</template>
<div class="panel-scroll panel-scroll--tall area-list">
<el-card v-for="(area, index) in draft.windowAreas" :key="area.id" class="area-item" shadow="never">
<template #header>
<div class="row-between">
<strong>子div {{ index + 1 }}</strong>
<el-button type="danger" link @click="removeWindowArea(index)"></el-button>
</div>
</template>
<el-form label-width="120px" size="small">
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="窗口区域编号">
<el-input-number v-model="area.windowId" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="宽度">
<el-input-number v-model="area.width" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="高度">
<el-input-number v-model="area.height" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="X">
<el-input-number v-model="area.x" :min="0" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y">
<el-input-number v-model="area.y" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">窗口号区域占比 1</el-divider>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="窗口编号">
<el-input v-model="area.windowNumber" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆圈边框">
<el-switch v-model="area.windowNumberCircle" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="字号">
<el-input-number v-model="area.windowNumberStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="颜色">
<el-input v-model="area.windowNumberStyle.color" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="粗细">
<el-input-number v-model="area.windowNumberStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">文本区域占比 2.5静态/动态=1:1</el-divider>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="静态文本">
<el-input v-model="area.staticText" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="静态字号">
<el-input-number v-model="area.staticTextStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="静态颜色">
<el-input v-model="area.staticTextStyle.color" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="静态粗细">
<el-input-number v-model="area.staticTextStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="动态文本">
<el-input v-model="area.dynamicText" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="动态字号">
<el-input-number v-model="area.dynamicTextStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="动态颜色">
<el-input v-model="area.dynamicTextStyle.color" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="动态粗细">
<el-input-number v-model="area.dynamicTextStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</el-collapse-item>
</el-collapse>
<div class="actions-row">
<el-button type="primary" @click="saveConfig"></el-button>
<el-button type="success" plain :disabled="socketRunning" @click="startSocketService">
启动 Socket 服务
</el-button>
<el-button type="danger" plain :disabled="!socketRunning" @click="stopSocketService">
停止 Socket 服务
</el-button>
<span class="socket-status" :class="socketRunning ? 'running' : 'stopped'">
<i class="socket-dot" />
Socket {{ socketRunning ? "运行中" : "未启动" }} (9501)
</span>
<span class="save-hint">{{ saveMessage }}</span>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { currentMonitor } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { BroadcastConfig, ChildWindowAreaConfig, SegmentConfigItem } from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import { useBroadcastConfig } from "../composables/useBroadcastConfig";
import {
buildSegmentsFromConfig,
normalizeScreenWidth,
normalizeSegmentConfigItem,
normalizeSegmentHeight,
} from "../services/segmentService";
interface SocketStatusPayload {
running: boolean;
port: number;
}
//
const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920));
const { config, patchConfig } = useBroadcastConfig();
const saveMessage = ref("修改后请点击“保存配置”。");
const activePanels = ref(["base", "segments", "areas"]);
const socketRunning = ref(false);
// store -> draft
const syncingFromStore = ref(false);
let socketStatusUnlisten: UnlistenFn | null = null;
const draft = reactive<BroadcastConfig>({ ...DEFAULT_BROADCAST_CONFIG });
// 稿
const appliedSegments = computed(() =>
buildSegmentsFromConfig(
draft.segments,
screenWidth.value,
Math.min(draft.totalWidth, screenWidth.value),
draft.segmentHeight,
),
);
/**
* 深拷贝分段数组避免引用同一对象导致联动副作用
*/
function cloneSegments(segments: SegmentConfigItem[]) {
return segments.map((item) => ({ ...item }));
}
function cloneWindowAreas(areas: ChildWindowAreaConfig[]) {
return areas.map((item) => ({
...item,
windowNumberStyle: { ...item.windowNumberStyle },
staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle },
}));
}
function normalizeStyle(style: { fontSize: number; color: string; fontWeight: number }) {
return {
fontSize: Number.isFinite(style.fontSize) && style.fontSize > 0 ? Math.floor(style.fontSize) : 14,
color: typeof style.color === "string" && style.color.trim() ? style.color.trim() : "#ffffff",
fontWeight:
Number.isFinite(style.fontWeight) && style.fontWeight >= 100 ? Math.floor(style.fontWeight) : 500,
};
}
function createDefaultWindowArea(index: number): ChildWindowAreaConfig {
return {
id: `area-${Date.now()}-${index}`,
windowId: index + 1,
width: 220,
height: 48,
x: 0,
y: index * 50,
windowNumber: String(index + 1),
windowNumberCircle: false,
windowNumberStyle: {
fontSize: 16,
color: "#ffffff",
fontWeight: 700,
},
staticText: "静态文本",
staticTextStyle: {
fontSize: 14,
color: "#ffffff",
fontWeight: 500,
},
dynamicText: "动态文本",
dynamicTextStyle: {
fontSize: 14,
color: "#ffffff",
fontWeight: 500,
},
};
}
/**
* 添加一个新分段默认放在当前末段之后
*/
function addSegment() {
const nextIndex = draft.segments.length;
draft.segments.push(
normalizeSegmentConfigItem(
{
length: Math.min(screenWidth.value, draft.totalWidth),
x: 0,
y: nextIndex * normalizeSegmentHeight(draft.segmentHeight),
},
nextIndex,
screenWidth.value,
draft.segmentHeight,
),
);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
/**
* 删除指定下标的分段配置
*/
function removeSegment(index: number) {
draft.segments.splice(index, 1);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function addWindowArea() {
draft.windowAreas.push(createDefaultWindowArea(draft.windowAreas.length));
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function removeWindowArea(index: number) {
draft.windowAreas.splice(index, 1);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
async function startSocketService() {
try {
const result = await invoke<SocketStatusPayload>("start_socket_service");
socketRunning.value = result.running;
saveMessage.value = "Socket 服务已启动,正在监听 9501 端口。";
} catch (error) {
saveMessage.value = `Socket 启动失败: ${String(error)}`;
}
}
async function stopSocketService() {
try {
const result = await invoke<SocketStatusPayload>("stop_socket_service");
socketRunning.value = result.running;
saveMessage.value = "Socket 服务已停止。";
} catch (error) {
saveMessage.value = `Socket 停止失败: ${String(error)}`;
}
}
/**
* 手动保存配置到持久化存储并广播给同步屏窗口实时生效
*/
async function saveConfig() {
const totalWidth = Math.min(Math.max(1, Math.floor(draft.totalWidth)), screenWidth.value);
const segmentHeight = normalizeSegmentHeight(draft.segmentHeight);
const segments = draft.segments.map((item, index) =>
normalizeSegmentConfigItem(item, index, screenWidth.value, segmentHeight),
);
const windowAreas = draft.windowAreas.map((area, index) => ({
id: area.id || `area-${index + 1}`,
windowId:
Number.isFinite(area.windowId) && area.windowId > 0 ? Math.floor(area.windowId) : index + 1,
width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 220,
height: Number.isFinite(area.height) && area.height > 0 ? Math.floor(area.height) : 48,
x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0,
y: Number.isFinite(area.y) ? Math.max(0, Math.floor(area.y)) : 0,
windowNumber: area.windowNumber || String(index + 1),
windowNumberCircle: area.windowNumberCircle === true,
windowNumberStyle: normalizeStyle(area.windowNumberStyle),
staticText: area.staticText || "静态文本",
staticTextStyle: normalizeStyle(area.staticTextStyle),
dynamicText: area.dynamicText || "动态文本",
dynamicTextStyle: normalizeStyle(area.dynamicTextStyle),
}));
await patchConfig({
totalWidth,
segmentHeight,
showRuler: draft.showRuler,
segments,
windowAreas,
});
saveMessage.value = "保存成功,已写入配置文件并实时应用。";
}
watch(
config,
(next) => {
syncingFromStore.value = true;
draft.totalWidth = Math.min(next.totalWidth, screenWidth.value);
draft.segmentHeight = next.segmentHeight;
draft.showRuler = next.showRuler;
draft.segments = cloneSegments(next.segments);
draft.windowAreas = cloneWindowAreas(next.windowAreas);
syncingFromStore.value = false;
},
{ immediate: true, deep: true },
);
watch(
() => ({
totalWidth: draft.totalWidth,
segmentHeight: draft.segmentHeight,
showRuler: draft.showRuler,
segments: draft.segments.map((item) => ({ ...item })),
windowAreas: draft.windowAreas.map((item) => ({
...item,
windowNumberStyle: { ...item.windowNumberStyle },
staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle },
})),
}),
() => {
if (!syncingFromStore.value) {
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
},
{ deep: true },
);
onMounted(async () => {
try {
const monitor = await currentMonitor();
const width = monitor?.size?.width;
if (typeof width === "number" && Number.isFinite(width) && width > 0) {
screenWidth.value = normalizeScreenWidth(width);
if (draft.totalWidth > screenWidth.value) {
draft.totalWidth = screenWidth.value;
}
}
} catch {
//
}
try {
const status = await invoke<SocketStatusPayload>("get_socket_service_status");
socketRunning.value = status.running;
} catch {
socketRunning.value = false;
}
try {
socketStatusUnlisten = await listen<SocketStatusPayload>("socket-status", (event) => {
socketRunning.value = event.payload.running;
});
} catch {
socketStatusUnlisten = null;
}
});
onUnmounted(() => {
socketStatusUnlisten?.();
socketStatusUnlisten = null;
});
</script>

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// @ts-expect-error process is a Node.js global
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(() => ({
plugins: [vue()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));

@ -0,0 +1,40 @@
广播系统需求文档
1. 系统概述
开发一个基于Tauri v2框架的同步屏应用程序用于显示可滚动的标尺刻度界面。
2. 技术栈
框架Tauri v2
前端HTML/CSS/JavaScript、vue3
3. 功能需求
3.1 窗口管理
窗口类型:无边框窗口
初始位置:窗口左上角固定在屏幕左上角坐标(0,0)
窗口行为:程序启动时自动打开
3.2 主要显示区域
主容器高64px宽5000px的div元素
显示逻辑:
当屏幕宽度小于5000px时例如1080px需要将主div按屏幕高度分段显示
分段规则每个分段高度为64px宽度等于屏幕宽度
分段位置:
第一段:位置(0, 0)尺寸1080px × 64px
第二段:位置(0, 64)尺寸1080px × 64px
第三段:位置(0, 128)尺寸1080px × 64px
依此类推直到完整显示整个5000px宽度的内容
3.3 标尺刻度显示
大格每100px一个大刻度标记
小格每10px一个小刻度标记
显示范围覆盖整个5000px宽度区域
3.4 子元素处理
子div支持主div内可包含子div元素
切断处理当子div位于分段切断位置时同样需要进行切断显示
渲染一致性:确保子元素在各分段中的显示保持视觉连续性
4. 非功能需求
性能要求流畅渲染5000px宽度的标尺内容
兼容性:适配不同屏幕分辨率
用户体验:无边框设计,精确的刻度显示
5. 验收标准
程序启动时创建无边框窗口并定位到屏幕左上角
正确显示5000px × 64px的主div内容
读取主屏幕的宽度并正确分段显示每段64px高度
标尺刻度按100px大格、10px小格准确显示
子div在切断处正确处理显示

@ -0,0 +1,182 @@
# 广播系统技术方案
## 1. 文档目的
基于《广播系统需求文档》,输出可直接指导开发与联调的技术落地方案,覆盖:
- Tauri v2 窗口与生命周期实现
- 5000px 标尺分段渲染策略
- 子元素切段一致性处理
- 性能、兼容性与验收测试方案
## 2. 目标与范围
### 2.1 目标
- 程序启动后自动创建无边框窗口,并固定在 `(0, 0)`
- 在任意屏幕宽度下,正确展示宽 `5000px`、高 `64px` 的标尺内容。
- 当屏幕宽度小于 `5000px` 时,按“横向切片、纵向堆叠”方式渲染。
- 标尺满足 `10px` 小格、`100px` 大格。
- 主容器内子元素在切断位置保持视觉连续。
### 2.2 范围
- **包含**:桌面端单窗口显示、标尺渲染、分段映射、子元素切段、基础性能优化。
- **不包含**:网络同步、多用户协作、云端配置、复杂拖拽编辑器。
## 3. 技术选型
- 桌面容器:`Tauri v2`
- 前端框架:`Vue 3` + `TypeScript`
- 构建工具:`Vite`
- 样式:`CSS`(必要时搭配 CSS 变量)
- 渲染策略DOM + CSS优先保留 Canvas 升级路径
选择理由:
- Tauri 体积小、启动快,适合常驻轻量显示工具。
- Vue 3 组件化能力可清晰拆分“窗口层 / 标尺层 / 分段层”。
- 5000px*64px 属于中低复杂度场景DOM 足以支撑且便于调试与维护。
## 4. 核心实现方案
## 4.1 窗口管理实现
在 Tauri v2 配置中设置:
- 无边框:`decorations: false`
- 启动即显示:默认窗口自动创建
- 初始位置:`x: 0, y: 0`
- 可选增强:置顶 `alwaysOnTop`(按产品决策开关)
建议窗口配置(示意):
- 初始宽度:屏幕宽度(或合理默认值)
- 初始高度:由分段数量计算(见 4.2
## 4.2 分段显示算法
定义:
- `TOTAL_WIDTH = 5000`
- `SEGMENT_HEIGHT = 64`
- `screenWidth = 当前主屏宽度`
- `segmentCount = ceil(TOTAL_WIDTH / screenWidth)`
每段 `i`(从 `0` 开始)映射:
- 源区域横向起点:`sourceX = i * screenWidth`
- 当前段有效宽度:`sliceWidth = min(screenWidth, TOTAL_WIDTH - sourceX)`
- 目标位置:`x = 0, y = i * SEGMENT_HEIGHT`
- 目标尺寸:`sliceWidth x SEGMENT_HEIGHT`
渲染方式:
- 外层容器高度:`segmentCount * 64px`
- 每一段都渲染同一条“5000px 虚拟标尺”,但通过 `transform: translateX(-sourceX)` 显示对应片段
- 段容器设置 `overflow: hidden`,实现“切片窗口”效果
## 4.3 标尺刻度绘制策略
总宽范围 `[0, 5000)`
- 每 `10px` 一个小刻度
- 每 `100px` 一个大刻度(并可带数值标签)
实现建议:
- 通过循环生成刻度数据结构,交由 Vue 渲染
- 大刻度与小刻度使用不同高度和样式类
- 统一使用绝对定位,避免复杂流式布局导致抖动
数据模型示例:
- `ticks: Array<{ x: number; type: "minor" | "major"; label?: string }>`
## 4.4 子元素切断与连续性
子元素抽象:
- `child = { id, left, width, top, height, style }`
对第 `i` 段,子元素可见区间计算:
- `segmentStart = i * screenWidth`
- `segmentEnd = segmentStart + screenWidth`
- `childStart = child.left`
- `childEnd = child.left + child.width`
- 相交宽度:`visibleWidth = max(0, min(childEnd, segmentEnd) - max(childStart, segmentStart))`
`visibleWidth > 0`,则在该段渲染一个“切片子元素”:
- 本段左偏移:`renderLeft = max(childStart, segmentStart) - segmentStart`
- 源内容偏移:`clipOffset = max(0, segmentStart - childStart)`
连续性保证:
- 同一子元素跨段时复用同一视觉样式与层级
- 文本类子元素建议使用 `white-space: nowrap` 并通过裁剪窗口显示,避免换行错位
## 4.5 分辨率适配
- 启动时读取主屏宽度
- 监听窗口尺寸变化(可选)
- 变化后重新计算 `segmentCount` 与各段 `sourceX`
- 对高 DPI 环境,使用 CSS 像素布局,必要时读取缩放比做微调
## 5. 性能优化策略
- 刻度数据缓存:`ticks` 在 `TOTAL_WIDTH` 不变时仅初始化一次
- 分段列表缓存:仅在屏宽变化时重算
- 避免深层响应式:大数组使用浅响应或只读数据
- 降低重排:段内使用绝对定位,减少文档流参与
- 控制重绘:样式变化优先 `transform` / `opacity`
目标性能指标:
- 冷启动到首屏可见 < 1.5s
- resize 重算后 100ms 内完成主要布局更新
- 无明显掉帧和视觉撕裂
## 6. 错误处理与边界条件
- `screenWidth <= 0`:回退默认宽度(如 1920
- 极窄屏幕(如 800保证 `segmentCount` 正确增长
- 最后一段不足整宽:必须按 `sliceWidth` 渲染,禁止越界
- 子元素超出 `[0, 5000]`:按相交逻辑自动裁剪
## 7. 开发实施计划
### 阶段 1工程初始化
- 初始化 Tauri v2 + Vue3 + TS 项目
- 完成基础窗口配置(无边框、坐标)
### 阶段 2核心渲染
- 实现标尺刻度生成
- 实现分段容器与映射逻辑
- 验证不同屏宽下显示正确
### 阶段 3子元素切段
- 定义子元素数据结构
- 完成相交裁剪与跨段显示
- 增加典型跨段用例
### 阶段 4优化与验收
- 性能优化与边界处理
- 执行验收用例并修复问题
## 8. 测试与验收方案
对照需求验收项设计测试用例:
- 启动后窗口:无边框、位于 `(0, 0)`
- 主容器:逻辑宽 `5000px`、高 `64px`
- 分段:`segmentCount = ceil(5000 / screenWidth)`,每段高度 `64px`
- 刻度:`10px` 小格、`100px` 大格位置准确
- 子元素:跨段后位置连续、无错位/跳变
建议增加自动化:
- 单元测试:分段计算函数、子元素相交函数
- 组件测试:段渲染快照
- 手工测试:多分辨率与 DPI 场景
## 9. 风险与应对
- 风险:高 DPI 下 1px 线条发虚
应对:使用整数像素对齐与样式微调。
- 风险:窗口尺寸变化频繁触发抖动
应对:对 resize 处理加入节流(如 100ms
- 风险:后续需求扩展(动画、动态数据)导致 DOM 压力上升
应对:预留 Canvas 渲染替换层接口。
## 10. 交付物
- 可运行的 Tauri 广播客户端
- 本技术方案文档
- 软件架构文档
- 基础测试用例与验收记录

@ -0,0 +1,55 @@
#更新需求
##1.第一阶段更新需求
1.1. 修改:同步屏窗口不要使用圆角,底色使用黑色#000同步屏窗口置顶显示。
1.2. 添加使用tauri::menu API 创建菜单项,右键点击同步屏窗口,弹出该菜单,菜单项有:配置同步屏窗口、退出。点击配置同步屏窗口,弹出一个窗口。点击退出,退出应用。
##2.第二阶段更新需求
2.1. 添加配置文件使用XGD规则保存到本地使用@tauri-apps/plugin-store读写配置文件使配置参数在应用整个生命周期内中可以随时读写。
2.2. 在配置窗口添加如下配置项:
- 同步屏中主div的总长度(默认800px),高度(默认64px)
- 主div切割后分成几段每段的长度、每段左顶点的起始坐标
- 标尺是否显示
2.3. 在2.2中的配置项使用2.1的配置文件保存,并在应用启动时读取并应用配置文件且实时更新同步屏窗口。
##3.第二阶段更新问题修复
3.1. 配置项窗口不是无边框窗口,可拖动,背景为白色#fff
3.2. 配置项窗口没有保存配置的按钮,点击保存配置后再写入配置文件。
3.3. 主div切割分成几段需要使用添加按钮手动来添加并且每段的参数需要可以修改可以通过列表来展示修改的段并修改参数。
3.4. 同步屏窗口的长度不要超过主屏幕分辨的宽度。
##4.第三阶段更新需求
4.1. 修改:去除子元素跨段切片行为验证,去除验证代码,保留功能
4.2. 添加配置项窗口加入添加子div(窗口区域)功能,可以设置窗口区域的宽度、高度、左顶点起始坐标。
4.3. 添加窗口区域中包含两个子区域窗口号区域、文本区域子区域横向排列在窗口区域中显示占比为12.5。
4.4. 添加:窗口号区域显示窗口编号,文本水平垂直居中显示,可设置文本是否带圆圈边框,文字大小、颜色、粗细。
4.5. 添加文本区域包两个字区域静态文本区域、动态文本区域两个文本区域纵向排列在文本区域中显示占比为11两个区域中的文字水平垂直居中显示可设置文字大小、颜色、粗细。
4.6. 有需要的话可以引入前端UI框架来优化配置窗口界面。
##5.第三阶段更新问题修复
5.1. 配置了3个窗口区域只有第一个分段显示了窗口区域第2个窗口区域没有跨段显示第3个窗口区域没有显示在第二个分段上。
5.2. 窗口区域及其子区域去除边框,背景使用黑色。
5.3. 配置窗口每个大配置项做成折叠面板,每个面板和窗口背景色的色差调大一些。
##6.第四阶段更新需求
6.1. 添加在保存配置旁边添加一个按钮点击按钮启动socket服务并监听9501端口并用红绿灯方式展示socket服务状态。
6.2. 添加根据socket消息内容动态修改窗口区域中动态文本区域的内容。收到的消息报文如下
{
"action": "CALL",
"windowId": 1,
"windowName": "A12窗口",
"ledAddress": "192.168.1.100",
"timestamp": 1712567890000,
"payload": {
"ticketNumber": "A001",
"displayText": "请 A001 号办理",
"voiceText": "请 A001 号到 A12 窗口办理",
"flash": true
}
}
其中windowId代表窗口区域的编号displayText代表动态文本区域所要展示的内容。
##7.第五阶段更新需求
7.1. 添加日志模块用于记录socket服务启动、停止、消息接收、消息处理等操作。日志文件使用XGD规则保存到本地

@ -0,0 +1,220 @@
# 广播系统软件架构文档
## 1. 架构目标
构建一个基于 Tauri v2 的桌面广播显示系统,满足以下架构目标:
- 支持固定窗口行为(无边框、左上角定位、启动即开)
- 支持 5000px 标尺在任意屏宽下的稳定分段显示
- 支持子元素跨段连续渲染
- 保持模块边界清晰,便于后续扩展(动态数据、渲染升级)
## 2. 总体架构
采用“宿主层 + 前端应用层 + 渲染域层”的分层结构:
- 宿主层Tauri Runtime
- 负责窗口生命周期、屏幕信息读取、系统能力接入
- 前端应用层Vue Application
- 负责状态管理、分段计算、组件编排
- 渲染域层Ruler Rendering Domain
- 负责刻度绘制、切片映射、子元素裁剪与连续性
架构原则:
- 计算与渲染分离:算法在 `composables/services`,界面在 `components/views`
- 数据驱动:通过状态变化触发渲染,不在组件中写复杂业务判断
- 可替换渲染:先 DOM后续可平滑迁移到 Canvas
## 3. 逻辑视图(模块划分)
建议目录结构:
```text
broadcast-client/
src/
main.ts
App.vue
views/
BroadcastView.vue
components/
RulerCanvas.vue
RulerSegment.vue
RulerTicks.vue
SegmentChildren.vue
composables/
useScreenInfo.ts
useSegmentLayout.ts
useRulerTicks.ts
services/
segmentService.ts
childSliceService.ts
models/
ruler.ts
segment.ts
child.ts
utils/
math.ts
src-tauri/
tauri.conf.json
src/
lib.rs
```
模块职责:
- `useScreenInfo`读取并维护当前主屏宽度、DPI 信息
- `segmentService`:计算段数量、每段映射关系
- `useRulerTicks`:生成 10/100 刻度数据
- `childSliceService`:计算子元素在每段的可见切片
- `RulerSegment`:单段渲染容器(裁剪窗口)
- `SegmentChildren`:渲染跨段子元素切片
## 4. 关键数据模型
```ts
type TickType = "minor" | "major";
interface Tick {
x: number;
type: TickType;
label?: string;
}
interface Segment {
index: number;
sourceX: number;
sliceWidth: number;
top: number;
height: number; // 固定 64
}
interface ChildElement {
id: string;
left: number;
width: number;
top: number;
height: number;
zIndex?: number;
className?: string;
}
interface ChildSlice {
childId: string;
segmentIndex: number;
renderLeft: number;
renderTop: number;
renderWidth: number;
renderHeight: number;
clipOffset: number;
}
```
设计说明:
- `Segment` 只描述“段映射”,不关心具体 UI。
- `ChildSlice` 是“子元素在某段中的投影结果”,用于直接渲染。
- 所有坐标统一使用同一逻辑坐标系(以 5000px 主轴为基准)。
## 5. 运行时流程(时序)
启动流程:
1. Tauri 启动并创建窗口(无边框,位置 `(0,0)`)。
2. 前端初始化,读取屏幕宽度 `screenWidth`
3. 计算 `segments = ceil(5000 / screenWidth)`
4. 生成刻度数据 `ticks`,构建段列表 `segmentList`
5. 渲染每段:段容器裁剪 + 虚拟标尺偏移显示。
6. 若存在子元素,计算 `ChildSlice[]` 并叠加渲染。
更新流程(屏宽变化):
1. 监听窗口尺寸/屏幕变化事件。
2. 重新计算 `segmentList``ChildSlice[]`
3. 增量更新视图(避免全量销毁重建)。
## 6. 关键算法设计
### 6.1 分段算法
- 输入:`TOTAL_WIDTH=5000`, `screenWidth`, `SEGMENT_HEIGHT=64`
- 输出:`Segment[]`
- 复杂度:`O(n)``n = segmentCount`
核心规则:
- `segmentCount = ceil(5000 / screenWidth)`
- 每段 `sourceX = i * screenWidth`
- `sliceWidth = min(screenWidth, 5000 - sourceX)`
### 6.2 子元素切片算法
- 输入:`ChildElement[]`, `Segment[]`
- 输出:`ChildSlice[]`
- 复杂度:`O(m * n)`(可按区间优化)
相交判定:
- 仅当子元素区间与段区间重叠时生成切片
- 切片宽度为区间交集长度
- 渲染左偏移为交集起点相对段起点
## 7. 部署视图
单机本地部署:
- 可执行文件Tauri 打包产物)
- 前端静态资源内嵌
- 无外部服务依赖
跨平台建议:
- Windows 为主目标平台
- 后续可扩展到 Linux/macOS保持 API 使用跨平台)
## 8. 非功能架构设计
### 8.1 性能
- 预计算与缓存刻度数据
- 分段与切片计算函数纯函数化,便于 memoization
- 控制组件重渲染范围(按段粒度更新)
### 8.2 可维护性
- 算法层独立文件,配套单元测试
- 组件职责单一,避免“巨型组件”
- 使用 TypeScript 类型约束输入输出
### 8.3 兼容性
- 适配不同分辨率,屏宽变化自动重算
- 高 DPI 场景下进行像素对齐优化
## 9. 异常与容错架构
- 屏幕宽度读取失败:降级默认值(如 1920
- 输入数据异常(负宽度、越界坐标):统一在 service 层校正/裁剪
- 渲染失败保护:关键计算异常时记录日志并回退最小可用视图
## 10. 安全与边界
当前系统以本地渲染为主,安全关注点较轻,建议:
- 最小化 Tauri 权限配置
- 不暴露不必要的系统 API
- 对未来外部输入(若接入)进行 schema 校验
## 11. 测试架构
测试分层:
- 单元测试:`segmentService`、`childSliceService`
- 组件测试:`RulerSegment`、`SegmentChildren`
- 集成测试:启动后窗口行为与全链路渲染
关键断言:
- 分段数量与位置正确
- 末段宽度裁剪正确
- 子元素跨段连续且无视觉跳变
- 大小刻度在每段中对齐正确
## 12. 可扩展路线
- 渲染升级DOM -> Canvas/WebGL
- 数据升级:静态子元素 -> 实时广播内容流
- 交互升级:缩放、定位、标记线、主题切换
- 多屏支持:副屏渲染与窗口同步控制
## 13. 架构决策记录ADR 简版)
- ADR-001采用 Tauri v2 作为宿主框架(轻量、跨平台)
- ADR-002采用 Vue3 + TS 作为前端基础(可维护、类型安全)
- ADR-003先采用 DOM 裁剪方案(交付快、调试友好),保留 Canvas 替换能力
- ADR-004分段按屏宽计算并纵向堆叠严格满足需求中的显示规则

@ -0,0 +1,33 @@
# 阶段4验收记录
## 1. 验收范围
- 性能优化:窗口 `resize` 事件处理节流/防抖
- 边界处理:屏宽异常值回退、末段裁剪
- 测试补齐:分段算法、子元素切片、刻度生成
## 2. 验收项对照
- [x] 启动后窗口可定位到 `(0, 0)`,并根据分段数量同步高度
- [x] 分段计算满足 `segmentCount = ceil(5000 / screenWidth)`
- [x] 最后一段宽度按剩余值裁剪,不越界
- [x] 子元素跨段切片显示连续,无丢失
- [x] 刻度覆盖 `0-5000`,小格 `10px`,大格 `100px`
- [x] resize 高频触发时,布局刷新使用 100ms 防抖,避免抖动
## 3. 自动化测试清单
- `src/services/segmentService.test.ts`
- `src/services/childSliceService.test.ts`
- `src/services/tickService.test.ts`
## 4. 执行命令
- `npm run test:run`
- `npm run build`
## 5. 结果
- 单元测试:通过
- 构建检查:通过
- 当前阶段结论阶段4完成可进入联调与发布准备

@ -14,6 +14,7 @@ dist-ssr
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
src-tauri/target/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save