综合屏、叫号声音、优化UI

master
cysamurai 2 months ago
parent 9148bdacb5
commit 39d83e2c94

@ -14,6 +14,7 @@
"build:appimage:x64": "tauri build --bundles appimage --target x86_64-unknown-linux-gnu", "build:appimage:x64": "tauri build --bundles appimage --target x86_64-unknown-linux-gnu",
"build:appimage:arm64": "tauri build --bundles appimage --target aarch64-unknown-linux-gnu", "build:appimage:arm64": "tauri build --bundles appimage --target aarch64-unknown-linux-gnu",
"build:appimage:all": "npm run build:appimage:x64 && npm run build:appimage:arm64", "build:appimage:all": "npm run build:appimage:x64 && npm run build:appimage:arm64",
"build:win32": "tauri build --target i686-pc-windows-msvc",
"build:release": "npm run build:deb", "build:release": "npm run build:deb",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",

File diff suppressed because it is too large Load Diff

@ -17,7 +17,7 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]
[dependencies] [dependencies]
tauri = { version = "1", features = ["window-all", "dialog"] } tauri = { version = "1", features = ["api-all", "system-tray"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
chrono = { version = "0.4", features = ["clock"] } chrono = { version = "0.4", features = ["clock"] }

@ -16,7 +16,9 @@ use std::{
use chrono::Local; use chrono::Local;
use fs2::FileExt; use fs2::FileExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::Manager; use tauri::{
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowBuilder, WindowUrl,
};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::process::Command; use std::process::Command;
@ -27,6 +29,8 @@ const LOG_FILE_PREFIX: &str = "socket-service-";
const LOG_FILE_EXT: &str = ".log"; const LOG_FILE_EXT: &str = ".log";
const LOG_FILE_MAX_BYTES: u64 = 5 * 1024 * 1024; const LOG_FILE_MAX_BYTES: u64 = 5 * 1024 * 1024;
const LOG_RETENTION_DAYS: u64 = 7; const LOG_RETENTION_DAYS: u64 = 7;
const TRAY_MENU_OPEN_CONFIG_ID: &str = "open_config_window";
const TRAY_MENU_QUIT_ID: &str = "quit_application";
#[allow(dead_code)] #[allow(dead_code)]
struct SingleInstanceLock(File); struct SingleInstanceLock(File);
@ -106,6 +110,8 @@ struct SocketCallEventPayload {
window_id: u32, window_id: u32,
#[serde(rename = "displayText")] #[serde(rename = "displayText")]
display_text: String, display_text: String,
#[serde(rename = "voiceText")]
voice_text: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -123,7 +129,28 @@ struct AptUpdateCheckResult {
/// 应用入口:注册插件、菜单事件与前端命令。 /// 应用入口:注册插件、菜单事件与前端命令。
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let tray_menu = SystemTrayMenu::new()
.add_item(CustomMenuItem::new(
TRAY_MENU_OPEN_CONFIG_ID,
"打开配置窗口",
))
.add_item(CustomMenuItem::new(TRAY_MENU_QUIT_ID, "退出"));
let system_tray = SystemTray::new().with_menu(tray_menu);
tauri::Builder::default() tauri::Builder::default()
.system_tray(system_tray)
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::DoubleClick { .. } | SystemTrayEvent::LeftClick { .. } => {
let _ = show_and_focus_main_window(app);
}
SystemTrayEvent::MenuItemClick { id, .. } if id.as_str() == TRAY_MENU_OPEN_CONFIG_ID => {
let _ = open_or_focus_config_window(app);
}
SystemTrayEvent::MenuItemClick { id, .. } if id.as_str() == TRAY_MENU_QUIT_ID => {
app.exit(0);
}
_ => {}
})
.setup(|app| match acquire_single_instance_lock(app) { .setup(|app| match acquire_single_instance_lock(app) {
Ok(lock) => { Ok(lock) => {
app.manage(lock); app.manage(lock);
@ -146,6 +173,7 @@ pub fn run() {
stop_socket_service, stop_socket_service,
get_socket_service_status, get_socket_service_status,
check_apt_update, check_apt_update,
debug_log,
quit_app quit_app
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
@ -157,6 +185,50 @@ fn quit_app(app: tauri::AppHandle) {
app.exit(0); app.exit(0);
} }
#[tauri::command]
fn debug_log(app: tauri::AppHandle, level: String, message: String) {
let level = if level.trim().is_empty() {
"INFO".to_string()
} else {
level.trim().to_uppercase()
};
append_socket_log(&app, &format!("CONFIG-{level}"), message);
}
fn show_and_focus_main_window(app: &tauri::AppHandle) -> Result<(), String> {
let Some(main) = app.get_window("main") else {
return Err("主窗口不存在".to_string());
};
let _ = main.show();
let _ = main.unminimize();
let _ = main.set_focus();
Ok(())
}
fn open_or_focus_config_window(app: &tauri::AppHandle) -> Result<(), String> {
if let Some(config_window) = app.get_window("sync-config") {
let _ = config_window.show();
let _ = config_window.unminimize();
let _ = config_window.set_focus();
return Ok(());
}
let config_window = WindowBuilder::new(app, "sync-config", WindowUrl::App("/#/config".into()))
.title("配置同步屏窗口")
.inner_size(720.0, 460.0)
.min_inner_size(720.0, 460.0)
.resizable(true)
.decorations(true)
.always_on_top(true)
.build()
.map_err(|error| format!("创建配置窗口失败: {error}"))?;
let _ = config_window.show();
let _ = config_window.set_focus();
Ok(())
}
/// 启动本地 socket 服务9501并推送服务状态事件。 /// 启动本地 socket 服务9501并推送服务状态事件。
#[tauri::command] #[tauri::command]
fn start_socket_service( fn start_socket_service(
@ -496,6 +568,10 @@ fn try_dispatch_one_message(app: &tauri::AppHandle, text: &str) -> bool {
SocketCallEventPayload { SocketCallEventPayload {
window_id, window_id,
display_text, display_text,
voice_text: payload
.as_ref()
.and_then(|p| p.voice_text.clone())
.unwrap_or_default(),
}, },
); );
append_socket_log(app, "INFO", format!("窗口 {} 动态文本已更新", window_id)); append_socket_log(app, "INFO", format!("窗口 {} 动态文本已更新", window_id));

@ -12,6 +12,11 @@
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
"all": true,
"fs": {
"all": true,
"scope": ["$APPCONFIG/*", "$APPCONFIG/**", "$APPDATA/*", "$APPDATA/**"]
},
"window": { "window": {
"all": true "all": true
} }
@ -33,6 +38,9 @@
"visible": true "visible": true
} }
], ],
"systemTray": {
"iconPath": "icons/icon.ico"
},
"security": { "security": {
"csp": null "csp": null
}, },

@ -1,11 +1,13 @@
<template> <template>
<ConfigView v-if="isConfigPage" /> <ConfigView v-if="isConfigPage" />
<CompositeView v-else-if="isCompositePage" />
<BroadcastView v-else /> <BroadcastView v-else />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref } from "vue";
import BroadcastView from "./views/BroadcastView.vue"; import BroadcastView from "./views/BroadcastView.vue";
import CompositeView from "./views/CompositeView.vue";
import ConfigView from "./views/ConfigView.vue"; import ConfigView from "./views/ConfigView.vue";
// hash // hash
@ -30,4 +32,9 @@ const isConfigPage = computed(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return params.get("view") === "config" || hash.value === "#/config"; return params.get("view") === "config" || hash.value === "#/config";
}); });
const isCompositePage = computed(() => {
const params = new URLSearchParams(window.location.search);
return params.get("view") === "composite" || hash.value === "#/composite";
});
</script> </script>

@ -23,6 +23,7 @@ export interface CircleStyleConfig {
// 子div窗口区域配置模型。 // 子div窗口区域配置模型。
export interface ChildWindowAreaConfig { export interface ChildWindowAreaConfig {
id: string; id: string;
title: string;
windowId: number; windowId: number;
isClockWindow: boolean; isClockWindow: boolean;
width: number; width: number;
@ -42,6 +43,7 @@ export interface ChildWindowAreaConfig {
// 滚动字幕区域配置。 // 滚动字幕区域配置。
export interface SubtitleAreaConfig { export interface SubtitleAreaConfig {
id: string; id: string;
title: string;
width: number; width: number;
height: number; height: number;
x: number; x: number;
@ -51,11 +53,43 @@ export interface SubtitleAreaConfig {
speed: number; speed: number;
} }
export interface DeliveryTargetConfig {
syncScreen: boolean;
compositeScreen: boolean;
voiceBroadcast: boolean;
}
export interface CompositeTextStyleConfig {
fontSize: number;
color: string;
fontWeight: number;
}
export interface CompositeScreenConfig {
screenId: number;
hallName: string;
headerBackgroundColor: string;
hallStyle: CompositeTextStyleConfig;
footerSubtitle: string;
footerBackgroundColor: string;
footerSubtitleStyle: CompositeTextStyleConfig;
footerSubtitleSpeed: number;
middleBackgroundColor: string;
middleTextStyle: CompositeTextStyleConfig;
middleMaxLines: number;
showVideo: boolean;
videoUrls: string;
videoVolume: number;
}
// 广播渲染配置模型。 // 广播渲染配置模型。
export interface BroadcastConfig { export interface BroadcastConfig {
totalWidth: number; totalWidth: number;
segmentHeight: number; segmentHeight: number;
showRuler: boolean; showRuler: boolean;
autoStartSocket: boolean;
deliveryTargets: DeliveryTargetConfig;
compositeScreen: CompositeScreenConfig;
segments: SegmentConfigItem[]; segments: SegmentConfigItem[];
windowAreas: ChildWindowAreaConfig[]; windowAreas: ChildWindowAreaConfig[];
subtitleAreas: SubtitleAreaConfig[]; subtitleAreas: SubtitleAreaConfig[];
@ -66,6 +100,40 @@ export const DEFAULT_BROADCAST_CONFIG: BroadcastConfig = {
totalWidth: DEFAULT_TOTAL_WIDTH, totalWidth: DEFAULT_TOTAL_WIDTH,
segmentHeight: DEFAULT_SEGMENT_HEIGHT, segmentHeight: DEFAULT_SEGMENT_HEIGHT,
showRuler: true, showRuler: true,
autoStartSocket: false,
deliveryTargets: {
syncScreen: true,
compositeScreen: false,
voiceBroadcast: false,
},
compositeScreen: {
screenId: 1,
hallName: "综合服务大厅",
headerBackgroundColor: "#03050a",
hallStyle: {
fontSize: 42,
color: "#ffffff",
fontWeight: 700,
},
footerSubtitle: "欢迎光临,请按序办理业务",
footerBackgroundColor: "#03050a",
footerSubtitleStyle: {
fontSize: 32,
color: "#ffffff",
fontWeight: 600,
},
footerSubtitleSpeed: 120,
middleBackgroundColor: "#050a14",
middleTextStyle: {
fontSize: 30,
color: "#f5f7fa",
fontWeight: 600,
},
middleMaxLines: 6,
showVideo: false,
videoUrls: "",
videoVolume: 60,
},
segments: [], segments: [],
windowAreas: [], windowAreas: [],
subtitleAreas: [], subtitleAreas: [],

@ -1,4 +1,7 @@
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"; import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/tauri";
import { appConfigDir, join } from "@tauri-apps/api/path";
import { BaseDirectory, exists, readTextFile, writeTextFile } from "@tauri-apps/api/fs";
import type { import type {
BroadcastConfig, BroadcastConfig,
ChildWindowAreaConfig, ChildWindowAreaConfig,
@ -14,10 +17,35 @@ import {
normalizeTotalWidth, normalizeTotalWidth,
} from "./segmentService"; } from "./segmentService";
const CONFIG_KEY = "runtime_broadcast_config";
const CONFIG_EVENT = "broadcast-config-updated"; const CONFIG_EVENT = "broadcast-config-updated";
const CONFIG_FILE_NAME = "broadcast-config.json";
const LEGACY_LOCAL_STORAGE_KEY = "runtime_broadcast_config";
const LOCAL_STORAGE_KEY = "broadcast_config_local_fallback"; const LOCAL_STORAGE_KEY = "broadcast_config_local_fallback";
function formatError(error: unknown): string {
if (error instanceof Error) {
return `${error.name}: ${error.message}`;
}
return String(error);
}
async function writeConfigDebugLog(level: "INFO" | "WARN" | "ERROR", message: string) {
const line = `[config-store][${level}] ${message}`;
if (level === "ERROR") {
console.error(line);
} else if (level === "WARN") {
console.warn(line);
} else {
console.info(line);
}
try {
await invoke("debug_log", { level, message: line });
} catch {
// tauri 命令不可用时忽略(例如纯浏览器环境)。
}
}
function normalizeFontSize(raw: unknown, fallback: number): number { function normalizeFontSize(raw: unknown, fallback: number): number {
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback; return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
} }
@ -43,6 +71,10 @@ function normalizeTextStyle(raw: unknown, fallback: TextStyleConfig): TextStyleC
}; };
} }
function normalizeCompositeTextStyle(raw: unknown, fallback: TextStyleConfig): TextStyleConfig {
return normalizeTextStyle(raw, fallback);
}
function normalizeCircleStyle(raw: unknown, fallback: CircleStyleConfig): CircleStyleConfig { function normalizeCircleStyle(raw: unknown, fallback: CircleStyleConfig): CircleStyleConfig {
const source = (raw ?? {}) as Partial<CircleStyleConfig>; const source = (raw ?? {}) as Partial<CircleStyleConfig>;
return { return {
@ -69,6 +101,7 @@ function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig
const source = (raw ?? {}) as Partial<ChildWindowAreaConfig>; const source = (raw ?? {}) as Partial<ChildWindowAreaConfig>;
return { return {
id: typeof source.id === "string" && source.id.trim() ? source.id : `area-${index + 1}`, id: typeof source.id === "string" && source.id.trim() ? source.id : `area-${index + 1}`,
title: typeof source.title === "string" ? source.title : "",
windowId: windowId:
typeof source.windowId === "number" && Number.isFinite(source.windowId) && source.windowId > 0 typeof source.windowId === "number" && Number.isFinite(source.windowId) && source.windowId > 0
? Math.floor(source.windowId) ? Math.floor(source.windowId)
@ -112,6 +145,7 @@ function normalizeSubtitleArea(raw: unknown, index: number): SubtitleAreaConfig
const source = (raw ?? {}) as Partial<SubtitleAreaConfig>; const source = (raw ?? {}) as Partial<SubtitleAreaConfig>;
return { return {
id: typeof source.id === "string" && source.id.trim() ? source.id : `subtitle-${index + 1}`, id: typeof source.id === "string" && source.id.trim() ? source.id : `subtitle-${index + 1}`,
title: typeof source.title === "string" ? source.title : "",
width: normalizeFontSize(source.width, 320), width: normalizeFontSize(source.width, 320),
height: normalizeFontSize(source.height, 32), height: normalizeFontSize(source.height, 32),
x: typeof source.x === "number" && Number.isFinite(source.x) ? Math.max(0, Math.floor(source.x)) : 0, x: typeof source.x === "number" && Number.isFinite(source.x) ? Math.max(0, Math.floor(source.x)) : 0,
@ -147,6 +181,89 @@ function normalizeConfig(raw: unknown): BroadcastConfig {
segmentHeight, segmentHeight,
showRuler: showRuler:
typeof source.showRuler === "boolean" ? source.showRuler : DEFAULT_BROADCAST_CONFIG.showRuler, typeof source.showRuler === "boolean" ? source.showRuler : DEFAULT_BROADCAST_CONFIG.showRuler,
autoStartSocket:
typeof source.autoStartSocket === "boolean"
? source.autoStartSocket
: DEFAULT_BROADCAST_CONFIG.autoStartSocket,
deliveryTargets: {
syncScreen:
typeof source.deliveryTargets?.syncScreen === "boolean"
? source.deliveryTargets.syncScreen
: DEFAULT_BROADCAST_CONFIG.deliveryTargets.syncScreen,
compositeScreen:
typeof source.deliveryTargets?.compositeScreen === "boolean"
? source.deliveryTargets.compositeScreen
: DEFAULT_BROADCAST_CONFIG.deliveryTargets.compositeScreen,
voiceBroadcast:
typeof source.deliveryTargets?.voiceBroadcast === "boolean"
? source.deliveryTargets.voiceBroadcast
: DEFAULT_BROADCAST_CONFIG.deliveryTargets.voiceBroadcast,
},
compositeScreen: {
screenId:
typeof source.compositeScreen?.screenId === "number" &&
Number.isFinite(source.compositeScreen.screenId) &&
source.compositeScreen.screenId > 0
? Math.floor(source.compositeScreen.screenId)
: DEFAULT_BROADCAST_CONFIG.compositeScreen.screenId,
hallName:
typeof source.compositeScreen?.hallName === "string" && source.compositeScreen.hallName.trim()
? source.compositeScreen.hallName
: DEFAULT_BROADCAST_CONFIG.compositeScreen.hallName,
headerBackgroundColor: normalizeColor(
source.compositeScreen?.headerBackgroundColor,
DEFAULT_BROADCAST_CONFIG.compositeScreen.headerBackgroundColor,
),
hallStyle: normalizeCompositeTextStyle(
source.compositeScreen?.hallStyle,
DEFAULT_BROADCAST_CONFIG.compositeScreen.hallStyle,
),
footerSubtitle:
typeof source.compositeScreen?.footerSubtitle === "string"
? source.compositeScreen.footerSubtitle
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitle,
footerBackgroundColor: normalizeColor(
source.compositeScreen?.footerBackgroundColor,
DEFAULT_BROADCAST_CONFIG.compositeScreen.footerBackgroundColor,
),
footerSubtitleStyle: normalizeCompositeTextStyle(
source.compositeScreen?.footerSubtitleStyle,
DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitleStyle,
),
footerSubtitleSpeed:
typeof source.compositeScreen?.footerSubtitleSpeed === "number" &&
Number.isFinite(source.compositeScreen.footerSubtitleSpeed) &&
source.compositeScreen.footerSubtitleSpeed > 0
? source.compositeScreen.footerSubtitleSpeed
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitleSpeed,
middleBackgroundColor: normalizeColor(
source.compositeScreen?.middleBackgroundColor,
DEFAULT_BROADCAST_CONFIG.compositeScreen.middleBackgroundColor,
),
middleTextStyle: normalizeCompositeTextStyle(
source.compositeScreen?.middleTextStyle,
DEFAULT_BROADCAST_CONFIG.compositeScreen.middleTextStyle,
),
middleMaxLines:
typeof source.compositeScreen?.middleMaxLines === "number" &&
Number.isFinite(source.compositeScreen.middleMaxLines) &&
source.compositeScreen.middleMaxLines > 0
? Math.floor(source.compositeScreen.middleMaxLines)
: DEFAULT_BROADCAST_CONFIG.compositeScreen.middleMaxLines,
showVideo:
typeof source.compositeScreen?.showVideo === "boolean"
? source.compositeScreen.showVideo
: DEFAULT_BROADCAST_CONFIG.compositeScreen.showVideo,
videoUrls:
typeof source.compositeScreen?.videoUrls === "string"
? source.compositeScreen.videoUrls
: DEFAULT_BROADCAST_CONFIG.compositeScreen.videoUrls,
videoVolume:
typeof source.compositeScreen?.videoVolume === "number" &&
Number.isFinite(source.compositeScreen.videoVolume)
? Math.min(100, Math.max(0, source.compositeScreen.videoVolume))
: DEFAULT_BROADCAST_CONFIG.compositeScreen.videoVolume,
},
segments: segmentsRaw.map((item, index) => segments: segmentsRaw.map((item, index) =>
normalizeSegmentConfigItem(item, index, screenWidth, segmentHeight), normalizeSegmentConfigItem(item, index, screenWidth, segmentHeight),
), ),
@ -192,19 +309,99 @@ async function emitConfigUpdated(config: BroadcastConfig) {
} }
} }
/**
* AppConfigDir/broadcast-config.json
*/
async function resolveConfigFilePath(): Promise<string | null> {
try {
const configDirectory = await appConfigDir();
const filePath = await join(configDirectory, CONFIG_FILE_NAME);
await writeConfigDebugLog("INFO", `配置文件路径: ${filePath}`);
return filePath;
} catch (error) {
await writeConfigDebugLog("WARN", `解析配置文件路径失败: ${formatError(error)}`);
return null;
}
}
/**
*
*/
async function readPersistentConfigFile(): Promise<BroadcastConfig | null> {
const filePath = await resolveConfigFilePath();
if (!filePath) {
await writeConfigDebugLog("WARN", "读取配置中止:无法获取配置文件路径");
return null;
}
try {
const fileExists = await exists(CONFIG_FILE_NAME, { dir: BaseDirectory.AppConfig });
if (!fileExists) {
await writeConfigDebugLog("INFO", "配置文件不存在,将走默认配置或迁移逻辑");
return null;
}
const raw = await readTextFile(CONFIG_FILE_NAME, { dir: BaseDirectory.AppConfig });
if (!raw.trim()) {
await writeConfigDebugLog("WARN", "配置文件为空,将走默认配置或迁移逻辑");
return null;
}
const parsed = normalizeConfig(JSON.parse(raw));
await writeConfigDebugLog("INFO", "配置文件读取成功");
return parsed;
} catch (error) {
await writeConfigDebugLog("ERROR", `读取配置文件失败: ${formatError(error)}`);
return null;
}
}
/**
*
*/
async function writePersistentConfigFile(config: BroadcastConfig): Promise<boolean> {
const filePath = await resolveConfigFilePath();
if (!filePath) {
await writeConfigDebugLog("WARN", "写入配置中止:无法获取配置文件路径");
return false;
}
try {
await writeTextFile(CONFIG_FILE_NAME, JSON.stringify(config, null, 2), {
dir: BaseDirectory.AppConfig,
});
await writeConfigDebugLog("INFO", `配置文件写入成功: ${filePath}`);
return true;
} catch (error) {
await writeConfigDebugLog("ERROR", `配置文件写入失败: ${formatError(error)}`);
return false;
}
}
/** /**
* *
*/ */
export async function loadBroadcastConfig(): Promise<BroadcastConfig> { export async function loadBroadcastConfig(): Promise<BroadcastConfig> {
await writeConfigDebugLog("INFO", "开始加载配置");
const fromFile = await readPersistentConfigFile();
if (fromFile) {
await writeConfigDebugLog("INFO", "加载配置完成:来自配置文件");
return fromFile;
}
try { try {
const raw = window.localStorage.getItem(CONFIG_KEY); // 兼容旧版本 localStorage 配置,并在首次读取后迁移到统一配置文件。
const raw = window.localStorage.getItem(LEGACY_LOCAL_STORAGE_KEY);
const saved = raw ? (JSON.parse(raw) as BroadcastConfig) : null; const saved = raw ? (JSON.parse(raw) as BroadcastConfig) : null;
const config = saved ? normalizeConfig(saved) : DEFAULT_BROADCAST_CONFIG; const config = saved ? normalizeConfig(saved) : DEFAULT_BROADCAST_CONFIG;
if (!saved) { const migrated = await writePersistentConfigFile(config);
window.localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); await writeConfigDebugLog(
} migrated ? "INFO" : "WARN",
migrated
? "配置迁移/初始化写入成功(来源:旧 localStorage 或默认值)"
: "配置迁移/初始化写入失败,后续将使用回退逻辑",
);
return config; return config;
} catch { } catch (error) {
await writeConfigDebugLog("ERROR", `加载配置异常,改用回退配置: ${formatError(error)}`);
return getFallbackConfig(); return getFallbackConfig();
} }
} }
@ -214,10 +411,13 @@ export async function loadBroadcastConfig(): Promise<BroadcastConfig> {
*/ */
export async function saveBroadcastConfig(payload: BroadcastConfig): Promise<BroadcastConfig> { export async function saveBroadcastConfig(payload: BroadcastConfig): Promise<BroadcastConfig> {
const config = normalizeConfig(payload); const config = normalizeConfig(payload);
try { await writeConfigDebugLog("INFO", "开始保存配置");
window.localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); const fileSaved = await writePersistentConfigFile(config);
} catch { if (!fileSaved) {
await writeConfigDebugLog("WARN", "配置写入文件失败,已回退到 localStorage");
setFallbackConfig(config); setFallbackConfig(config);
} else {
await writeConfigDebugLog("INFO", "配置保存完成(已写入配置文件)");
} }
await emitConfigUpdated(config); await emitConfigUpdated(config);

@ -103,13 +103,16 @@ body,
height: 100%; height: 100%;
background: #eef1f6; background: #eef1f6;
color: #111; color: #111;
padding: 0 16px 16px; padding: 8px 16px;
overflow: auto; overflow: hidden;
} }
.config-page { .config-page {
display: grid; height: 100%;
gap: 12px; display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
} }
.config-card { .config-card {
@ -138,14 +141,63 @@ body,
padding: 14px; padding: 14px;
} }
.config-tabs {
border: 1px solid #b8c3d9;
border-radius: 6px;
background: #f8fbff;
padding: 10px 12px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.config-tabs .el-tabs__header {
margin-bottom: 8px;
flex: 0 0 auto;
}
.config-tabs .el-tabs__nav-wrap::after {
background-color: #b8c3d9;
}
.config-tabs .el-tabs__content {
flex: 1;
min-height: 0;
}
.config-tabs .el-tab-pane {
height: 100%;
}
.tab-pane-content {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
min-height: 0;
}
.tab-pane-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tab-pane-actions--left {
justify-content: flex-start;
}
.panel-scroll { .panel-scroll {
max-height: 42vh; flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
padding-right: 6px; padding-right: 6px;
} }
.panel-scroll--tall { .panel-scroll--tall {
max-height: 56vh; flex: 1;
} }
.card-header { .card-header {
@ -160,16 +212,114 @@ body,
} }
.area-list { .area-list {
display: grid; display: flex;
flex-direction: column;
gap: 10px; gap: 10px;
} }
.window-area-tabs {
flex: 1;
min-height: 0;
display: flex;
}
.window-area-tabs .el-tabs__header {
margin-right: 10px;
}
.window-area-tabs .el-tabs__content {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 6px;
}
.subtitle-area-tabs {
flex: 1;
min-height: 0;
display: flex;
}
.subtitle-area-tabs .el-tabs__header {
margin-right: 10px;
}
.subtitle-area-tabs .el-tabs__content {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 6px;
}
.composite-config-tabs {
flex: 1;
min-height: 0;
display: flex;
}
.composite-config-tabs .el-tabs__header {
margin-right: 10px;
}
.composite-config-tabs .el-tabs__content {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 6px;
}
.window-area-form-vertical {
display: flex;
flex-direction: column;
gap: 2px;
}
.window-area-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
margin-bottom: 8px;
border: 1px solid #d8deea;
border-radius: 6px;
background: #f5f8ff;
}
.window-area-editor-title {
font-size: 16px;
font-weight: 600;
color: #1f2d3d;
}
.editable-tab-label {
display: inline-flex;
align-items: center;
min-width: 80px;
}
.editable-tab-input {
width: 100%;
min-width: 90px;
padding: 2px 6px;
border: 1px solid #8cb3ff;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.area-item { .area-item {
background: #ffffff; background: #ffffff;
} }
.el-input-number { .el-input-number {
width: 100%; width: 100%;
min-width: 120px;
}
.el-input-number .el-input__inner {
min-width: 42px;
text-align: center;
} }
.config-title { .config-title {
@ -235,19 +385,39 @@ body,
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 14px; margin-top: 0;
flex: 0 0 auto;
min-height: 36px;
} }
.config-top-status { .config-top-status {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
flex: 0 0 auto;
margin-bottom: 0;
}
.config-status-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-height: 36px; min-height: 36px;
margin-bottom: 10px; }
.delivery-targets {
display: inline-flex;
align-items: center;
gap: 10px;
} }
.top-actions { .top-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
@ -467,3 +637,87 @@ body,
left: 100%; left: 100%;
} }
} }
.composite-root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #000;
color: #fff;
overflow: hidden;
}
.composite-header,
.composite-footer {
width: 100%;
height: 12%;
display: flex;
align-items: center;
justify-content: center;
background: #03050a;
}
.composite-header-text {
letter-spacing: 1px;
white-space: nowrap;
}
.composite-middle {
flex: 1;
width: 100%;
min-height: 0;
display: flex;
}
.composite-text-panel,
.composite-right-panel {
width: 50%;
height: 100%;
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.composite-text-line {
margin: 0;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composite-video {
width: 100%;
height: 100%;
object-fit: cover;
background: #000;
}
.composite-footer-track {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.composite-footer-text {
position: absolute;
top: 50%;
transform: translateY(-50%);
white-space: nowrap;
animation-name: composite-marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes composite-marquee {
0% {
left: 100%;
}
100% {
left: -120%;
}
}

@ -1,5 +1,6 @@
<template> <template>
<main <main
v-if="syncScreenEnabled"
class="broadcast-root" class="broadcast-root"
:style="{ width: `${screenWidth}px`, height: `${containerHeight}px` }" :style="{ width: `${screenWidth}px`, height: `${containerHeight}px` }"
@dblclick.capture="openConfigWindow" @dblclick.capture="openConfigWindow"
@ -18,9 +19,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { WebviewWindow } from "@tauri-apps/api/window"; import { WebviewWindow, appWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/tauri";
import RulerSegment from "../components/RulerSegment.vue"; import RulerSegment from "../components/RulerSegment.vue";
import { useBroadcastConfig } from "../composables/useBroadcastConfig"; import { useBroadcastConfig } from "../composables/useBroadcastConfig";
import { useRulerTicks } from "../composables/useRulerTicks"; import { useRulerTicks } from "../composables/useRulerTicks";
@ -34,8 +36,13 @@ const { config } = useBroadcastConfig();
const totalWidth = computed(() => config.value.totalWidth); const totalWidth = computed(() => config.value.totalWidth);
const segmentHeight = computed(() => config.value.segmentHeight); const segmentHeight = computed(() => config.value.segmentHeight);
const showRuler = computed(() => config.value.showRuler); const showRuler = computed(() => config.value.showRuler);
const syncScreenEnabled = computed(() => config.value.deliveryTargets.syncScreen);
const compositeScreenEnabled = computed(() => config.value.deliveryTargets.compositeScreen);
const autoStartSocketEnabled = computed(() => config.value.autoStartSocket);
const socketDynamicTexts = ref<Record<number, string>>({}); const socketDynamicTexts = ref<Record<number, string>>({});
let socketMessageUnlisten: UnlistenFn | null = null; let socketMessageUnlisten: UnlistenFn | null = null;
const COMPOSITE_WINDOW_LABEL = "composite-screen";
const autoStartSocketAttempted = ref(false);
const windowAreas = computed<ChildWindowAreaConfig[]>(() => const windowAreas = computed<ChildWindowAreaConfig[]>(() =>
config.value.windowAreas.map((area) => ({ config.value.windowAreas.map((area) => ({
@ -54,23 +61,108 @@ const configuredWindowHeight = computed(() => {
const { screenWidth } = useScreenInfo(totalWidth, segmentHeight, undefined, configuredWindowHeight); const { screenWidth } = useScreenInfo(totalWidth, segmentHeight, undefined, configuredWindowHeight);
const segments = computed(() => const segments = computed(() =>
buildSegmentsFromConfig( syncScreenEnabled.value
? buildSegmentsFromConfig(
config.value.segments, config.value.segments,
screenWidth.value, screenWidth.value,
totalWidth.value, totalWidth.value,
segmentHeight.value, segmentHeight.value,
), )
: [],
); );
const containerHeight = computed(() => { const containerHeight = computed(() => {
if (!syncScreenEnabled.value) {
return 0;
}
if (segments.value.length === 0) { if (segments.value.length === 0) {
return segmentHeight.value; return segmentHeight.value;
} }
return Math.max(...segments.value.map((item) => item.top + item.height)); return Math.max(...segments.value.map((item) => item.top + item.height));
}); });
const { ticks } = useRulerTicks(totalWidth); const { ticks } = useRulerTicks(totalWidth);
const windowAreaSlices = computed(() => buildWindowAreaSlices(windowAreas.value, segments.value)); const windowAreaSlices = computed(() =>
syncScreenEnabled.value ? buildWindowAreaSlices(windowAreas.value, segments.value) : [],
);
const subtitleAreaSlices = computed(() => const subtitleAreaSlices = computed(() =>
buildSubtitleAreaSlices(config.value.subtitleAreas, segments.value), syncScreenEnabled.value ? buildSubtitleAreaSlices(config.value.subtitleAreas, segments.value) : [],
);
async function syncMainWindowVisibility(show: boolean) {
try {
if (show) {
await appWindow.show();
await appWindow.unminimize();
await appWindow.setFocus();
return;
}
await appWindow.hide();
} catch {
//
}
}
watch(
syncScreenEnabled,
(enabled) => {
void syncMainWindowVisibility(enabled);
},
{ immediate: true },
);
async function syncCompositeWindowVisibility(show: boolean) {
try {
const existing = WebviewWindow.getByLabel(COMPOSITE_WINDOW_LABEL);
if (!show) {
if (existing) {
await existing.close();
}
return;
}
if (existing) {
await existing.show();
await existing.unminimize();
await existing.setFocus();
await existing.setFullscreen(true);
return;
}
const window = new WebviewWindow(COMPOSITE_WINDOW_LABEL, {
url: "/#/composite",
title: "综合屏",
fullscreen: true,
decorations: false,
resizable: true,
alwaysOnTop: true,
});
void window;
} catch {
//
}
}
watch(
compositeScreenEnabled,
(enabled) => {
void syncCompositeWindowVisibility(enabled);
},
{ immediate: true },
);
watch(
autoStartSocketEnabled,
async (enabled) => {
if (!enabled || autoStartSocketAttempted.value) {
return;
}
autoStartSocketAttempted.value = true;
try {
await invoke("start_socket_service");
} catch {
//
}
},
{ immediate: true },
); );
/** /**
@ -91,6 +183,8 @@ async function openConfigWindow() {
title: "配置同步屏窗口", title: "配置同步屏窗口",
width: 720, width: 720,
height: 460, height: 460,
minWidth: 720,
minHeight: 460,
resizable: true, resizable: true,
decorations: true, decorations: true,
alwaysOnTop: true, alwaysOnTop: true,
@ -124,5 +218,6 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
socketMessageUnlisten?.(); socketMessageUnlisten?.();
socketMessageUnlisten = null; socketMessageUnlisten = null;
void syncCompositeWindowVisibility(false);
}); });
</script> </script>

@ -0,0 +1,219 @@
<template>
<main class="composite-root" @dblclick="toggleFullscreenMode">
<header class="composite-header" :style="{ backgroundColor: compositeConfig.headerBackgroundColor }">
<span
class="composite-header-text"
:style="{
fontSize: `${compositeConfig.hallStyle.fontSize}px`,
color: compositeConfig.hallStyle.color,
fontWeight: compositeConfig.hallStyle.fontWeight,
}"
>
{{ compositeConfig.hallName }}
</span>
</header>
<section class="composite-middle" :style="middleBackgroundStyle">
<section class="composite-text-panel" :style="middleBackgroundStyle">
<p
v-for="(line, index) in leftLines"
:key="`left-${index}-${line}`"
class="composite-text-line"
:style="middleTextStyle"
>
{{ line }}
</p>
</section>
<section class="composite-right-panel" :style="middleBackgroundStyle">
<template v-if="compositeConfig.showVideo && hasVideos">
<video
ref="videoRef"
class="composite-video"
:src="currentVideoUrl"
autoplay
:volume="videoVolumeRatio"
@ended="playNextVideo"
/>
</template>
<template v-else>
<p
v-for="(line, index) in rightLines"
:key="`right-${index}-${line}`"
class="composite-text-line"
:style="middleTextStyle"
>
{{ line }}
</p>
</template>
</section>
</section>
<footer class="composite-footer" :style="{ backgroundColor: compositeConfig.footerBackgroundColor }">
<div class="composite-footer-track">
<div
class="composite-footer-text"
:style="{
fontSize: `${compositeConfig.footerSubtitleStyle.fontSize}px`,
color: compositeConfig.footerSubtitleStyle.color,
fontWeight: compositeConfig.footerSubtitleStyle.fontWeight,
animationDuration: `${footerDuration}s`,
}"
>
{{ compositeConfig.footerSubtitle }}
</div>
</div>
</footer>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { appWindow, currentMonitor, LogicalPosition, LogicalSize } from "@tauri-apps/api/window";
import { useBroadcastConfig } from "../composables/useBroadcastConfig";
interface SocketCallPayload {
windowId?: number;
displayText?: string;
voiceText?: string;
}
const { config } = useBroadcastConfig();
const voiceQueue = ref<string[]>([]);
const videoRef = ref<HTMLVideoElement | null>(null);
const videoIndex = ref(0);
let socketMessageUnlisten: UnlistenFn | null = null;
const compositeConfig = computed(() => config.value.compositeScreen);
const maxLinesPerColumn = computed(() => Math.max(1, compositeConfig.value.middleMaxLines));
const middleTextStyle = computed(() => ({
fontSize: `${compositeConfig.value.middleTextStyle.fontSize}px`,
color: compositeConfig.value.middleTextStyle.color,
fontWeight: compositeConfig.value.middleTextStyle.fontWeight,
}));
const middleBackgroundStyle = computed(() => ({
backgroundColor: compositeConfig.value.middleBackgroundColor,
}));
const videoList = computed(() =>
compositeConfig.value.videoUrls
.split(";")
.map((item) => item.trim())
.filter((item) => item.length > 0),
);
const hasVideos = computed(() => videoList.value.length > 0);
const currentVideoUrl = computed(() => {
if (!hasVideos.value) {
return "";
}
return videoList.value[videoIndex.value % videoList.value.length];
});
const videoVolumeRatio = computed(() => Math.min(1, Math.max(0, compositeConfig.value.videoVolume / 100)));
const leftLines = computed(() => voiceQueue.value.slice(0, maxLinesPerColumn.value));
const rightLines = computed(() => {
if (compositeConfig.value.showVideo) {
return [];
}
const start = maxLinesPerColumn.value;
const end = maxLinesPerColumn.value * 2;
return voiceQueue.value.slice(start, end);
});
const footerDuration = computed(() => {
const speed = Math.max(1, compositeConfig.value.footerSubtitleSpeed);
const textLength = Math.max(8, compositeConfig.value.footerSubtitle.length);
return Math.max(3, (textLength * 28) / speed);
});
function appendVoiceText(raw: string) {
const text = raw.trim();
if (!text) {
return;
}
const cap = compositeConfig.value.showVideo
? maxLinesPerColumn.value
: maxLinesPerColumn.value * 2;
voiceQueue.value = [text, ...voiceQueue.value].slice(0, cap);
}
function playNextVideo() {
if (!hasVideos.value) {
return;
}
videoIndex.value = (videoIndex.value + 1) % videoList.value.length;
}
async function toggleFullscreenMode() {
try {
const isFullscreen = await appWindow.isFullscreen();
if (!isFullscreen) {
await appWindow.setDecorations(false);
await appWindow.setFullscreen(true);
return;
}
await appWindow.setFullscreen(false);
await appWindow.setDecorations(true);
await appWindow.setSize(new LogicalSize(1280, 720));
const monitor = await currentMonitor();
const width = monitor?.size?.width ?? 1280;
const height = monitor?.size?.height ?? 720;
await appWindow.setPosition(new LogicalPosition((width - 1280) / 2, (height - 720) / 2));
} catch {
//
}
}
watch(
[currentVideoUrl, videoVolumeRatio],
async () => {
const video = videoRef.value;
if (!video) {
return;
}
video.volume = videoVolumeRatio.value;
try {
await video.play();
} catch {
//
}
},
{ immediate: true },
);
onMounted(async () => {
try {
await appWindow.setFullscreen(true);
await appWindow.setDecorations(false);
} catch {
//
}
try {
socketMessageUnlisten = await listen<SocketCallPayload>("socket-call-message", (event) => {
const payload = event.payload;
if (!payload) {
return;
}
if (
typeof payload.windowId !== "number" ||
payload.windowId !== compositeConfig.value.screenId
) {
return;
}
const voiceText = typeof payload.voiceText === "string" ? payload.voiceText : "";
const fallback = typeof payload.displayText === "string" ? payload.displayText : "";
appendVoiceText(voiceText || fallback);
});
} catch {
socketMessageUnlisten = null;
}
});
onUnmounted(() => {
socketMessageUnlisten?.();
socketMessageUnlisten = null;
});
</script>

@ -1,12 +1,13 @@
<template> <template>
<main class="config-root config-page"> <main class="config-root config-page">
<div class="config-top-status row-between"> <div class="config-top-status">
<div class="config-status-row">
<span class="socket-status" :class="socketRunning ? 'running' : 'stopped'"> <span class="socket-status" :class="socketRunning ? 'running' : 'stopped'">
<i class="socket-dot" /> <i class="socket-dot" />
Socket {{ socketRunning ? "运行中" : "未启动" }} (9501) Socket {{ socketRunning ? "运行中" : "未启动" }} (9501)
</span> </span>
<div class="top-actions"> <div class="top-actions">
<el-button type="primary" @click="saveConfig"></el-button> <el-checkbox v-model="draft.autoStartSocket"></el-checkbox>
<el-button type="success" plain :disabled="socketRunning" @click="startSocketService"> <el-button type="success" plain :disabled="socketRunning" @click="startSocketService">
启动 Socket 服务 启动 Socket 服务
</el-button> </el-button>
@ -15,43 +16,39 @@
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="config-status-row">
<el-checkbox-group class="delivery-targets" v-model="selectedDeliveryTargets">
<el-checkbox label="syncScreen">同步屏</el-checkbox>
<el-checkbox label="compositeScreen">综合屏</el-checkbox>
<el-checkbox label="voiceBroadcast">语音播报</el-checkbox>
</el-checkbox-group>
<el-button type="primary" @click="saveConfig"></el-button>
</div>
</div>
<el-collapse v-model="activePanels" class="config-collapse"> <el-tabs v-model="activeTab" class="config-tabs">
<el-collapse-item name="base"> <el-tab-pane label="基础配置" name="base">
<template #title> <div class="tab-pane-content panel-scroll">
<div class="card-header">基础配置</div>
</template>
<div class="panel-scroll">
<el-form label-width="140px"> <el-form label-width="140px">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="主div总长度(px)"> <el-form-item label="主div总长度(px)">
<el-input-number v-model="draft.totalWidth" :min="1" :max="screenWidth" :step="1" /> <el-input-number v-model="draft.totalWidth" :min="1" :max="screenWidth" :step="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="主div高度(px)"> <el-form-item label="主div高度(px)">
<el-input-number v-model="draft.segmentHeight" :min="1" :step="1" /> <el-input-number v-model="draft.segmentHeight" :min="1" :step="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="标尺显示"> <el-form-item label="标尺显示">
<el-switch v-model="draft.showRuler" /> <el-switch v-model="draft.showRuler" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
</div> </div>
</el-collapse-item> </el-tab-pane>
<el-collapse-item name="segments"> <el-tab-pane label="分段配置" name="segments">
<template #title> <div class="tab-pane-content panel-scroll">
<div class="card-header row-between"> <div class="tab-pane-actions">
<span>分段列表主屏宽度上限 {{ screenWidth }}px</span> <span>分段列表主屏宽度上限 {{ screenWidth }}px</span>
<el-button type="primary" plain size="small" @click.stop="addSegment">添加分段</el-button> <el-button type="primary" plain size="small" @click="addSegment"></el-button>
</div> </div>
</template>
<div class="panel-scroll">
<el-table :data="draft.segments" border size="small"> <el-table :data="draft.segments" border size="small">
<el-table-column type="index" label="#" width="56" /> <el-table-column type="index" label="#" width="56" />
<el-table-column label="段长度(px)" width="140"> <el-table-column label="段长度(px)" width="140">
@ -75,239 +72,389 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </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> </div>
</el-collapse-item> </el-tab-pane>
<el-collapse-item name="areas"> <el-tab-pane label="窗口区域" name="areas">
<template #title> <div class="tab-pane-content panel-scroll panel-scroll--tall area-list">
<div class="card-header row-between"> <div class="tab-pane-actions tab-pane-actions--left">
<span>子div窗口区域</span> <el-button type="primary" plain size="small" @click="addWindowArea">div</el-button>
<el-button type="primary" plain size="small" @click.stop="addWindowArea">添加子div</el-button> </div>
<el-empty v-if="draft.windowAreas.length === 0" description="暂无窗口区域请先添加子div" />
<el-tabs
v-else
v-model="activeWindowAreaTab"
tab-position="left"
class="window-area-tabs"
>
<el-tab-pane
v-for="(area, index) in draft.windowAreas"
:key="area.id"
:name="area.id"
>
<template #label>
<div
class="editable-tab-label"
@dblclick.stop="startEditingWindowAreaTabLabel(area, index)"
>
<input
v-if="editingWindowAreaTabId === area.id"
ref="windowAreaTabInputRef"
v-model="editingWindowAreaTabName"
class="editable-tab-input"
@click.stop
@blur="finishEditingWindowAreaTabLabel(area, true)"
@keyup.enter="finishEditingWindowAreaTabLabel(area, true)"
@keyup.esc="finishEditingWindowAreaTabLabel(area, false)"
/>
<span v-else>{{ resolveWindowAreaTabLabel(area, index) }}</span>
</div> </div>
</template> </template>
<div class="panel-scroll panel-scroll--tall area-list"> <div class="window-area-editor-header">
<el-card v-for="(area, index) in draft.windowAreas" :key="area.id" class="area-item" shadow="never"> <span class="window-area-editor-title">编辑子div {{ index + 1 }}</span>
<template #header>
<div class="row-between">
<strong>子div {{ index + 1 }}</strong>
<el-button type="danger" link @click="removeWindowArea(index)"></el-button> <el-button type="danger" link @click="removeWindowArea(index)"></el-button>
</div> </div>
</template> <el-form label-width="120px" size="small" class="window-area-form-vertical">
<el-form label-width="120px" size="small">
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="窗口区域编号"> <el-form-item label="窗口区域编号">
<el-input-number v-model="area.windowId" :min="1" /> <el-input-number v-model="area.windowId" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="时钟窗口"> <el-form-item label="时钟窗口">
<el-switch v-model="area.isClockWindow" /> <el-switch v-model="area.isClockWindow" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="宽度"> <el-form-item label="宽度">
<el-input-number v-model="area.width" :min="1" /> <el-input-number v-model="area.width" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="高度"> <el-form-item label="高度">
<el-input-number v-model="area.height" :min="1" /> <el-input-number v-model="area.height" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="X"> <el-form-item label="X">
<el-input-number v-model="area.x" :min="0" /> <el-input-number v-model="area.x" :min="0" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y"> <el-form-item label="Y">
<el-input-number v-model="area.y" :min="0" /> <el-input-number v-model="area.y" :min="0" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">窗口号区域占比 1</el-divider> <el-divider content-position="left">窗口号区域占比 1</el-divider>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="窗口编号"> <el-form-item label="窗口编号">
<el-input v-model="area.windowNumber" /> <el-input v-model="area.windowNumber" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆圈边框"> <el-form-item label="圆圈边框">
<el-switch v-model="area.windowNumberCircle" /> <el-switch v-model="area.windowNumberCircle" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="字号"> <el-form-item label="字号">
<el-input-number v-model="area.windowNumberStyle.fontSize" :min="1" /> <el-input-number v-model="area.windowNumberStyle.fontSize" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="颜色"> <el-form-item label="颜色">
<el-input v-model="area.windowNumberStyle.color" /> <el-input
v-model="area.windowNumberStyle.color"
@blur="validateColorOnBlur(area.windowNumberStyle, 'color', '#ffffff', '窗口号文字')"
/>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="粗细"> <el-form-item label="粗细">
<el-input-number v-model="area.windowNumberStyle.fontWeight" :min="100" :step="100" /> <el-input-number v-model="area.windowNumberStyle.fontWeight" :min="100" :step="100" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆圈大小"> <el-form-item label="圆圈大小">
<el-input-number v-model="area.windowNumberCircleStyle.size" :min="1" /> <el-input-number v-model="area.windowNumberCircleStyle.size" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="边框粗细"> <el-form-item label="边框粗细">
<el-input-number v-model="area.windowNumberCircleStyle.borderWidth" :min="1" /> <el-input-number v-model="area.windowNumberCircleStyle.borderWidth" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆角半径"> <el-form-item label="圆角半径">
<el-input-number v-model="area.windowNumberCircleStyle.borderRadius" :min="0" /> <el-input-number v-model="area.windowNumberCircleStyle.borderRadius" :min="0" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">文本区域占比 2.5静态/动态=1:1</el-divider> <el-divider content-position="left">文本区域占比 2.5静态/动态=1:1</el-divider>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="静态文本"> <el-form-item label="静态文本">
<el-input v-model="area.staticText" /> <el-input v-model="area.staticText" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="静态字号"> <el-form-item label="静态字号">
<el-input-number v-model="area.staticTextStyle.fontSize" :min="1" /> <el-input-number v-model="area.staticTextStyle.fontSize" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="静态颜色"> <el-form-item label="静态颜色">
<el-input v-model="area.staticTextStyle.color" /> <el-input
v-model="area.staticTextStyle.color"
@blur="validateColorOnBlur(area.staticTextStyle, 'color', '#ffffff', '静态文本')"
/>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="静态粗细"> <el-form-item label="静态粗细">
<el-input-number v-model="area.staticTextStyle.fontWeight" :min="100" :step="100" /> <el-input-number v-model="area.staticTextStyle.fontWeight" :min="100" :step="100" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="动态文本"> <el-form-item label="动态文本">
<el-input v-model="area.dynamicText" /> <el-input v-model="area.dynamicText" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="动态字号"> <el-form-item label="动态字号">
<el-input-number v-model="area.dynamicTextStyle.fontSize" :min="1" /> <el-input-number v-model="area.dynamicTextStyle.fontSize" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="动态颜色"> <el-form-item label="动态颜色">
<el-input v-model="area.dynamicTextStyle.color" /> <el-input
v-model="area.dynamicTextStyle.color"
@blur="validateColorOnBlur(area.dynamicTextStyle, 'color', '#ffffff', '动态文本')"
/>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="动态粗细"> <el-form-item label="动态粗细">
<el-input-number v-model="area.dynamicTextStyle.fontWeight" :min="100" :step="100" /> <el-input-number v-model="area.dynamicTextStyle.fontWeight" :min="100" :step="100" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
</el-card> </el-tab-pane>
</el-tabs>
</div> </div>
</el-collapse-item> </el-tab-pane>
<el-collapse-item name="subtitles"> <el-tab-pane label="滚动字幕" name="subtitles">
<template #title> <div class="tab-pane-content panel-scroll panel-scroll--tall">
<div class="card-header row-between"> <div class="tab-pane-actions tab-pane-actions--left">
<span>滚动字幕区域</span> <el-button type="primary" plain size="small" @click="addSubtitleArea">
<el-button type="primary" plain size="small" @click.stop="addSubtitleArea">
添加滚动字幕 添加滚动字幕
</el-button> </el-button>
</div> </div>
</template> <el-empty v-if="draft.subtitleAreas.length === 0" description="暂无滚动字幕区域,请先添加" />
<div class="panel-scroll area-list"> <el-tabs
<el-card v-else
v-model="activeSubtitleAreaTab"
tab-position="left"
class="subtitle-area-tabs"
>
<el-tab-pane
v-for="(subtitle, index) in draft.subtitleAreas" v-for="(subtitle, index) in draft.subtitleAreas"
:key="subtitle.id" :key="subtitle.id"
class="area-item" :name="subtitle.id"
shadow="never"
> >
<template #header> <template #label>
<div class="row-between"> <div
<strong>字幕区域 {{ index + 1 }}</strong> class="editable-tab-label"
<el-button type="danger" link @click="removeSubtitleArea(index)"></el-button> @dblclick.stop="startEditingSubtitleTabLabel(subtitle, index)"
>
<input
v-if="editingSubtitleTabId === subtitle.id"
ref="subtitleTabInputRef"
v-model="editingSubtitleTabName"
class="editable-tab-input"
@click.stop
@blur="finishEditingSubtitleTabLabel(subtitle, true)"
@keyup.enter="finishEditingSubtitleTabLabel(subtitle, true)"
@keyup.esc="finishEditingSubtitleTabLabel(subtitle, false)"
/>
<span v-else>{{ resolveSubtitleAreaTabLabel(subtitle, index) }}</span>
</div> </div>
</template> </template>
<el-form label-width="120px" size="small"> <div class="window-area-editor-header">
<el-row :gutter="12"> <span class="window-area-editor-title">编辑字幕区域 {{ index + 1 }}</span>
<el-col :span="6"> <el-button type="danger" link @click="removeSubtitleArea(index)"></el-button>
</div>
<el-form label-width="120px" size="small" class="window-area-form-vertical">
<el-form-item label="宽度"> <el-form-item label="宽度">
<el-input-number v-model="subtitle.width" :min="1" /> <el-input-number v-model="subtitle.width" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="高度"> <el-form-item label="高度">
<el-input-number v-model="subtitle.height" :min="1" /> <el-input-number v-model="subtitle.height" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="X"> <el-form-item label="X">
<el-input-number v-model="subtitle.x" :min="0" /> <el-input-number v-model="subtitle.x" :min="0" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y"> <el-form-item label="Y">
<el-input-number v-model="subtitle.y" :min="0" /> <el-input-number v-model="subtitle.y" :min="0" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="字幕文本"> <el-form-item label="字幕文本">
<el-input v-model="subtitle.text" /> <el-input v-model="subtitle.text" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="字号"> <el-form-item label="字号">
<el-input-number v-model="subtitle.textStyle.fontSize" :min="1" /> <el-input-number v-model="subtitle.textStyle.fontSize" :min="1" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="颜色"> <el-form-item label="颜色">
<el-input v-model="subtitle.textStyle.color" /> <el-input
v-model="subtitle.textStyle.color"
@blur="validateColorOnBlur(subtitle.textStyle, 'color', '#ffffff', '字幕文本')"
/>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="粗细"> <el-form-item label="粗细">
<el-input-number v-model="subtitle.textStyle.fontWeight" :min="100" :step="100" /> <el-input-number v-model="subtitle.textStyle.fontWeight" :min="100" :step="100" />
</el-form-item> </el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="滚动速度"> <el-form-item label="滚动速度">
<el-input-number v-model="subtitle.speed" :min="1" /> <el-input-number v-model="subtitle.speed" :min="1" />
</el-form-item> </el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
</el-card> </el-tab-pane>
</el-tabs>
</div> </div>
</el-collapse-item> </el-tab-pane>
</el-collapse>
<el-tab-pane label="综合屏配置" name="composite">
<div class="tab-pane-content panel-scroll panel-scroll--tall">
<el-form label-width="160px" size="small" class="window-area-form-vertical">
<el-form-item label="屏号">
<el-input-number v-model="draft.compositeScreen.screenId" :min="1" />
</el-form-item>
</el-form>
<el-tabs
v-model="activeCompositeConfigTab"
tab-position="left"
class="composite-config-tabs"
>
<el-tab-pane label="头部区域" name="header">
<div class="window-area-editor-header">
<span class="window-area-editor-title">头部区域配置</span>
</div>
<el-form label-width="160px" size="small" class="window-area-form-vertical">
<el-form-item label="大厅名称">
<el-input v-model="draft.compositeScreen.hallName" />
</el-form-item>
<el-form-item label="头部背景色">
<el-input
v-model="draft.compositeScreen.headerBackgroundColor"
@blur="
validateColorOnBlur(
draft.compositeScreen,
'headerBackgroundColor',
'#03050a',
'头部背景',
)
"
/>
</el-form-item>
<el-form-item label="字号">
<el-input-number v-model="draft.compositeScreen.hallStyle.fontSize" :min="1" />
</el-form-item>
<el-form-item label="颜色">
<el-input
v-model="draft.compositeScreen.hallStyle.color"
@blur="validateColorOnBlur(draft.compositeScreen.hallStyle, 'color', '#ffffff', '大厅名称')"
/>
</el-form-item>
<el-form-item label="粗细">
<el-input-number
v-model="draft.compositeScreen.hallStyle.fontWeight"
:min="100"
:step="100"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="底部字幕" name="footer">
<div class="window-area-editor-header">
<span class="window-area-editor-title">底部字幕配置</span>
</div>
<el-form label-width="160px" size="small" class="window-area-form-vertical">
<el-form-item label="滚动字幕文本">
<el-input v-model="draft.compositeScreen.footerSubtitle" />
</el-form-item>
<el-form-item label="底部背景色">
<el-input
v-model="draft.compositeScreen.footerBackgroundColor"
@blur="
validateColorOnBlur(
draft.compositeScreen,
'footerBackgroundColor',
'#03050a',
'底部背景',
)
"
/>
</el-form-item>
<el-form-item label="字号">
<el-input-number v-model="draft.compositeScreen.footerSubtitleStyle.fontSize" :min="1" />
</el-form-item>
<el-form-item label="颜色">
<el-input
v-model="draft.compositeScreen.footerSubtitleStyle.color"
@blur="
validateColorOnBlur(
draft.compositeScreen.footerSubtitleStyle,
'color',
'#ffffff',
'底部字幕',
)
"
/>
</el-form-item>
<el-form-item label="粗细">
<el-input-number
v-model="draft.compositeScreen.footerSubtitleStyle.fontWeight"
:min="100"
:step="100"
/>
</el-form-item>
<el-form-item label="滚动速度(px/s)">
<el-input-number v-model="draft.compositeScreen.footerSubtitleSpeed" :min="1" />
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="中间文本区域" name="middle-text">
<div class="window-area-editor-header">
<span class="window-area-editor-title">中间文本配置</span>
</div>
<el-form label-width="160px" size="small" class="window-area-form-vertical">
<el-form-item label="中间背景色">
<el-input
v-model="draft.compositeScreen.middleBackgroundColor"
@blur="
validateColorOnBlur(
draft.compositeScreen,
'middleBackgroundColor',
'#050a14',
'中间区域背景',
)
"
/>
</el-form-item>
<el-form-item label="文字字号">
<el-input-number v-model="draft.compositeScreen.middleTextStyle.fontSize" :min="1" />
</el-form-item>
<el-form-item label="文字颜色">
<el-input
v-model="draft.compositeScreen.middleTextStyle.color"
@blur="
validateColorOnBlur(
draft.compositeScreen.middleTextStyle,
'color',
'#f5f7fa',
'中间文字',
)
"
/>
</el-form-item>
<el-form-item label="文字粗细">
<el-input-number
v-model="draft.compositeScreen.middleTextStyle.fontWeight"
:min="100"
:step="100"
/>
</el-form-item>
<el-form-item label="单列行数">
<el-input-number v-model="draft.compositeScreen.middleMaxLines" :min="1" />
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="中间视频区域" name="middle-video">
<div class="window-area-editor-header">
<span class="window-area-editor-title">中间视频配置</span>
</div>
<el-form label-width="160px" size="small" class="window-area-form-vertical">
<el-form-item label="显示视频区域">
<el-switch v-model="draft.compositeScreen.showVideo" />
</el-form-item>
<el-form-item label="视频URL;分隔)">
<el-input
v-model="draft.compositeScreen.videoUrls"
:disabled="!draft.compositeScreen.showVideo"
/>
</el-form-item>
<el-form-item label="视频音量(0-100)">
<el-input-number
v-model="draft.compositeScreen.videoVolume"
:min="0"
:max="100"
:disabled="!draft.compositeScreen.showVideo"
/>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</el-tab-pane>
</el-tabs>
<div class="actions-row"> <div class="actions-row">
<span class="version-text">版本号V{{ appVersion }}</span> <span class="version-text">版本号V{{ appVersion }}</span>
@ -322,7 +469,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue"; import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { appWindow, currentMonitor } from "@tauri-apps/api/window"; import { appWindow, currentMonitor } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/tauri"; import { invoke } from "@tauri-apps/api/tauri";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@ -330,13 +477,13 @@ import { ElMessage } from "element-plus";
import type { import type {
BroadcastConfig, BroadcastConfig,
ChildWindowAreaConfig, ChildWindowAreaConfig,
CompositeScreenConfig,
SegmentConfigItem, SegmentConfigItem,
SubtitleAreaConfig, SubtitleAreaConfig,
} from "../models/config"; } from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config"; import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import { useBroadcastConfig } from "../composables/useBroadcastConfig"; import { useBroadcastConfig } from "../composables/useBroadcastConfig";
import { import {
buildSegmentsFromConfig,
normalizeScreenWidth, normalizeScreenWidth,
normalizeSegmentConfigItem, normalizeSegmentConfigItem,
normalizeSegmentHeight, normalizeSegmentHeight,
@ -361,7 +508,7 @@ interface AptUpdateCheckResult {
const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920)); const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920));
const { config, patchConfig } = useBroadcastConfig(); const { config, patchConfig } = useBroadcastConfig();
const saveMessage = ref("修改后请点击“保存配置”。"); const saveMessage = ref("修改后请点击“保存配置”。");
const activePanels = ref(["base", "segments", "areas", "subtitles"]); const activeTab = ref("base");
const socketRunning = ref(false); const socketRunning = ref(false);
const checkingUpdate = ref(false); const checkingUpdate = ref(false);
const appVersion = ref("0.1.0"); const appVersion = ref("0.1.0");
@ -373,16 +520,16 @@ const syncingFromStore = ref(false);
let socketStatusUnlisten: UnlistenFn | null = null; let socketStatusUnlisten: UnlistenFn | null = null;
const draft = reactive<BroadcastConfig>({ ...DEFAULT_BROADCAST_CONFIG }); const draft = reactive<BroadcastConfig>({ ...DEFAULT_BROADCAST_CONFIG });
const selectedDeliveryTargets = ref<string[]>([]);
// 稿 const activeWindowAreaTab = ref("");
const appliedSegments = computed(() => const activeSubtitleAreaTab = ref("");
buildSegmentsFromConfig( const activeCompositeConfigTab = ref("header");
draft.segments, const editingWindowAreaTabId = ref("");
screenWidth.value, const editingWindowAreaTabName = ref("");
Math.min(draft.totalWidth, screenWidth.value), const windowAreaTabInputRef = ref<HTMLInputElement | null>(null);
draft.segmentHeight, const editingSubtitleTabId = ref("");
), const editingSubtitleTabName = ref("");
); const subtitleTabInputRef = ref<HTMLInputElement | null>(null);
/** /**
* 深拷贝分段数组避免引用同一对象导致联动副作用 * 深拷贝分段数组避免引用同一对象导致联动副作用
@ -408,6 +555,15 @@ function cloneSubtitleAreas(areas: SubtitleAreaConfig[]) {
})); }));
} }
function cloneCompositeScreen(config: CompositeScreenConfig): CompositeScreenConfig {
return {
...config,
hallStyle: { ...config.hallStyle },
footerSubtitleStyle: { ...config.footerSubtitleStyle },
middleTextStyle: { ...config.middleTextStyle },
};
}
function normalizeStyle(style: { fontSize: number; color: string; fontWeight: number }) { function normalizeStyle(style: { fontSize: number; color: string; fontWeight: number }) {
return { return {
fontSize: Number.isFinite(style.fontSize) && style.fontSize > 0 ? Math.floor(style.fontSize) : 14, fontSize: Number.isFinite(style.fontSize) && style.fontSize > 0 ? Math.floor(style.fontSize) : 14,
@ -417,9 +573,29 @@ function normalizeStyle(style: { fontSize: number; color: string; fontWeight: nu
}; };
} }
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function validateColorOnBlur(
target: Record<string, unknown>,
key: string,
fallback: string,
label: string,
) {
const raw = typeof target[key] === "string" ? String(target[key]) : "";
const value = raw.trim();
if (HEX_COLOR_PATTERN.test(value)) {
target[key] = value;
return;
}
target[key] = fallback;
ElMessage.warning(`${label}颜色格式无效,已回退为 ${fallback}`);
}
function createDefaultWindowArea(index: number): ChildWindowAreaConfig { function createDefaultWindowArea(index: number): ChildWindowAreaConfig {
return { return {
id: `area-${Date.now()}-${index}`, id: `area-${Date.now()}-${index}`,
title: "",
windowId: index + 1, windowId: index + 1,
isClockWindow: false, isClockWindow: false,
width: 220, width: 220,
@ -456,6 +632,7 @@ function createDefaultWindowArea(index: number): ChildWindowAreaConfig {
function createDefaultSubtitleArea(index: number): SubtitleAreaConfig { function createDefaultSubtitleArea(index: number): SubtitleAreaConfig {
return { return {
id: `subtitle-${Date.now()}-${index}`, id: `subtitle-${Date.now()}-${index}`,
title: "",
width: 420, width: 420,
height: 28, height: 28,
x: 0, x: 0,
@ -470,6 +647,40 @@ function createDefaultSubtitleArea(index: number): SubtitleAreaConfig {
}; };
} }
function resolveWindowAreaTabLabel(area: ChildWindowAreaConfig, index: number): string {
const title = typeof area.title === "string" ? area.title.trim() : "";
if (title) {
return title;
}
const staticText = typeof area.staticText === "string" ? area.staticText.trim() : "";
if (staticText) {
return staticText;
}
const windowId =
Number.isFinite(area.windowId) && area.windowId > 0 ? Math.floor(area.windowId) : index + 1;
return `${windowId}窗口`;
}
function startEditingWindowAreaTabLabel(area: ChildWindowAreaConfig, index: number) {
editingWindowAreaTabId.value = area.id;
editingWindowAreaTabName.value = resolveWindowAreaTabLabel(area, index);
void nextTick(() => {
windowAreaTabInputRef.value?.focus();
windowAreaTabInputRef.value?.select();
});
}
function finishEditingWindowAreaTabLabel(area: ChildWindowAreaConfig, save: boolean) {
if (editingWindowAreaTabId.value !== area.id) {
return;
}
if (save) {
area.title = editingWindowAreaTabName.value.trim();
}
editingWindowAreaTabId.value = "";
editingWindowAreaTabName.value = "";
}
/** /**
* 添加一个新分段默认放在当前末段之后 * 添加一个新分段默认放在当前末段之后
*/ */
@ -499,25 +710,78 @@ function removeSegment(index: number) {
} }
function addWindowArea() { function addWindowArea() {
draft.windowAreas.push(createDefaultWindowArea(draft.windowAreas.length)); const next = createDefaultWindowArea(draft.windowAreas.length);
draft.windowAreas.push(next);
activeWindowAreaTab.value = next.id;
saveMessage.value = "已修改,点击“保存配置”后生效。"; saveMessage.value = "已修改,点击“保存配置”后生效。";
} }
function removeWindowArea(index: number) { function removeWindowArea(index: number) {
const removed = draft.windowAreas[index];
draft.windowAreas.splice(index, 1); draft.windowAreas.splice(index, 1);
if (removed && editingWindowAreaTabId.value === removed.id) {
editingWindowAreaTabId.value = "";
editingWindowAreaTabName.value = "";
}
if (removed && activeWindowAreaTab.value === removed.id) {
activeWindowAreaTab.value = draft.windowAreas[index]?.id ?? draft.windowAreas[index - 1]?.id ?? "";
}
saveMessage.value = "已修改,点击“保存配置”后生效。"; saveMessage.value = "已修改,点击“保存配置”后生效。";
} }
function addSubtitleArea() { function addSubtitleArea() {
draft.subtitleAreas.push(createDefaultSubtitleArea(draft.subtitleAreas.length)); const next = createDefaultSubtitleArea(draft.subtitleAreas.length);
draft.subtitleAreas.push(next);
activeSubtitleAreaTab.value = next.id;
saveMessage.value = "已修改,点击“保存配置”后生效。"; saveMessage.value = "已修改,点击“保存配置”后生效。";
} }
function removeSubtitleArea(index: number) { function removeSubtitleArea(index: number) {
const removed = draft.subtitleAreas[index];
draft.subtitleAreas.splice(index, 1); draft.subtitleAreas.splice(index, 1);
if (removed && editingSubtitleTabId.value === removed.id) {
editingSubtitleTabId.value = "";
editingSubtitleTabName.value = "";
}
if (removed && activeSubtitleAreaTab.value === removed.id) {
activeSubtitleAreaTab.value =
draft.subtitleAreas[index]?.id ?? draft.subtitleAreas[index - 1]?.id ?? "";
}
saveMessage.value = "已修改,点击“保存配置”后生效。"; saveMessage.value = "已修改,点击“保存配置”后生效。";
} }
function resolveSubtitleAreaTabLabel(area: SubtitleAreaConfig, index: number): string {
const title = typeof area.title === "string" ? area.title.trim() : "";
if (title) {
return title;
}
const text = typeof area.text === "string" ? area.text.trim() : "";
if (text) {
return text;
}
return `字幕${index + 1}`;
}
function startEditingSubtitleTabLabel(area: SubtitleAreaConfig, index: number) {
editingSubtitleTabId.value = area.id;
editingSubtitleTabName.value = resolveSubtitleAreaTabLabel(area, index);
void nextTick(() => {
subtitleTabInputRef.value?.focus();
subtitleTabInputRef.value?.select();
});
}
function finishEditingSubtitleTabLabel(area: SubtitleAreaConfig, save: boolean) {
if (editingSubtitleTabId.value !== area.id) {
return;
}
if (save) {
area.title = editingSubtitleTabName.value.trim();
}
editingSubtitleTabId.value = "";
editingSubtitleTabName.value = "";
}
async function startSocketService() { async function startSocketService() {
try { try {
const result = await invoke<SocketStatusPayload>("start_socket_service"); const result = await invoke<SocketStatusPayload>("start_socket_service");
@ -614,6 +878,7 @@ async function saveConfig() {
const circleStyle = area.windowNumberCircleStyle ?? { size: 36, borderWidth: 1, borderRadius: 18 }; const circleStyle = area.windowNumberCircleStyle ?? { size: 36, borderWidth: 1, borderRadius: 18 };
return { return {
id: area.id || `area-${index + 1}`, id: area.id || `area-${index + 1}`,
title: typeof area.title === "string" ? area.title.trim() : "",
windowId: windowId:
Number.isFinite(area.windowId) && area.windowId > 0 ? Math.floor(area.windowId) : index + 1, 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, width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 220,
@ -643,6 +908,7 @@ async function saveConfig() {
}); });
const subtitleAreas = draft.subtitleAreas.map((area, index) => ({ const subtitleAreas = draft.subtitleAreas.map((area, index) => ({
id: area.id || `subtitle-${index + 1}`, id: area.id || `subtitle-${index + 1}`,
title: typeof area.title === "string" ? area.title.trim() : "",
width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 420, width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 420,
height: Number.isFinite(area.height) && area.height > 0 ? Math.floor(area.height) : 28, height: Number.isFinite(area.height) && area.height > 0 ? Math.floor(area.height) : 28,
x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0, x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0,
@ -651,13 +917,69 @@ async function saveConfig() {
textStyle: normalizeStyle(area.textStyle), textStyle: normalizeStyle(area.textStyle),
speed: Number.isFinite(area.speed) && area.speed > 0 ? area.speed : 80, speed: Number.isFinite(area.speed) && area.speed > 0 ? area.speed : 80,
})); }));
const compositeScreen = {
screenId:
Number.isFinite(draft.compositeScreen.screenId) && draft.compositeScreen.screenId > 0
? Math.floor(draft.compositeScreen.screenId)
: DEFAULT_BROADCAST_CONFIG.compositeScreen.screenId,
hallName:
typeof draft.compositeScreen.hallName === "string" && draft.compositeScreen.hallName.trim()
? draft.compositeScreen.hallName.trim()
: DEFAULT_BROADCAST_CONFIG.compositeScreen.hallName,
headerBackgroundColor:
typeof draft.compositeScreen.headerBackgroundColor === "string" &&
draft.compositeScreen.headerBackgroundColor.trim()
? draft.compositeScreen.headerBackgroundColor.trim()
: DEFAULT_BROADCAST_CONFIG.compositeScreen.headerBackgroundColor,
hallStyle: normalizeStyle(draft.compositeScreen.hallStyle),
footerSubtitle:
typeof draft.compositeScreen.footerSubtitle === "string"
? draft.compositeScreen.footerSubtitle
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitle,
footerBackgroundColor:
typeof draft.compositeScreen.footerBackgroundColor === "string" &&
draft.compositeScreen.footerBackgroundColor.trim()
? draft.compositeScreen.footerBackgroundColor.trim()
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerBackgroundColor,
footerSubtitleStyle: normalizeStyle(draft.compositeScreen.footerSubtitleStyle),
footerSubtitleSpeed:
Number.isFinite(draft.compositeScreen.footerSubtitleSpeed) &&
draft.compositeScreen.footerSubtitleSpeed > 0
? draft.compositeScreen.footerSubtitleSpeed
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitleSpeed,
middleBackgroundColor:
typeof draft.compositeScreen.middleBackgroundColor === "string" &&
draft.compositeScreen.middleBackgroundColor.trim()
? draft.compositeScreen.middleBackgroundColor.trim()
: DEFAULT_BROADCAST_CONFIG.compositeScreen.middleBackgroundColor,
middleTextStyle: normalizeStyle(draft.compositeScreen.middleTextStyle),
middleMaxLines:
Number.isFinite(draft.compositeScreen.middleMaxLines) && draft.compositeScreen.middleMaxLines > 0
? Math.floor(draft.compositeScreen.middleMaxLines)
: DEFAULT_BROADCAST_CONFIG.compositeScreen.middleMaxLines,
showVideo: draft.compositeScreen.showVideo === true,
videoUrls:
typeof draft.compositeScreen.videoUrls === "string" ? draft.compositeScreen.videoUrls.trim() : "",
videoVolume:
Number.isFinite(draft.compositeScreen.videoVolume)
? Math.min(100, Math.max(0, Math.floor(draft.compositeScreen.videoVolume)))
: DEFAULT_BROADCAST_CONFIG.compositeScreen.videoVolume,
};
const selectedTargetSet = new Set(selectedDeliveryTargets.value);
await patchConfig({ await patchConfig({
totalWidth, totalWidth,
segmentHeight, segmentHeight,
showRuler: draft.showRuler, showRuler: draft.showRuler,
autoStartSocket: draft.autoStartSocket === true,
deliveryTargets: {
syncScreen: selectedTargetSet.has("syncScreen"),
compositeScreen: selectedTargetSet.has("compositeScreen"),
voiceBroadcast: selectedTargetSet.has("voiceBroadcast"),
},
segments, segments,
windowAreas, windowAreas,
subtitleAreas, subtitleAreas,
compositeScreen,
}); });
saveMessage.value = "保存成功,已写入配置文件并实时应用。"; saveMessage.value = "保存成功,已写入配置文件并实时应用。";
} }
@ -669,9 +991,19 @@ watch(
draft.totalWidth = Math.min(next.totalWidth, screenWidth.value); draft.totalWidth = Math.min(next.totalWidth, screenWidth.value);
draft.segmentHeight = next.segmentHeight; draft.segmentHeight = next.segmentHeight;
draft.showRuler = next.showRuler; draft.showRuler = next.showRuler;
draft.autoStartSocket = next.autoStartSocket;
draft.deliveryTargets = { ...next.deliveryTargets };
selectedDeliveryTargets.value = [
...(next.deliveryTargets.syncScreen ? ["syncScreen"] : []),
...(next.deliveryTargets.compositeScreen ? ["compositeScreen"] : []),
...(next.deliveryTargets.voiceBroadcast ? ["voiceBroadcast"] : []),
];
draft.segments = cloneSegments(next.segments); draft.segments = cloneSegments(next.segments);
draft.windowAreas = cloneWindowAreas(next.windowAreas); draft.windowAreas = cloneWindowAreas(next.windowAreas);
activeWindowAreaTab.value = draft.windowAreas[0]?.id ?? "";
draft.subtitleAreas = cloneSubtitleAreas(next.subtitleAreas); draft.subtitleAreas = cloneSubtitleAreas(next.subtitleAreas);
activeSubtitleAreaTab.value = draft.subtitleAreas[0]?.id ?? "";
draft.compositeScreen = cloneCompositeScreen(next.compositeScreen);
syncingFromStore.value = false; syncingFromStore.value = false;
}, },
{ immediate: true, deep: true }, { immediate: true, deep: true },
@ -682,6 +1014,9 @@ watch(
totalWidth: draft.totalWidth, totalWidth: draft.totalWidth,
segmentHeight: draft.segmentHeight, segmentHeight: draft.segmentHeight,
showRuler: draft.showRuler, showRuler: draft.showRuler,
autoStartSocket: draft.autoStartSocket,
deliveryTargets: { ...draft.deliveryTargets },
selectedDeliveryTargets: [...selectedDeliveryTargets.value],
segments: draft.segments.map((item) => ({ ...item })), segments: draft.segments.map((item) => ({ ...item })),
windowAreas: draft.windowAreas.map((item) => ({ windowAreas: draft.windowAreas.map((item) => ({
...item, ...item,
@ -690,10 +1025,19 @@ watch(
staticTextStyle: { ...item.staticTextStyle }, staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle }, dynamicTextStyle: { ...item.dynamicTextStyle },
})), })),
activeWindowAreaTab: activeWindowAreaTab.value,
subtitleAreas: draft.subtitleAreas.map((item) => ({ subtitleAreas: draft.subtitleAreas.map((item) => ({
...item, ...item,
textStyle: { ...item.textStyle }, textStyle: { ...item.textStyle },
})), })),
activeSubtitleAreaTab: activeSubtitleAreaTab.value,
activeCompositeConfigTab: activeCompositeConfigTab.value,
compositeScreen: {
...draft.compositeScreen,
hallStyle: { ...draft.compositeScreen.hallStyle },
footerSubtitleStyle: { ...draft.compositeScreen.footerSubtitleStyle },
middleTextStyle: { ...draft.compositeScreen.middleTextStyle },
},
}), }),
() => { () => {
if (!syncingFromStore.value) { if (!syncingFromStore.value) {

@ -63,3 +63,14 @@
##9.补充细节修改 ##9.补充细节修改
9.1. 修改配置文件和日志的保存位置在不同的路径下日志文件生成时文件名拼接上日期和时间限制单个日志文件大小日志最多保存7天。 9.1. 修改配置文件和日志的保存位置在不同的路径下日志文件生成时文件名拼接上日期和时间限制单个日志文件大小日志最多保存7天。
9.2. 修改:窗口号区域的圆圈边框增加可以修改边框大小、边框粗细、圆角半径。 9.2. 修改:窗口号区域的圆圈边框增加可以修改边框大小、边框粗细、圆角半径。
##10.第六阶段更新需求
10.1. 添加应用打开后在系统托盘处添加小图标双击图标激活应用右键点击图标弹出菜单菜单项有打开配置窗口、退出注意需要兼容麒麟V10桌面操作系统程序退出时需要将应用从系统托盘中移除。
10.2. 添加在配置窗口的socket红绿灯后添加三个勾选框可多选分别是同步屏、综合屏、语音播报勾选参数需要写到配置项。
10.3. 添加如果10.1中未勾选同步屏则BroadcastView.vue的窗口不显示且内容不做渲染。
##11.第七阶段更新需求
11.1. 添加如果勾选的是综合屏则打和BroadcastView.vue用一个新窗口去显示综合屏界面综合屏需要直接全屏显示双击综合屏界面在全屏显示和窗口化之间切换。
11.2. 添加综合屏分成三个部分头部区域占高度的12%底部区域占高度的12%中间的占剩下的区域三个区域宽度都100%。头部展示综合屏配置中的大厅名称底部展示配置的滚动字幕。中间区域根据配置划分为左右两个区域左边固定显示为文字显示区域右边根据配置显示文字区域或视频播放区域。文字区域根据配置的文字大小颜色行数进行从上往下堆叠显示先来的往下移后来的显示在第一行如果显示超过了设置的行数则删除最早显示的文本。如果左边不显示视频区域则左边显示满了之后将先来的显示右边新来的显示在左边第一行。如果显示视频区域则播放配置好的视频的url多个或一个视频轮播。文字区域的文字展示的是上文6.2中报文的voiceText。
11.3. 添加添加一个折叠面板用于添加和配置综合屏需要配置的内容包括大厅名称及文字大小、颜色滚动字幕及文字大小、颜色、滚动速度中间区域显示文字大小、颜色、行数视频区域是否显示的勾选框、url(多个地址用;隔开)、视频音量大小。

@ -392,7 +392,7 @@ dependencies = [
[[package]] [[package]]
name = "call-client" name = "call-client"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"fs2", "fs2",
"serde", "serde",

@ -7,7 +7,7 @@ use std::{
use serde_json::{Map, Value}; use serde_json::{Map, Value};
const APP_NAME: &str = "call-client"; const APP_NAME: &str = "com.ziyun.callclient";
const CONFIG_FILE_NAME: &str = "config.json"; const CONFIG_FILE_NAME: &str = "config.json";
fn get_home_dir() -> PathBuf { fn get_home_dir() -> PathBuf {

@ -6,7 +6,7 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH}, time::{Duration, SystemTime, UNIX_EPOCH},
}; };
const APP_NAME: &str = "call-client"; const APP_NAME: &str = "com.ziyun.callclient";
const LOG_FILE_NAME: &str = "app.log"; const LOG_FILE_NAME: &str = "app.log";
const MAX_LOG_SIZE_BYTES: u64 = 100 * 1024 * 1024; const MAX_LOG_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const LOG_RETENTION_DAYS: u64 = 7; const LOG_RETENTION_DAYS: u64 = 7;
@ -25,18 +25,18 @@ fn get_home_dir() -> PathBuf {
fn get_state_dir() -> PathBuf { fn get_state_dir() -> PathBuf {
if cfg!(target_os = "linux") { if cfg!(target_os = "linux") {
if let Ok(value) = env::var("XDG_STATE_HOME") { if let Ok(value) = env::var("XDG_CONFIG_HOME") {
return PathBuf::from(value).join(APP_NAME); return PathBuf::from(value).join(APP_NAME);
} }
return get_home_dir().join(".local").join("state").join(APP_NAME); return get_home_dir().join(".config").join(APP_NAME);
} }
if let Ok(value) = env::var("LOCALAPPDATA") { if let Ok(value) = env::var("APPDATA") {
return PathBuf::from(value).join(APP_NAME).join("state"); return PathBuf::from(value).join(APP_NAME);
} }
get_home_dir().join(".").join(APP_NAME).join("state") get_home_dir().join(".").join(APP_NAME)
} }
fn ensure_parent_dir(path: &Path) -> Result<(), String> { fn ensure_parent_dir(path: &Path) -> Result<(), String> {

@ -9,7 +9,7 @@ import path from "node:path";
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const APP_NAME = "call-client"; const APP_NAME = "com.ziyun.callclient";
const CONFIG_FILE_NAME = "config.json"; const CONFIG_FILE_NAME = "config.json";
const API_QUEUE_CALLER_PATH = "/api/queue/caller"; const API_QUEUE_CALLER_PATH = "/api/queue/caller";
const DEFAULT_API_PORT = 8845; const DEFAULT_API_PORT = 8845;

Loading…
Cancel
Save