综合屏、叫号声音、优化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
config.value.segments, ? buildSegmentsFromConfig(
screenWidth.value, config.value.segments,
totalWidth.value, screenWidth.value,
segmentHeight.value, totalWidth.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>

File diff suppressed because it is too large Load Diff

@ -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